redesigned finished downloads

This commit is contained in:
Linrador 2026-05-28 15:05:53 +02:00
parent 831b51b4a7
commit 06b4ff2666
17 changed files with 2517 additions and 953 deletions

View File

@ -987,6 +987,73 @@ func ratingConfidenceWeight(conf float64) float64 {
return 0.58 + 0.42*n return 0.58 + 0.42*n
} }
func ratingLinearGate(value float64, start float64, full float64) float64 {
if full <= start {
if value >= full {
return 1
}
return 0
}
return ratingClamp01((value - start) / (full - start))
}
func ratingExcellenceBonus(
peakQuality float64,
positionEffectiveWeighted float64,
positionDensity float64,
weightedCoverageRatio float64,
totalFlagged float64,
longest float64,
avgConfidence float64,
segmentsPerMinute float64,
n int,
) float64 {
// Kein pauschaler Score-Shift:
// Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind.
if n < 2 {
return 0
}
if positionEffectiveWeighted <= 0 {
return 0
}
if totalFlagged < 12.0 {
return 0
}
if peakQuality < 0.68 {
return 0
}
if avgConfidence < 0.42 {
return 0
}
peakGate := ratingLinearGate(peakQuality, 0.68, 0.90)
positionGate := ratingLinearGate(positionDensity, 2.20, 7.00)
coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160)
durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0)
longestGate := ratingLinearGate(longest, 8.0, 45.0)
confGate := ratingLinearGate(avgConfidence, 0.42, 0.78)
frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00)
bonus :=
0.030*peakGate +
0.022*positionGate +
0.016*coverageGate +
0.012*durationGate +
0.012*longestGate +
0.010*confGate +
0.006*frequencyGate
// Maximal +8.5 Scorepunkte.
// Dadurch werden starke 72-79 Scores 5-Sterne-fähig,
// aber mittelmäßige Scores springen nicht einfach pauschal hoch.
if bonus > 0.085 {
bonus = 0.085
}
return bonus
}
func starsFromHighlightScore(score float64) int { func starsFromHighlightScore(score float64) int {
switch { switch {
case score < 18: case score < 18:
@ -1154,7 +1221,21 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
raw = math.Min(raw, 0.44) raw = math.Min(raw, 0.44)
} }
score := ratingRound(ratingClamp01(raw)*100, 1) excellenceBonus := ratingExcellenceBonus(
peakQuality,
positionEffectiveWeighted,
positionDensity,
weightedCoverageRatio,
totalFlagged,
longest,
avgConfidence,
segmentsPerMinute,
n,
)
raw = ratingClamp01(raw + excellenceBonus)
score := ratingRound(raw*100, 1)
r.Score = score r.Score = score
r.Stars = starsFromHighlightScore(score) r.Stars = starsFromHighlightScore(score)
@ -1168,10 +1249,11 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
r.AvgConfidence = ratingRound(avgConfidence, 3) r.AvgConfidence = ratingRound(avgConfidence, 3)
appLogf( appLogf(
"✅ %s rating result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f", "✅ %s rating result score=%.1f stars=%d bonus=%.1f segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f",
ratingLogSubject(username), ratingLogSubject(username),
r.Score, r.Score,
r.Stars, r.Stars,
ratingRound(excellenceBonus*100, 1),
r.Segments, r.Segments,
r.FlaggedSeconds, r.FlaggedSeconds,
r.WeightedFlaggedSeconds, r.WeightedFlaggedSeconds,

View File

@ -1322,6 +1322,78 @@ func getEffectivePostworkState(job *RecordJob) string {
return "none" return "none"
} }
func postworkAudioSidecarCandidatesForOutput(out string) []string {
out = strings.TrimSpace(out)
if out == "" {
return nil
}
dir := filepath.Dir(out)
ext := filepath.Ext(out)
base := strings.TrimSuffix(filepath.Base(out), ext)
candidates := []string{}
// Standard-Pfad aus deiner Recorder-Logik:
// foo.ts -> .foo.audio.m4s
if p := strings.TrimSpace(streamSidecarPath(out, "audio", ".m4s")); p != "" {
candidates = append(candidates, p)
}
// Explizite Fallbacks, falls canonicalAssetIDFromName anders normalisiert.
if base != "" {
candidates = append(candidates, filepath.Join(dir, "."+base+".audio.m4s"))
}
if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" {
candidates = append(candidates, filepath.Join(dir, "."+canonical+".audio.m4s"))
}
seen := map[string]bool{}
outUnique := make([]string, 0, len(candidates))
for _, p := range candidates {
p = strings.TrimSpace(p)
if p == "" {
continue
}
cp := filepath.Clean(p)
if seen[cp] {
continue
}
seen[cp] = true
outUnique = append(outUnique, cp)
}
return outUnique
}
func removePostworkAudioSidecarsForOutput(out string) []string {
removed := []string{}
for _, p := range postworkAudioSidecarCandidatesForOutput(out) {
fi, err := os.Stat(p)
if err != nil {
continue
}
if fi == nil || fi.IsDir() {
continue
}
if err := removeWithRetry(p); err != nil && !os.IsNotExist(err) {
appLogln("⚠️ postwork audio sidecar konnte nicht gelöscht werden:", filepath.Base(p), err)
continue
}
removed = append(removed, p)
appLogln("🗑️ postwork audio sidecar gelöscht:", filepath.Base(p))
}
return removed
}
func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) { if !mustMethod(w, r, http.MethodPost) {
return return
@ -1364,8 +1436,18 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
} }
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
removedAudioSidecars := []string{}
if out != "" { if out != "" {
_ = removeWithRetry(out) // Erst passende Audio-Sidecars merken/löschen, solange der TS-Name noch bekannt ist.
removedAudioSidecars = removePostworkAudioSidecarsForOutput(out)
if err := removeWithRetry(out); err != nil && !os.IsNotExist(err) {
appLogln("⚠️ postwork output konnte nicht gelöscht werden:", filepath.Base(out), err)
} else {
appLogln("🗑️ postwork output gelöscht:", filepath.Base(out))
}
purgeDurationCacheForPath(out) purgeDurationCacheForPath(out)
id1 := canonicalAssetIDFromName(out) id1 := canonicalAssetIDFromName(out)
@ -1382,8 +1464,9 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
notifyDoneChanged() notifyDoneChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
"id": id, "id": id,
"removedAudioSidecars": len(removedAudioSidecars),
}) })
} }

View File

@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -130,16 +129,18 @@ func RecordStream(
wait = 8 * time.Second wait = 8 * time.Second
} }
appLogln( if verboseLogs() {
"⚠️ chaturbate hls refresh retry", appLogln(
reason, "⚠️ chaturbate hls refresh retry",
"attempt", reason,
attempt, "attempt",
"of", attempt,
maxAttempts, "of",
"error:", maxAttempts,
err, "error:",
) err,
)
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -188,10 +189,12 @@ func RecordStream(
updatePreviewFromStream(stream) updatePreviewFromStream(stream)
const maxPlaylistRefreshes = 30 const maxPlaylistRefreshes = 10
refreshes := 0 refreshes := 0
attempt := 0 attempt := 0
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts") // Der Segment-Recorder schreibt selbst fortlaufend in die Hauptdatei.
// Retry-Chunks würden nur .retry-Dateien im records-Ordner erzeugen.
useChunkMerge := false
for { for {
attempt++ attempt++
@ -258,10 +261,21 @@ func RecordStream(
return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
} }
wait := 1500 * time.Millisecond
// Wenn die Seite gerade keinen hls_source enthält, bringt schneller Spam nichts.
// Das passiert bei CB oft bei public/private-Flackern oder wenn der Room noch nicht
// vollständig initialisiert ist.
if strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "room dossier nicht gefunden") ||
strings.Contains(msg, "stream-parsing") {
wait = 10 * time.Second
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-time.After(1500 * time.Millisecond): case <-time.After(wait):
} }
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error") newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
@ -279,7 +293,7 @@ func RecordStream(
} }
} }
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) // ParseStream (roomDossier → hls_source)
func ParseStream(html string) (string, error) { func ParseStream(html string) (string, error) {
matches := roomDossierRegexp.FindStringSubmatch(html) matches := roomDossierRegexp.FindStringSubmatch(html)
if len(matches) == 0 { if len(matches) == 0 {

View File

@ -545,6 +545,271 @@ func hlsResolveURI(baseRaw string, refRaw string) (string, error) {
return base.ResolveReference(ref).String(), nil return base.ResolveReference(ref).String(), nil
} }
func hlsWithoutBlockingReloadParams(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
q.Del("_HLS_msn")
q.Del("_HLS_part")
u.RawQuery = q.Encode()
return u.String()
}
func hlsWithBlockingReloadParams(rawURL string, msn int, part int) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" || msn < 0 {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
q.Set("_HLS_msn", strconv.Itoa(msn))
if part >= 0 {
q.Set("_HLS_part", strconv.Itoa(part))
} else {
q.Del("_HLS_part")
}
u.RawQuery = q.Encode()
return u.String()
}
func hlsURLPathKey(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil || u == nil {
return rawURL
}
return strings.TrimSpace(u.Path)
}
func hlsSamePlaylistPath(a string, b string) bool {
ak := hlsURLPathKey(a)
bk := hlsURLPathKey(b)
return ak != "" && bk != "" && ak == bk
}
func hlsParseChaturbateMediaSeqPart(uri string) (seq int, part int, isPart bool, ok bool) {
uri = strings.TrimSpace(uri)
if uri == "" {
return 0, 0, false, false
}
if before, _, found := strings.Cut(uri, "?"); found {
uri = before
}
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
uri = uri[idx+1:]
}
pieces := strings.Split(uri, "_")
for i, p := range pieces {
switch p {
case "seg":
// seg_6_4240_audio_...
if i+2 >= len(pieces) {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(pieces[i+2]))
if err != nil {
continue
}
return n, -1, false, true
case "part":
// part_6_4241_0_audio_...
if i+3 >= len(pieces) {
continue
}
n, err1 := strconv.Atoi(strings.TrimSpace(pieces[i+2]))
pn, err2 := strconv.Atoi(strings.TrimSpace(pieces[i+3]))
if err1 != nil || err2 != nil {
continue
}
return n, pn, true, true
}
}
return 0, 0, false, false
}
func hlsNextBlockingReloadParams(playlistText string) (msn int, part int, ok bool) {
maxSeg := -1
maxPartSeq := -1
maxPart := -1
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#EXT-X-PART:") {
uri := hlsAttrValue(line, "URI")
if seq, pn, isPart, ok := hlsParseChaturbateMediaSeqPart(uri); ok && isPart {
if seq > maxPartSeq || (seq == maxPartSeq && pn > maxPart) {
maxPartSeq = seq
maxPart = pn
}
}
continue
}
if strings.HasPrefix(line, "#") {
continue
}
if seq, _, isPart, ok := hlsParseChaturbateMediaSeqPart(line); ok && !isPart {
if seq > maxSeg {
maxSeg = seq
}
}
}
// Wenn Parts sichtbar sind, beim nächsten Request nach dem nächsten Part fragen.
if maxPartSeq >= 0 && maxPart >= 0 {
return maxPartSeq, maxPart + 1, true
}
// Sonst beim nächsten Media Sequence warten.
if maxSeg >= 0 {
return maxSeg + 1, 0, true
}
return -1, -1, false
}
func hlsRenditionReportPosition(
playlistText string,
basePlaylistURL string,
targetPlaylistURL string,
) (msn int, part int, ok bool) {
targetPlaylistURL = hlsWithoutBlockingReloadParams(targetPlaylistURL)
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "#EXT-X-RENDITION-REPORT:") {
continue
}
attrText := strings.TrimPrefix(line, "#EXT-X-RENDITION-REPORT:")
attrs := parseHLSAttributeList(attrText)
reportURI := strings.TrimSpace(attrs["URI"])
if reportURI == "" {
continue
}
reportURL, err := hlsResolveURI(basePlaylistURL, reportURI)
if err != nil {
continue
}
reportURL = hlsWithoutBlockingReloadParams(reportURL)
if !hlsSamePlaylistPath(reportURL, targetPlaylistURL) {
continue
}
rawMSN := strings.TrimSpace(attrs["LAST-MSN"])
if rawMSN == "" {
continue
}
n, err := strconv.Atoi(rawMSN)
if err != nil {
continue
}
pn := 0
if rawPart := strings.TrimSpace(attrs["LAST-PART"]); rawPart != "" {
if v, err := strconv.Atoi(rawPart); err == nil {
pn = v
}
}
return n, pn, true
}
return -1, -1, false
}
func hlsPrimeRenditionURLFromPlaylist(
ctx context.Context,
sourcePlaylistURL string,
targetPlaylistURL string,
httpCookie string,
userAgent string,
referer string,
logPrefix string,
kind string,
) string {
sourcePlaylistURL = strings.TrimSpace(sourcePlaylistURL)
targetPlaylistURL = strings.TrimSpace(targetPlaylistURL)
if sourcePlaylistURL == "" || targetPlaylistURL == "" {
return targetPlaylistURL
}
body, err := hlsGetBytesWithHeadersRetry(
ctx,
sourcePlaylistURL,
httpCookie,
userAgent,
referer,
3,
)
if err != nil {
if verboseLogs() {
appLogln("⚠️", logPrefix, "unable to prime", kind, "playlist:", err)
}
return targetPlaylistURL
}
msn, part, ok := hlsRenditionReportPosition(
string(body),
sourcePlaylistURL,
targetPlaylistURL,
)
if !ok {
return targetPlaylistURL
}
primed := hlsWithBlockingReloadParams(targetPlaylistURL, msn, part)
if verboseLogs() {
appLogln("🔁", logPrefix, "primed", kind, "playlist:", primed)
}
return primed
}
func hlsGetBytesWithHeaders( func hlsGetBytesWithHeaders(
ctx context.Context, ctx context.Context,
rawURL string, rawURL string,
@ -616,6 +881,18 @@ func hlsGetBytesWithHeadersRetry(
return nil, ctx.Err() return nil, ctx.Err()
} }
msg := strings.ToLower(strings.TrimSpace(err.Error()))
// 401/403/404/410 sind bei CB meistens kein kurzer Netzwerkhänger,
// sondern stale/ungültige Playlist-URL. Nicht 3x denselben URL hämmern,
// sondern direkt nach außen geben, damit RecordStream frische HLS-URLs holt.
if strings.Contains(msg, "http 401") ||
strings.Contains(msg, "http 403") ||
strings.Contains(msg, "http 404") ||
strings.Contains(msg, "http 410") {
return nil, err
}
if i < attempts { if i < attempts {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -671,6 +948,141 @@ func streamLogPrefix(job *RecordJob, referer string, outFile string) string {
return "[stream]" return "[stream]"
} }
func streamSidecarPath(outFile string, suffix string, ext string) string {
outFile = strings.TrimSpace(outFile)
suffix = strings.Trim(strings.TrimSpace(suffix), ".")
ext = strings.TrimSpace(ext)
if outFile == "" || suffix == "" {
return ""
}
if ext == "" {
ext = filepath.Ext(outFile)
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
dir := filepath.Dir(outFile)
base := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile))
canonical := canonicalAssetIDFromName(base)
if canonical == "" {
canonical = base
}
return filepath.Join(dir, "."+canonical+"."+suffix+ext)
}
func muxAudioVideoSegmentFiles(
ctx context.Context,
videoPath string,
audioPath string,
outFile string,
logPrefix string,
) error {
videoPath = strings.TrimSpace(videoPath)
audioPath = strings.TrimSpace(audioPath)
outFile = strings.TrimSpace(outFile)
if videoPath == "" || audioPath == "" || outFile == "" {
return appErrorf("%s mux paths empty", logPrefix)
}
if !fileExistsNonEmpty(videoPath) {
return appErrorf("%s mux video missing or empty", logPrefix)
}
if !fileExistsNonEmpty(audioPath) {
return appErrorf("%s mux audio missing or empty", logPrefix)
}
ext := strings.ToLower(filepath.Ext(outFile))
tmp := streamSidecarPath(outFile, "muxing", ext)
backup := streamSidecarPath(outFile, "video-original", ".bak")
_ = removeWithRetry(tmp)
_ = removeWithRetry(backup)
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
"-fflags", "+genpts+discardcorrupt",
"-err_detect", "ignore_err",
"-i", videoPath,
"-i", audioPath,
"-map", "0:v:0",
"-map", "1:a:0",
"-dn",
"-ignore_unknown",
"-c", "copy",
}
switch ext {
case ".ts":
args = append(args, "-f", "mpegts", tmp)
case ".mp4":
args = append(args, "-movflags", "+faststart", tmp)
default:
args = append(args, "-f", "mpegts", tmp)
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = removeWithRetry(tmp)
return appErrorf("%s mux failed: %w (%s)", logPrefix, err, strings.TrimSpace(stderr.String()))
}
if err := requireNonEmptyRegularFile(tmp, "mux result"); err != nil {
_ = removeWithRetry(tmp)
return err
}
sameOutputAsVideo := filepath.Clean(videoPath) == filepath.Clean(outFile)
if sameOutputAsVideo {
// Recording-Fall:
// outFile ist die laufende Video-Datei selbst, also sicher ersetzen.
if err := os.Rename(outFile, backup); err != nil {
_ = removeWithRetry(tmp)
return appErrorf("%s mux backup rename failed: %w", logPrefix, err)
}
if err := os.Rename(tmp, outFile); err != nil {
_ = os.Rename(backup, outFile)
_ = removeWithRetry(tmp)
return appErrorf("%s mux final rename failed: %w", logPrefix, err)
}
_ = removeWithRetry(backup)
} else {
// Postwork-Fall:
// videoPath ist z.B. records/foo.ts, outFile ist eine neue temp/foo.mp4.
// Hier darf outFile noch nicht existieren.
_ = removeWithRetry(outFile)
if err := os.Rename(tmp, outFile); err != nil {
_ = removeWithRetry(tmp)
return appErrorf("%s mux final rename failed: %w", logPrefix, err)
}
}
_ = removeWithRetry(audioPath)
return nil
}
func recordHLSPlaylistSegments( func recordHLSPlaylistSegments(
ctx context.Context, ctx context.Context,
stream *selectedHLSStream, stream *selectedHLSStream,
@ -686,23 +1098,198 @@ func recordHLSPlaylistSegments(
logPrefix := streamLogPrefix(job, referer, outFile) logPrefix := streamLogPrefix(job, referer, outFile)
mediaURL := strings.TrimSpace(stream.VideoURL) videoURL := strings.TrimSpace(stream.VideoURL)
if mediaURL == "" { audioURL := strings.TrimSpace(stream.AudioURL)
return appErrorf("%s media playlist url empty", logPrefix)
if videoURL == "" {
return appErrorf("%s video media playlist url empty", logPrefix)
} }
outFile = strings.TrimSpace(outFile) // Chaturbate LL-HLS:
if outFile == "" { // Die Website lädt Audio/Video-Playlists oft mit _HLS_msn/_HLS_part.
return appErrorf("%s output path empty", logPrefix) // Die rohen chunklist_...m3u8 URLs ohne diese Parameter liefern bei dir häufig 403.
// Deshalb primen wir die Audio-URL aus den RENDITION-REPORTs der Video-Playlist.
if audioURL != "" {
audioURL = hlsPrimeRenditionURLFromPlaylist(
ctx,
videoURL,
audioURL,
httpCookie,
userAgent,
referer,
logPrefix,
"audio",
)
} }
// Kein separates Audio vorhanden: bisheriges Verhalten.
if audioURL == "" {
return recordHLSMediaPlaylistToFile(
ctx,
videoURL,
outFile,
job,
httpCookie,
userAgent,
referer,
logPrefix,
"video",
)
}
// Separates Audio vorhanden: Video in Hauptdatei, Audio in Sidecar-Datei.
audioOut := streamSidecarPath(outFile, "audio", ".m4s")
if audioOut == "" {
return appErrorf("%s audio sidecar path empty", logPrefix)
}
if verboseLogs() {
appLogln("🎥", logPrefix, "recording separate video/audio playlists")
appLogln("🎥", logPrefix, "video playlist:", videoURL)
appLogln("🎧", logPrefix, "audio playlist:", audioURL)
}
recCtx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, 2)
go func() {
errCh <- recordHLSMediaPlaylistToFile(
recCtx,
videoURL,
outFile,
job,
httpCookie,
userAgent,
referer,
logPrefix,
"video",
)
}()
go func() {
errCh <- recordHLSMediaPlaylistToFile(
recCtx,
audioURL,
audioOut,
nil,
httpCookie,
userAgent,
referer,
logPrefix,
"audio",
)
}()
firstErr := <-errCh
cancel()
secondErr := <-errCh
parentCanceled := ctx.Err() != nil
endOfStream := errors.Is(firstErr, io.EOF) || errors.Is(secondErr, io.EOF)
// Wenn einer der beiden Streams wegen Fehler endet, canceln wir den anderen.
// Dessen "context canceled" ist dann nur Folgefehler und soll nicht das Log aufblasen.
finalErr := firstErr
if errors.Is(finalErr, context.Canceled) && !parentCanceled {
finalErr = secondErr
}
if errors.Is(secondErr, context.Canceled) && !parentCanceled {
secondErr = nil
}
// Falls firstErr nil/context-canceled ist, aber secondErr der echte Fehler war.
if finalErr == nil || (errors.Is(finalErr, context.Canceled) && !parentCanceled) {
finalErr = secondErr
}
shouldFinalize := parentCanceled || endOfStream || finalErr == nil
if shouldFinalize {
if !fileExistsNonEmpty(outFile) {
if finalErr != nil {
return finalErr
}
return appErrorf("%s video output missing or empty", logPrefix)
}
if !fileExistsNonEmpty(audioOut) {
if finalErr != nil {
return appErrorf("%s audio missing/empty; refusing video-only file: %w", logPrefix, finalErr)
}
return appErrorf("%s audio missing/empty; refusing video-only file", logPrefix)
}
muxCtx, muxCancel := context.WithTimeout(context.Background(), 5*time.Minute)
muxErr := muxAudioVideoSegmentFiles(muxCtx, outFile, audioOut, outFile, logPrefix)
muxCancel()
if muxErr != nil {
return appErrorf("%s audio/video mux failed; refusing video-only file: %w", logPrefix, muxErr)
}
// Sicherheitscheck: Nach dem Mux muss wirklich Audio vorhanden sein.
if !videoHasAudioStream(context.Background(), outFile) {
return appErrorf("%s mux result has no audio stream; refusing video-only file", logPrefix)
}
if job != nil {
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJobSnapshot(job.ID)
}
}
if verboseLogs() {
appLogln("✅", logPrefix, "audio/video mux succeeded:", filepath.Base(outFile))
}
if parentCanceled {
return ctx.Err()
}
if endOfStream {
return nil
}
}
return finalErr
}
func recordHLSMediaPlaylistToFile(
ctx context.Context,
mediaURL string,
outFile string,
job *RecordJob,
httpCookie string,
userAgent string,
referer string,
logPrefix string,
kind string,
) error {
mediaURL = strings.TrimSpace(mediaURL)
outFile = strings.TrimSpace(outFile)
kind = strings.TrimSpace(kind)
if mediaURL == "" {
return appErrorf("%s %s playlist url empty", logPrefix, kind)
}
if outFile == "" {
return appErrorf("%s %s output path empty", logPrefix, kind)
}
baseMediaURL := hlsWithoutBlockingReloadParams(mediaURL)
requestURL := mediaURL
if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil {
return appErrorf("%s mkdir failed: %w", logPrefix, err) return appErrorf("%s %s mkdir failed: %w", logPrefix, kind, err)
} }
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil { if err != nil {
return appErrorf("%s open output failed: %w", logPrefix, err) return appErrorf("%s %s open output failed: %w", logPrefix, kind, err)
} }
defer f.Close() defer f.Close()
@ -712,7 +1299,7 @@ func recordHLSPlaylistSegments(
lastNewSegmentAt := time.Now() lastNewSegmentAt := time.Now()
if verboseLogs() { if verboseLogs() {
appLogln("🎥", logPrefix, "recording media playlist:", mediaURL) appLogln("🎥", logPrefix, "recording", kind, "media playlist:", mediaURL)
} }
for { for {
@ -720,19 +1307,24 @@ func recordHLSPlaylistSegments(
return err return err
} }
body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer) body, err := hlsGetBytesWithHeadersRetry(ctx, requestURL, httpCookie, userAgent, referer, 3)
if err != nil { if err != nil {
return appErrorf("%s get playlist: %w", logPrefix, err) return appErrorf("%s %s get playlist: %w", logPrefix, kind, err)
} }
playlistText := string(body) playlistText := string(body)
if nextMSN, nextPart, ok := hlsNextBlockingReloadParams(playlistText); ok {
requestURL = hlsWithBlockingReloadParams(baseMediaURL, nextMSN, nextPart)
} else {
requestURL = baseMediaURL
}
if strings.Contains(playlistText, "#EXT-X-STREAM-INF") { if strings.Contains(playlistText, "#EXT-X-STREAM-INF") {
return appErrorf("%s expected media playlist, got master playlist", logPrefix) return appErrorf("%s %s expected media playlist, got master playlist", logPrefix, kind)
} }
segmentURIs := hlsMediaSegmentURIsFromText(playlistText) segmentURIs := hlsMediaSegmentURIsFromText(playlistText)
if len(segmentURIs) == 0 { if len(segmentURIs) == 0 {
segmentURIs = hlsPartURIsFromText(playlistText) segmentURIs = hlsPartURIsFromText(playlistText)
} }
@ -744,13 +1336,13 @@ func recordHLSPlaylistSegments(
if mapURI != "" { if mapURI != "" {
mapURL, err := hlsResolveURI(mediaURL, mapURI) mapURL, err := hlsResolveURI(mediaURL, mapURI)
if err != nil { if err != nil {
return appErrorf("%s resolve map uri: %w", logPrefix, err) return appErrorf("%s %s resolve map uri: %w", logPrefix, kind, err)
} }
if _, ok := writtenMaps[mapURL]; !ok { if _, ok := writtenMaps[mapURL]; !ok {
initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3) initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3)
if err != nil { if err != nil {
appLogln("⚠️", logPrefix, "init segment failed:", err) appLogln("⚠️", logPrefix, kind, "init segment failed:", err)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -762,7 +1354,7 @@ func recordHLSPlaylistSegments(
} }
if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil { if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil {
return appErrorf("%s write init: %w", logPrefix, err) return appErrorf("%s %s write init: %w", logPrefix, kind, err)
} }
writtenMaps[mapURL] = struct{}{} writtenMaps[mapURL] = struct{}{}
@ -779,7 +1371,7 @@ func recordHLSPlaylistSegments(
segmentURL, err := hlsResolveURI(mediaURL, uri) segmentURL, err := hlsResolveURI(mediaURL, uri)
if err != nil { if err != nil {
appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err) appLogln("⚠️", logPrefix, kind, "resolve segment failed:", uri, err)
continue continue
} }
@ -789,12 +1381,12 @@ func recordHLSPlaylistSegments(
segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3) segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3)
if err != nil { if err != nil {
appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err) appLogln("⚠️", logPrefix, kind, "segment failed:", filepath.Base(uri), err)
continue continue
} }
if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil { if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil {
return appErrorf("%s write segment: %w", logPrefix, err) return appErrorf("%s %s write segment: %w", logPrefix, kind, err)
} }
seen[segmentURL] = struct{}{} seen[segmentURL] = struct{}{}
@ -944,7 +1536,16 @@ func handleM3U8Mode(
args = append(args, args = append(args,
"-i", inputURL, "-i", inputURL,
"-map", "0:v:0", "-map", "0:v:0",
"-map", "0:a:0?", )
if isChaturbate && stream.HasSeparateAudio {
// Bei CB mit separater Audio-Playlist darf keine stumme Datei entstehen.
args = append(args, "-map", "0:a:0")
} else {
args = append(args, "-map", "0:a:0?")
}
args = append(args,
"-dn", "-dn",
"-ignore_unknown", "-ignore_unknown",
"-c", "copy", "-c", "copy",

View File

@ -448,6 +448,17 @@ func isLikelyNaturalStreamEndError(err error) bool {
s := strings.ToLower(strings.TrimSpace(err.Error())) s := strings.ToLower(strings.TrimSpace(err.Error()))
// Wichtig: Wenn Audio Pflicht war und fehlt/fehlschlägt,
// darf das NICHT als normales Stream-Ende gelten.
if strings.Contains(s, "audio") &&
(strings.Contains(s, "403") ||
strings.Contains(s, "forbidden") ||
strings.Contains(s, "no such stream") ||
strings.Contains(s, "matches no streams") ||
strings.Contains(s, "stream specifier")) {
return false
}
return strings.Contains(s, "eof") || return strings.Contains(s, "eof") ||
strings.Contains(s, "unexpected eof") || strings.Contains(s, "unexpected eof") ||
strings.Contains(s, "stream ended") || strings.Contains(s, "stream ended") ||
@ -726,6 +737,48 @@ func buildPostworkTempMP4Path(tsPath string) (string, error) {
return uniqueDestPath(tmpDir, base) return uniqueDestPath(tmpDir, base)
} }
func findPostworkAudioSidecarForTS(tsPath string) (string, bool) {
tsPath = strings.TrimSpace(tsPath)
if tsPath == "" {
return "", false
}
candidates := []string{}
if p := strings.TrimSpace(streamSidecarPath(tsPath, "audio", ".m4s")); p != "" {
candidates = append(candidates, p)
}
dir := filepath.Dir(tsPath)
base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath))
candidates = append(candidates,
filepath.Join(dir, "."+base+".audio.m4s"),
filepath.Join(dir, "."+canonicalAssetIDFromName(base)+".audio.m4s"),
)
seen := map[string]bool{}
for _, p := range candidates {
p = strings.TrimSpace(p)
if p == "" {
continue
}
cp := filepath.Clean(p)
if seen[cp] {
continue
}
seen[cp] = true
fi, err := os.Stat(cp)
if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return cp, true
}
}
return "", false
}
func runPrimaryPostworkPipeline( func runPrimaryPostworkPipeline(
ctx context.Context, ctx context.Context,
job *RecordJob, job *RecordJob,
@ -761,43 +814,85 @@ func runPrimaryPostworkPipeline(
} }
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
dur := 0.0 audioSidecar, hasAudioSidecar := findPostworkAudioSidecarForTS(tsPath)
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 {
dur = d
}
cancel()
err = remuxTSToMP4WithProgress( if hasAudioSidecar {
ctx, if setPhase != nil {
tsPath, setPhase("remuxing", 15)
mp4Path, }
dur,
inSize,
func(r float64) {
if setPhase == nil {
return
}
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
setPhase("remuxing", int(math.Round(r*100)))
},
)
if err != nil { logPrefix := "[" + strings.TrimSpace(modelNameFromFilename(strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)))) + "]"
_ = removeWithRetry(mp4Path) if strings.TrimSpace(logPrefix) == "[]" {
return tsPath, appErrorf("ts remux failed: %w", err) logPrefix = "[postwork]"
}
appLogln(
"🎧",
logPrefix,
"found audio sidecar, muxing TS+audio to MP4:",
filepath.Base(tsPath),
"+",
filepath.Base(audioSidecar),
)
muxCtx, muxCancel := context.WithTimeout(ctx, 10*time.Minute)
err = muxAudioVideoSegmentFiles(muxCtx, tsPath, audioSidecar, mp4Path, logPrefix)
muxCancel()
if err != nil {
_ = removeWithRetry(mp4Path)
return tsPath, appErrorf("ts+audio mux failed: %w", err)
}
if setPhase != nil {
setPhase("remuxing", 100)
}
} else {
dur := 0.0
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 {
dur = d
}
cancel()
err = remuxTSToMP4WithProgress(
ctx,
tsPath,
mp4Path,
dur,
inSize,
func(r float64) {
if setPhase == nil {
return
}
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
setPhase("remuxing", int(math.Round(r*100)))
},
)
if err != nil {
_ = removeWithRetry(mp4Path)
return tsPath, appErrorf("ts remux failed: %w", err)
}
} }
if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil { if err := requireNonEmptyRegularFile(mp4Path, "remux/mux result"); err != nil {
_ = removeWithRetry(mp4Path) _ = removeWithRetry(mp4Path)
return tsPath, err return tsPath, err
} }
if hasAudioSidecar || (job != nil && detectProvider(job.SourceURL) == "chaturbate") {
if !videoHasAudioStream(ctx, mp4Path) {
_ = removeWithRetry(mp4Path)
return tsPath, appErrorf("remux/mux result has no audio stream; refusing silent MP4")
}
}
if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil { if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil {
appLogln("⚠️ remux mp4 decode validation failed; keeping remux result without repair:", err) appLogln("⚠️ remux mp4 decode validation failed; keeping remux result without repair:", err)
} }

View File

@ -682,6 +682,8 @@ func main() {
startPostWorkStatusRefresher() startPostWorkStatusRefresher()
startEnrichStatusRefresher() startEnrichStatusRefresher()
startPostworkLeftoverScanOnStartup()
go startGeneratedGarbageCollector() go startGeneratedGarbageCollector()
// Altes NSFW-ONNX nicht mehr hart initialisieren. // Altes NSFW-ONNX nicht mehr hart initialisieren.

View File

@ -0,0 +1,285 @@
// backend\startup_leftovers.go
package main
import (
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
const startupLeftoverMinAge = 60 * time.Second
func startPostworkLeftoverScanOnStartup() {
go func() {
// Kurz warten, bis Settings, Queues und UI/SSE bereit sind.
time.Sleep(8 * time.Second)
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
if queued > 0 || skipped > 0 {
appLogln(
"🧩 [startup-postwork] leftover scan fertig:",
"queued=", queued,
"skipped=", skipped,
)
}
}()
}
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
s := getSettings()
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordAbs) == "" {
recordAbs = strings.TrimSpace(s.RecordDir)
}
recordAbs = strings.TrimSpace(recordAbs)
if recordAbs == "" {
appLogln("⚠️ [startup-postwork] recordDir ist leer")
return 0, 0
}
entries, err := os.ReadDir(recordAbs)
if err != nil {
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
return 0, 0
}
now := time.Now()
skip := func(name string, reason string, extra ...any) {
skipped++
args := []any{
"⏭️ [startup-postwork] skip:",
name,
"reason:",
reason,
}
args = append(args, extra...)
appLogln(args...)
}
for _, e := range entries {
if e == nil || e.IsDir() {
continue
}
name := strings.TrimSpace(e.Name())
if name == "" {
continue
}
if strings.HasPrefix(name, ".") {
continue
}
if !strings.EqualFold(filepath.Ext(name), ".ts") {
continue
}
tsPath := filepath.Join(recordAbs, name)
tsInfo, err := os.Stat(tsPath)
if err != nil {
skip(name, "ts stat failed", err)
continue
}
if tsInfo == nil || tsInfo.IsDir() {
skip(name, "ts invalid")
continue
}
if tsInfo.Size() <= 0 {
skip(name, "ts empty")
continue
}
tsAge := now.Sub(tsInfo.ModTime())
if tsAge < startupLeftoverMinAge {
skip(
name,
"ts too fresh",
"age=", tsAge.Round(time.Second),
"min=", startupLeftoverMinAge,
)
continue
}
audioPath, ok := findPostworkAudioSidecarForTS(tsPath)
if !ok {
skip(name, "matching audio sidecar not found")
continue
}
audioInfo, err := os.Stat(audioPath)
if err != nil {
skip(name, "audio stat failed", filepath.Base(audioPath), err)
continue
}
if audioInfo == nil || audioInfo.IsDir() {
skip(name, "audio invalid", filepath.Base(audioPath))
continue
}
if audioInfo.Size() <= 0 {
skip(name, "audio empty", filepath.Base(audioPath))
continue
}
audioAge := now.Sub(audioInfo.ModTime())
if audioAge < startupLeftoverMinAge {
skip(
name,
"audio too fresh",
filepath.Base(audioPath),
"age=", audioAge.Round(time.Second),
"min=", startupLeftoverMinAge,
)
continue
}
if postworkLeftoverAlreadyTracked(tsPath) {
skip(name, "already tracked as job")
continue
}
if postworkLeftoverDoneMP4Exists(tsPath) {
skip(name, "done mp4 already exists")
continue
}
if !postworkLeftoverFilesStable(tsPath, audioPath) {
skip(name, "files not stable", filepath.Base(audioPath))
continue
}
if enqueuePostworkLeftover(tsPath, tsInfo) {
queued++
} else {
skip(name, "enqueue failed")
}
}
return queued, skipped
}
func postworkLeftoverAlreadyTracked(tsPath string) bool {
tsPath = filepath.Clean(strings.TrimSpace(tsPath))
if tsPath == "" {
return false
}
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
out := filepath.Clean(strings.TrimSpace(j.Output))
if out != "" && strings.EqualFold(out, tsPath) {
return true
}
}
return false
}
func postworkLeftoverDoneMP4Exists(tsPath string) bool {
tsPath = strings.TrimSpace(tsPath)
if tsPath == "" {
return false
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
doneAbs = strings.TrimSpace(s.DoneDir)
}
doneAbs = strings.TrimSpace(doneAbs)
if doneAbs == "" {
return false
}
base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)) + ".mp4"
donePath := filepath.Join(doneAbs, base)
fi, err := os.Stat(donePath)
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
func postworkLeftoverFilesStable(tsPath string, audioPath string) bool {
tsPath = strings.TrimSpace(tsPath)
audioPath = strings.TrimSpace(audioPath)
if tsPath == "" || audioPath == "" {
return false
}
sizeOf := func(p string) (int64, bool) {
fi, err := os.Stat(p)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return 0, false
}
return fi.Size(), true
}
ts1, ok1 := sizeOf(tsPath)
audio1, ok2 := sizeOf(audioPath)
if !ok1 || !ok2 {
return false
}
time.Sleep(1500 * time.Millisecond)
ts2, ok1 := sizeOf(tsPath)
audio2, ok2 := sizeOf(audioPath)
if !ok1 || !ok2 {
return false
}
return ts1 == ts2 && audio1 == audio2
}
func enqueuePostworkLeftover(tsPath string, tsInfo os.FileInfo) bool {
tsPath = strings.TrimSpace(tsPath)
if tsPath == "" || tsInfo == nil {
return false
}
now := time.Now()
startedAt := tsInfo.ModTime()
if startedAt.IsZero() {
startedAt = now
}
job := &RecordJob{
ID: uuid.NewString(),
SourceURL: "",
Status: JobFinished,
StartedAt: startedAt,
StartedAtMs: startedAt.UnixMilli(),
EndedAt: &now,
EndedAtMs: now.UnixMilli(),
Output: tsPath,
Phase: "postwork",
Progress: 0,
SizeBytes: tsInfo.Size(),
Hidden: false,
}
jobsMu.Lock()
jobs[job.ID] = job
jobsMu.Unlock()
appLogln(
"🧩 [startup-postwork] queued leftover:",
filepath.Base(tsPath),
)
enqueuePostworkOrFail(job, tsPath, JobFinished)
return true
}

View File

@ -861,6 +861,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
} }
if terr != nil { if terr != nil {
appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", terr)
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady { } else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
if genTeaser { if genTeaser {
@ -868,6 +869,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
} }
publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser") publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser")
} else { } else {
appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", "preview.mp4 fehlt oder ist leer")
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} }
} }

View File

@ -25,13 +25,15 @@ type cleanupCandidate struct {
type cleanupResp struct { type cleanupResp struct {
// Small / broken 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"` 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"`
ErrorCount int `json:"errorCount"` DeletedRecordOrphans int `json:"deletedRecordOrphans"`
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
ErrorCount int `json:"errorCount"`
// Generated-GC // Generated-GC
GeneratedOrphansChecked int `json:"generatedOrphansChecked"` GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
@ -149,6 +151,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordAbs) == "" {
recordAbs = strings.TrimSpace(s.RecordDir)
}
mb := int(s.AutoDeleteSmallDownloadsBelowMB) mb := int(s.AutoDeleteSmallDownloadsBelowMB)
var req cleanupReq var req cleanupReq
@ -188,7 +195,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
publishTaskState() publishTaskState()
go func(jobID string, ctx context.Context, doneAbs string, mb int) { go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int) {
if err := acquireExclusiveTask(ctx); err != nil { if err := acquireExclusiveTask(ctx); err != nil {
finishCleanupJob(jobID, "", err) finishCleanupJob(jobID, "", err)
return return
@ -212,6 +219,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
finishCleanupJob(jobID, "", err)
return
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
finishCleanupJob(jobID, "", ctx.Err()) finishCleanupJob(jobID, "", ctx.Err())
@ -237,7 +249,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes), cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
nil, nil,
) )
}(jobID, ctx, doneAbs, mb) }(jobID, ctx, doneAbs, recordAbs, mb)
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
@ -337,6 +349,212 @@ func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
return false, "" return false, ""
} }
const cleanupRecordOrphanMinAge = 60 * time.Second
func cleanupRecordFileIsActive(path string) bool {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" {
return false
}
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
out := filepath.Clean(strings.TrimSpace(j.Output))
if out != "" && strings.EqualFold(out, path) {
return true
}
for _, sidecar := range postworkAudioSidecarCandidatesForOutput(out) {
if sidecar != "" && strings.EqualFold(filepath.Clean(sidecar), path) {
return true
}
}
}
return false
}
func cleanupFileStable(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
fi1, err := os.Stat(path)
if err != nil || fi1 == nil || fi1.IsDir() || fi1.Size() <= 0 {
return false
}
time.Sleep(1200 * time.Millisecond)
fi2, err := os.Stat(path)
if err != nil || fi2 == nil || fi2.IsDir() || fi2.Size() <= 0 {
return false
}
return fi1.Size() == fi2.Size()
}
func cleanupTSPathForAudioSidecar(audioPath string) string {
audioPath = strings.TrimSpace(audioPath)
if audioPath == "" {
return ""
}
dir := filepath.Dir(audioPath)
name := filepath.Base(audioPath)
if !strings.HasPrefix(name, ".") {
return ""
}
if !strings.HasSuffix(strings.ToLower(name), ".audio.m4s") {
return ""
}
base := strings.TrimPrefix(name, ".")
base = strings.TrimSuffix(base, ".audio.m4s")
if strings.TrimSpace(base) == "" {
return ""
}
return filepath.Join(dir, base+".ts")
}
func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path string, resp *cleanupResp, reason string) {
if resp == nil {
return
}
select {
case <-ctx.Done():
return
default:
}
path = strings.TrimSpace(path)
if path == "" {
return
}
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return
}
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
resp.SkippedFiles++
return
}
if cleanupRecordFileIsActive(path) {
resp.SkippedFiles++
return
}
if !cleanupFileStable(path) {
resp.SkippedFiles++
return
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.CurrentFile = filepath.Base(path)
st.Text = "Räume verwaiste Record-Dateien auf…"
})
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
resp.ErrorCount++
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", filepath.Base(path), reason, err)
return
}
resp.DeletedFiles++
resp.DeletedRecordOrphans++
resp.DeletedBytes += fi.Size()
resp.DeletedRecordOrphanBytes += fi.Size()
purgeDurationCacheForPath(path)
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
base = strings.TrimPrefix(base, ".")
base = strings.TrimSuffix(base, ".audio")
if strings.TrimSpace(base) != "" {
removeGeneratedForID(stripHotPrefix(base))
}
appLogln("🧹 cleanup deleted record orphan:", filepath.Base(path), "reason:", reason)
}
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
recordAbs = strings.TrimSpace(recordAbs)
if recordAbs == "" || resp == nil {
return nil
}
entries, err := os.ReadDir(recordAbs)
if err != nil {
return nil
}
for _, e := range entries {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if e == nil || e.IsDir() {
continue
}
name := strings.TrimSpace(e.Name())
if name == "" {
continue
}
full := filepath.Join(recordAbs, name)
low := strings.ToLower(name)
// Fall 1:
// foo.ts existiert, aber .foo.audio.m4s fehlt.
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
resp.ScannedFiles++
if _, ok := findPostworkAudioSidecarForTS(full); ok {
continue
}
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing audio sidecar")
continue
}
// Fall 2:
// .foo.audio.m4s existiert, aber foo.ts fehlt.
if strings.HasPrefix(name, ".") && strings.HasSuffix(low, ".audio.m4s") {
resp.ScannedFiles++
tsPath := cleanupTSPathForAudioSidecar(full)
if strings.TrimSpace(tsPath) != "" {
if fi, err := os.Stat(tsPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
continue
}
}
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing ts file")
continue
}
}
return nil
}
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)

View File

@ -105,13 +105,97 @@ func generateTeaserClipsMP4WithProgress(
return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio) return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio)
} }
func videoHasAudioStream(ctx context.Context, srcPath string) bool {
srcPath = strings.TrimSpace(srcPath)
if srcPath == "" {
return false
}
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(
probeCtx,
ffprobePath,
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=index",
"-of", "csv=p=0",
srcPath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) != ""
}
func teaserMissingAudioError(msg string) bool {
msg = strings.ToLower(strings.TrimSpace(msg))
if msg == "" {
return false
}
return strings.Contains(msg, "stream specifier ':a'") ||
strings.Contains(msg, "matches no streams") ||
strings.Contains(msg, "no such stream") ||
(strings.Contains(msg, "stream specifier") && strings.Contains(msg, ":a"))
}
func logTeaserGenerationFailure(srcPath string, outPath string, err error) {
if err == nil {
return
}
srcPath = strings.TrimSpace(srcPath)
outPath = strings.TrimSpace(outPath)
srcName := filepath.Base(srcPath)
if strings.TrimSpace(srcName) == "" || srcName == "." {
srcName = srcPath
}
outName := filepath.Base(outPath)
if strings.TrimSpace(outName) == "" || outName == "." {
outName = outPath
}
appLogln(
"❌ teaser generation fehlgeschlagen:",
srcName,
"->",
outName,
"error:",
err,
)
}
func logTeaserGenerationFallback(srcPath string, reason error) {
if reason == nil {
return
}
appLogln(
"⚠️ teaser generation fallback ohne Audio:",
filepath.Base(strings.TrimSpace(srcPath)),
"reason:",
reason,
)
}
func generateTeaserChunkMP4( func generateTeaserChunkMP4(
ctx context.Context, ctx context.Context,
src, out string, src, out string,
start, dur float64, start, dur float64,
opts TeaserPreviewOptions, opts TeaserPreviewOptions,
) error { ) error {
opts.Audio = true hasAudio := true
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4" tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
segDur := dur segDur := dur
@ -139,28 +223,39 @@ func generateTeaserChunkMP4(
args = append(args, "-vsync", "2") args = append(args, "-vsync", "2")
} }
args = append(args, if hasAudio {
"-map", "0:a:0", args = append(args,
"-c:a", "aac", "-map", "0:a:0",
"-b:a", opts.AudioBitrate, "-c:a", "aac",
"-ac", "2", "-b:a", opts.AudioBitrate,
"-shortest", "-ac", "2",
) "-shortest",
)
} else {
args = append(args, "-an")
}
args = append(args, "-movflags", "+faststart", tmp) args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true, HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW CreationFlags: 0x08000000,
} }
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
msg := strings.TrimSpace(stderr.String())
wrappedErr := appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, msg)
logTeaserGenerationFailure(src, out, wrappedErr)
return wrappedErr
} }
_ = os.Remove(out) _ = os.Remove(out)
return os.Rename(tmp, out) return os.Rename(tmp, out)
} }
@ -211,7 +306,6 @@ func generateTeaserPreviewMP4WithProgress(
opts TeaserPreviewOptions, opts TeaserPreviewOptions,
onRatio func(r float64), onRatio func(r float64),
) error { ) error {
opts.Audio = true
if opts.SegmentDuration <= 0 { if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1 opts.SegmentDuration = 1
} }
@ -230,6 +324,7 @@ func generateTeaserPreviewMP4WithProgress(
if opts.AudioBitrate == "" { if opts.AudioBitrate == "" {
opts.AudioBitrate = "128k" opts.AudioBitrate = "128k"
} }
hasAudio := true
segDur := opts.SegmentDuration segDur := opts.SegmentDuration
if segDur < minSegmentDuration { if segDur < minSegmentDuration {
segDur = minSegmentDuration segDur = minSegmentDuration
@ -265,17 +360,32 @@ func generateTeaserPreviewMP4WithProgress(
} }
var fc strings.Builder var fc strings.Builder
for i := range starts { for i := range starts {
fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i) fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i)
fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
}
for i := range starts {
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
}
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
args = append(args, "-filter_complex", fc.String()) if hasAudio {
args = append(args, "-map", "[v]", "-map", "[a]") fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
}
}
for i := range starts {
if hasAudio {
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
} else {
fmt.Fprintf(&fc, "[v%d]", i)
}
}
if hasAudio {
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
args = append(args, "-filter_complex", fc.String())
args = append(args, "-map", "[v]", "-map", "[a]")
} else {
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts))
args = append(args, "-filter_complex", fc.String())
args = append(args, "-map", "[v]", "-an")
}
args = append(args, args = append(args,
"-c:v", "libx264", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
@ -288,12 +398,14 @@ func generateTeaserPreviewMP4WithProgress(
if opts.UseVsync2 { if opts.UseVsync2 {
args = append(args, "-vsync", "2") args = append(args, "-vsync", "2")
} }
args = append(args, if hasAudio {
"-c:a", "aac", args = append(args,
"-b:a", opts.AudioBitrate, "-c:a", "aac",
"-ac", "2", "-b:a", opts.AudioBitrate,
"-shortest", "-ac", "2",
) "-shortest",
)
}
args = append(args, "-movflags", "+faststart", tmp) args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
@ -377,7 +489,12 @@ func generateTeaserPreviewMP4WithProgress(
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return appErrorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
msg := strings.TrimSpace(stderr.String())
wrappedErr := appErrorf("ffmpeg teaser preview failed: %v (%s)", err, msg)
logTeaserGenerationFailure(srcPath, outPath, wrappedErr)
return wrappedErr
} }
_ = os.Remove(outPath) _ = os.Remove(outPath)
@ -456,12 +573,15 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true, HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW CreationFlags: 0x08000000,
} }
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
wrappedErr := appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
logTeaserGenerationFailure(srcPath, outPath, wrappedErr)
return wrappedErr
} }
_ = os.Remove(outPath) _ = os.Remove(outPath)
@ -553,7 +673,23 @@ func generatedTeaser(w http.ResponseWriter, r *http.Request) {
defer cancel() defer cancel()
if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil { if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil {
appLogln(
"⚠️ teaser clips generation fehlgeschlagen, versuche fallback:",
filepath.Base(outPath),
"error:",
err,
)
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
appLogln(
"❌ teaser generation endgültig fehlgeschlagen:",
filepath.Base(outPath),
"clips-error:",
err,
"fallback-error:",
err2,
)
http.Error( http.Error(
w, w,
"konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")",

View File

@ -3925,7 +3925,7 @@ export default function App() {
} }
const headerShellClass = const headerShellClass =
'rounded-2xl border border-gray-200/70 bg-white/65 p-2.5 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] sm:p-3' 'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06]'
const headerStatClass = const headerStatClass =
'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200' 'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
@ -3959,9 +3959,7 @@ export default function App() {
return ( return (
<div <div
className={[ className={[
isTrainingTab 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden',
? 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden'
: 'min-h-[100dvh]',
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100', 'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
].join(' ')} ].join(' ')}
> >
@ -3970,14 +3968,9 @@ export default function App() {
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" /> <div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
</div> </div>
<div <div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
className={[
'relative',
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
].join(' ')}
>
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0"> <header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
<div className="mx-auto max-w-7xl px-4 pb-2.5 pt-2.5 sm:px-6 sm:py-3 lg:px-8"> <div className="mx-auto max-w-7xl p-3">
<div className={headerShellClass}> <div className={headerShellClass}>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
@ -4129,16 +4122,23 @@ export default function App() {
</header> </header>
<main <main
data-app-scroll-area="true"
className={[ className={[
'mx-auto w-full py-2', 'w-full lg:min-h-0',
isTrainingTab && trainingImageExpanded
? 'max-w-none px-2 sm:px-3 lg:px-4'
: 'max-w-7xl px-4 sm:px-6 lg:px-8',
isTrainingTab isTrainingTab
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden' ? 'pb-4 lg:flex-1 lg:overflow-hidden'
: 'space-y-2', : 'lg:flex-1 lg:overflow-y-auto lg:overscroll-contain lg:[scrollbar-gutter:stable_both-edges]',
].join(' ')} ].join(' ')}
> >
<div
className={[
'w-full p-3',
isTrainingTab && trainingImageExpanded
? 'max-w-none px-2 sm:px-3 lg:px-4'
: 'mx-auto max-w-7xl',
isTrainingTab ? 'lg:h-full lg:min-h-0 lg:overflow-hidden' : 'space-y-2',
].join(' ')}
>
{selectedTab === 'running' ? ( {selectedTab === 'running' ? (
<Downloads <Downloads
jobs={jobs} jobs={jobs}
@ -4235,6 +4235,7 @@ export default function App() {
onAssetsGenerated={bumpAssets} onAssetsGenerated={bumpAssets}
/> />
) : null} ) : null}
</div>
</main> </main>
<CookieModal <CookieModal

View File

@ -1545,8 +1545,8 @@ export default function Downloads({
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds]) }, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
return ( return (
<div className="grid gap-3"> <div className="grid gap-2">
<div className="sticky top-[56px] z-20"> <div className="sticky z-20">
<div <div
className=" className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm rounded-xl border border-gray-200/70 bg-white/80 shadow-sm

File diff suppressed because it is too large Load Diff

View File

@ -110,8 +110,8 @@ export default function Slider({
<div <div
className={[ className={[
compact compact
? 'inline-flex min-w-0 items-center gap-3 rounded-md border border-gray-200/70 bg-white/60 px-3 h-9 shadow-sm dark:border-white/10 dark:bg-white/5' ? 'flex w-full min-w-0 items-center gap-3 rounded-md border border-gray-200/70 bg-white/60 px-3 h-9 shadow-sm dark:border-white/10 dark:bg-white/5'
: 'rounded-xl border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60', : 'w-full rounded-xl border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
className, className,
].join(' ')} ].join(' ')}
> >

View File

@ -237,7 +237,7 @@ export default function Tabs({
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white', : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
idx === 0 ? 'rounded-l-lg' : '', idx === 0 ? 'rounded-l-lg' : '',
idx === tabs.length - 1 ? 'rounded-r-lg' : '', idx === tabs.length - 1 ? 'rounded-r-lg' : '',
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5', 'group relative min-w-0 flex-1 overflow-hidden p-3 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400' disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-gray-500 dark:hover:text-gray-400'
)} )}
> >

View File

@ -2,7 +2,8 @@
'use client' 'use client'
import { useEffect, useRef, useState, useMemo, useCallback, type KeyboardEvent } from 'react' import { useEffect, useRef, useState, useMemo, useCallback, type CSSProperties, type KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx' import clsx from 'clsx'
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline' import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
import Button from './Button' import Button from './Button'
@ -36,6 +37,8 @@ export default function TagFilterBar({
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const [menuStyle, setMenuStyle] = useState<CSSProperties>({})
const activeTagSet = useMemo( const activeTagSet = useMemo(
() => new Set(activeTags.map(lower)), () => new Set(activeTags.map(lower)),
@ -86,6 +89,7 @@ export default function TagFilterBar({
const target = e.target as Node | null const target = e.target as Node | null
if (!target) return if (!target) return
if (rootRef.current?.contains(target)) return if (rootRef.current?.contains(target)) return
if (menuRef.current?.contains(target)) return
close() close()
} }
@ -119,7 +123,54 @@ export default function TagFilterBar({
[addableTags, query, filteredOptions, close, selectTag] [addableTags, query, filteredOptions, close, selectTag]
) )
const renderInlineMobileMenu = mobile && open const updateMenuPosition = useCallback(() => {
const anchor = inputRef.current || rootRef.current
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const viewportW = window.innerWidth
const viewportH = window.innerHeight
const gap = 8
const desiredWidth = mobile
? Math.max(220, Math.min(rect.width, viewportW - gap * 2))
: Math.max(220, Math.min(rect.width, viewportW - gap * 2))
const spaceBelow = viewportH - rect.bottom
const spaceAbove = rect.top
const openUp = spaceBelow < 220 && spaceAbove > spaceBelow
const availableHeight = openUp
? Math.max(120, spaceAbove - gap * 2)
: Math.max(120, spaceBelow - gap * 2)
setMenuStyle({
position: 'fixed',
left: Math.max(gap, Math.min(rect.left, viewportW - desiredWidth - gap)),
top: openUp ? undefined : rect.bottom + gap,
bottom: openUp ? viewportH - rect.top + gap : undefined,
width: desiredWidth,
maxHeight: Math.min(224, availableHeight),
zIndex: 2147483647,
})
}, [mobile])
useEffect(() => {
if (!open) return
updateMenuPosition()
const onUpdate = () => updateMenuPosition()
window.addEventListener('resize', onUpdate)
window.addEventListener('scroll', onUpdate, true)
return () => {
window.removeEventListener('resize', onUpdate)
window.removeEventListener('scroll', onUpdate, true)
}
}, [open, updateMenuPosition])
const renderMenu = open && typeof document !== 'undefined'
return ( return (
<div className={`flex flex-wrap items-center gap-1.5 overflow-visible ${className}`}> <div className={`flex flex-wrap items-center gap-1.5 overflow-visible ${className}`}>
@ -144,14 +195,8 @@ export default function TagFilterBar({
ref={rootRef} ref={rootRef}
className={[ className={[
'relative shrink-0 z-[90] leading-none', 'relative shrink-0 z-[90] leading-none',
renderInlineMobileMenu open ? 'flex min-w-0 items-center' : 'flex h-6 items-center',
? 'flex w-full min-w-0 flex-col items-stretch' mobile ? (open ? 'w-full' : 'w-auto min-w-0') : '',
: 'flex h-6 items-center',
mobile
? renderInlineMobileMenu
? 'w-full min-w-0'
: 'w-auto min-w-0'
: '',
].join(' ')} ].join(' ')}
> >
{!open ? ( {!open ? (
@ -210,40 +255,43 @@ export default function TagFilterBar({
</button> </button>
</div> </div>
<div {renderMenu
className={[ ? createPortal(
renderInlineMobileMenu <div
? 'mt-2 overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-lg dark:border-white/10 dark:bg-gray-900' ref={menuRef}
: 'absolute left-0 top-full z-[100] mt-2 overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-lg dark:border-white/10 dark:bg-gray-900', style={menuStyle}
mobile ? 'w-full' : 'min-w-[220px]', className="overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/10 dark:bg-gray-900 dark:ring-white/15"
].join(' ')} onMouseDown={(e) => e.stopPropagation()}
> >
<div className="max-h-56 overflow-auto py-1"> <div className="overflow-auto py-1" style={{ maxHeight: menuStyle.maxHeight }}>
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((tag) => ( filteredOptions.map((tag) => (
<button <button
key={tag} key={tag}
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => selectTag(tag)} onClick={() => selectTag(tag)}
className=" className="
flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm
text-gray-800 transition-colors hover:bg-sky-50 text-gray-800 transition-colors hover:bg-sky-50
dark:text-gray-100 dark:hover:bg-sky-400/10 dark:text-gray-100 dark:hover:bg-sky-400/10
" "
title={tag} title={tag}
> >
<span className="truncate">{tag}</span> <span className="truncate">{tag}</span>
<PlusIcon className="size-4 shrink-0 text-sky-600 dark:text-sky-300" /> <PlusIcon className="size-4 shrink-0 text-sky-600 dark:text-sky-300" />
</button> </button>
)) ))
) : ( ) : (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400"> <div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{emptyText} {emptyText}
</div> </div>
)} )}
</div> </div>
</div> </div>,
document.body
)
: null}
</> </>
)} )}
</div> </div>

View File

@ -4955,15 +4955,15 @@ export default function TrainingTab(props: {
className={[ className={[
'min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3', 'min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
imageExpanded imageExpanded
? 'lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden' ? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto] lg:gap-3 lg:overflow-hidden'
: 'lg:self-start', : 'lg:self-start'
].join(' ')} ].join(' ')}
> >
<div <div
className={[ className={[
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3', 'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
imageExpanded imageExpanded
? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-auto lg:max-h-none lg:flex-1 lg:self-stretch' ? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch'
: imageStageHeightClass, : imageStageHeightClass,
'overflow-visible', 'overflow-visible',
].join(' ')} ].join(' ')}
@ -5462,7 +5462,7 @@ export default function TrainingTab(props: {
className={[ className={[
'z-10 grid shrink-0 grid-cols-2 gap-2', 'z-10 grid shrink-0 grid-cols-2 gap-2',
imageExpanded imageExpanded
? 'mt-3' ? 'mt-3 lg:mt-0'
: 'sticky bottom-0 mt-3 sm:static sm:mt-4', : 'sticky bottom-0 mt-3 sm:static sm:mt-4',
].join(' ')} ].join(' ')}
> >