bugfixes
This commit is contained in:
parent
dccb6ff1ae
commit
831b51b4a7
@ -239,6 +239,39 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
|
||||
out := strings.TrimSpace(job.Output)
|
||||
wasVisible := !job.Hidden
|
||||
|
||||
// Sichtbare Jobs nicht endlos als "running" stehen lassen,
|
||||
// wenn nach outputProbeMax keine Datei entstanden ist.
|
||||
if wasVisible {
|
||||
if recordCancel != nil {
|
||||
recordCancel()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
job.Status = JobFailed
|
||||
job.EndedAt = &now
|
||||
job.EndedAtMs = now.UnixMilli()
|
||||
job.Phase = ""
|
||||
job.Progress = 100
|
||||
job.Error = "Kein Output nach Start – ffmpeg konnte keine Datei schreiben"
|
||||
job.PostWorkKey = ""
|
||||
job.PostWork = nil
|
||||
|
||||
jobsMu.Unlock()
|
||||
|
||||
if verboseLogs() {
|
||||
appLogln("❌ [autostart] no output after probe; marking failed:", jobID)
|
||||
}
|
||||
|
||||
_ = publishJobSnapshot(jobID)
|
||||
|
||||
if onNoOutput != nil {
|
||||
onNoOutput()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
jobsMu.Unlock()
|
||||
|
||||
if previewCancel != nil {
|
||||
@ -290,12 +323,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
const retryCooldown = 25 * time.Second
|
||||
|
||||
// wie lange wir warten, ob eine echte Datei entsteht
|
||||
const outputProbeMax = 20 * time.Second
|
||||
const outputProbeMax = 60 * time.Second
|
||||
|
||||
queue := make([]autoStartItem, 0, 64)
|
||||
queued := map[string]bool{}
|
||||
lastTry := map[string]time.Time{}
|
||||
|
||||
// verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln
|
||||
nonPublicSince := map[string]time.Time{}
|
||||
|
||||
scanTicker := time.NewTicker(scanInterval)
|
||||
startTicker := time.NewTicker(startInterval)
|
||||
defer scanTicker.Stop()
|
||||
@ -573,6 +609,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
|
||||
show := chaturbateShowFromSnapshot(showByUser, user)
|
||||
if show != "private" && show != "hidden" && show != "away" {
|
||||
delete(nonPublicSince, user)
|
||||
continue
|
||||
}
|
||||
|
||||
firstSeen, ok := nonPublicSince[user]
|
||||
if !ok {
|
||||
nonPublicSince[user] = now
|
||||
continue
|
||||
}
|
||||
|
||||
// Nicht sofort stoppen. CB/API kann kurz flackern.
|
||||
if now.Sub(firstSeen) < 30*time.Second {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@ -195,6 +195,50 @@ func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
func publishJobSnapshot(jobID string) bool {
|
||||
jobsMu.RLock()
|
||||
job := jobs[jobID]
|
||||
if job == nil {
|
||||
jobsMu.RUnlock()
|
||||
return false
|
||||
}
|
||||
|
||||
var endedAtCopy *time.Time
|
||||
if job.EndedAt != nil {
|
||||
t := *job.EndedAt
|
||||
endedAtCopy = &t
|
||||
}
|
||||
|
||||
var pw *PostWorkKeyStatus
|
||||
if job.PostWork != nil {
|
||||
tmp := *job.PostWork
|
||||
pw = &tmp
|
||||
}
|
||||
|
||||
snap := jobEventSnapshot{
|
||||
ID: job.ID,
|
||||
SourceURL: job.SourceURL,
|
||||
Output: job.Output,
|
||||
Status: job.Status,
|
||||
StartedAt: job.StartedAt,
|
||||
StartedAtMs: job.StartedAtMs,
|
||||
EndedAt: endedAtCopy,
|
||||
EndedAtMs: job.EndedAtMs,
|
||||
Error: job.Error,
|
||||
Phase: job.Phase,
|
||||
Progress: job.Progress,
|
||||
SizeBytes: job.SizeBytes,
|
||||
DurationSeconds: job.DurationSeconds,
|
||||
PreviewState: job.PreviewState,
|
||||
PostWorkKey: job.PostWorkKey,
|
||||
PostWork: pw,
|
||||
}
|
||||
jobsMu.RUnlock()
|
||||
|
||||
publishJobUpsertSnapshot(snap)
|
||||
return true
|
||||
}
|
||||
|
||||
func jobMatchesModelKey(j *RecordJob, modelKey string) bool {
|
||||
if j == nil {
|
||||
return false
|
||||
|
||||
@ -188,7 +188,7 @@ func RecordStream(
|
||||
|
||||
updatePreviewFromStream(stream)
|
||||
|
||||
const maxPlaylistRefreshes = 12
|
||||
const maxPlaylistRefreshes = 30
|
||||
refreshes := 0
|
||||
attempt := 0
|
||||
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
|
||||
@ -208,12 +208,21 @@ func RecordStream(
|
||||
mergeAfterRun = true
|
||||
}
|
||||
|
||||
err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
|
||||
err = recordHLSPlaylistSegments(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
|
||||
|
||||
if mergeAfterRun && fileExistsNonEmpty(attemptOut) {
|
||||
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
|
||||
return appErrorf("retry-chunk merge failed: %w", aerr)
|
||||
}
|
||||
|
||||
if job != nil {
|
||||
if fi, statErr := os.Stat(outputPath); statErr == nil && fi != nil && !fi.IsDir() {
|
||||
jobsMu.Lock()
|
||||
job.SizeBytes = fi.Size()
|
||||
jobsMu.Unlock()
|
||||
_ = publishJobSnapshot(job.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
@ -405,6 +405,416 @@ func appendFileAndRemove(dstPath, srcPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hlsAttrValue(line string, key string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
key = strings.TrimSpace(key)
|
||||
if line == "" || key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
needle := key + "="
|
||||
idx := strings.Index(line, needle)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(line[idx+len(needle):])
|
||||
if rest == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rest, `"`) {
|
||||
rest = rest[1:]
|
||||
end := strings.IndexByte(rest, '"')
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
|
||||
if comma := strings.IndexByte(rest, ','); comma >= 0 {
|
||||
rest = rest[:comma]
|
||||
}
|
||||
|
||||
return strings.Trim(strings.TrimSpace(rest), `"`)
|
||||
}
|
||||
|
||||
func hlsExtXMapURI(playlistText string) string {
|
||||
for _, line := range strings.Split(playlistText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#EXT-X-MAP:") {
|
||||
return hlsAttrValue(line, "URI")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hlsPartURIsFromText(playlistText string) []string {
|
||||
out := make([]string, 0, 16)
|
||||
|
||||
for _, line := range strings.Split(playlistText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "#EXT-X-PART:") {
|
||||
continue
|
||||
}
|
||||
|
||||
uri := hlsAttrValue(line, "URI")
|
||||
if uri != "" {
|
||||
out = append(out, uri)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func hlsMediaSegmentURIsFromText(playlistText string) []string {
|
||||
out := make([]string, 0, 16)
|
||||
|
||||
for _, line := range strings.Split(playlistText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func hlsRefreshDelay(playlistText string) time.Duration {
|
||||
for _, line := range strings.Split(playlistText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(line, "#EXT-X-PART-INF:") {
|
||||
raw := hlsAttrValue(line, "PART-TARGET")
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
d := time.Duration(f * float64(time.Second))
|
||||
if d < 500*time.Millisecond {
|
||||
d = 500 * time.Millisecond
|
||||
}
|
||||
if d > 2*time.Second {
|
||||
d = 2 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") {
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(line, "#EXT-X-TARGETDURATION:"))
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
||||
d := time.Duration(f * float64(time.Second))
|
||||
if d < 1*time.Second {
|
||||
d = 1 * time.Second
|
||||
}
|
||||
if d > 3*time.Second {
|
||||
d = 3 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 1 * time.Second
|
||||
}
|
||||
|
||||
func hlsResolveURI(baseRaw string, refRaw string) (string, error) {
|
||||
baseRaw = strings.TrimSpace(baseRaw)
|
||||
refRaw = strings.TrimSpace(refRaw)
|
||||
|
||||
if refRaw == "" {
|
||||
return "", appErrorf("empty hls uri")
|
||||
}
|
||||
|
||||
ref, err := url.Parse(refRaw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ref.IsAbs() {
|
||||
return ref.String(), nil
|
||||
}
|
||||
|
||||
base, err := url.Parse(baseRaw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
func hlsGetBytesWithHeaders(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
httpCookie string,
|
||||
userAgent string,
|
||||
referer string,
|
||||
) ([]byte, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, appErrorf("empty hls url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(userAgent) != "" {
|
||||
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
|
||||
}
|
||||
if strings.TrimSpace(httpCookie) != "" {
|
||||
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
|
||||
}
|
||||
if strings.TrimSpace(referer) != "" {
|
||||
ref := strings.TrimSpace(referer)
|
||||
req.Header.Set("Referer", ref)
|
||||
|
||||
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
|
||||
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, appErrorf("HTTP %d: %s", resp.StatusCode, rawURL)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func hlsGetBytesWithHeadersRetry(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
httpCookie string,
|
||||
userAgent string,
|
||||
referer string,
|
||||
attempts int,
|
||||
) ([]byte, error) {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
for i := 1; i <= attempts; i++ {
|
||||
b, err := hlsGetBytesWithHeaders(ctx, rawURL, httpCookie, userAgent, referer)
|
||||
if err == nil {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if i < attempts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(600 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func writeStreamBytesAndPublish(f *os.File, job *RecordJob, b []byte) error {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := f.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if job != nil {
|
||||
if fi, err := f.Stat(); err == nil && fi != nil && !fi.IsDir() {
|
||||
jobsMu.Lock()
|
||||
job.SizeBytes = fi.Size()
|
||||
jobsMu.Unlock()
|
||||
|
||||
_ = publishJobSnapshot(job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamLogPrefix(job *RecordJob, referer string, outFile string) string {
|
||||
if s := strings.TrimSpace(hlsErrorModelName(job, referer)); s != "" {
|
||||
return "[" + s + "]"
|
||||
}
|
||||
|
||||
outFile = strings.TrimSpace(outFile)
|
||||
if outFile != "" {
|
||||
stem := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile))
|
||||
stem = stripHotPrefix(stem)
|
||||
|
||||
if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" {
|
||||
return "[" + s + "]"
|
||||
}
|
||||
if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" {
|
||||
return "[" + s + "]"
|
||||
}
|
||||
}
|
||||
|
||||
return "[stream]"
|
||||
}
|
||||
|
||||
func recordHLSPlaylistSegments(
|
||||
ctx context.Context,
|
||||
stream *selectedHLSStream,
|
||||
outFile string,
|
||||
job *RecordJob,
|
||||
httpCookie string,
|
||||
userAgent string,
|
||||
referer string,
|
||||
) error {
|
||||
if stream == nil {
|
||||
return errors.New("stream config nil")
|
||||
}
|
||||
|
||||
logPrefix := streamLogPrefix(job, referer, outFile)
|
||||
|
||||
mediaURL := strings.TrimSpace(stream.VideoURL)
|
||||
if mediaURL == "" {
|
||||
return appErrorf("%s media playlist url empty", logPrefix)
|
||||
}
|
||||
|
||||
outFile = strings.TrimSpace(outFile)
|
||||
if outFile == "" {
|
||||
return appErrorf("%s output path empty", logPrefix)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil {
|
||||
return appErrorf("%s mkdir failed: %w", logPrefix, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return appErrorf("%s open output failed: %w", logPrefix, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
writtenMaps := map[string]struct{}{}
|
||||
|
||||
lastNewSegmentAt := time.Now()
|
||||
|
||||
if verboseLogs() {
|
||||
appLogln("🎥", logPrefix, "recording media playlist:", mediaURL)
|
||||
}
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer)
|
||||
if err != nil {
|
||||
return appErrorf("%s get playlist: %w", logPrefix, err)
|
||||
}
|
||||
|
||||
playlistText := string(body)
|
||||
|
||||
if strings.Contains(playlistText, "#EXT-X-STREAM-INF") {
|
||||
return appErrorf("%s expected media playlist, got master playlist", logPrefix)
|
||||
}
|
||||
|
||||
segmentURIs := hlsMediaSegmentURIsFromText(playlistText)
|
||||
|
||||
if len(segmentURIs) == 0 {
|
||||
segmentURIs = hlsPartURIsFromText(playlistText)
|
||||
}
|
||||
|
||||
wroteAny := false
|
||||
|
||||
if len(segmentURIs) > 0 {
|
||||
mapURI := hlsExtXMapURI(playlistText)
|
||||
if mapURI != "" {
|
||||
mapURL, err := hlsResolveURI(mediaURL, mapURI)
|
||||
if err != nil {
|
||||
return appErrorf("%s resolve map uri: %w", logPrefix, err)
|
||||
}
|
||||
|
||||
if _, ok := writtenMaps[mapURL]; !ok {
|
||||
initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "init segment failed:", err)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(hlsRefreshDelay(playlistText)):
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil {
|
||||
return appErrorf("%s write init: %w", logPrefix, err)
|
||||
}
|
||||
|
||||
writtenMaps[mapURL] = struct{}{}
|
||||
wroteAny = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, uri := range segmentURIs {
|
||||
uri = strings.TrimSpace(uri)
|
||||
if uri == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
segmentURL, err := hlsResolveURI(mediaURL, uri)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[segmentURL]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil {
|
||||
return appErrorf("%s write segment: %w", logPrefix, err)
|
||||
}
|
||||
|
||||
seen[segmentURL] = struct{}{}
|
||||
wroteAny = true
|
||||
}
|
||||
|
||||
if wroteAny {
|
||||
lastNewSegmentAt = time.Now()
|
||||
} else if time.Since(lastNewSegmentAt) > 75*time.Second {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(hlsRefreshDelay(playlistText)):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isExitStatus(err error, code int) bool {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
@ -429,12 +839,30 @@ func handleM3U8Mode(
|
||||
videoURL := strings.TrimSpace(stream.VideoURL)
|
||||
audioURL := strings.TrimSpace(stream.AudioURL)
|
||||
|
||||
u, err := url.Parse(videoURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return appErrorf("ungültige video URL: %q", videoURL)
|
||||
isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com")
|
||||
|
||||
// Wichtig:
|
||||
// Bei Chaturbate die MasterURL NICHT nochmal an ffmpeg geben.
|
||||
// Die Master-Playlist wurde vorher schon in Go geöffnet/parst.
|
||||
// Das erneute Öffnen der llhls.m3u8 führt bei dir häufig zu 403.
|
||||
inputURL := videoURL
|
||||
useSeparateAudioInput := false
|
||||
|
||||
if isChaturbate {
|
||||
// Stabilitäts-Test:
|
||||
// Chaturbate erstmal NICHT mit separatem Audio-Input öffnen.
|
||||
// Wenn danach sofort .ts-Dateien entstehen, war der separate Audio-Input der Blocker.
|
||||
useSeparateAudioInput = false
|
||||
} else if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" {
|
||||
inputURL = strings.TrimSpace(stream.MasterURL)
|
||||
}
|
||||
|
||||
if audioURL != "" {
|
||||
u, err := url.Parse(inputURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return appErrorf("ungültige input URL: %q", inputURL)
|
||||
}
|
||||
|
||||
if useSeparateAudioInput {
|
||||
au, err := url.Parse(audioURL)
|
||||
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
|
||||
return appErrorf("ungültige audio URL: %q", audioURL)
|
||||
@ -471,15 +899,15 @@ func handleM3U8Mode(
|
||||
|
||||
appendInputOptions := func(args []string) []string {
|
||||
args = append(args,
|
||||
"-rw_timeout", "15000000",
|
||||
// 30s statt 15s: CB/CDN hat gelegentlich kurze Hänger.
|
||||
"-rw_timeout", "30000000",
|
||||
|
||||
// HLS-Segmente robuster laden:
|
||||
// kurze Netzwerkhänger / einzelne kaputte Segment-Requests nicht sofort hart abbrechen.
|
||||
"-reconnect", "1",
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_on_network_error", "1",
|
||||
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
|
||||
"-reconnect_delay_max", "4",
|
||||
"-reconnect_delay_max", "10",
|
||||
)
|
||||
|
||||
if ua != "" {
|
||||
@ -497,7 +925,7 @@ func handleM3U8Mode(
|
||||
return args
|
||||
}
|
||||
|
||||
if audioURL != "" {
|
||||
if useSeparateAudioInput {
|
||||
args = appendInputOptions(args)
|
||||
args = append(args, "-i", videoURL)
|
||||
|
||||
@ -514,7 +942,7 @@ func handleM3U8Mode(
|
||||
} else {
|
||||
args = appendInputOptions(args)
|
||||
args = append(args,
|
||||
"-i", videoURL,
|
||||
"-i", inputURL,
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-dn",
|
||||
@ -574,7 +1002,7 @@ func handleM3U8Mode(
|
||||
jobsMu.Unlock()
|
||||
|
||||
last = sz
|
||||
_ = publishJob(job.ID)
|
||||
_ = publishJobSnapshot(job.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -599,7 +1027,7 @@ func handleM3U8Mode(
|
||||
jobsMu.Lock()
|
||||
job.SizeBytes = fi.Size()
|
||||
jobsMu.Unlock()
|
||||
_ = publishJob(job.ID)
|
||||
_ = publishJobSnapshot(job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ func RecordStreamMFC(
|
||||
) error {
|
||||
mfc := NewMyFreeCams(username)
|
||||
|
||||
const waitPublicMax = 20 * time.Second
|
||||
const waitPublicMax = 90 * time.Second
|
||||
deadline := time.Now().Add(waitPublicMax)
|
||||
|
||||
for {
|
||||
@ -51,7 +51,7 @@ func RecordStreamMFC(
|
||||
}
|
||||
|
||||
// ✅ erst jetzt die Video URL holen (weil public)
|
||||
stream, err := mfc.GetSelectedStream(false)
|
||||
stream, err := mfc.GetSelectedStream(ctx, false, hc.userAgent)
|
||||
if err != nil {
|
||||
return appErrorf("mfc get video url: %w", err)
|
||||
}
|
||||
@ -134,7 +134,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
|
||||
return m.VideoURL, nil
|
||||
}
|
||||
|
||||
func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error) {
|
||||
func (m *MyFreeCams) GetSelectedStream(ctx context.Context, refresh bool, userAgent string) (*selectedHLSStream, error) {
|
||||
m3u8URL, err := m.GetVideoURL(refresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -144,10 +144,10 @@ func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error)
|
||||
}
|
||||
|
||||
return getWantedResolutionPlaylistWithHeaders(
|
||||
context.Background(),
|
||||
ctx,
|
||||
m3u8URL,
|
||||
"", // MFC normalerweise ohne Cookies
|
||||
"", // optionaler UA, hier leer lassen oder später ergänzen
|
||||
userAgent,
|
||||
m.GetWebsiteURL(),
|
||||
)
|
||||
}
|
||||
@ -243,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
|
||||
return appErrorf("Stream ist nicht live (Status: %s)", st)
|
||||
}
|
||||
|
||||
stream, err := mfc.GetSelectedStream(false)
|
||||
stream, err := mfc.GetSelectedStream(ctx, false, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -24,13 +24,14 @@ type cleanupCandidate struct {
|
||||
}
|
||||
|
||||
type cleanupResp struct {
|
||||
// Small downloads cleanup
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
DeletedFiles int `json:"deletedFiles"`
|
||||
SkippedFiles int `json:"skippedFiles"`
|
||||
DeletedBytes int64 `json:"deletedBytes"`
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
// Small / broken downloads cleanup
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
DeletedFiles int `json:"deletedFiles"`
|
||||
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
|
||||
SkippedFiles int `json:"skippedFiles"`
|
||||
DeletedBytes int64 `json:"deletedBytes"`
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
|
||||
// Generated-GC
|
||||
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
||||
@ -233,7 +234,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
finishCleanupJob(
|
||||
jobID,
|
||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
nil,
|
||||
)
|
||||
}(jobID, ctx, doneAbs, mb)
|
||||
@ -265,7 +266,17 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
||||
func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string {
|
||||
if broken > 0 {
|
||||
return fmt.Sprintf(
|
||||
"gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s",
|
||||
deleted,
|
||||
total,
|
||||
broken,
|
||||
formatBytesSI(freedBytes),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"gelöscht: %d/%d · freigegeben: %s",
|
||||
deleted,
|
||||
@ -274,6 +285,58 @@ func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
||||
)
|
||||
}
|
||||
|
||||
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".mp4" && ext != ".ts" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return true, "file missing/empty"
|
||||
}
|
||||
|
||||
// Erst ffprobe/duration: wenn keine Dauer lesbar ist, ist die Datei meistens kaputt.
|
||||
dctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
dur, derr := durationSecondsCached(dctx, path)
|
||||
dctxErr := dctx.Err()
|
||||
cancel()
|
||||
|
||||
if errors.Is(dctxErr, context.DeadlineExceeded) || errors.Is(dctxErr, context.Canceled) {
|
||||
// Timeout beim Prüfen nicht als "kaputt" werten, um keine guten Dateien wegen Last zu löschen.
|
||||
return false, "duration check timeout"
|
||||
}
|
||||
|
||||
if derr != nil || dur <= 0 {
|
||||
if derr != nil {
|
||||
return true, "duration failed: " + derr.Error()
|
||||
}
|
||||
return true, "duration <= 0"
|
||||
}
|
||||
|
||||
// Danach konservative Decode-Prüfung am Ende.
|
||||
// Diese Funktion nutzt du bereits nach dem Remux in recorder.go.
|
||||
vctx, vcancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
verr := validateVideoDecodesNearEnd(vctx, path)
|
||||
vctxErr := vctx.Err()
|
||||
vcancel()
|
||||
|
||||
if errors.Is(vctxErr, context.DeadlineExceeded) || errors.Is(vctxErr, context.Canceled) {
|
||||
return false, "decode check timeout"
|
||||
}
|
||||
|
||||
if verr != nil {
|
||||
return true, "decode failed: " + verr.Error()
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error {
|
||||
isCandidate := func(name string) bool {
|
||||
low := strings.ToLower(name)
|
||||
@ -377,7 +440,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
st.Done = 0
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = ""
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
@ -388,14 +451,34 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
deleteReason := ""
|
||||
|
||||
if c.size < threshold {
|
||||
deleteReason = "small"
|
||||
} else {
|
||||
broken, why := cleanupVideoLooksBroken(ctx, c.path)
|
||||
if broken {
|
||||
deleteReason = "broken"
|
||||
appLogln("🧹 cleanup deleting broken video:", filepath.Base(c.path), why)
|
||||
}
|
||||
}
|
||||
|
||||
if deleteReason != "" {
|
||||
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
||||
id := stripHotPrefix(base)
|
||||
|
||||
// Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei,
|
||||
// damit Windows-Dateihandles nicht blockieren.
|
||||
releaseFileForMutation(c.name)
|
||||
|
||||
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedBytes += c.size
|
||||
|
||||
if deleteReason == "broken" {
|
||||
resp.DeletedBrokenFiles++
|
||||
}
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
@ -414,8 +497,8 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
}
|
||||
st.Done = i + 1
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = ""
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.CurrentFile = c.name
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
|
||||
@ -1103,6 +1103,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const ok = window.confirm(
|
||||
`Aufräumen:\n` +
|
||||
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
|
||||
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
|
||||
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
||||
`Fortfahren?`
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user