nsfwapp/backend/main.go
2026-03-31 19:24:19 +02:00

1475 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\main.go
package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/grafov/m3u8"
)
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
type JobStatus string
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"`
Hidden bool `json:"-"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
LiveThumbStarted bool `json:"-"`
// ✅ 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:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
PreviewUA string `json:"-"` // user-agent
previewCancel context.CancelFunc `json:"-"`
previewLastHit time.Time `json:"-"`
previewStartMu sync.Mutex `json:"-"`
// ✅ Frontend Progress beim Stop/Finalize
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
Progress int `json:"progress,omitempty"` // 0..100
PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
cancel context.CancelFunc `json:"-"`
}
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 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, fmt.Errorf("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,
)
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, fmt.Errorf("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 startPreviewIdleKiller() {
t := time.NewTicker(5 * time.Second)
go func() {
defer t.Stop()
for range t.C {
jobsMu.RLock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j != nil {
list = append(list, j)
}
}
jobsMu.RUnlock()
for _, j := range list {
jobsMu.RLock()
cmdRunning := j.previewCmd != nil
last := j.previewLastHit
st := j.Status
jobsMu.RUnlock()
if !cmdRunning {
continue
}
// wenn Job nicht mehr läuft oder Hover weg
if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) {
stopPreview(j)
}
}
}
}()
}
func init() {
initFFmpegLimiters()
startCPUUsageSampler(context.Background())
startPreviewIdleKiller()
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) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
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
}
// relativ -> absolut versuchen (best effort)
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 {
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,
)
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)
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, fmt.Errorf("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, fmt.Errorf("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.Lock()
defer jobsMu.Unlock()
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
}
if isAssetIDInUse(id) {
return
}
// falls jemand "file.mp4" übergibt
id = strings.TrimSuffix(id, filepath.Ext(id))
// HOT Prefix weg
id = stripHotPrefix(id)
// wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!)
var err error
id, err = sanitizeID(id)
if err != nil || id == "" {
return
}
// 1) NEU: generated/meta/<id>/ ...
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id))
}
// 2) Temp Preview Segmente (HLS) wegräumen
// (%TEMP%/rec_preview/<assetID>)
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", 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) {
thumbsRoot, _ := generatedThumbsRoot()
teaserRoot, _ := generatedTeaserRoot()
oldThumb := filepath.Join(thumbsRoot, oldID)
newThumb := filepath.Join(thumbsRoot, newID)
if _, err := os.Stat(oldThumb); err == nil {
if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) {
_ = os.Rename(oldThumb, newThumb)
} else {
_ = os.RemoveAll(oldThumb)
}
}
oldTeaser := filepath.Join(teaserRoot, oldID+".mp4")
newTeaser := filepath.Join(teaserRoot, newID+".mp4")
if _, err := os.Stat(oldTeaser); err == nil {
if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) {
_ = os.Rename(oldTeaser, newTeaser)
} else {
_ = os.Remove(oldTeaser)
}
}
}
// --- 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 "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
}
return body, nil
}
func remuxTSToMP4(tsPath, mp4Path string) error {
// ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4
cmd := exec.Command(ffmpegPath,
"-y",
"-i", tsPath,
"-c", "copy",
"-movflags", "+faststart",
mp4Path,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String())
}
return 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) // Sekunden können Dezimalstellen haben
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 {
// ffmpeg progress kommt auf stdout als key=value
cmd := exec.CommandContext(ctx, ffmpegPath,
"-y",
"-nostats",
"-progress", "pipe:1",
"-i", tsPath,
"-c", "copy",
"-movflags", "+faststart",
mp4Path,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
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) {
// bevorzugt: Zeit/Dauer
if durationSec > 0 && outSec > 0 {
r := outSec / durationSec
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
if onRatio != nil {
onRatio(r)
}
return
}
// fallback: Bytes (bei remux meist okay-ish)
if inSize > 0 && totalSize > 0 {
r := float64(totalSize) / float64(inSize)
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
if onRatio != nil {
onRatio(r)
}
return
}
// force (z.B. end)
if force && onRatio != nil {
onRatio(1)
}
}
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch 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 {
// out_time_ms ist i.d.R. Millisekunden
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)
}
}
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
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,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
// atomar austauschen
if err := os.Rename(path, bak); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename original to bak failed: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Rename(bak, path)
_ = os.Remove(tmp)
return fmt.Errorf("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 "", fmt.Errorf("empty assetID")
}
// gleiche Normalisierung wie bei Ordnernamen
var err error
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
return "", fmt.Errorf("invalid assetID: %w", err)
}
dir, err := ensureGeneratedDir(assetID)
if err != nil || strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("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 "", fmt.Errorf("too many duplicates for %s", file)
} else if err != nil && !os.IsNotExist(err) {
return "", err
}
return dst, nil
}
func idFromVideoPath(videoPath string) string {
name := filepath.Base(strings.TrimSpace(videoPath))
return strings.TrimSuffix(name, filepath.Ext(name))
}
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
}
fmt.Println("⚠️ 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 {
fmt.Println("⚠️ 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 {
fmt.Println("⚠️ keep fix: too many duplicates, skip:", name)
skipped++
continue
}
} else if err != nil && !os.IsNotExist(err) {
fmt.Println("⚠️ keep stat dst failed:", err)
skipped++
continue
}
// Verschieben (Windows-lock robust)
if err := renameWithRetry(src, dst); err != nil {
fmt.Println("⚠️ 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 "", fmt.Errorf("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 "", fmt.Errorf("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, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return nil, fmt.Errorf("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, fmt.Errorf("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, fmt.Errorf("variant uri parse failed: %w", err)
}
playlistURL := baseURL.ResolveReference(variantRef).String()
playlistParsed, err := url.Parse(playlistURL)
if err != nil {
return nil, fmt.Errorf("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
}