1908 lines
42 KiB
Go
1908 lines
42 KiB
Go
// backend\main.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/grafov/m3u8"
|
||
)
|
||
|
||
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
|
||
|
||
type JobStatus string
|
||
|
||
type Playlist struct {
|
||
PlaylistURL string
|
||
RootURL string
|
||
Resolution int
|
||
Framerate int
|
||
}
|
||
|
||
var recordedAssetStemPrefixRe = regexp.MustCompile(
|
||
`^(.+?_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2})`,
|
||
)
|
||
|
||
const (
|
||
JobRunning JobStatus = "running"
|
||
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
||
JobFinished JobStatus = "finished"
|
||
JobStopped JobStatus = "stopped"
|
||
JobFailed JobStatus = "failed"
|
||
)
|
||
|
||
type RecordJob struct {
|
||
ID string `json:"id"`
|
||
SourceURL string `json:"sourceUrl"`
|
||
Output string `json:"output"`
|
||
Status JobStatus `json:"status"`
|
||
StartedAt time.Time `json:"startedAt"`
|
||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||
EndedAtMs int64 `json:"endedAtMs,omitempty"`
|
||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||
VideoWidth int `json:"videoWidth,omitempty"`
|
||
VideoHeight int `json:"videoHeight,omitempty"`
|
||
FPS float64 `json:"fps,omitempty"`
|
||
Meta *videoMeta `json:"meta,omitempty"`
|
||
ModelImageURL string `json:"modelImageUrl,omitempty"`
|
||
Avatar string `json:"avatar,omitempty"`
|
||
|
||
Hidden bool `json:"-"`
|
||
|
||
Error string `json:"error,omitempty"`
|
||
|
||
// ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler)
|
||
PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error"
|
||
PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano
|
||
PreviewStateMsg string `json:"previewStateMsg,omitempty"` // kurze Info
|
||
|
||
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
|
||
previewMu sync.Mutex `json:"-"`
|
||
previewJPG []byte `json:"-"`
|
||
previewJPGAt time.Time `json:"-"`
|
||
previewGen bool `json:"-"`
|
||
|
||
LiveThumbStarted bool `json:"-"`
|
||
PreviewDir string `json:"-"`
|
||
previewCmd *exec.Cmd `json:"-"`
|
||
previewCancel context.CancelFunc `json:"-"`
|
||
previewLastHit time.Time `json:"-"`
|
||
previewStartMu sync.Mutex `json:"-"`
|
||
|
||
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
|
||
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
|
||
PreviewUA string `json:"-"` // user-agent
|
||
|
||
// ✅ Frontend Progress beim Stop/Finalize
|
||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||
Progress int `json:"progress,omitempty"` // 0..100
|
||
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"` // ms since epoch
|
||
StopAttempts int `json:"stopAttempts,omitempty"`
|
||
|
||
PostWorkKey string `json:"postWorkKey,omitempty"`
|
||
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
|
||
|
||
cancel context.CancelFunc `json:"-"`
|
||
}
|
||
|
||
type jobEventSnapshot struct {
|
||
ID string
|
||
SourceURL string
|
||
Output string
|
||
Status JobStatus
|
||
StartedAt time.Time
|
||
StartedAtMs int64
|
||
EndedAt *time.Time
|
||
EndedAtMs int64
|
||
Error string
|
||
Phase string
|
||
Progress int
|
||
StopRequestedAtMs int64
|
||
StopAttempts int
|
||
SizeBytes int64
|
||
DurationSeconds float64
|
||
PreviewState string
|
||
PostWorkKey string
|
||
PostWork *PostWorkKeyStatus
|
||
ModelImageURL string
|
||
Avatar string
|
||
}
|
||
|
||
type dummyResponseWriter struct {
|
||
h http.Header
|
||
}
|
||
|
||
type ffprobeStreamInfo struct {
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
AvgFrameRate string `json:"avg_frame_rate"`
|
||
RFrameRate string `json:"r_frame_rate"`
|
||
}
|
||
|
||
type ffprobeInfo struct {
|
||
Streams []ffprobeStreamInfo `json:"streams"`
|
||
}
|
||
|
||
func canonicalAssetIDFromName(name string) string {
|
||
base := strings.TrimSpace(filepath.Base(name))
|
||
if base == "" {
|
||
return ""
|
||
}
|
||
|
||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||
stem = stripHotPrefix(strings.TrimSpace(stem))
|
||
stem = strings.TrimPrefix(stem, ".")
|
||
if stem == "" {
|
||
return ""
|
||
}
|
||
|
||
// Wichtig:
|
||
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000"
|
||
// -> "chaesoffel_04_22_2026__12-22-16"
|
||
if m := recordedAssetStemPrefixRe.FindStringSubmatch(stem); m != nil {
|
||
return strings.TrimSpace(m[1])
|
||
}
|
||
|
||
return stem
|
||
}
|
||
|
||
func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
||
ev := map[string]any{
|
||
"type": "job_upsert",
|
||
"jobId": s.ID,
|
||
"status": string(s.Status),
|
||
"sourceUrl": s.SourceURL,
|
||
"output": s.Output,
|
||
"startedAt": s.StartedAt,
|
||
"startedAtMs": s.StartedAtMs,
|
||
"endedAt": s.EndedAt,
|
||
"endedAtMs": s.EndedAtMs,
|
||
"error": s.Error,
|
||
"phase": s.Phase,
|
||
"progress": s.Progress,
|
||
"stopRequestedAtMs": s.StopRequestedAtMs,
|
||
"stopAttempts": s.StopAttempts,
|
||
"sizeBytes": s.SizeBytes,
|
||
"durationSeconds": s.DurationSeconds,
|
||
"previewState": s.PreviewState,
|
||
"postWorkKey": s.PostWorkKey,
|
||
"ts": time.Now().UnixMilli(),
|
||
"modelImageUrl": s.ModelImageURL,
|
||
"avatar": s.Avatar,
|
||
}
|
||
|
||
if s.PostWork != nil {
|
||
ev["postWork"] = map[string]any{
|
||
"state": s.PostWork.State,
|
||
"position": s.PostWork.Position,
|
||
"waiting": s.PostWork.Waiting,
|
||
"running": s.PostWork.Running,
|
||
"maxParallel": s.PostWork.MaxParallel,
|
||
}
|
||
}
|
||
|
||
if b, err := json.Marshal(ev); err == nil {
|
||
publishSSE("job", b)
|
||
}
|
||
}
|
||
|
||
func publishJobSnapshot(jobID string) bool {
|
||
jobsMu.RLock()
|
||
job := jobs[jobID]
|
||
if job == nil {
|
||
jobsMu.RUnlock()
|
||
return false
|
||
}
|
||
|
||
var endedAtCopy *time.Time
|
||
if job.EndedAt != nil {
|
||
t := *job.EndedAt
|
||
endedAtCopy = &t
|
||
}
|
||
|
||
var pw *PostWorkKeyStatus
|
||
if job.PostWork != nil {
|
||
tmp := *job.PostWork
|
||
pw = &tmp
|
||
}
|
||
|
||
snap := jobEventSnapshot{
|
||
ID: job.ID,
|
||
SourceURL: job.SourceURL,
|
||
Output: job.Output,
|
||
Status: job.Status,
|
||
StartedAt: job.StartedAt,
|
||
StartedAtMs: job.StartedAtMs,
|
||
EndedAt: endedAtCopy,
|
||
EndedAtMs: job.EndedAtMs,
|
||
Error: job.Error,
|
||
Phase: job.Phase,
|
||
Progress: job.Progress,
|
||
StopRequestedAtMs: job.StopRequestedAtMs,
|
||
StopAttempts: job.StopAttempts,
|
||
SizeBytes: job.SizeBytes,
|
||
DurationSeconds: job.DurationSeconds,
|
||
PreviewState: job.PreviewState,
|
||
PostWorkKey: job.PostWorkKey,
|
||
PostWork: pw,
|
||
ModelImageURL: job.ModelImageURL,
|
||
Avatar: job.Avatar,
|
||
}
|
||
jobsMu.RUnlock()
|
||
|
||
publishJobUpsertSnapshot(snap)
|
||
return true
|
||
}
|
||
|
||
func jobMatchesModelKey(j *RecordJob, modelKey string) bool {
|
||
if j == nil {
|
||
return false
|
||
}
|
||
mk := strings.ToLower(strings.TrimSpace(modelKey))
|
||
if mk == "" {
|
||
return false
|
||
}
|
||
|
||
// 1) Output-Name (bei dir: <model>_MM_DD_YYYY__HH-MM-SS...)
|
||
out := strings.TrimSpace(j.Output)
|
||
if out != "" {
|
||
stem := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out))
|
||
// modelNameFromFilename() hast du schon irgendwo (wird in modelKeyFromFilenameOrPath benutzt)
|
||
guess := strings.ToLower(strings.TrimSpace(modelNameFromFilename(stripHotPrefix(stem))))
|
||
if guess == mk {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 2) Fallback: SourceURL enthält /<model>
|
||
src := strings.ToLower(strings.TrimSpace(j.SourceURL))
|
||
if src != "" && strings.Contains(src, "/"+mk) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func parseFFRate(s string) float64 {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || s == "0/0" {
|
||
return 0
|
||
}
|
||
// "30000/1001"
|
||
if a, b, ok := strings.Cut(s, "/"); ok {
|
||
num, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64)
|
||
den, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64)
|
||
if err1 == nil && err2 == nil && den != 0 {
|
||
return num / den
|
||
}
|
||
return 0
|
||
}
|
||
// "25"
|
||
f, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return f
|
||
}
|
||
|
||
func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps float64, err error) {
|
||
filePath = strings.TrimSpace(filePath)
|
||
if filePath == "" {
|
||
return 0, 0, 0, appErrorf("empty path")
|
||
}
|
||
|
||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||
"-v", "error",
|
||
"-select_streams", "v:0",
|
||
"-show_entries", "stream=width,height,avg_frame_rate,r_frame_rate",
|
||
"-of", "json",
|
||
filePath,
|
||
)
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return 0, 0, 0, err
|
||
}
|
||
|
||
var info ffprobeInfo
|
||
if err := json.Unmarshal(out, &info); err != nil {
|
||
return 0, 0, 0, err
|
||
}
|
||
if len(info.Streams) == 0 {
|
||
return 0, 0, 0, appErrorf("no video stream")
|
||
}
|
||
|
||
s := info.Streams[0]
|
||
w, h = s.Width, s.Height
|
||
|
||
// bevorzugt avg_frame_rate, fallback r_frame_rate
|
||
fps = parseFFRate(s.AvgFrameRate)
|
||
if fps <= 0 {
|
||
fps = parseFFRate(s.RFrameRate)
|
||
}
|
||
|
||
return w, h, fps, nil
|
||
}
|
||
|
||
func (d *dummyResponseWriter) Header() http.Header {
|
||
if d.h == nil {
|
||
d.h = make(http.Header)
|
||
}
|
||
return d.h
|
||
}
|
||
func (d *dummyResponseWriter) Write(b []byte) (int, error) { return len(b), nil }
|
||
func (d *dummyResponseWriter) WriteHeader(statusCode int) {}
|
||
|
||
var (
|
||
jobs = map[string]*RecordJob{}
|
||
jobsMu = sync.RWMutex{}
|
||
)
|
||
|
||
func init() {
|
||
initFFmpegLimiters()
|
||
initSSE()
|
||
}
|
||
|
||
func publishJob(jobID string) bool {
|
||
jobsMu.Lock()
|
||
j := jobs[jobID]
|
||
if j == nil || !j.Hidden {
|
||
jobsMu.Unlock()
|
||
return false
|
||
}
|
||
j.Hidden = false
|
||
jobsMu.Unlock()
|
||
|
||
return true
|
||
}
|
||
|
||
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
||
var ffmpegPath = detectFFmpegPath()
|
||
|
||
var ffprobePath = detectFFprobePath()
|
||
|
||
func detectFFprobePath() string {
|
||
// 1) Env-Override
|
||
if p := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); p != "" {
|
||
if abs, err := filepath.Abs(p); err == nil {
|
||
return abs
|
||
}
|
||
return p
|
||
}
|
||
|
||
// 2) Neben ffmpeg.exe (gleicher Ordner)
|
||
fp := strings.TrimSpace(ffmpegPath)
|
||
if fp != "" && fp != "ffmpeg" {
|
||
dir := filepath.Dir(fp)
|
||
ext := ""
|
||
if strings.HasSuffix(strings.ToLower(fp), ".exe") {
|
||
ext = ".exe"
|
||
}
|
||
c := filepath.Join(dir, "ffprobe"+ext)
|
||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
|
||
return c
|
||
}
|
||
}
|
||
|
||
// 3) Im EXE-Ordner
|
||
if exe, err := os.Executable(); err == nil {
|
||
exeDir := filepath.Dir(exe)
|
||
candidates := []string{
|
||
filepath.Join(exeDir, "ffprobe"),
|
||
filepath.Join(exeDir, "ffprobe.exe"),
|
||
}
|
||
for _, c := range candidates {
|
||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
|
||
return c
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4) PATH
|
||
if lp, err := exec.LookPath("ffprobe"); err == nil {
|
||
if abs, err2 := filepath.Abs(lp); err2 == nil {
|
||
return abs
|
||
}
|
||
return lp
|
||
}
|
||
|
||
return "ffprobe"
|
||
}
|
||
|
||
type durEntry struct {
|
||
size int64
|
||
mod time.Time
|
||
sec float64
|
||
}
|
||
|
||
var durCache = struct {
|
||
mu sync.Mutex
|
||
m map[string]durEntry
|
||
}{m: map[string]durEntry{}}
|
||
|
||
var startedAtFromFilenameRe = regexp.MustCompile(
|
||
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
|
||
)
|
||
|
||
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
||
s := getSettings()
|
||
if !s.AutoDeleteSmallDownloads {
|
||
return false, 0, 0
|
||
}
|
||
|
||
mb := s.AutoDeleteSmallDownloadsBelowMB
|
||
if mb <= 0 {
|
||
return false, 0, 0
|
||
}
|
||
|
||
p := strings.TrimSpace(filePath)
|
||
if p == "" {
|
||
return false, 0, 0
|
||
}
|
||
|
||
if !filepath.IsAbs(p) {
|
||
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
|
||
p = abs
|
||
}
|
||
}
|
||
|
||
fi, err := os.Stat(p)
|
||
if err != nil || fi.IsDir() {
|
||
return false, 0, int64(mb) * 1024 * 1024
|
||
}
|
||
|
||
size := fi.Size()
|
||
thr := int64(mb) * 1024 * 1024
|
||
|
||
if size > 0 && size < thr {
|
||
if isFavoriteProtectedDownload(p, "") {
|
||
return false, size, thr
|
||
}
|
||
|
||
return true, size, thr
|
||
}
|
||
|
||
return false, size, thr
|
||
}
|
||
|
||
func setJobPhase(job *RecordJob, phase string, progress int) {
|
||
if job == nil {
|
||
return
|
||
}
|
||
if progress < 0 {
|
||
progress = 0
|
||
}
|
||
if progress > 100 {
|
||
progress = 100
|
||
}
|
||
|
||
jobsMu.Lock()
|
||
job.Phase = phase
|
||
job.Progress = progress
|
||
jobsMu.Unlock()
|
||
}
|
||
|
||
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
||
fi, err := os.Stat(path)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
durCache.mu.Lock()
|
||
if e, ok := durCache.m[path]; ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
|
||
durCache.mu.Unlock()
|
||
return e.sec, nil
|
||
}
|
||
durCache.mu.Unlock()
|
||
|
||
// ✅ Concurrency Limit für ffprobe/ffmpeg
|
||
if durSem != nil {
|
||
// kurzer Acquire: wenn Server busy ist, lieber später erneut probieren
|
||
cctx := ctx
|
||
if cctx == nil {
|
||
cctx = context.Background()
|
||
}
|
||
acqCtx, cancel := context.WithTimeout(cctx, 2*time.Second)
|
||
defer cancel()
|
||
|
||
if err := durSem.Acquire(acqCtx); err != nil {
|
||
return 0, err
|
||
}
|
||
defer durSem.Release()
|
||
}
|
||
|
||
// 1) ffprobe (bevorzugt)
|
||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||
"-v", "error",
|
||
"-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||
path,
|
||
)
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
|
||
out, err := cmd.Output()
|
||
if err == nil {
|
||
s := strings.TrimSpace(string(out))
|
||
sec, err2 := strconv.ParseFloat(s, 64)
|
||
if err2 == nil && sec > 0 {
|
||
durCache.mu.Lock()
|
||
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
|
||
durCache.mu.Unlock()
|
||
return sec, nil
|
||
}
|
||
}
|
||
|
||
// 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen
|
||
cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path)
|
||
cmd2.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da
|
||
text := string(b)
|
||
|
||
re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
|
||
m := re.FindStringSubmatch(text)
|
||
if len(m) != 4 {
|
||
return 0, appErrorf("duration not found")
|
||
}
|
||
hh, _ := strconv.ParseFloat(m[1], 64)
|
||
mm, _ := strconv.ParseFloat(m[2], 64)
|
||
ss, _ := strconv.ParseFloat(m[3], 64)
|
||
sec := hh*3600 + mm*60 + ss
|
||
if sec <= 0 {
|
||
return 0, appErrorf("invalid duration")
|
||
}
|
||
|
||
durCache.mu.Lock()
|
||
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
|
||
durCache.mu.Unlock()
|
||
return sec, nil
|
||
}
|
||
|
||
func detectFFmpegPath() string {
|
||
// 0. Settings-Override (ffmpegPath in recorder_settings.json / UI)
|
||
s := getSettings()
|
||
if p := strings.TrimSpace(s.FFmpegPath); p != "" {
|
||
// Relativ zur EXE auflösen, falls nötig
|
||
if !filepath.IsAbs(p) {
|
||
if abs, err := resolvePathRelativeToApp(p); err == nil {
|
||
p = abs
|
||
}
|
||
}
|
||
return p
|
||
}
|
||
|
||
// 1. Umgebungsvariable FFMPEG_PATH erlaubt Override
|
||
if p := strings.TrimSpace(os.Getenv("FFMPEG_PATH")); p != "" {
|
||
if abs, err := filepath.Abs(p); err == nil {
|
||
return abs
|
||
}
|
||
return p
|
||
}
|
||
|
||
// 2. ffmpeg / ffmpeg.exe im selben Ordner wie dein Go-Programm
|
||
if exe, err := os.Executable(); err == nil {
|
||
exeDir := filepath.Dir(exe)
|
||
candidates := []string{
|
||
filepath.Join(exeDir, "ffmpeg"),
|
||
filepath.Join(exeDir, "ffmpeg.exe"),
|
||
}
|
||
for _, c := range candidates {
|
||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
|
||
return c
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. ffmpeg über PATH suchen und absolut machen
|
||
if lp, err := exec.LookPath("ffmpeg"); err == nil {
|
||
if abs, err2 := filepath.Abs(lp); err2 == nil {
|
||
return abs
|
||
}
|
||
return lp
|
||
}
|
||
|
||
// 4. Fallback: plain "ffmpeg" – kann dann immer noch fehlschlagen
|
||
return "ffmpeg"
|
||
}
|
||
|
||
func isAssetIDInUse(id string) bool {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return false
|
||
}
|
||
|
||
jobsMu.RLock()
|
||
defer jobsMu.RUnlock()
|
||
|
||
for _, j := range jobs {
|
||
if j == nil {
|
||
continue
|
||
}
|
||
|
||
aid := strings.TrimSpace(assetIDForJob(j))
|
||
if aid == "" || !strings.EqualFold(aid, id) {
|
||
continue
|
||
}
|
||
|
||
// Alles was noch nicht terminal ist, gilt als "in Benutzung"
|
||
if !isTerminalJobStatus(j.Status) {
|
||
return true
|
||
}
|
||
|
||
// Auch Postwork/Queue lieber schützen
|
||
if isPostworkJob(j) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func removeGeneratedForID(id string) {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return
|
||
}
|
||
|
||
id = stripHotPrefix(id)
|
||
|
||
if isAssetIDInUse(id) {
|
||
return
|
||
}
|
||
|
||
var err error
|
||
id, err = sanitizeID(id)
|
||
if err != nil || id == "" {
|
||
return
|
||
}
|
||
|
||
removed := map[string]struct{}{}
|
||
|
||
removeDir := func(p string) {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
return
|
||
}
|
||
cp := filepath.Clean(p)
|
||
if _, seen := removed[cp]; seen {
|
||
return
|
||
}
|
||
_ = os.RemoveAll(cp)
|
||
removed[cp] = struct{}{}
|
||
}
|
||
|
||
// 1) Asset-Ordner mit preview.jpg / preview.mp4 / preview-sprite.jpg
|
||
if dir, err := generatedDirForID(id); err == nil && strings.TrimSpace(dir) != "" {
|
||
removeDir(dir)
|
||
}
|
||
|
||
// 2) Separater Meta-Ordner generated/meta/<id>
|
||
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
||
removeDir(filepath.Join(root, id))
|
||
}
|
||
}
|
||
|
||
func purgeDurationCacheForPath(p string) {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
return
|
||
}
|
||
durCache.mu.Lock()
|
||
delete(durCache.m, p)
|
||
durCache.mu.Unlock()
|
||
}
|
||
|
||
func renameGenerated(oldID, newID string) {
|
||
oldID = stripHotPrefix(strings.TrimSpace(oldID))
|
||
newID = stripHotPrefix(strings.TrimSpace(newID))
|
||
|
||
if oldID == "" || newID == "" || strings.EqualFold(oldID, newID) {
|
||
return
|
||
}
|
||
|
||
var err error
|
||
oldID, err = sanitizeID(oldID)
|
||
if err != nil || oldID == "" {
|
||
return
|
||
}
|
||
newID, err = sanitizeID(newID)
|
||
if err != nil || newID == "" {
|
||
return
|
||
}
|
||
|
||
oldDir, errOld := generatedDirForID(oldID)
|
||
newDir, errNew := generatedDirForID(newID)
|
||
if errOld != nil || errNew != nil || oldDir == "" || newDir == "" {
|
||
return
|
||
}
|
||
|
||
if _, err := os.Stat(oldDir); err != nil {
|
||
return
|
||
}
|
||
|
||
if _, err := os.Stat(newDir); os.IsNotExist(err) {
|
||
_ = renameWithRetry(oldDir, newDir)
|
||
return
|
||
}
|
||
|
||
files := []string{
|
||
"preview.jpg",
|
||
"preview.mp4",
|
||
"preview-sprite.jpg",
|
||
"meta.json",
|
||
}
|
||
|
||
for _, name := range files {
|
||
src := filepath.Join(oldDir, name)
|
||
dst := filepath.Join(newDir, name)
|
||
|
||
if _, err := os.Stat(src); err != nil {
|
||
continue
|
||
}
|
||
if _, err := os.Stat(dst); os.IsNotExist(err) {
|
||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||
_ = renameWithRetry(src, dst)
|
||
} else {
|
||
_ = removeWithRetry(src)
|
||
}
|
||
}
|
||
|
||
_ = os.Remove(oldDir)
|
||
}
|
||
|
||
// --- Gemeinsame Status-Werte für MFC ---
|
||
type Status int
|
||
|
||
const (
|
||
StatusUnknown Status = iota
|
||
StatusPublic
|
||
StatusPrivate
|
||
StatusOffline
|
||
StatusNotExist
|
||
)
|
||
|
||
func (s Status) String() string {
|
||
switch s {
|
||
case StatusPublic:
|
||
return "PUBLIC"
|
||
case StatusPrivate:
|
||
return "PRIVATE"
|
||
case StatusOffline:
|
||
return "OFFLINE"
|
||
case StatusNotExist:
|
||
return "NOTEXIST"
|
||
default:
|
||
return "UNKNOWN"
|
||
}
|
||
}
|
||
|
||
// HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR)
|
||
type HTTPClient struct {
|
||
client *http.Client
|
||
userAgent string
|
||
}
|
||
|
||
// gemeinsamen HTTP-Client erzeugen
|
||
func NewHTTPClient(userAgent string) *HTTPClient {
|
||
if userAgent == "" {
|
||
// Default, falls kein UA übergeben wird
|
||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||
}
|
||
|
||
return &HTTPClient{
|
||
client: &http.Client{
|
||
Timeout: 10 * time.Second,
|
||
},
|
||
userAgent: userAgent,
|
||
}
|
||
}
|
||
|
||
// Request-Erstellung mit User-Agent + Cookies
|
||
func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Basis-Header, die immer gesetzt werden
|
||
if h.userAgent != "" {
|
||
req.Header.Set("User-Agent", h.userAgent)
|
||
} else {
|
||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||
}
|
||
req.Header.Set("Accept", "*/*")
|
||
|
||
// Cookie-String wie "name=value; foo=bar"
|
||
addCookiesFromString(req, cookieStr)
|
||
|
||
return req, nil
|
||
}
|
||
|
||
// Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate)
|
||
func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) {
|
||
req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
resp, err := h.client.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
body := string(data)
|
||
|
||
// Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden"
|
||
if strings.Contains(body, "<title>Just a moment...</title>") {
|
||
return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") – kein Room-HTML")
|
||
}
|
||
if strings.Contains(body, "Verify your age") {
|
||
return "", errors.New("Altersverifikationsseite erhalten – kein Room-HTML")
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", appErrorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
|
||
}
|
||
|
||
return body, nil
|
||
}
|
||
|
||
func remuxTSToMP4(tsPath, mp4Path string) error {
|
||
return remuxTSToMP4WithProgress(
|
||
context.Background(),
|
||
tsPath,
|
||
mp4Path,
|
||
0,
|
||
0,
|
||
nil,
|
||
)
|
||
}
|
||
|
||
func parseFFmpegOutTime(v string) float64 {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return 0
|
||
}
|
||
|
||
parts := strings.Split(v, ":")
|
||
if len(parts) != 3 {
|
||
return 0
|
||
}
|
||
|
||
h, err1 := strconv.Atoi(parts[0])
|
||
m, err2 := strconv.Atoi(parts[1])
|
||
s, err3 := strconv.ParseFloat(parts[2], 64)
|
||
if err1 != nil || err2 != nil || err3 != nil {
|
||
return 0
|
||
}
|
||
|
||
return float64(h*3600+m*60) + s
|
||
}
|
||
|
||
func remuxTSToMP4WithProgress(
|
||
ctx context.Context,
|
||
tsPath, mp4Path string,
|
||
durationSec float64,
|
||
inSize int64,
|
||
onRatio func(r float64),
|
||
) error {
|
||
tsPath = strings.TrimSpace(tsPath)
|
||
mp4Path = strings.TrimSpace(mp4Path)
|
||
|
||
if tsPath == "" {
|
||
return appErrorf("remux input path empty")
|
||
}
|
||
if mp4Path == "" {
|
||
return appErrorf("remux output path empty")
|
||
}
|
||
|
||
_ = removeWithRetry(mp4Path)
|
||
|
||
// 1) Normaler schneller Remux.
|
||
err := runRemuxTSToMP4AttemptWithProgress(
|
||
ctx,
|
||
tsPath,
|
||
mp4Path,
|
||
durationSec,
|
||
inSize,
|
||
onRatio,
|
||
false,
|
||
)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
if ctx != nil && ctx.Err() != nil {
|
||
_ = removeWithRetry(mp4Path)
|
||
return err
|
||
}
|
||
|
||
firstErr := err
|
||
|
||
_ = removeWithRetry(mp4Path)
|
||
|
||
appLogln(
|
||
"⚠️ normal remux failed, retrying tolerant remux:",
|
||
filepath.Base(tsPath),
|
||
firstErr,
|
||
)
|
||
|
||
// 2) Toleranter Remux für Stream-Ende, kaputte Segmente, Timestamp-Probleme.
|
||
err = runRemuxTSToMP4AttemptWithProgress(
|
||
ctx,
|
||
tsPath,
|
||
mp4Path,
|
||
durationSec,
|
||
inSize,
|
||
onRatio,
|
||
true,
|
||
)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
_ = removeWithRetry(mp4Path)
|
||
|
||
return appErrorf(
|
||
"normal remux failed (%v), tolerant remux failed: %w",
|
||
firstErr,
|
||
err,
|
||
)
|
||
}
|
||
|
||
func runRemuxTSToMP4AttemptWithProgress(
|
||
ctx context.Context,
|
||
tsPath string,
|
||
mp4Path string,
|
||
durationSec float64,
|
||
inSize int64,
|
||
onRatio func(r float64),
|
||
tolerant bool,
|
||
) error {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
args := []string{
|
||
"-y",
|
||
"-nostats",
|
||
"-progress", "pipe:1",
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
}
|
||
|
||
if tolerant {
|
||
args = append(args,
|
||
"-fflags", "+genpts+discardcorrupt",
|
||
"-err_detect", "ignore_err",
|
||
)
|
||
} else {
|
||
args = append(args, ffmpegInputTol...)
|
||
}
|
||
|
||
args = append(args,
|
||
"-i", tsPath,
|
||
|
||
// Video muss vorhanden sein.
|
||
"-map", "0:v:0",
|
||
|
||
// Audio optional. Wenn kein Audio da ist, darf Remux trotzdem durchlaufen.
|
||
"-map", "0:a?",
|
||
|
||
"-dn",
|
||
"-ignore_unknown",
|
||
|
||
"-avoid_negative_ts", "make_zero",
|
||
"-max_interleave_delta", "0",
|
||
"-muxpreload", "0",
|
||
"-muxdelay", "0",
|
||
|
||
// Wichtig: kein Reencode, nur Containerwechsel TS -> MP4.
|
||
"-c", "copy",
|
||
|
||
"-movflags", "+faststart",
|
||
mp4Path,
|
||
)
|
||
|
||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
|
||
stdout, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return err
|
||
}
|
||
|
||
if onRatio != nil {
|
||
onRatio(0)
|
||
}
|
||
|
||
sc := bufio.NewScanner(stdout)
|
||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||
|
||
var (
|
||
lastOutSec float64
|
||
lastTotalSz int64
|
||
)
|
||
|
||
send := func(outSec float64, totalSize int64, force bool) {
|
||
if onRatio == nil {
|
||
return
|
||
}
|
||
|
||
if durationSec > 0 && outSec > 0 {
|
||
r := outSec / durationSec
|
||
if r < 0 {
|
||
r = 0
|
||
}
|
||
if r > 1 {
|
||
r = 1
|
||
}
|
||
onRatio(r)
|
||
return
|
||
}
|
||
|
||
if inSize > 0 && totalSize > 0 {
|
||
r := float64(totalSize) / float64(inSize)
|
||
if r < 0 {
|
||
r = 0
|
||
}
|
||
if r > 1 {
|
||
r = 1
|
||
}
|
||
onRatio(r)
|
||
return
|
||
}
|
||
|
||
if force {
|
||
onRatio(1)
|
||
}
|
||
}
|
||
|
||
for sc.Scan() {
|
||
line := strings.TrimSpace(sc.Text())
|
||
if line == "" {
|
||
continue
|
||
}
|
||
|
||
k, v, ok := strings.Cut(line, "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
switch strings.TrimSpace(k) {
|
||
case "out_time_us":
|
||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
|
||
lastOutSec = float64(n) / 1_000_000.0
|
||
send(lastOutSec, lastTotalSz, false)
|
||
}
|
||
|
||
case "out_time_ms":
|
||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
|
||
lastOutSec = float64(n) / 1_000.0
|
||
send(lastOutSec, lastTotalSz, false)
|
||
}
|
||
|
||
case "out_time":
|
||
if s := parseFFmpegOutTime(v); s > 0 {
|
||
lastOutSec = s
|
||
send(lastOutSec, lastTotalSz, false)
|
||
}
|
||
|
||
case "total_size":
|
||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
|
||
lastTotalSz = n
|
||
send(lastOutSec, lastTotalSz, false)
|
||
}
|
||
|
||
case "progress":
|
||
if strings.TrimSpace(v) == "end" {
|
||
send(lastOutSec, lastTotalSz, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
scanErr := sc.Err()
|
||
waitErr := cmd.Wait()
|
||
|
||
if waitErr != nil {
|
||
return appErrorf(
|
||
"ffmpeg remux failed tolerant=%v: %v (%s)",
|
||
tolerant,
|
||
waitErr,
|
||
strings.TrimSpace(stderr.String()),
|
||
)
|
||
}
|
||
|
||
if scanErr != nil {
|
||
return appErrorf("ffmpeg progress read failed tolerant=%v: %w", tolerant, scanErr)
|
||
}
|
||
|
||
if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil {
|
||
return err
|
||
}
|
||
|
||
if onRatio != nil {
|
||
onRatio(1)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// --- MP4 Streaming Optimierung (Fast Start) ---
|
||
// "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen)
|
||
func isFastStartMP4(path string) (bool, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer f.Close()
|
||
|
||
for i := 0; i < 256; i++ {
|
||
var hdr [8]byte
|
||
if _, err := io.ReadFull(f, hdr[:]); err != nil {
|
||
// unklar/kurz -> nicht anfassen
|
||
return true, nil
|
||
}
|
||
|
||
sz32 := binary.BigEndian.Uint32(hdr[0:4])
|
||
typ := string(hdr[4:8])
|
||
|
||
var boxSize int64
|
||
headerSize := int64(8)
|
||
|
||
if sz32 == 0 {
|
||
return true, nil
|
||
}
|
||
if sz32 == 1 {
|
||
var ext [8]byte
|
||
if _, err := io.ReadFull(f, ext[:]); err != nil {
|
||
return true, nil
|
||
}
|
||
boxSize = int64(binary.BigEndian.Uint64(ext[:]))
|
||
headerSize = 16
|
||
} else {
|
||
boxSize = int64(sz32)
|
||
}
|
||
|
||
if boxSize < headerSize {
|
||
return true, nil
|
||
}
|
||
|
||
switch typ {
|
||
case "moov":
|
||
return true, nil
|
||
case "mdat":
|
||
return false, nil
|
||
}
|
||
|
||
if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil {
|
||
return true, nil
|
||
}
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
func ensureFastStartMP4(path string) error {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(ffmpegPath) == "" {
|
||
return nil
|
||
}
|
||
|
||
ok, err := isFastStartMP4(path)
|
||
if err == nil && ok {
|
||
return nil
|
||
}
|
||
|
||
dir := filepath.Dir(path)
|
||
base := filepath.Base(path)
|
||
tmp := filepath.Join(dir, ".__faststart__"+base+".tmp")
|
||
bak := filepath.Join(dir, ".__faststart__"+base+".bak")
|
||
|
||
_ = os.Remove(tmp)
|
||
_ = os.Remove(bak)
|
||
|
||
cmd := exec.Command(ffmpegPath,
|
||
"-y",
|
||
"-i", path,
|
||
"-c", "copy",
|
||
"-movflags", "+faststart",
|
||
tmp,
|
||
)
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
HideWindow: true,
|
||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||
}
|
||
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return appErrorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
|
||
// atomar austauschen
|
||
if err := os.Rename(path, bak); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return appErrorf("rename original to bak failed: %w", err)
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
_ = os.Rename(bak, path)
|
||
_ = os.Remove(tmp)
|
||
return appErrorf("rename tmp to original failed: %w", err)
|
||
}
|
||
_ = os.Remove(bak)
|
||
return nil
|
||
}
|
||
|
||
func stripHotPrefix(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
// akzeptiere "HOT " auch case-insensitive
|
||
if len(s) >= 4 && strings.EqualFold(s[:4], "HOT ") {
|
||
return strings.TrimSpace(s[4:])
|
||
}
|
||
return s
|
||
}
|
||
|
||
func generatedRoot() (string, error) {
|
||
return resolvePathRelativeToApp("generated")
|
||
}
|
||
|
||
func generatedMetaRoot() (string, error) {
|
||
return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
|
||
}
|
||
|
||
// generatedThumbJPGFile gibt den Pfad zu generated/<assetID>/preview.jpg zurück.
|
||
// assetID darf "HOT " enthalten; wird entfernt und wie überall sonst sanitisiert.
|
||
func generatedThumbJPGFile(assetID string) (string, error) {
|
||
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
||
if assetID == "" {
|
||
return "", appErrorf("empty assetID")
|
||
}
|
||
|
||
// gleiche Normalisierung wie bei Ordnernamen
|
||
var err error
|
||
assetID, err = sanitizeID(assetID)
|
||
if err != nil || assetID == "" {
|
||
return "", appErrorf("invalid assetID: %w", err)
|
||
}
|
||
|
||
dir, err := ensureGeneratedDir(assetID)
|
||
if err != nil || strings.TrimSpace(dir) == "" {
|
||
return "", appErrorf("ensureGeneratedDir: %w", err)
|
||
}
|
||
|
||
return filepath.Join(dir, "preview.jpg"), nil
|
||
}
|
||
|
||
// Legacy (falls noch alte Assets liegen):
|
||
func generatedThumbsRoot() (string, error) {
|
||
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
|
||
}
|
||
func generatedTeaserRoot() (string, error) {
|
||
return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
|
||
}
|
||
|
||
func sanitizeModelKey(k string) string {
|
||
k = stripHotPrefix(strings.TrimSpace(k))
|
||
if k == "" || k == "—" || strings.ContainsAny(k, `/\`) {
|
||
return ""
|
||
}
|
||
return k
|
||
}
|
||
|
||
func modelKeyFromFilenameOrPath(file string, srcPath string, doneAbs string) string {
|
||
// 1) bevorzugt aus Dateiname (OHNE Extension!)
|
||
stem := strings.TrimSuffix(filepath.Base(strings.TrimSpace(file)), filepath.Ext(file))
|
||
k := sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem)))
|
||
if k != "" {
|
||
return k
|
||
}
|
||
|
||
// 2) Fallback: aus Quellpfad ableiten, wenn Datei in done/<model>/... lag
|
||
if strings.TrimSpace(srcPath) != "" && strings.TrimSpace(doneAbs) != "" {
|
||
srcDir := filepath.Clean(filepath.Dir(srcPath))
|
||
doneAbs = filepath.Clean(doneAbs)
|
||
|
||
// srcDir != doneAbs => wir sind in einem Unterordner, dessen Name i.d.R. das Model ist
|
||
if !strings.EqualFold(srcDir, doneAbs) {
|
||
k2 := sanitizeModelKey(filepath.Base(srcDir))
|
||
if k2 != "" && !strings.EqualFold(k2, "keep") {
|
||
return k2
|
||
}
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func uniqueDestPath(dstDir, file string) (string, error) {
|
||
dst := filepath.Join(dstDir, file)
|
||
if _, err := os.Stat(dst); err == nil {
|
||
ext := filepath.Ext(file)
|
||
base := strings.TrimSuffix(file, ext)
|
||
for i := 2; i <= 200; i++ {
|
||
alt := fmt.Sprintf("%s__dup%d%s", base, i, ext)
|
||
cand := filepath.Join(dstDir, alt)
|
||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||
return cand, nil
|
||
}
|
||
}
|
||
return "", appErrorf("too many duplicates for %s", file)
|
||
} else if err != nil && !os.IsNotExist(err) {
|
||
return "", err
|
||
}
|
||
return dst, nil
|
||
}
|
||
|
||
func clamp01(v float64) float64 {
|
||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||
return 0
|
||
}
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
func idFromVideoPath(videoPath string) string {
|
||
return canonicalAssetIDFromName(videoPath)
|
||
}
|
||
|
||
func assetIDForJob(job *RecordJob) string {
|
||
if job == nil {
|
||
return ""
|
||
}
|
||
|
||
// Prefer: Dateiname ohne Endung (und ohne HOT Prefix)
|
||
out := strings.TrimSpace(job.Output)
|
||
if out != "" {
|
||
id := stripHotPrefix(idFromVideoPath(out))
|
||
if strings.TrimSpace(id) != "" {
|
||
return id
|
||
}
|
||
}
|
||
|
||
// Fallback: JobID (sollte praktisch nie nötig sein)
|
||
return strings.TrimSpace(job.ID)
|
||
}
|
||
|
||
func atomicWriteFile(dst string, data []byte) error {
|
||
dir := filepath.Dir(dst)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmpName := tmp.Name()
|
||
_ = tmp.Chmod(0o644)
|
||
|
||
_, werr := tmp.Write(data)
|
||
cerr := tmp.Close()
|
||
|
||
if werr != nil {
|
||
_ = os.Remove(tmpName)
|
||
return werr
|
||
}
|
||
if cerr != nil {
|
||
_ = os.Remove(tmpName)
|
||
return cerr
|
||
}
|
||
|
||
// ✅ Wichtig: Windows/SMB kann Rename nicht überschreiben
|
||
_ = os.Remove(dst)
|
||
|
||
// ✅ Wenn du renameWithRetry hast: nutzen (robuster bei Shares)
|
||
if err := renameWithRetry(tmpName, dst); err != nil {
|
||
_ = os.Remove(tmpName)
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Beim Start: loose Dateien in /done/keep (Root) in /done/keep/<model>/ einsortieren.
|
||
// Best-effort: wenn Model nicht ableitbar oder Ziel kollidiert, wird geskippt bzw. umbenannt.
|
||
func fixKeepRootFilesIntoModelSubdirs() {
|
||
s := getSettings()
|
||
|
||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||
return
|
||
}
|
||
|
||
keepRoot := filepath.Join(doneAbs, "keep")
|
||
|
||
ents, err := os.ReadDir(keepRoot)
|
||
if err != nil {
|
||
// keep existiert evtl. noch nicht -> nichts zu tun
|
||
if os.IsNotExist(err) {
|
||
return
|
||
}
|
||
appLogln("⚠️ keep scan failed:", err)
|
||
return
|
||
}
|
||
|
||
moved := 0
|
||
skipped := 0
|
||
|
||
isVideo := func(name string) bool {
|
||
low := strings.ToLower(name)
|
||
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
|
||
return false
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(name))
|
||
return ext == ".mp4" || ext == ".ts"
|
||
}
|
||
|
||
for _, e := range ents {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
|
||
name := e.Name()
|
||
if !isVideo(name) {
|
||
continue
|
||
}
|
||
|
||
// Quelle: /done/keep/<file>
|
||
src := filepath.Join(keepRoot, name)
|
||
|
||
// Model aus Dateiname ableiten (wie im keep-handler)
|
||
stem := strings.TrimSuffix(name, filepath.Ext(name)) // ✅ ohne .mp4/.ts
|
||
modelKey := sanitizeModelKey(strings.TrimSpace(modelNameFromFilename(stem)))
|
||
|
||
// wenn nicht ableitbar -> skip
|
||
if modelKey == "" || modelKey == "—" || strings.ContainsAny(modelKey, `/\`) {
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
dstDir := filepath.Join(keepRoot, modelKey)
|
||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||
appLogln("⚠️ keep mkdir failed:", err)
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
dst := filepath.Join(dstDir, name)
|
||
|
||
// Wenn Ziel schon existiert -> unique Name finden
|
||
if _, err := os.Stat(dst); err == nil {
|
||
ext := filepath.Ext(name)
|
||
base := strings.TrimSuffix(name, ext)
|
||
|
||
found := false
|
||
for i := 2; i <= 200; i++ {
|
||
alt := fmt.Sprintf("%s__dup%d%s", base, i, ext)
|
||
cand := filepath.Join(dstDir, alt)
|
||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||
dst = cand
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
appLogln("⚠️ keep fix: too many duplicates, skip:", name)
|
||
skipped++
|
||
continue
|
||
}
|
||
} else if err != nil && !os.IsNotExist(err) {
|
||
appLogln("⚠️ keep stat dst failed:", err)
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
// Verschieben (Windows-lock robust)
|
||
if err := renameWithRetry(src, dst); err != nil {
|
||
appLogln("⚠️ keep fix rename failed:", err)
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
moved++
|
||
}
|
||
|
||
if moved > 0 || skipped > 0 {
|
||
fmt.Printf("🧹 keep fix: moved=%d skipped=%d (root=%s)\n", moved, skipped, keepRoot)
|
||
}
|
||
}
|
||
|
||
func findFinishedFileByID(id string) (string, error) {
|
||
s := getSettings()
|
||
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
|
||
|
||
base := stripHotPrefix(strings.TrimSpace(id))
|
||
if base == "" {
|
||
return "", appErrorf("not found")
|
||
}
|
||
|
||
names := []string{
|
||
base + ".mp4",
|
||
"HOT " + base + ".mp4",
|
||
base + ".ts",
|
||
"HOT " + base + ".ts",
|
||
}
|
||
|
||
// done (root + /done/<subdir>/) + keep (root + /done/keep/<subdir>/)
|
||
for _, name := range names {
|
||
if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok {
|
||
return p, nil
|
||
}
|
||
if p, _, ok := findFileInDirOrOneLevelSubdirs(filepath.Join(doneAbs, "keep"), name, ""); ok {
|
||
return p, nil
|
||
}
|
||
if p, _, ok := findFileInDirOrOneLevelSubdirs(recordAbs, name, ""); ok {
|
||
return p, nil
|
||
}
|
||
}
|
||
|
||
return "", appErrorf("not found")
|
||
}
|
||
|
||
const maxInt64 = int64(^uint64(0) >> 1)
|
||
|
||
func removeJobsByOutputBasename(file string) {
|
||
file = strings.TrimSpace(file)
|
||
if file == "" {
|
||
return
|
||
}
|
||
|
||
removedJobs := make([]*RecordJob, 0, 4)
|
||
|
||
jobsMu.Lock()
|
||
for id, j := range jobs {
|
||
if j == nil {
|
||
continue
|
||
}
|
||
out := strings.TrimSpace(j.Output)
|
||
if out == "" {
|
||
continue
|
||
}
|
||
if filepath.Base(out) == file {
|
||
if !j.Hidden {
|
||
removedJobs = append(removedJobs, j)
|
||
}
|
||
delete(jobs, id)
|
||
}
|
||
}
|
||
jobsMu.Unlock()
|
||
|
||
for _, j := range removedJobs {
|
||
publishJobRemove(j)
|
||
}
|
||
}
|
||
|
||
func renameJobsOutputBasename(oldFile, newFile string) {
|
||
oldFile = strings.TrimSpace(oldFile)
|
||
newFile = strings.TrimSpace(newFile)
|
||
if oldFile == "" || newFile == "" {
|
||
return
|
||
}
|
||
|
||
jobsMu.Lock()
|
||
for _, j := range jobs {
|
||
if j == nil {
|
||
continue
|
||
}
|
||
out := strings.TrimSpace(j.Output)
|
||
if out == "" {
|
||
continue
|
||
}
|
||
if filepath.Base(out) == oldFile {
|
||
j.Output = filepath.Join(filepath.Dir(out), newFile)
|
||
}
|
||
}
|
||
jobsMu.Unlock()
|
||
}
|
||
|
||
// nimmt jetzt *HTTPClient entgegen
|
||
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
|
||
if strings.TrimSpace(hlsSource) == "" {
|
||
return nil, errors.New("HLS-URL leer")
|
||
}
|
||
|
||
req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie)
|
||
if err != nil {
|
||
return nil, appErrorf("Fehler beim Erstellen der Playlist-Request: %w", err)
|
||
}
|
||
|
||
resp, err := hc.client.Do(req)
|
||
if err != nil {
|
||
return nil, appErrorf("Fehler beim Laden der Playlist: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, appErrorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource)
|
||
}
|
||
|
||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||
if err != nil {
|
||
return nil, appErrorf("master playlist decode failed: %w", err)
|
||
}
|
||
if listType != m3u8.MASTER {
|
||
return nil, errors.New("keine gültige Master-Playlist")
|
||
}
|
||
|
||
master, ok := playlist.(*m3u8.MasterPlaylist)
|
||
if !ok || master == nil {
|
||
return nil, errors.New("master playlist parse fehlgeschlagen")
|
||
}
|
||
|
||
baseURL, err := url.Parse(hlsSource)
|
||
if err != nil {
|
||
return nil, appErrorf("master url parse failed: %w", err)
|
||
}
|
||
|
||
var bestVariant *m3u8.Variant
|
||
var bestWidth int
|
||
var bestFramerate int
|
||
|
||
parseFramerate := func(v *m3u8.Variant) int {
|
||
if v == nil {
|
||
return 0
|
||
}
|
||
|
||
// 1) Falls BANDWIDTH/FRAME-RATE Infos im Namen kodiert sind
|
||
name := strings.ToUpper(strings.TrimSpace(v.Name))
|
||
switch {
|
||
case strings.Contains(name, "FPS:60"), strings.Contains(name, "FPS=60"), strings.Contains(name, "60FPS"):
|
||
return 60
|
||
case strings.Contains(name, "FPS:50"), strings.Contains(name, "FPS=50"), strings.Contains(name, "50FPS"):
|
||
return 50
|
||
case strings.Contains(name, "FPS:30"), strings.Contains(name, "FPS=30"), strings.Contains(name, "30FPS"):
|
||
return 30
|
||
}
|
||
|
||
// 2) Fallback Standard
|
||
return 30
|
||
}
|
||
|
||
parseWidth := func(v *m3u8.Variant) int {
|
||
if v == nil {
|
||
return 0
|
||
}
|
||
|
||
res := strings.TrimSpace(v.Resolution)
|
||
if res == "" {
|
||
return 0
|
||
}
|
||
|
||
parts := strings.Split(res, "x")
|
||
if len(parts) != 2 {
|
||
return 0
|
||
}
|
||
|
||
w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return w
|
||
}
|
||
|
||
for _, variant := range master.Variants {
|
||
if variant == nil || strings.TrimSpace(variant.URI) == "" {
|
||
continue
|
||
}
|
||
|
||
width := parseWidth(variant)
|
||
fr := parseFramerate(variant)
|
||
|
||
// auch Varianten ohne Resolution zulassen, aber niedriger priorisieren
|
||
if bestVariant == nil ||
|
||
width > bestWidth ||
|
||
(width == bestWidth && fr > bestFramerate) {
|
||
bestVariant = variant
|
||
bestWidth = width
|
||
bestFramerate = fr
|
||
}
|
||
}
|
||
|
||
if bestVariant == nil {
|
||
return nil, errors.New("keine gültige Variante gefunden")
|
||
}
|
||
|
||
variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI))
|
||
if err != nil {
|
||
return nil, appErrorf("variant uri parse failed: %w", err)
|
||
}
|
||
|
||
playlistURL := baseURL.ResolveReference(variantRef).String()
|
||
|
||
playlistParsed, err := url.Parse(playlistURL)
|
||
if err != nil {
|
||
return nil, appErrorf("playlist url parse failed: %w", err)
|
||
}
|
||
|
||
rootParsed := *playlistParsed
|
||
rootParsed.RawQuery = ""
|
||
rootParsed.Fragment = ""
|
||
|
||
lastSlash := strings.LastIndex(rootParsed.Path, "/")
|
||
if lastSlash >= 0 {
|
||
rootParsed.Path = rootParsed.Path[:lastSlash+1]
|
||
} else {
|
||
rootParsed.Path = "/"
|
||
}
|
||
|
||
return &Playlist{
|
||
PlaylistURL: playlistURL,
|
||
RootURL: rootParsed.String(),
|
||
Resolution: bestWidth,
|
||
Framerate: bestFramerate,
|
||
}, nil
|
||
}
|
||
|
||
func readLine() string {
|
||
r := bufio.NewReader(os.Stdin)
|
||
s, _ := r.ReadString('\n')
|
||
return strings.TrimRight(s, "\r\n")
|
||
}
|
||
|
||
func fileExists(path string) bool {
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|
||
|
||
func validateVideoDecodesNearEnd(ctx context.Context, path string) error {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return appErrorf("empty path")
|
||
}
|
||
|
||
dur, err := durationSecondsCached(ctx, path)
|
||
if err != nil || dur <= 0 {
|
||
return nil // best effort
|
||
}
|
||
|
||
t := dur * 0.90
|
||
if t < 0.1 {
|
||
t = 0.1
|
||
}
|
||
|
||
img, err := extractFrameAtTimeJPG(path, t)
|
||
if err != nil {
|
||
return appErrorf("decode near end failed at %.3fs: %w", t, err)
|
||
}
|
||
if len(img) == 0 {
|
||
return appErrorf("decode near end returned empty frame at %.3fs", t)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func uniqueSiblingTempPath(finalPath, suffix string) string {
|
||
finalPath = strings.TrimSpace(finalPath)
|
||
|
||
dir := filepath.Dir(finalPath)
|
||
ext := filepath.Ext(finalPath)
|
||
base := strings.TrimSuffix(filepath.Base(finalPath), ext)
|
||
|
||
ts := time.Now().Format("20060102_150405_000")
|
||
|
||
if strings.TrimSpace(suffix) == "" {
|
||
return filepath.Join(dir, fmt.Sprintf(".%s.%s%s", base, ts, ext))
|
||
}
|
||
|
||
return filepath.Join(dir, fmt.Sprintf(".%s.%s.%s%s", base, suffix, ts, ext))
|
||
}
|
||
|
||
func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
|
||
tmp := uniqueSiblingTempPath(mp4Path, "tmp")
|
||
_ = os.Remove(tmp)
|
||
|
||
args := []string{
|
||
"-y",
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
}
|
||
args = append(args, ffmpegInputTol...)
|
||
args = append(args,
|
||
"-i", tsPath,
|
||
"-map", "0:v:0",
|
||
"-map", "0:a:0?",
|
||
"-dn",
|
||
"-ignore_unknown",
|
||
|
||
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||
"-c:v", "libx264",
|
||
"-threads", "2",
|
||
"-preset", "veryfast",
|
||
"-crf", "20",
|
||
"-pix_fmt", "yuv420p",
|
||
|
||
"-c:a", "aac",
|
||
"-b:a", "128k",
|
||
"-ac", "2",
|
||
|
||
"-af", "aresample=async=1:first_pts=0",
|
||
"-shortest",
|
||
"-movflags", "+faststart",
|
||
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 {
|
||
_ = os.Remove(tmp)
|
||
return appErrorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
|
||
if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
|
||
_ = os.Remove(mp4Path)
|
||
|
||
if err := os.Rename(tmp, mp4Path); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return appErrorf("rename reencode tmp -> target failed: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|