redesigned finished downloads
This commit is contained in:
parent
831b51b4a7
commit
06b4ff2666
@ -987,6 +987,73 @@ func ratingConfidenceWeight(conf float64) float64 {
|
||||
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 {
|
||||
switch {
|
||||
case score < 18:
|
||||
@ -1154,7 +1221,21 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
|
||||
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.Stars = starsFromHighlightScore(score)
|
||||
@ -1168,10 +1249,11 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl
|
||||
r.AvgConfidence = ratingRound(avgConfidence, 3)
|
||||
|
||||
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),
|
||||
r.Score,
|
||||
r.Stars,
|
||||
ratingRound(excellenceBonus*100, 1),
|
||||
r.Segments,
|
||||
r.FlaggedSeconds,
|
||||
r.WeightedFlaggedSeconds,
|
||||
|
||||
@ -1322,6 +1322,78 @@ func getEffectivePostworkState(job *RecordJob) string {
|
||||
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) {
|
||||
if !mustMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
@ -1364,8 +1436,18 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
out := strings.TrimSpace(job.Output)
|
||||
removedAudioSidecars := []string{}
|
||||
|
||||
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)
|
||||
|
||||
id1 := canonicalAssetIDFromName(out)
|
||||
@ -1382,8 +1464,9 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
|
||||
notifyDoneChanged()
|
||||
|
||||
respondJSON(w, map[string]any{
|
||||
"ok": true,
|
||||
"id": id,
|
||||
"ok": true,
|
||||
"id": id,
|
||||
"removedAudioSidecars": len(removedAudioSidecars),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -130,16 +129,18 @@ func RecordStream(
|
||||
wait = 8 * time.Second
|
||||
}
|
||||
|
||||
appLogln(
|
||||
"⚠️ chaturbate hls refresh retry",
|
||||
reason,
|
||||
"attempt",
|
||||
attempt,
|
||||
"of",
|
||||
maxAttempts,
|
||||
"error:",
|
||||
err,
|
||||
)
|
||||
if verboseLogs() {
|
||||
appLogln(
|
||||
"⚠️ chaturbate hls refresh retry",
|
||||
reason,
|
||||
"attempt",
|
||||
attempt,
|
||||
"of",
|
||||
maxAttempts,
|
||||
"error:",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -188,10 +189,12 @@ func RecordStream(
|
||||
|
||||
updatePreviewFromStream(stream)
|
||||
|
||||
const maxPlaylistRefreshes = 30
|
||||
const maxPlaylistRefreshes = 10
|
||||
refreshes := 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 {
|
||||
attempt++
|
||||
@ -258,10 +261,21 @@ func RecordStream(
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
case <-time.After(wait):
|
||||
}
|
||||
|
||||
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) {
|
||||
matches := roomDossierRegexp.FindStringSubmatch(html)
|
||||
if len(matches) == 0 {
|
||||
|
||||
@ -545,6 +545,271 @@ func hlsResolveURI(baseRaw string, refRaw string) (string, error) {
|
||||
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(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
@ -616,6 +881,18 @@ func hlsGetBytesWithHeadersRetry(
|
||||
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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -671,6 +948,141 @@ func streamLogPrefix(job *RecordJob, referer string, outFile string) string {
|
||||
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(
|
||||
ctx context.Context,
|
||||
stream *selectedHLSStream,
|
||||
@ -686,23 +1098,198 @@ func recordHLSPlaylistSegments(
|
||||
|
||||
logPrefix := streamLogPrefix(job, referer, outFile)
|
||||
|
||||
mediaURL := strings.TrimSpace(stream.VideoURL)
|
||||
if mediaURL == "" {
|
||||
return appErrorf("%s media playlist url empty", logPrefix)
|
||||
videoURL := strings.TrimSpace(stream.VideoURL)
|
||||
audioURL := strings.TrimSpace(stream.AudioURL)
|
||||
|
||||
if videoURL == "" {
|
||||
return appErrorf("%s video media playlist url empty", logPrefix)
|
||||
}
|
||||
|
||||
outFile = strings.TrimSpace(outFile)
|
||||
if outFile == "" {
|
||||
return appErrorf("%s output path empty", logPrefix)
|
||||
// Chaturbate LL-HLS:
|
||||
// Die Website lädt Audio/Video-Playlists oft mit _HLS_msn/_HLS_part.
|
||||
// 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 {
|
||||
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)
|
||||
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()
|
||||
|
||||
@ -712,7 +1299,7 @@ func recordHLSPlaylistSegments(
|
||||
lastNewSegmentAt := time.Now()
|
||||
|
||||
if verboseLogs() {
|
||||
appLogln("🎥", logPrefix, "recording media playlist:", mediaURL)
|
||||
appLogln("🎥", logPrefix, "recording", kind, "media playlist:", mediaURL)
|
||||
}
|
||||
|
||||
for {
|
||||
@ -720,19 +1307,24 @@ func recordHLSPlaylistSegments(
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer)
|
||||
body, err := hlsGetBytesWithHeadersRetry(ctx, requestURL, httpCookie, userAgent, referer, 3)
|
||||
if err != nil {
|
||||
return appErrorf("%s get playlist: %w", logPrefix, err)
|
||||
return appErrorf("%s %s get playlist: %w", logPrefix, kind, err)
|
||||
}
|
||||
|
||||
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") {
|
||||
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)
|
||||
|
||||
if len(segmentURIs) == 0 {
|
||||
segmentURIs = hlsPartURIsFromText(playlistText)
|
||||
}
|
||||
@ -744,13 +1336,13 @@ func recordHLSPlaylistSegments(
|
||||
if mapURI != "" {
|
||||
mapURL, err := hlsResolveURI(mediaURL, mapURI)
|
||||
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 {
|
||||
initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "init segment failed:", err)
|
||||
appLogln("⚠️", logPrefix, kind, "init segment failed:", err)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -762,7 +1354,7 @@ func recordHLSPlaylistSegments(
|
||||
}
|
||||
|
||||
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{}{}
|
||||
@ -779,7 +1371,7 @@ func recordHLSPlaylistSegments(
|
||||
|
||||
segmentURL, err := hlsResolveURI(mediaURL, uri)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err)
|
||||
appLogln("⚠️", logPrefix, kind, "resolve segment failed:", uri, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -789,12 +1381,12 @@ func recordHLSPlaylistSegments(
|
||||
|
||||
segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3)
|
||||
if err != nil {
|
||||
appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err)
|
||||
appLogln("⚠️", logPrefix, kind, "segment failed:", filepath.Base(uri), err)
|
||||
continue
|
||||
}
|
||||
|
||||
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{}{}
|
||||
@ -944,7 +1536,16 @@ func handleM3U8Mode(
|
||||
args = append(args,
|
||||
"-i", inputURL,
|
||||
"-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",
|
||||
"-ignore_unknown",
|
||||
"-c", "copy",
|
||||
|
||||
@ -448,6 +448,17 @@ func isLikelyNaturalStreamEndError(err error) bool {
|
||||
|
||||
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") ||
|
||||
strings.Contains(s, "unexpected eof") ||
|
||||
strings.Contains(s, "stream ended") ||
|
||||
@ -726,6 +737,48 @@ func buildPostworkTempMP4Path(tsPath string) (string, error) {
|
||||
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(
|
||||
ctx context.Context,
|
||||
job *RecordJob,
|
||||
@ -761,43 +814,85 @@ func runPrimaryPostworkPipeline(
|
||||
}
|
||||
_ = removeWithRetry(mp4Path)
|
||||
|
||||
dur := 0.0
|
||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
if d, derr := durationSecondsCached(dctx, tsPath); derr == nil && d > 0 {
|
||||
dur = d
|
||||
}
|
||||
cancel()
|
||||
audioSidecar, hasAudioSidecar := findPostworkAudioSidecarForTS(tsPath)
|
||||
|
||||
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 hasAudioSidecar {
|
||||
if setPhase != nil {
|
||||
setPhase("remuxing", 15)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = removeWithRetry(mp4Path)
|
||||
return tsPath, appErrorf("ts remux failed: %w", err)
|
||||
logPrefix := "[" + strings.TrimSpace(modelNameFromFilename(strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)))) + "]"
|
||||
if strings.TrimSpace(logPrefix) == "[]" {
|
||||
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)
|
||||
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 {
|
||||
appLogln("⚠️ remux mp4 decode validation failed; keeping remux result without repair:", err)
|
||||
}
|
||||
|
||||
@ -682,6 +682,8 @@ func main() {
|
||||
startPostWorkStatusRefresher()
|
||||
startEnrichStatusRefresher()
|
||||
|
||||
startPostworkLeftoverScanOnStartup()
|
||||
|
||||
go startGeneratedGarbageCollector()
|
||||
|
||||
// Altes NSFW-ONNX nicht mehr hart initialisieren.
|
||||
|
||||
285
backend/startup_leftovers.go
Normal file
285
backend/startup_leftovers.go
Normal 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
|
||||
}
|
||||
@ -861,6 +861,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
|
||||
}
|
||||
|
||||
if terr != nil {
|
||||
appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", terr)
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
|
||||
if genTeaser {
|
||||
@ -868,6 +869,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
|
||||
}
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser")
|
||||
} else {
|
||||
appLogln("❌ teaser generation fehlgeschlagen für", it.path+":", "preview.mp4 fehlt oder ist leer")
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,13 +25,15 @@ type cleanupCandidate struct {
|
||||
|
||||
type cleanupResp struct {
|
||||
// Small / broken downloads cleanup
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
DeletedFiles int `json:"deletedFiles"`
|
||||
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
|
||||
SkippedFiles int `json:"skippedFiles"`
|
||||
DeletedBytes int64 `json:"deletedBytes"`
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
DeletedFiles int `json:"deletedFiles"`
|
||||
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
|
||||
SkippedFiles int `json:"skippedFiles"`
|
||||
DeletedBytes int64 `json:"deletedBytes"`
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
DeletedRecordOrphans int `json:"deletedRecordOrphans"`
|
||||
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
|
||||
// Generated-GC
|
||||
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
||||
@ -149,6 +151,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
|
||||
if err != nil || strings.TrimSpace(recordAbs) == "" {
|
||||
recordAbs = strings.TrimSpace(s.RecordDir)
|
||||
}
|
||||
|
||||
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
||||
|
||||
var req cleanupReq
|
||||
@ -188,7 +195,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
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),
|
||||
nil,
|
||||
)
|
||||
}(jobID, ctx, doneAbs, mb)
|
||||
}(jobID, ctx, doneAbs, recordAbs, mb)
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
@ -337,6 +349,212 @@ func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
|
||||
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 {
|
||||
isCandidate := func(name string) bool {
|
||||
low := strings.ToLower(name)
|
||||
|
||||
@ -105,13 +105,97 @@ func generateTeaserClipsMP4WithProgress(
|
||||
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(
|
||||
ctx context.Context,
|
||||
src, out string,
|
||||
start, dur float64,
|
||||
opts TeaserPreviewOptions,
|
||||
) error {
|
||||
opts.Audio = true
|
||||
hasAudio := true
|
||||
|
||||
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
|
||||
segDur := dur
|
||||
@ -139,28 +223,39 @@ func generateTeaserChunkMP4(
|
||||
args = append(args, "-vsync", "2")
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", opts.AudioBitrate,
|
||||
"-ac", "2",
|
||||
"-shortest",
|
||||
)
|
||||
if hasAudio {
|
||||
args = append(args,
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", opts.AudioBitrate,
|
||||
"-ac", "2",
|
||||
"-shortest",
|
||||
)
|
||||
} else {
|
||||
args = append(args, "-an")
|
||||
}
|
||||
|
||||
args = append(args, "-movflags", "+faststart", tmp)
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
_ = 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)
|
||||
return os.Rename(tmp, out)
|
||||
}
|
||||
@ -211,7 +306,6 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
opts TeaserPreviewOptions,
|
||||
onRatio func(r float64),
|
||||
) error {
|
||||
opts.Audio = true
|
||||
if opts.SegmentDuration <= 0 {
|
||||
opts.SegmentDuration = 1
|
||||
}
|
||||
@ -230,6 +324,7 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
if opts.AudioBitrate == "" {
|
||||
opts.AudioBitrate = "128k"
|
||||
}
|
||||
hasAudio := true
|
||||
segDur := opts.SegmentDuration
|
||||
if segDur < minSegmentDuration {
|
||||
segDur = minSegmentDuration
|
||||
@ -265,17 +360,32 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
}
|
||||
|
||||
var fc strings.Builder
|
||||
|
||||
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: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())
|
||||
args = append(args, "-map", "[v]", "-map", "[a]")
|
||||
if hasAudio {
|
||||
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,
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
@ -288,12 +398,14 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
if opts.UseVsync2 {
|
||||
args = append(args, "-vsync", "2")
|
||||
}
|
||||
args = append(args,
|
||||
"-c:a", "aac",
|
||||
"-b:a", opts.AudioBitrate,
|
||||
"-ac", "2",
|
||||
"-shortest",
|
||||
)
|
||||
if hasAudio {
|
||||
args = append(args,
|
||||
"-c:a", "aac",
|
||||
"-b:a", opts.AudioBitrate,
|
||||
"-ac", "2",
|
||||
"-shortest",
|
||||
)
|
||||
}
|
||||
args = append(args, "-movflags", "+faststart", tmp)
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
@ -377,7 +489,12 @@ func generateTeaserPreviewMP4WithProgress(
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
_ = 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)
|
||||
@ -456,12 +573,15 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = 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)
|
||||
@ -553,7 +673,23 @@ func generatedTeaser(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
|
||||
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 {
|
||||
appLogln(
|
||||
"❌ teaser generation endgültig fehlgeschlagen:",
|
||||
filepath.Base(outPath),
|
||||
"clips-error:",
|
||||
err,
|
||||
"fallback-error:",
|
||||
err2,
|
||||
)
|
||||
|
||||
http.Error(
|
||||
w,
|
||||
"konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")",
|
||||
|
||||
@ -3925,7 +3925,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
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 =
|
||||
'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 (
|
||||
<div
|
||||
className={[
|
||||
isTrainingTab
|
||||
? 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden'
|
||||
: 'min-h-[100dvh]',
|
||||
'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden',
|
||||
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
|
||||
].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>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'relative',
|
||||
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
|
||||
<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="grid gap-2.5">
|
||||
<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>
|
||||
|
||||
<main
|
||||
data-app-scroll-area="true"
|
||||
className={[
|
||||
'mx-auto w-full py-2',
|
||||
isTrainingTab && trainingImageExpanded
|
||||
? 'max-w-none px-2 sm:px-3 lg:px-4'
|
||||
: 'max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||
'w-full lg:min-h-0',
|
||||
isTrainingTab
|
||||
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
|
||||
: 'space-y-2',
|
||||
? 'pb-4 lg:flex-1 lg:overflow-hidden'
|
||||
: 'lg:flex-1 lg:overflow-y-auto lg:overscroll-contain lg:[scrollbar-gutter:stable_both-edges]',
|
||||
].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' ? (
|
||||
<Downloads
|
||||
jobs={jobs}
|
||||
@ -4235,6 +4235,7 @@ export default function App() {
|
||||
onAssetsGenerated={bumpAssets}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CookieModal
|
||||
|
||||
@ -1545,8 +1545,8 @@ export default function Downloads({
|
||||
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="sticky top-[56px] z-20">
|
||||
<div className="grid gap-2">
|
||||
<div className="sticky z-20">
|
||||
<div
|
||||
className="
|
||||
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -110,8 +110,8 @@ export default function Slider({
|
||||
<div
|
||||
className={[
|
||||
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'
|
||||
: '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',
|
||||
? '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'
|
||||
: '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,
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@ -237,7 +237,7 @@ export default function Tabs({
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
|
||||
idx === 0 ? 'rounded-l-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'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
'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 { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import Button from './Button'
|
||||
@ -36,6 +37,8 @@ export default function TagFilterBar({
|
||||
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({})
|
||||
|
||||
const activeTagSet = useMemo(
|
||||
() => new Set(activeTags.map(lower)),
|
||||
@ -86,6 +89,7 @@ export default function TagFilterBar({
|
||||
const target = e.target as Node | null
|
||||
if (!target) return
|
||||
if (rootRef.current?.contains(target)) return
|
||||
if (menuRef.current?.contains(target)) return
|
||||
close()
|
||||
}
|
||||
|
||||
@ -119,7 +123,54 @@ export default function TagFilterBar({
|
||||
[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 (
|
||||
<div className={`flex flex-wrap items-center gap-1.5 overflow-visible ${className}`}>
|
||||
@ -144,14 +195,8 @@ export default function TagFilterBar({
|
||||
ref={rootRef}
|
||||
className={[
|
||||
'relative shrink-0 z-[90] leading-none',
|
||||
renderInlineMobileMenu
|
||||
? 'flex w-full min-w-0 flex-col items-stretch'
|
||||
: 'flex h-6 items-center',
|
||||
mobile
|
||||
? renderInlineMobileMenu
|
||||
? 'w-full min-w-0'
|
||||
: 'w-auto min-w-0'
|
||||
: '',
|
||||
open ? 'flex min-w-0 items-center' : 'flex h-6 items-center',
|
||||
mobile ? (open ? 'w-full' : 'w-auto min-w-0') : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{!open ? (
|
||||
@ -210,40 +255,43 @@ export default function TagFilterBar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={[
|
||||
renderInlineMobileMenu
|
||||
? 'mt-2 overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-lg dark:border-white/10 dark:bg-gray-900'
|
||||
: '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',
|
||||
mobile ? 'w-full' : 'min-w-[220px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="max-h-56 overflow-auto py-1">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => selectTag(tag)}
|
||||
className="
|
||||
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
|
||||
dark:text-gray-100 dark:hover:bg-sky-400/10
|
||||
"
|
||||
title={tag}
|
||||
>
|
||||
<span className="truncate">{tag}</span>
|
||||
<PlusIcon className="size-4 shrink-0 text-sky-600 dark:text-sky-300" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderMenu
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={menuStyle}
|
||||
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"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="overflow-auto py-1" style={{ maxHeight: menuStyle.maxHeight }}>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => selectTag(tag)}
|
||||
className="
|
||||
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
|
||||
dark:text-gray-100 dark:hover:bg-sky-400/10
|
||||
"
|
||||
title={tag}
|
||||
>
|
||||
<span className="truncate">{tag}</span>
|
||||
<PlusIcon className="size-4 shrink-0 text-sky-600 dark:text-sky-300" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4955,15 +4955,15 @@ export default function TrainingTab(props: {
|
||||
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',
|
||||
imageExpanded
|
||||
? 'lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden'
|
||||
: 'lg:self-start',
|
||||
? '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'
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
|
||||
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,
|
||||
'overflow-visible',
|
||||
].join(' ')}
|
||||
@ -5462,7 +5462,7 @@ export default function TrainingTab(props: {
|
||||
className={[
|
||||
'z-10 grid shrink-0 grid-cols-2 gap-2',
|
||||
imageExpanded
|
||||
? 'mt-3'
|
||||
? 'mt-3 lg:mt-0'
|
||||
: 'sticky bottom-0 mt-3 sm:static sm:mt-4',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user