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)
|
out := strings.TrimSpace(job.Output)
|
||||||
wasVisible := !job.Hidden
|
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()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
if previewCancel != nil {
|
if previewCancel != nil {
|
||||||
@ -290,12 +323,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
const retryCooldown = 25 * time.Second
|
const retryCooldown = 25 * time.Second
|
||||||
|
|
||||||
// wie lange wir warten, ob eine echte Datei entsteht
|
// wie lange wir warten, ob eine echte Datei entsteht
|
||||||
const outputProbeMax = 20 * time.Second
|
const outputProbeMax = 60 * time.Second
|
||||||
|
|
||||||
queue := make([]autoStartItem, 0, 64)
|
queue := make([]autoStartItem, 0, 64)
|
||||||
queued := map[string]bool{}
|
queued := map[string]bool{}
|
||||||
lastTry := map[string]time.Time{}
|
lastTry := map[string]time.Time{}
|
||||||
|
|
||||||
|
// verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln
|
||||||
|
nonPublicSince := map[string]time.Time{}
|
||||||
|
|
||||||
scanTicker := time.NewTicker(scanInterval)
|
scanTicker := time.NewTicker(scanInterval)
|
||||||
startTicker := time.NewTicker(startInterval)
|
startTicker := time.NewTicker(startInterval)
|
||||||
defer scanTicker.Stop()
|
defer scanTicker.Stop()
|
||||||
@ -573,6 +609,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
show := chaturbateShowFromSnapshot(showByUser, user)
|
show := chaturbateShowFromSnapshot(showByUser, user)
|
||||||
if show != "private" && show != "hidden" && show != "away" {
|
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
|
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 {
|
func jobMatchesModelKey(j *RecordJob, modelKey string) bool {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -188,7 +188,7 @@ func RecordStream(
|
|||||||
|
|
||||||
updatePreviewFromStream(stream)
|
updatePreviewFromStream(stream)
|
||||||
|
|
||||||
const maxPlaylistRefreshes = 12
|
const maxPlaylistRefreshes = 30
|
||||||
refreshes := 0
|
refreshes := 0
|
||||||
attempt := 0
|
attempt := 0
|
||||||
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
|
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
|
||||||
@ -208,12 +208,21 @@ func RecordStream(
|
|||||||
mergeAfterRun = true
|
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 mergeAfterRun && fileExistsNonEmpty(attemptOut) {
|
||||||
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
|
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
|
||||||
return appErrorf("retry-chunk merge failed: %w", aerr)
|
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 {
|
if err == nil {
|
||||||
|
|||||||
@ -405,6 +405,416 @@ func appendFileAndRemove(dstPath, srcPath string) error {
|
|||||||
return nil
|
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 {
|
func isExitStatus(err error, code int) bool {
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
if errors.As(err, &exitErr) {
|
if errors.As(err, &exitErr) {
|
||||||
@ -429,12 +839,30 @@ func handleM3U8Mode(
|
|||||||
videoURL := strings.TrimSpace(stream.VideoURL)
|
videoURL := strings.TrimSpace(stream.VideoURL)
|
||||||
audioURL := strings.TrimSpace(stream.AudioURL)
|
audioURL := strings.TrimSpace(stream.AudioURL)
|
||||||
|
|
||||||
u, err := url.Parse(videoURL)
|
isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com")
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
||||||
return appErrorf("ungültige video URL: %q", videoURL)
|
// 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)
|
au, err := url.Parse(audioURL)
|
||||||
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
|
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
|
||||||
return appErrorf("ungültige audio URL: %q", audioURL)
|
return appErrorf("ungültige audio URL: %q", audioURL)
|
||||||
@ -471,15 +899,15 @@ func handleM3U8Mode(
|
|||||||
|
|
||||||
appendInputOptions := func(args []string) []string {
|
appendInputOptions := func(args []string) []string {
|
||||||
args = append(args,
|
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", "1",
|
||||||
|
"-reconnect_at_eof", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_on_network_error", "1",
|
"-reconnect_on_network_error", "1",
|
||||||
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
|
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
|
||||||
"-reconnect_delay_max", "4",
|
"-reconnect_delay_max", "10",
|
||||||
)
|
)
|
||||||
|
|
||||||
if ua != "" {
|
if ua != "" {
|
||||||
@ -497,7 +925,7 @@ func handleM3U8Mode(
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
if audioURL != "" {
|
if useSeparateAudioInput {
|
||||||
args = appendInputOptions(args)
|
args = appendInputOptions(args)
|
||||||
args = append(args, "-i", videoURL)
|
args = append(args, "-i", videoURL)
|
||||||
|
|
||||||
@ -514,7 +942,7 @@ func handleM3U8Mode(
|
|||||||
} else {
|
} else {
|
||||||
args = appendInputOptions(args)
|
args = appendInputOptions(args)
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-i", videoURL,
|
"-i", inputURL,
|
||||||
"-map", "0:v:0",
|
"-map", "0:v:0",
|
||||||
"-map", "0:a:0?",
|
"-map", "0:a:0?",
|
||||||
"-dn",
|
"-dn",
|
||||||
@ -574,7 +1002,7 @@ func handleM3U8Mode(
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
last = sz
|
last = sz
|
||||||
_ = publishJob(job.ID)
|
_ = publishJobSnapshot(job.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -599,7 +1027,7 @@ func handleM3U8Mode(
|
|||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.SizeBytes = fi.Size()
|
job.SizeBytes = fi.Size()
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
_ = publishJob(job.ID)
|
_ = publishJobSnapshot(job.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ func RecordStreamMFC(
|
|||||||
) error {
|
) error {
|
||||||
mfc := NewMyFreeCams(username)
|
mfc := NewMyFreeCams(username)
|
||||||
|
|
||||||
const waitPublicMax = 20 * time.Second
|
const waitPublicMax = 90 * time.Second
|
||||||
deadline := time.Now().Add(waitPublicMax)
|
deadline := time.Now().Add(waitPublicMax)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@ -51,7 +51,7 @@ func RecordStreamMFC(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ erst jetzt die Video URL holen (weil public)
|
// ✅ erst jetzt die Video URL holen (weil public)
|
||||||
stream, err := mfc.GetSelectedStream(false)
|
stream, err := mfc.GetSelectedStream(ctx, false, hc.userAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return appErrorf("mfc get video url: %w", err)
|
return appErrorf("mfc get video url: %w", err)
|
||||||
}
|
}
|
||||||
@ -134,7 +134,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
|
|||||||
return m.VideoURL, nil
|
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)
|
m3u8URL, err := m.GetVideoURL(refresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -144,10 +144,10 @@ func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return getWantedResolutionPlaylistWithHeaders(
|
return getWantedResolutionPlaylistWithHeaders(
|
||||||
context.Background(),
|
ctx,
|
||||||
m3u8URL,
|
m3u8URL,
|
||||||
"", // MFC normalerweise ohne Cookies
|
"", // MFC normalerweise ohne Cookies
|
||||||
"", // optionaler UA, hier leer lassen oder später ergänzen
|
userAgent,
|
||||||
m.GetWebsiteURL(),
|
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)
|
return appErrorf("Stream ist nicht live (Status: %s)", st)
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := mfc.GetSelectedStream(false)
|
stream, err := mfc.GetSelectedStream(ctx, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,9 +24,10 @@ type cleanupCandidate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cleanupResp struct {
|
type cleanupResp struct {
|
||||||
// Small downloads cleanup
|
// Small / broken downloads cleanup
|
||||||
ScannedFiles int `json:"scannedFiles"`
|
ScannedFiles int `json:"scannedFiles"`
|
||||||
DeletedFiles int `json:"deletedFiles"`
|
DeletedFiles int `json:"deletedFiles"`
|
||||||
|
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
|
||||||
SkippedFiles int `json:"skippedFiles"`
|
SkippedFiles int `json:"skippedFiles"`
|
||||||
DeletedBytes int64 `json:"deletedBytes"`
|
DeletedBytes int64 `json:"deletedBytes"`
|
||||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||||
@ -233,7 +234,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
finishCleanupJob(
|
finishCleanupJob(
|
||||||
jobID,
|
jobID,
|
||||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
}(jobID, ctx, doneAbs, mb)
|
}(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(
|
return fmt.Sprintf(
|
||||||
"gelöscht: %d/%d · freigegeben: %s",
|
"gelöscht: %d/%d · freigegeben: %s",
|
||||||
deleted,
|
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 {
|
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error {
|
||||||
isCandidate := func(name string) bool {
|
isCandidate := func(name string) bool {
|
||||||
low := strings.ToLower(name)
|
low := strings.ToLower(name)
|
||||||
@ -377,7 +440,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
|||||||
st.Done = 0
|
st.Done = 0
|
||||||
st.Total = resp.ScannedFiles
|
st.Total = resp.ScannedFiles
|
||||||
st.CurrentFile = ""
|
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.Error = ""
|
||||||
st.FinishedAt = nil
|
st.FinishedAt = nil
|
||||||
})
|
})
|
||||||
@ -388,14 +451,34 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
deleteReason := ""
|
||||||
|
|
||||||
if c.size < threshold {
|
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))
|
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
||||||
id := stripHotPrefix(base)
|
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) {
|
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
|
||||||
resp.DeletedFiles++
|
resp.DeletedFiles++
|
||||||
resp.DeletedBytes += c.size
|
resp.DeletedBytes += c.size
|
||||||
|
|
||||||
|
if deleteReason == "broken" {
|
||||||
|
resp.DeletedBrokenFiles++
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(id) != "" {
|
if strings.TrimSpace(id) != "" {
|
||||||
removeGeneratedForID(id)
|
removeGeneratedForID(id)
|
||||||
}
|
}
|
||||||
@ -414,8 +497,8 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
|||||||
}
|
}
|
||||||
st.Done = i + 1
|
st.Done = i + 1
|
||||||
st.Total = resp.ScannedFiles
|
st.Total = resp.ScannedFiles
|
||||||
st.CurrentFile = ""
|
st.CurrentFile = c.name
|
||||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
|
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||||
st.Error = ""
|
st.Error = ""
|
||||||
st.FinishedAt = nil
|
st.FinishedAt = nil
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1103,6 +1103,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`Aufräumen:\n` +
|
`Aufräumen:\n` +
|
||||||
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\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` +
|
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
||||||
`Fortfahren?`
|
`Fortfahren?`
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user