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
|
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,
|
||||||
|
|||||||
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
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 {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()+")",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user