From 99837f0ed3c6ecf136518c6eee9224998a92166d Mon Sep 17 00:00:00 2001
From: Linrador <68631622+Linrador@users.noreply.github.com>
Date: Fri, 19 Dec 2025 17:52:14 +0100
Subject: [PATCH] updated
---
backend/go.mod | 16 +
backend/go.sum | 79 +
backend/main.go | 1787 +++++++
backend/models.go | 228 +
backend/models_api.go | 191 +
backend/models_store.go | 238 +
backend/myapp.exe | Bin 0 -> 8680448 bytes
backend/recorder_settings.json | 4 +
frontend/.gitignore | 24 +
frontend/README.md | 73 +
frontend/eslint.config.js | 23 +
frontend/index.html | 13 +
frontend/package-lock.json | 4236 +++++++++++++++++
frontend/package.json | 37 +
frontend/public/vite.svg | 1 +
frontend/src/App.css | 0
frontend/src/App.tsx | 413 ++
frontend/src/components/ui/Button.tsx | 124 +
frontend/src/components/ui/Card.tsx | 75 +
frontend/src/components/ui/ContextMenu.tsx | 117 +
frontend/src/components/ui/CookieModal.tsx | 130 +
.../src/components/ui/DownloadContextMenu.tsx | 75 +
.../src/components/ui/FinishedDownloads.tsx | 300 ++
.../components/ui/FinishedVideoPreview.tsx | 55 +
frontend/src/components/ui/HoverPopover.tsx | 118 +
frontend/src/components/ui/LiveHlsVideo.tsx | 110 +
frontend/src/components/ui/Modal.tsx | 61 +
frontend/src/components/ui/ModelPreview.tsx | 39 +
frontend/src/components/ui/ModelsTab.tsx | 318 ++
frontend/src/components/ui/Player.tsx | 176 +
.../src/components/ui/RecorderSettings.tsx | 180 +
.../src/components/ui/RunningDownloads.tsx | 218 +
frontend/src/components/ui/Table.tsx | 228 +
frontend/src/components/ui/Tabs.tsx | 91 +
frontend/src/index.css | 39 +
frontend/src/main.tsx | 10 +
frontend/src/types.ts | 21 +
frontend/tsconfig.app.json | 28 +
frontend/tsconfig.json | 7 +
frontend/tsconfig.node.json | 26 +
frontend/vite.config.ts | 20 +
41 files changed, 9929 insertions(+)
create mode 100644 backend/go.mod
create mode 100644 backend/go.sum
create mode 100644 backend/main.go
create mode 100644 backend/models.go
create mode 100644 backend/models_api.go
create mode 100644 backend/models_store.go
create mode 100644 backend/myapp.exe
create mode 100644 backend/recorder_settings.json
create mode 100644 frontend/.gitignore
create mode 100644 frontend/README.md
create mode 100644 frontend/eslint.config.js
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/public/vite.svg
create mode 100644 frontend/src/App.css
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/components/ui/Button.tsx
create mode 100644 frontend/src/components/ui/Card.tsx
create mode 100644 frontend/src/components/ui/ContextMenu.tsx
create mode 100644 frontend/src/components/ui/CookieModal.tsx
create mode 100644 frontend/src/components/ui/DownloadContextMenu.tsx
create mode 100644 frontend/src/components/ui/FinishedDownloads.tsx
create mode 100644 frontend/src/components/ui/FinishedVideoPreview.tsx
create mode 100644 frontend/src/components/ui/HoverPopover.tsx
create mode 100644 frontend/src/components/ui/LiveHlsVideo.tsx
create mode 100644 frontend/src/components/ui/Modal.tsx
create mode 100644 frontend/src/components/ui/ModelPreview.tsx
create mode 100644 frontend/src/components/ui/ModelsTab.tsx
create mode 100644 frontend/src/components/ui/Player.tsx
create mode 100644 frontend/src/components/ui/RecorderSettings.tsx
create mode 100644 frontend/src/components/ui/RunningDownloads.tsx
create mode 100644 frontend/src/components/ui/Table.tsx
create mode 100644 frontend/src/components/ui/Tabs.tsx
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/types.ts
create mode 100644 frontend/tsconfig.app.json
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.ts
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..1beac26
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,16 @@
+module nsfwapp
+
+go 1.25.3
+
+require (
+ github.com/PuerkitoBio/goquery v1.11.0
+ github.com/google/uuid v1.6.0
+ github.com/grafov/m3u8 v0.12.1
+)
+
+require (
+ github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
+ github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect
+ golang.org/x/net v0.47.0 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..97ce432
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,79 @@
+github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
+github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
+github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
+github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
+github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
+github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
+github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
+github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
+github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/backend/main.go b/backend/main.go
new file mode 100644
index 0000000..f3cee0f
--- /dev/null
+++ b/backend/main.go
@@ -0,0 +1,1787 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/google/uuid"
+ "github.com/grafov/m3u8"
+ "github.com/sqweek/dialog"
+)
+
+var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
+
+type JobStatus string
+
+const (
+ JobRunning JobStatus = "running"
+ JobFinished JobStatus = "finished"
+ JobFailed JobStatus = "failed"
+ JobStopped JobStatus = "stopped"
+)
+
+type RecordJob struct {
+ ID string `json:"id"`
+ model string `json:"model"`
+ SourceURL string `json:"sourceUrl"`
+ Output string `json:"output"`
+ Status JobStatus `json:"status"`
+ StartedAt time.Time `json:"startedAt"`
+ EndedAt *time.Time `json:"endedAt,omitempty"`
+ Error string `json:"error,omitempty"`
+
+ PreviewDir string `json:"-"`
+ previewCmd *exec.Cmd `json:"-"`
+
+ cancel context.CancelFunc `json:"-"`
+}
+
+var (
+ jobs = map[string]*RecordJob{}
+ jobsMu = sync.Mutex{}
+)
+
+type RecorderSettings struct {
+ RecordDir string `json:"recordDir"`
+ DoneDir string `json:"doneDir"`
+}
+
+var (
+ settingsMu sync.Mutex
+ settings = RecorderSettings{
+ RecordDir: "/records",
+ DoneDir: "/records/done",
+ }
+ settingsFile = "recorder_settings.json"
+)
+
+func getSettings() RecorderSettings {
+ settingsMu.Lock()
+ defer settingsMu.Unlock()
+ return settings
+}
+
+func loadSettings() {
+ b, err := os.ReadFile(settingsFile)
+ if err == nil {
+ var s RecorderSettings
+ if json.Unmarshal(b, &s) == nil {
+ if strings.TrimSpace(s.RecordDir) != "" {
+ s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir))
+ }
+ if strings.TrimSpace(s.DoneDir) != "" {
+ s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir))
+ }
+ settingsMu.Lock()
+ settings = s
+ settingsMu.Unlock()
+ }
+ }
+
+ // Ordner sicherstellen
+ s := getSettings()
+ _ = os.MkdirAll(s.RecordDir, 0o755)
+ _ = os.MkdirAll(s.DoneDir, 0o755)
+}
+
+func saveSettingsToDisk() {
+ s := getSettings()
+ b, _ := json.MarshalIndent(s, "", " ")
+ _ = os.WriteFile(settingsFile, b, 0o644)
+}
+
+func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(getSettings())
+ return
+
+ case http.MethodPost:
+ var in RecorderSettings
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir))
+ in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir))
+
+ if in.RecordDir == "" || in.DoneDir == "" {
+ http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest)
+ return
+ }
+
+ // Ordner erstellen (Fehler zurückgeben, falls z.B. keine Rechte)
+ if err := os.MkdirAll(in.RecordDir, 0o755); err != nil {
+ http.Error(w, "konnte recordDir nicht erstellen: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := os.MkdirAll(in.DoneDir, 0o755); err != nil {
+ http.Error(w, "konnte doneDir nicht erstellen: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ settingsMu.Lock()
+ settings = in
+ settingsMu.Unlock()
+ saveSettingsToDisk()
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(getSettings())
+ return
+
+ default:
+ http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
+ return
+ }
+}
+
+func settingsBrowse(w http.ResponseWriter, r *http.Request) {
+ target := r.URL.Query().Get("target")
+ if target != "record" && target != "done" {
+ http.Error(w, "target muss record oder done sein", http.StatusBadRequest)
+ return
+ }
+
+ p, err := dialog.Directory().Title("Ordner auswählen").Browse()
+ if err != nil {
+ // User cancelled → 204 No Content ist praktisch fürs Frontend
+ if strings.Contains(strings.ToLower(err.Error()), "cancel") {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // optional: wenn innerhalb exe-dir, als RELATIV zurückgeben
+ p = maybeMakeRelativeToExe(p)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"path": p})
+}
+
+func maybeMakeRelativeToExe(abs string) string {
+ exe, err := os.Executable()
+ if err != nil {
+ return abs
+ }
+ base := filepath.Dir(exe)
+
+ rel, err := filepath.Rel(base, abs)
+ if err != nil {
+ return abs
+ }
+ // wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten
+ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return abs
+ }
+ return filepath.ToSlash(rel) // frontend-freundlich
+}
+
+// --- 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, "
Just a moment...") {
+ 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("ffmpeg",
+ "-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 extractLastFrameJPEG(path string) ([]byte, error) {
+ // “Letzter Frame” über -sseof nahe Dateiende.
+ // Bei laufenden Aufnahmen kann das manchmal fehlschlagen -> Fallback oben.
+ cmd := exec.Command(
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel", "error",
+ "-sseof", "-0.1",
+ "-i", path,
+ "-frames:v", "1",
+ "-q:v", "4",
+ "-f", "image2pipe",
+ "-vcodec", "mjpeg",
+ "pipe:1",
+ )
+
+ var out bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("ffmpeg last-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
+ }
+ return out.Bytes(), nil
+}
+
+func recordPreview(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ http.Error(w, "id fehlt", http.StatusBadRequest)
+ return
+ }
+
+ // HLS mode: ?file=index.m3u8 / seg_00001.ts / index_hq.m3u8 ...
+ if file := r.URL.Query().Get("file"); file != "" {
+ servePreviewHLSFile(w, r, id, file)
+ return
+ }
+
+ // sonst: JPEG Fallback
+ jobsMu.Lock()
+ job, ok := jobs[id]
+ jobsMu.Unlock()
+
+ if !ok {
+ http.Error(w, "job nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ outPath := strings.TrimSpace(job.Output)
+ if outPath == "" {
+ http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
+ return
+ }
+
+ outPath = filepath.Clean(outPath)
+
+ // ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen.
+ // (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.)
+ if !filepath.IsAbs(outPath) {
+ if outPath == "." || outPath == ".." ||
+ strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) {
+ http.Error(w, "ungültiger output-pfad", http.StatusBadRequest)
+ return
+ }
+ }
+
+ fi, err := os.Stat(outPath)
+ if err != nil {
+ http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
+ return
+ }
+ if fi.IsDir() || fi.Size() == 0 {
+ http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
+ return
+ }
+
+ img, err := extractLastFrameJPEG(outPath)
+ if err != nil {
+ // Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger
+ img2, err2 := extractFirstFrameJPEG(outPath)
+ if err2 != nil {
+ http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ img = img2
+ }
+
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(img)
+}
+
+func recordList(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
+ return
+ }
+
+ jobsMu.Lock()
+ list := make([]*RecordJob, 0, len(jobs))
+ for _, j := range jobs {
+ list = append(list, j)
+ }
+ jobsMu.Unlock()
+
+ // optional: neueste zuerst
+ sort.Slice(list, func(i, j int) bool {
+ return list[i].StartedAt.After(list[j].StartedAt)
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(list)
+}
+
+var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts)$`)
+
+func servePreviewHLSFile(w http.ResponseWriter, r *http.Request, id, file string) {
+ file = strings.TrimSpace(file)
+ if file == "" || filepath.Base(file) != file || !previewFileRe.MatchString(file) {
+ http.Error(w, "ungültige file", http.StatusBadRequest)
+ return
+ }
+
+ isIndex := file == "index.m3u8" || file == "index_hq.m3u8"
+
+ jobsMu.Lock()
+ job, ok := jobs[id]
+ jobsMu.Unlock()
+ if !ok {
+ // Job wirklich unbekannt => 404 ist ok
+ http.Error(w, "job nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ // Preview noch nicht initialisiert? Für index => 204 (kein roter Fehler im Browser)
+ if strings.TrimSpace(job.PreviewDir) == "" {
+ if isIndex {
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
+ return
+ }
+
+ p := filepath.Join(job.PreviewDir, file)
+ if _, err := os.Stat(p); err != nil {
+ if isIndex {
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.Error(w, "datei nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ switch strings.ToLower(filepath.Ext(p)) {
+ case ".m3u8":
+ w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
+ case ".ts":
+ w.Header().Set("Content-Type", "video/mp2t")
+ default:
+ w.Header().Set("Content-Type", "application/octet-stream")
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.ServeFile(w, r, p)
+}
+
+func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string {
+ lines := strings.Split(m3u8, "\n")
+ escapedID := url.QueryEscape(id)
+
+ for i, line := range lines {
+ l := strings.TrimSpace(line)
+ if l == "" || strings.HasPrefix(l, "#") {
+ continue
+ }
+ // Segment/URI-Zeilen umschreiben
+ lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l)
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie, userAgent string) error {
+ if err := os.MkdirAll(previewDir, 0755); err != nil {
+ return err
+ }
+
+ commonIn := []string{"-y"}
+ if strings.TrimSpace(userAgent) != "" {
+ commonIn = append(commonIn, "-user_agent", userAgent)
+ }
+ if strings.TrimSpace(httpCookie) != "" {
+ commonIn = append(commonIn, "-headers", fmt.Sprintf("Cookie: %s\r\n", httpCookie))
+ }
+ commonIn = append(commonIn, "-i", m3u8URL)
+
+ baseURL := fmt.Sprintf("/api/record/preview?id=%s&file=", url.QueryEscape(jobID))
+
+ // LOW (ohne Audio – spart Bandbreite)
+ lowArgs := append(commonIn,
+ "-vf", "scale=160:-2",
+ "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
+ "-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
+ "-an",
+ "-f", "hls",
+ "-hls_time", "2",
+ "-hls_list_size", "4",
+ "-hls_flags", "delete_segments+append_list+independent_segments",
+ "-hls_segment_filename", filepath.Join(previewDir, "seg_low_%05d.ts"),
+ "-hls_base_url", baseURL,
+ filepath.Join(previewDir, "index.m3u8"),
+ )
+
+ // HQ (mit Audio)
+ hqArgs := append(commonIn,
+ "-vf", "scale=640:-2",
+ "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
+ "-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
+ "-c:a", "aac", "-b:a", "128k", "-ac", "2",
+ "-f", "hls",
+ "-hls_time", "2",
+ "-hls_list_size", "4",
+ "-hls_flags", "delete_segments+append_list+independent_segments",
+ "-hls_segment_filename", filepath.Join(previewDir, "seg_hq_%05d.ts"),
+ "-hls_base_url", baseURL,
+ filepath.Join(previewDir, "index_hq.m3u8"),
+ )
+
+ // beide Prozesse starten (einfach & robust)
+ go func(kind string, args []string) {
+ cmd := exec.CommandContext(ctx, "ffmpeg", args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil && ctx.Err() == nil {
+ fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
+ }
+ }("low", lowArgs)
+
+ go func(kind string, args []string) {
+ cmd := exec.CommandContext(ctx, "ffmpeg", args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil && ctx.Err() == nil {
+ fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
+ }
+ }("hq", hqArgs)
+
+ return nil
+}
+
+func extractFirstFrameJPEG(path string) ([]byte, error) {
+ cmd := exec.Command(
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel", "error",
+ "-i", path,
+ "-frames:v", "1",
+ "-q:v", "4",
+ "-f", "image2pipe",
+ "-vcodec", "mjpeg",
+ "pipe:1",
+ )
+
+ var out bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("ffmpeg first-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
+ }
+ return out.Bytes(), nil
+}
+
+func resolvePathRelativeToExe(p string) (string, error) {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return "", nil
+ }
+
+ // akzeptiere sowohl "records/done" als auch "records\done"
+ p = filepath.Clean(filepath.FromSlash(p))
+
+ if filepath.IsAbs(p) {
+ return p, nil
+ }
+
+ exe, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ base := filepath.Dir(exe)
+ return filepath.Join(base, p), nil
+}
+
+// routes.go (package main)
+func registerRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/settings", recordSettingsHandler)
+ mux.HandleFunc("/api/settings/browse", settingsBrowse)
+
+ mux.HandleFunc("/api/record", startRecordingFromRequest)
+ mux.HandleFunc("/api/record/status", recordStatus)
+ mux.HandleFunc("/api/record/stop", recordStop)
+ mux.HandleFunc("/api/record/preview", recordPreview)
+ mux.HandleFunc("/api/record/list", recordList)
+ mux.HandleFunc("/api/record/video", recordVideo)
+ mux.HandleFunc("/api/record/done", recordDoneList)
+
+ modelsPath, _ := resolvePathRelativeToExe("data/models_store.json")
+ store := NewModelStore(modelsPath)
+ if err := store.Load(); err != nil {
+ fmt.Println("⚠️ models load:", err)
+ }
+
+ // ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
+ RegisterModelAPI(mux, store)
+}
+
+// --- main ---
+func main() {
+ loadSettings()
+
+ mux := http.NewServeMux()
+ registerRoutes(mux)
+
+ fmt.Println("🌐 HTTP-API aktiv: http://localhost:8080")
+ if err := http.ListenAndServe(":8080", mux); err != nil {
+ fmt.Println("❌ HTTP-Server Fehler:", err)
+ os.Exit(1)
+ }
+}
+
+type RecordRequest struct {
+ URL string `json:"url"`
+ Cookie string `json:"cookie,omitempty"`
+ UserAgent string `json:"userAgent,omitempty"`
+}
+
+func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req RecordRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if req.URL == "" {
+ http.Error(w, "url fehlt", http.StatusBadRequest)
+ return
+ }
+
+ jobID := uuid.NewString()
+ ctx, cancel := context.WithCancel(context.Background())
+
+ job := &RecordJob{
+ ID: jobID,
+ SourceURL: req.URL,
+ Status: JobRunning,
+ StartedAt: time.Now(),
+ cancel: cancel,
+ }
+
+ jobsMu.Lock()
+ jobs[jobID] = job
+ jobsMu.Unlock()
+
+ go runJob(ctx, job, req)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(job)
+}
+
+func parseCookieString(cookieStr string) map[string]string {
+ out := map[string]string{}
+ for _, pair := range strings.Split(cookieStr, ";") {
+ parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ name := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+ if name == "" {
+ continue
+ }
+ out[strings.ToLower(name)] = value
+ }
+ return out
+}
+
+func hasChaturbateCookies(cookieStr string) bool {
+ m := parseCookieString(cookieStr)
+ _, hasCF := m["cf_clearance"]
+ // akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower)
+ _, hasSessID := m["session_id"]
+ _, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt
+ return hasCF && (hasSessID || hasSessIdAlt)
+}
+
+func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
+ defer func() {
+ now := time.Now()
+ job.EndedAt = &now
+ }()
+
+ hc := NewHTTPClient(req.UserAgent)
+ provider := detectProvider(req.URL)
+
+ var err error
+ now := time.Now()
+
+ switch provider {
+ case "chaturbate":
+ if !hasChaturbateCookies(req.Cookie) {
+ err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich")
+ break
+ }
+
+ s := getSettings()
+
+ username := extractUsername(req.URL)
+ filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
+ outPath := filepath.Join(s.RecordDir, filename)
+
+ job.Output = outPath
+ err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
+
+ case "mfc":
+ s := getSettings()
+
+ username := extractMFCUsername(req.URL)
+ filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
+ outPath := filepath.Join(s.RecordDir, filename)
+
+ job.Output = outPath
+ err = RecordStreamMFC(ctx, hc, username, outPath, job)
+
+ default:
+ err = errors.New("unsupported provider")
+ }
+
+ jobsMu.Lock()
+ defer jobsMu.Unlock()
+
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ job.Status = JobStopped
+
+ // ✅ Auch bei STOP: .ts -> .mp4 remuxen (falls möglich)
+ if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
+ job.Output = newOut
+ }
+
+ // ✅ und danach nach "done" verschieben
+ if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
+ job.Output = moved
+ }
+ } else {
+ job.Status = JobFailed
+ job.Error = err.Error()
+
+ // ✅ best effort: trotzdem remuxen und nach done verschieben (falls Datei existiert)
+ if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
+ job.Output = newOut
+ }
+ if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
+ job.Output = moved
+ }
+ }
+ } else {
+ job.Status = JobFinished
+
+ // ✅ Erst remuxen (damit in /done direkt die .mp4 landet)
+ if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
+ job.Output = newOut
+ }
+
+ // ✅ nach "done" verschieben (robust)
+ if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
+ job.Output = moved
+ }
+ }
+
+}
+
+func recordVideo(w http.ResponseWriter, r *http.Request) {
+ // ✅ NEU: Wiedergabe über Dateiname (für doneDir / recordDir)
+ if raw := r.URL.Query().Get("file"); strings.TrimSpace(raw) != "" {
+ // ✅ explizit decoden (zur Sicherheit)
+ file, err := url.QueryUnescape(raw)
+ if err != nil {
+ http.Error(w, "ungültiger file", http.StatusBadRequest)
+ return
+ }
+ file = strings.TrimSpace(file)
+
+ // ✅ kein Pfad, keine Backslashes, kein Traversal
+ if file == "" ||
+ strings.Contains(file, "/") ||
+ strings.Contains(file, "\\") ||
+ filepath.Base(file) != file {
+ http.Error(w, "ungültiger file", http.StatusBadRequest)
+ return
+ }
+
+ ext := strings.ToLower(filepath.Ext(file))
+ if ext != ".mp4" && ext != ".ts" {
+ http.Error(w, "nicht erlaubt", http.StatusForbidden)
+ return
+ }
+
+ s := getSettings()
+ recordAbs, _ := resolvePathRelativeToExe(s.RecordDir)
+ doneAbs, _ := resolvePathRelativeToExe(s.DoneDir)
+
+ candidates := []string{
+ filepath.Join(doneAbs, file), // bevorzugt doneDir
+ filepath.Join(recordAbs, file), // fallback recordDir
+ }
+
+ var outPath string
+ for _, p := range candidates {
+ fi, err := os.Stat(p)
+ if err == nil && !fi.IsDir() && fi.Size() > 0 {
+ outPath = p
+ break
+ }
+ }
+
+ if outPath == "" {
+ http.Error(w, "datei nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ if ext == ".mp4" {
+ w.Header().Set("Content-Type", "video/mp4")
+ } else {
+ w.Header().Set("Content-Type", "video/mp2t")
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.ServeFile(w, r, outPath)
+ return
+ }
+
+ // --- ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) ---
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ http.Error(w, "id fehlt", http.StatusBadRequest)
+ return
+ }
+
+ jobsMu.Lock()
+ job, ok := jobs[id]
+ jobsMu.Unlock()
+ if !ok {
+ http.Error(w, "job nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ outPath := filepath.Clean(strings.TrimSpace(job.Output))
+ if outPath == "" {
+ http.Error(w, "output fehlt", http.StatusNotFound)
+ return
+ }
+
+ if !filepath.IsAbs(outPath) {
+ abs, err := resolvePathRelativeToExe(outPath)
+ if err != nil {
+ http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError)
+ return
+ }
+ outPath = abs
+ }
+
+ fi, err := os.Stat(outPath)
+ if err != nil || fi.IsDir() || fi.Size() == 0 {
+ http.Error(w, "datei nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ ext := strings.ToLower(filepath.Ext(outPath))
+ if ext == ".mp4" {
+ w.Header().Set("Content-Type", "video/mp4")
+ } else if ext == ".ts" {
+ w.Header().Set("Content-Type", "video/mp2t")
+ }
+
+ w.Header().Set("Cache-Control", "no-store")
+ http.ServeFile(w, r, outPath)
+}
+
+func recordDoneList(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
+ return
+ }
+
+ s := getSettings()
+ doneAbs, err := resolvePathRelativeToExe(s.DoneDir)
+ if err != nil {
+ http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ entries, err := os.ReadDir(doneAbs)
+ if err != nil {
+ http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ list := make([]*RecordJob, 0, len(entries))
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ ext := strings.ToLower(filepath.Ext(name))
+ if ext != ".mp4" && ext != ".ts" {
+ continue
+ }
+
+ full := filepath.Join(doneAbs, name)
+ fi, err := os.Stat(full)
+ if err != nil || fi.IsDir() {
+ continue
+ }
+
+ // ID stabil aus Dateiname (ohne Extension) – reicht für Player/Key
+ base := strings.TrimSuffix(name, filepath.Ext(name))
+
+ // best effort: Zeiten aus FileInfo
+ t := fi.ModTime()
+
+ list = append(list, &RecordJob{
+ ID: base,
+ SourceURL: "",
+ Output: full,
+ Status: JobFinished,
+ StartedAt: t,
+ EndedAt: &t,
+ })
+ }
+
+ // neueste zuerst
+ sort.Slice(list, func(i, j int) bool {
+ return list[i].EndedAt.After(*list[j].EndedAt)
+ })
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(list)
+}
+
+func maybeRemuxTS(path string) (string, error) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return "", nil
+ }
+
+ if !strings.EqualFold(filepath.Ext(path), ".ts") {
+ return "", nil
+ }
+
+ mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4"
+
+ // remux (ohne neu encoden)
+ if err := remuxTSToMP4(path, mp4); err != nil {
+ return "", err
+ }
+
+ _ = os.Remove(path) // TS entfernen, wenn MP4 ok
+ return mp4, nil
+}
+
+func moveFile(src, dst string) error {
+ // zuerst Rename (schnell)
+ if err := os.Rename(src, dst); err == nil {
+ return nil
+ } else {
+ // Fallback: Copy+Remove (z.B. bei EXDEV)
+ in, err2 := os.Open(src)
+ if err2 != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err2 := os.Create(dst)
+ if err2 != nil {
+ return err
+ }
+ if _, err2 := io.Copy(out, in); err2 != nil {
+ out.Close()
+ return err2
+ }
+ if err2 := out.Close(); err2 != nil {
+ return err2
+ }
+ return os.Remove(src)
+ }
+}
+
+func moveToDoneDir(outputPath string) (string, error) {
+ outputPath = strings.TrimSpace(outputPath)
+ if outputPath == "" {
+ return "", nil
+ }
+
+ s := getSettings()
+
+ // ✅ doneDir relativ zur exe auflösen (funktion hast du schon)
+ doneDirAbs, err := resolvePathRelativeToExe(s.DoneDir)
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(doneDirAbs, 0o755); err != nil {
+ return "", err
+ }
+
+ dst := filepath.Join(doneDirAbs, filepath.Base(outputPath))
+ if err := moveFile(outputPath, dst); err != nil {
+ return "", err
+ }
+ return dst, nil
+}
+
+func recordStatus(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ http.Error(w, "id fehlt", http.StatusBadRequest)
+ return
+ }
+
+ jobsMu.Lock()
+ job, ok := jobs[id]
+ jobsMu.Unlock()
+
+ if !ok {
+ http.Error(w, "job nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ json.NewEncoder(w).Encode(job)
+}
+
+func recordStop(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
+ return
+ }
+
+ id := r.URL.Query().Get("id")
+ jobsMu.Lock()
+ job, ok := jobs[id]
+ jobsMu.Unlock()
+
+ if !ok {
+ http.Error(w, "job nicht gefunden", http.StatusNotFound)
+ return
+ }
+
+ if job.previewCmd != nil && job.previewCmd.Process != nil {
+ _ = job.previewCmd.Process.Kill()
+ job.previewCmd = nil
+ }
+
+ if job.cancel != nil {
+ job.cancel()
+ }
+
+ out := job.Output
+ if strings.EqualFold(filepath.Ext(out), ".ts") {
+ mp4 := strings.TrimSuffix(out, filepath.Ext(out)) + ".mp4"
+
+ if err := remuxTSToMP4(out, mp4); err == nil {
+ _ = os.Remove(out) // optional: TS löschen
+ job.Output = mp4 // wichtig: Output umstellen
+ } else {
+ // optional: loggen, TS behalten
+ }
+ }
+
+ fmt.Println("📡 Aufnahme gestoppt:", job.ID)
+
+ w.Write([]byte(`{"ok":"stopped"}`))
+}
+
+// --- DVR-ähnlicher Recorder-Ablauf ---
+// Entspricht grob dem RecordStream aus dem Channel-Snippet:
+func RecordStream(
+ ctx context.Context,
+ hc *HTTPClient,
+ domain string,
+ username string,
+ outputPath string,
+ httpCookie string,
+ job *RecordJob,
+) error {
+ // 1) Seite laden
+ // Domain sauber zusammenbauen (mit/ohne Slash)
+ base := strings.TrimRight(domain, "/")
+ pageURL := base + "/" + username
+
+ body, err := hc.FetchPage(ctx, pageURL, httpCookie)
+
+ // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
+ hlsURL, err := ParseStream(body)
+ if err != nil {
+ return fmt.Errorf("stream-parsing: %w", err)
+ }
+
+ // 3) Playlist holen (wie stream.GetPlaylist im DVR)
+ playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
+ if err != nil {
+ return fmt.Errorf("playlist abrufen: %w", err)
+ }
+
+ if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
+ previewDir := filepath.Join(os.TempDir(), "rec_preview", job.ID)
+
+ jobsMu.Lock()
+ job.PreviewDir = previewDir
+ jobsMu.Unlock()
+
+ if err := startPreviewHLS(ctx, job.ID, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil {
+ fmt.Println("⚠️ preview start fehlgeschlagen:", err)
+ }
+ }
+
+ fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
+
+ // 4) Datei öffnen
+ file, err := os.Create(outputPath)
+ if err != nil {
+ return fmt.Errorf("datei erstellen: %w", err)
+ }
+ defer func() {
+ _ = file.Close()
+ }()
+
+ fmt.Println("📡 Aufnahme gestartet:", outputPath)
+
+ // 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR
+ err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
+ // Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben
+ if _, err := file.Write(b); err != nil {
+ return fmt.Errorf("schreibe segment: %w", err)
+ }
+ // Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
+ _ = duration // aktuell unbenutzt
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("watch segments: %w", err)
+ }
+
+ return nil
+}
+
+// RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf.
+// In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren.
+// RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC).
+func RecordStreamMFC(
+ ctx context.Context,
+ hc *HTTPClient,
+ username string,
+ outputPath string,
+ job *RecordJob,
+) error {
+ mfc := NewMyFreeCams(username)
+
+ // optional, aber sinnvoll: nur aufnehmen wenn Public
+ st, err := mfc.GetStatus()
+ if err != nil {
+ return fmt.Errorf("mfc status: %w", err)
+ }
+ if st != StatusPublic {
+ return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
+ }
+
+ m3u8URL, err := mfc.GetVideoURL(false)
+ if err != nil {
+ return fmt.Errorf("mfc get video url: %w", err)
+ }
+ if strings.TrimSpace(m3u8URL) == "" {
+ return fmt.Errorf("mfc: keine m3u8 URL gefunden")
+ }
+
+ // ✅ Preview starten
+ if job != nil && job.PreviewDir == "" {
+ previewDir := filepath.Join(os.TempDir(), "preview_"+job.ID)
+ job.PreviewDir = previewDir
+
+ if err := startPreviewHLS(ctx, job.ID, m3u8URL, previewDir, "", hc.userAgent); err != nil {
+ fmt.Println("⚠️ preview start fehlgeschlagen:", err)
+ job.PreviewDir = "" // rollback
+ }
+ }
+
+ // Aufnahme starten
+ return handleM3U8Mode(ctx, m3u8URL, outputPath)
+}
+
+func detectProvider(raw string) string {
+ s := strings.ToLower(raw)
+
+ if strings.Contains(s, "chaturbate.com") {
+ return "chaturbate"
+ }
+ if strings.Contains(s, "myfreecams.com") {
+ return "mfc"
+ }
+ return "unknown"
+}
+
+// --- helper ---
+func extractUsername(input string) string {
+ input = strings.TrimSpace(input)
+ input = strings.TrimPrefix(input, "https://")
+ input = strings.TrimPrefix(input, "http://")
+ input = strings.TrimPrefix(input, "www.")
+ if strings.HasPrefix(input, "chaturbate.com/") {
+ input = strings.TrimPrefix(input, "chaturbate.com/")
+ }
+
+ // alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.)
+ if idx := strings.IndexAny(input, "/?#"); idx != -1 {
+ input = input[:idx]
+ }
+
+ // zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen
+ return strings.Trim(input, "/\\")
+}
+
+// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
+func addCookiesFromString(req *http.Request, cookieStr string) {
+ if cookieStr == "" {
+ return
+ }
+ pairs := strings.Split(cookieStr, ";")
+ for _, pair := range pairs {
+ parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ name := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+ if name == "" {
+ continue
+ }
+ req.AddCookie(&http.Cookie{
+ Name: name,
+ Value: value,
+ })
+ }
+}
+
+// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
+func ParseStream(html string) (string, error) {
+ matches := roomDossierRegexp.FindStringSubmatch(html)
+ if len(matches) == 0 {
+ return "", errors.New("room dossier nicht gefunden")
+ }
+
+ // DVR-Style Unicode-Decode
+ decoded, err := strconv.Unquote(
+ strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
+ )
+ if err != nil {
+ return "", fmt.Errorf("Unicode-decode failed: %w", err)
+ }
+
+ var rd struct {
+ HLSSource string `json:"hls_source"`
+ }
+ if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
+ return "", fmt.Errorf("JSON-parse failed: %w", err)
+ }
+ if rd.HLSSource == "" {
+ return "", errors.New("kein HLS-Quell-URL im JSON")
+ }
+ return rd.HLSSource, nil
+}
+
+// --- Playlist/WatchSegments wie gehabt ---
+type Playlist struct {
+ PlaylistURL string
+ RootURL string
+ Resolution int
+ Framerate int
+}
+
+type Resolution struct {
+ Framerate map[int]string
+ Width int
+}
+
+// nimmt jetzt *HTTPClient entgegen
+func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
+ if 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()
+
+ playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
+ if err != nil || listType != m3u8.MASTER {
+ return nil, errors.New("keine gültige Master-Playlist")
+ }
+
+ master := playlist.(*m3u8.MasterPlaylist)
+ var bestURI string
+ var bestWidth int
+ var bestFramerate int
+
+ for _, variant := range master.Variants {
+ if variant == nil || variant.Resolution == "" {
+ continue
+ }
+
+ parts := strings.Split(variant.Resolution, "x")
+ if len(parts) != 2 {
+ continue
+ }
+
+ width, err := strconv.Atoi(parts[1])
+ if err != nil {
+ continue
+ }
+
+ fr := 30
+ if strings.Contains(variant.Name, "FPS:60.0") {
+ fr = 60
+ }
+
+ if width > bestWidth || (width == bestWidth && fr > bestFramerate) {
+ bestWidth = width
+ bestFramerate = fr
+ bestURI = variant.URI
+ }
+ }
+
+ if bestURI == "" {
+ return nil, errors.New("keine gültige Auflösung gefunden")
+ }
+
+ root := hlsSource[:strings.LastIndex(hlsSource, "/")+1]
+ return &Playlist{
+ PlaylistURL: root + bestURI,
+ RootURL: root,
+ Resolution: bestWidth,
+ Framerate: bestFramerate,
+ }, nil
+}
+
+// nutzt ebenfalls *HTTPClient
+func (p *Playlist) WatchSegments(
+ ctx context.Context,
+ hc *HTTPClient,
+ httpCookie string,
+ handler func([]byte, float64) error,
+) error {
+ var lastSeq int64 = -1
+ emptyRounds := 0
+ const maxEmptyRounds = 5
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ // Playlist holen
+ req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
+ if err != nil {
+ return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
+ }
+
+ resp, err := hc.client.Do(req)
+ if err != nil {
+ emptyRounds++
+ if emptyRounds >= maxEmptyRounds {
+ return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline")
+ }
+ time.Sleep(2 * time.Second)
+ continue
+ }
+
+ playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
+ resp.Body.Close()
+
+ if err != nil || listType != m3u8.MEDIA {
+ emptyRounds++
+ if emptyRounds >= maxEmptyRounds {
+ return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
+ }
+ time.Sleep(2 * time.Second)
+ continue
+ }
+
+ media := playlist.(*m3u8.MediaPlaylist)
+ newSegment := false
+
+ for _, segment := range media.Segments {
+ if segment == nil {
+ continue
+ }
+ if int64(segment.SeqId) <= lastSeq {
+ continue
+ }
+
+ lastSeq = int64(segment.SeqId)
+ newSegment = true
+
+ segmentURL := p.RootURL + segment.URI
+
+ segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
+ if err != nil {
+ continue
+ }
+
+ segResp, err := hc.client.Do(segReq)
+ if err != nil {
+ continue
+ }
+
+ data, err := io.ReadAll(segResp.Body)
+ segResp.Body.Close()
+ if err != nil || len(data) == 0 {
+ continue
+ }
+
+ if err := handler(data, segment.Duration); err != nil {
+ return err
+ }
+ }
+
+ if newSegment {
+ emptyRounds = 0
+ } else {
+ emptyRounds++
+ if emptyRounds >= maxEmptyRounds {
+ return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.")
+ }
+ }
+
+ time.Sleep(1 * time.Second)
+ }
+}
+
+/* ───────────────────────────────
+ MyFreeCams (übernommener Flow)
+ ─────────────────────────────── */
+
+type MyFreeCams struct {
+ Username string
+ Attrs map[string]string
+ VideoURL string
+}
+
+func NewMyFreeCams(username string) *MyFreeCams {
+ return &MyFreeCams{
+ Username: username,
+ Attrs: map[string]string{},
+ }
+}
+
+func (m *MyFreeCams) GetWebsiteURL() string {
+ return "https://www.myfreecams.com/#" + m.Username
+}
+
+func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
+ if !refresh && m.VideoURL != "" {
+ return m.VideoURL, nil
+ }
+
+ // Prüfen, ob alle benötigten Attribute vorhanden sind
+ if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok {
+ return "", nil
+ }
+ sid := m.Attrs["data-cam-preview-server-id-value"]
+ midBase := m.Attrs["data-cam-preview-model-id-value"]
+ isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true"
+
+ midInt, err := strconv.Atoi(midBase)
+ if err != nil {
+ return "", fmt.Errorf("model-id parse error: %w", err)
+ }
+ mid := 100000000 + midInt
+ a := ""
+ if isWzobs {
+ a = "a_"
+ }
+
+ playlistURL := fmt.Sprintf(
+ "https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8",
+ sid, a, mid,
+ )
+
+ // Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken
+ u, err := getWantedResolutionPlaylist(playlistURL)
+ if err != nil {
+ return "", err
+ }
+ m.VideoURL = u
+ return m.VideoURL, nil
+}
+
+func (m *MyFreeCams) GetStatus() (Status, error) {
+ // 1) share-Seite prüfen (existiert/nicht existiert)
+ shareURL := "https://share.myfreecams.com/" + m.Username
+ resp, err := http.Get(shareURL)
+ if err != nil {
+ return StatusUnknown, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ return StatusNotExist, nil
+ }
+ if resp.StatusCode != 200 {
+ return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML)
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return StatusUnknown, err
+ }
+
+ // 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist
+ start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?"))
+ if start == -1 {
+ // ohne tracking Parameter -> behandeln wie nicht existent
+ return StatusNotExist, nil
+ }
+ end := bytes.IndexByte(bodyBytes[start:], '"')
+ if end == -1 {
+ return StatusUnknown, errors.New("tracking url parse failed")
+ }
+ raw := string(bodyBytes[start : start+end])
+ u, err := url.Parse(raw)
+ if err != nil {
+ return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err)
+ }
+ qs := u.Query()
+ if qs.Get("model_id") == "" {
+ return StatusNotExist, nil
+ }
+
+ // 3) HTML parsen und Attribute auslesen
+ doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
+ if err != nil {
+ return StatusUnknown, err
+ }
+
+ params := doc.Find(".campreview").First()
+ if params.Length() == 0 {
+ // keine campreview -> offline
+ return StatusOffline, nil
+ }
+
+ attrs := map[string]string{}
+ params.Each(func(_ int, s *goquery.Selection) {
+ for _, a := range []string{
+ "data-cam-preview-server-id-value",
+ "data-cam-preview-model-id-value",
+ "data-cam-preview-is-wzobs-value",
+ } {
+ if v, ok := s.Attr(a); ok {
+ attrs[a] = v
+ }
+ }
+ })
+ m.Attrs = attrs
+
+ // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln
+ uStr, err := m.GetVideoURL(true)
+ if err != nil {
+ return StatusUnknown, err
+ }
+ if uStr != "" {
+ return StatusPublic, nil
+ }
+ // campreview vorhanden, aber keine playable url -> „PRIVATE“
+ return StatusPrivate, nil
+}
+
+func runMFC(ctx context.Context, username string, outArg string) error {
+ mfc := NewMyFreeCams(username)
+
+ st, err := mfc.GetStatus()
+ if err != nil {
+ return err
+ }
+ if st != StatusPublic {
+ return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
+ }
+
+ m3u8URL, err := mfc.GetVideoURL(false)
+ if err != nil {
+ return err
+ }
+ if m3u8URL == "" {
+ return errors.New("keine m3u8 URL gefunden")
+ }
+
+ return handleM3U8Mode(ctx, m3u8URL, outArg)
+}
+
+/* ───────────────────────────────
+ Gemeinsame HLS/M3U8-Helper (MFC)
+ ─────────────────────────────── */
+
+func getWantedResolutionPlaylist(playlistURL string) (string, error) {
+ // Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück.
+ resp, err := http.Get(playlistURL)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
+ }
+
+ playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
+ if err != nil {
+ return "", fmt.Errorf("m3u8 parse: %w", err)
+ }
+ if listType == m3u8.MEDIA {
+ return playlistURL, nil
+ }
+
+ master := playlist.(*m3u8.MasterPlaylist)
+ var bestURI string
+ var bestWidth int
+ var bestFramerate float64
+
+ for _, v := range master.Variants {
+ if v == nil {
+ continue
+ }
+ // Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert.
+ w := 0
+ if v.Resolution != "" {
+ parts := strings.Split(v.Resolution, "x")
+ if len(parts) == 2 {
+ if ww, err := strconv.Atoi(parts[1]); err == nil {
+ w = ww
+ }
+ }
+ }
+ fr := 30.0
+ if v.FrameRate > 0 {
+ fr = v.FrameRate
+ } else if strings.Contains(v.Name, "FPS:60") {
+ fr = 60
+ }
+ if w > bestWidth || (w == bestWidth && fr > bestFramerate) {
+ bestWidth = w
+ bestFramerate = fr
+ bestURI = v.URI
+ }
+ }
+ if bestURI == "" {
+ return "", errors.New("Master-Playlist ohne gültige Varianten")
+ }
+
+ // Absolutieren
+ root := playlistURL[:strings.LastIndex(playlistURL, "/")+1]
+ if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") {
+ return bestURI, nil
+ }
+ return root + bestURI, nil
+}
+
+func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error {
+ // Validierung
+ u, err := url.Parse(m3u8URL)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
+ return fmt.Errorf("ungültige URL: %q", m3u8URL)
+ }
+
+ // HTTP-Check MIT Context
+ req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
+ }
+
+ if strings.TrimSpace(outFile) == "" {
+ return errors.New("output file path leer")
+ }
+
+ // ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
+ cmd := exec.CommandContext(
+ ctx,
+ "ffmpeg",
+ "-y",
+ "-i", m3u8URL,
+ "-c", "copy",
+ outFile,
+ )
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ if errors.Is(ctx.Err(), context.Canceled) {
+ return ctx.Err()
+ }
+ return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err)
+ }
+
+ return nil
+}
+
+/* ───────────────────────────────
+ Kleine Helper für MFC
+ ─────────────────────────────── */
+
+func extractMFCUsername(input string) string {
+ if strings.Contains(input, "myfreecams.com/#") {
+ i := strings.Index(input, "#")
+ if i >= 0 && i < len(input)-1 {
+ return strings.TrimSpace(input[i+1:])
+ }
+ return ""
+ }
+ return strings.TrimSpace(input)
+}
+
+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
+}
diff --git a/backend/models.go b/backend/models.go
new file mode 100644
index 0000000..42d7f3a
--- /dev/null
+++ b/backend/models.go
@@ -0,0 +1,228 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Model struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ // optional Flags (kannst du später im UI nutzen)
+ Watching bool `json:"watching"`
+ Favorite bool `json:"favorite"`
+ Hot bool `json:"hot"`
+ Liked *bool `json:"liked"` // nil = keine Angabe
+}
+
+type modelStore struct {
+ mu sync.Mutex
+ path string
+ loaded bool
+ items []Model
+}
+
+var models = &modelStore{
+ path: filepath.Join("data", "models.json"),
+}
+
+func modelsHandler(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ list, err := modelsList()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, list)
+
+ case http.MethodPost:
+ var in Model
+ if err := readJSON(r.Body, &in); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ in.Name = strings.TrimSpace(in.Name)
+ if in.Name == "" {
+ http.Error(w, "name required", http.StatusBadRequest)
+ return
+ }
+
+ out, err := modelsUpsert(in)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, out)
+
+ case http.MethodDelete:
+ // /api/models?id=...
+ id := strings.TrimSpace(r.URL.Query().Get("id"))
+ if id == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ if err := modelsDelete(id); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+
+ default:
+ w.Header().Set("Allow", "GET, POST, DELETE")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// optional: /api/models/parse?file=ella_desire_12_18_2025__14-25-30.mp4
+func modelsParseHandler(w http.ResponseWriter, r *http.Request) {
+ file := strings.TrimSpace(r.URL.Query().Get("file"))
+ if file == "" {
+ http.Error(w, "file required", http.StatusBadRequest)
+ return
+ }
+ name := modelNameFromFilename(file)
+ writeJSON(w, http.StatusOK, map[string]string{"model": name})
+}
+
+var reModel = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`)
+
+func modelNameFromFilename(file string) string {
+ file = strings.ReplaceAll(file, "\\", "/")
+ base := file[strings.LastIndex(file, "/")+1:]
+ base = strings.TrimSuffix(base, filepath.Ext(base))
+
+ if m := reModel.FindStringSubmatch(base); len(m) == 2 && strings.TrimSpace(m[1]) != "" {
+ return m[1]
+ }
+ // fallback: bis zum letzten "_" (wie bisher)
+ if i := strings.LastIndex(base, "_"); i > 0 {
+ return base[:i]
+ }
+ if base == "" {
+ return "—"
+ }
+ return base
+}
+
+func modelsEnsureLoaded() error {
+ models.mu.Lock()
+ defer models.mu.Unlock()
+
+ if models.loaded {
+ return nil
+ }
+ models.loaded = true
+
+ b, err := os.ReadFile(models.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ models.items = []Model{}
+ return nil
+ }
+ return err
+ }
+ if len(b) == 0 {
+ models.items = []Model{}
+ return nil
+ }
+ return json.Unmarshal(b, &models.items)
+}
+
+func modelsSaveLocked() error {
+ if err := os.MkdirAll(filepath.Dir(models.path), 0o755); err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(models.items, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(models.path, b, 0o644)
+}
+
+func modelsList() ([]Model, error) {
+ if err := modelsEnsureLoaded(); err != nil {
+ return nil, err
+ }
+ models.mu.Lock()
+ defer models.mu.Unlock()
+ out := make([]Model, len(models.items))
+ copy(out, models.items)
+ return out, nil
+}
+
+func modelsUpsert(in Model) (Model, error) {
+ if err := modelsEnsureLoaded(); err != nil {
+ return Model{}, err
+ }
+
+ now := time.Now()
+ models.mu.Lock()
+ defer models.mu.Unlock()
+
+ // update by ID if provided
+ if strings.TrimSpace(in.ID) != "" {
+ for i := range models.items {
+ if models.items[i].ID == in.ID {
+ in.CreatedAt = models.items[i].CreatedAt
+ in.UpdatedAt = now
+ models.items[i] = in
+ return in, modelsSaveLocked()
+ }
+ }
+ }
+
+ // otherwise: create new
+ in.ID = newID()
+ in.CreatedAt = now
+ in.UpdatedAt = now
+ models.items = append(models.items, in)
+ return in, modelsSaveLocked()
+}
+
+func modelsDelete(id string) error {
+ if err := modelsEnsureLoaded(); err != nil {
+ return err
+ }
+ models.mu.Lock()
+ defer models.mu.Unlock()
+
+ out := models.items[:0]
+ for _, m := range models.items {
+ if m.ID != id {
+ out = append(out, m)
+ }
+ }
+ models.items = out
+ return modelsSaveLocked()
+}
+
+func newID() string {
+ var b [16]byte
+ _, _ = rand.Read(b[:])
+ return hex.EncodeToString(b[:])
+}
+
+func readJSON(r io.Reader, v any) error {
+ dec := json.NewDecoder(r)
+ dec.DisallowUnknownFields()
+ return dec.Decode(v)
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/backend/models_api.go b/backend/models_api.go
new file mode 100644
index 0000000..7ee81ba
--- /dev/null
+++ b/backend/models_api.go
@@ -0,0 +1,191 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// ✅ umbenannt, damit es nicht mit models.go kollidiert
+func modelsWriteJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func modelsReadJSON(r *http.Request, v any) error {
+ if r.Body == nil {
+ return errors.New("missing body")
+ }
+ defer r.Body.Close()
+ return json.NewDecoder(r.Body).Decode(v)
+}
+
+type parseReq struct {
+ Input string `json:"input"`
+}
+
+func parseModelFromURL(raw string) (ParsedModelDTO, error) {
+ in := strings.TrimSpace(raw)
+ if in == "" {
+ return ParsedModelDTO{}, errors.New("Bitte eine URL eingeben.")
+ }
+
+ // scheme ergänzen, falls User "chaturbate.com/xyz" eingibt
+ if !strings.Contains(in, "://") {
+ in = "https://" + in
+ }
+
+ u, err := url.Parse(in)
+ if err != nil || u.Scheme == "" || u.Hostname() == "" {
+ return ParsedModelDTO{}, errors.New("Ungültige URL.")
+ }
+
+ host := strings.ToLower(u.Hostname())
+ host = strings.TrimPrefix(host, "www.")
+
+ // ModelKey aus Pfad/Fragment ableiten
+ path := strings.Trim(u.Path, "/")
+ segs := strings.Split(path, "/")
+
+ skip := map[string]bool{
+ "models": true, "model": true, "profile": true, "users": true, "user": true,
+ }
+
+ var key string
+ for _, s := range segs {
+ s = strings.TrimSpace(s)
+ if s == "" || skip[strings.ToLower(s)] {
+ continue
+ }
+ key = s
+ break
+ }
+ if key == "" && strings.TrimSpace(u.Fragment) != "" {
+ key = strings.Trim(strings.TrimSpace(u.Fragment), "/")
+ }
+ if key == "" {
+ return ParsedModelDTO{}, errors.New("Konnte keinen Modelnamen aus der URL ableiten.")
+ }
+
+ // URL-decode + kleines Sanitizing
+ if dec, err := url.PathUnescape(key); err == nil {
+ key = dec
+ }
+ key = strings.TrimPrefix(strings.TrimSpace(key), "@")
+ key = strings.Map(func(r rune) rune {
+ switch {
+ case r >= 'a' && r <= 'z':
+ return r
+ case r >= 'A' && r <= 'Z':
+ return r
+ case r >= '0' && r <= '9':
+ return r
+ case r == '_' || r == '-' || r == '.':
+ return r
+ default:
+ return -1
+ }
+ }, key)
+ if key == "" {
+ return ParsedModelDTO{}, errors.New("Ungültiger Modelname in URL.")
+ }
+
+ return ParsedModelDTO{
+ Input: u.String(), // ✅ speicherst du als URL
+ IsURL: true,
+ Host: host,
+ Path: u.Path,
+ ModelKey: key, // ✅ kommt IMMER aus URL
+ }, nil
+}
+
+func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
+
+ // ✅ NEU: Parse-Endpoint (nur URL erlaubt)
+ mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+ return
+ }
+ var req parseReq
+ if err := modelsReadJSON(r, &req); err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ dto, err := parseModelFromURL(req.Input)
+ if err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ modelsWriteJSON(w, http.StatusOK, dto)
+ })
+
+ mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
+ modelsWriteJSON(w, http.StatusOK, store.List())
+ })
+
+ mux.HandleFunc("/api/models/upsert", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+ return
+ }
+ var req ParsedModelDTO
+ if err := modelsReadJSON(r, &req); err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+
+ // ✅ Server-seitig: nur URL akzeptieren (wird zusätzlich im Store geprüft)
+ if !req.IsURL {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Nur URL erlaubt."})
+ return
+ }
+
+ m, err := store.UpsertFromParsed(req)
+ if err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ modelsWriteJSON(w, http.StatusOK, m)
+ })
+
+ mux.HandleFunc("/api/models/flags", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+ return
+ }
+ var req ModelFlagsPatch
+ if err := modelsReadJSON(r, &req); err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ m, err := store.PatchFlags(req)
+ if err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ modelsWriteJSON(w, http.StatusOK, m)
+ })
+
+ mux.HandleFunc("/api/models/delete", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+ return
+ }
+ var req struct {
+ ID string `json:"id"`
+ }
+ if err := modelsReadJSON(r, &req); err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := store.Delete(req.ID); err != nil {
+ modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+}
diff --git a/backend/models_store.go b/backend/models_store.go
new file mode 100644
index 0000000..bc30aa5
--- /dev/null
+++ b/backend/models_store.go
@@ -0,0 +1,238 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+type StoredModel struct {
+ ID string `json:"id"` // i.d.R. modelKey (unique)
+ Input string `json:"input"` // Original-URL/Eingabe
+ IsURL bool `json:"isUrl"` // vom Parser
+ Host string `json:"host,omitempty"`
+ Path string `json:"path,omitempty"`
+ ModelKey string `json:"modelKey"` // Display/Key
+
+ Watching bool `json:"watching"`
+ Favorite bool `json:"favorite"`
+ Hot bool `json:"hot"`
+ Keep bool `json:"keep"`
+ Liked *bool `json:"liked,omitempty"` // null => unbekannt
+
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+type ModelStore struct {
+ path string
+ mu sync.RWMutex
+ items map[string]StoredModel
+}
+
+func NewModelStore(path string) *ModelStore {
+ return &ModelStore{
+ path: path,
+ items: map[string]StoredModel{},
+ }
+}
+
+func (s *ModelStore) Load() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ b, err := os.ReadFile(s.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil // ok
+ }
+ return err
+ }
+
+ var list []StoredModel
+ if err := json.Unmarshal(b, &list); err != nil {
+ return err
+ }
+
+ s.items = map[string]StoredModel{}
+ for _, m := range list {
+ if m.ID == "" {
+ m.ID = m.ModelKey
+ }
+ if m.ID != "" {
+ s.items[m.ID] = m
+ }
+ }
+ return nil
+}
+
+func (s *ModelStore) saveLocked() error {
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+
+ list := make([]StoredModel, 0, len(s.items))
+ for _, m := range s.items {
+ list = append(list, m)
+ }
+
+ // Neueste zuerst
+ sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt > list[j].UpdatedAt })
+
+ b, err := json.MarshalIndent(list, "", " ")
+ if err != nil {
+ return err
+ }
+
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o644); err != nil {
+ return err
+ }
+
+ // Windows: Rename überschreibt nicht immer zuverlässig -> erst versuchen, sonst löschen & retry
+ if err := os.Rename(tmp, s.path); err != nil {
+ _ = os.Remove(s.path)
+ if err2 := os.Rename(tmp, s.path); err2 != nil {
+ _ = os.Remove(tmp)
+ return err2
+ }
+ }
+ return nil
+}
+
+func (s *ModelStore) List() []StoredModel {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ out := make([]StoredModel, 0, len(s.items))
+ for _, m := range s.items {
+ out = append(out, m)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
+ return out
+}
+
+type ParsedModelDTO struct {
+ Input string `json:"input"`
+ IsURL bool `json:"isUrl"`
+ Host string `json:"host,omitempty"`
+ Path string `json:"path,omitempty"`
+ ModelKey string `json:"modelKey"`
+}
+
+func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
+ if p.ModelKey == "" {
+ return StoredModel{}, errors.New("modelKey fehlt")
+ }
+
+ input := strings.TrimSpace(p.Input)
+ if input == "" {
+ return StoredModel{}, errors.New("URL fehlt.")
+ }
+ if !p.IsURL {
+ return StoredModel{}, errors.New("Nur URL erlaubt.")
+ }
+ u, err := url.Parse(input)
+ if err != nil || u.Scheme == "" || u.Hostname() == "" {
+ return StoredModel{}, errors.New("Ungültige URL.")
+ }
+ if strings.TrimSpace(p.ModelKey) == "" {
+ return StoredModel{}, errors.New("ModelKey fehlt.")
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ id := p.ModelKey
+ existing, ok := s.items[id]
+ if !ok {
+ existing = StoredModel{
+ ID: id,
+ CreatedAt: now,
+ }
+ }
+
+ // Felder aktualisieren
+ existing.Input = p.Input
+ existing.IsURL = p.IsURL
+ existing.Host = p.Host
+ existing.Path = p.Path
+ existing.ModelKey = p.ModelKey
+ existing.UpdatedAt = now
+
+ s.items[id] = existing
+ if err := s.saveLocked(); err != nil {
+ return StoredModel{}, err
+ }
+ return existing, nil
+}
+
+type ModelFlagsPatch struct {
+ ID string `json:"id"`
+ Watching *bool `json:"watching,omitempty"`
+ Favorite *bool `json:"favorite,omitempty"`
+ Hot *bool `json:"hot,omitempty"`
+ Keep *bool `json:"keep,omitempty"`
+ Liked *bool `json:"liked,omitempty"`
+ ClearLiked bool `json:"clearLiked,omitempty"`
+}
+
+func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
+ if patch.ID == "" {
+ return StoredModel{}, errors.New("id fehlt")
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ m, ok := s.items[patch.ID]
+ if !ok {
+ return StoredModel{}, errors.New("model nicht gefunden")
+ }
+
+ if patch.Watching != nil {
+ m.Watching = *patch.Watching
+ }
+ if patch.Favorite != nil {
+ m.Favorite = *patch.Favorite
+ }
+ if patch.Hot != nil {
+ m.Hot = *patch.Hot
+ }
+ if patch.Keep != nil {
+ m.Keep = *patch.Keep
+ }
+ if patch.ClearLiked {
+ m.Liked = nil
+ } else if patch.Liked != nil {
+ m.Liked = patch.Liked
+ }
+
+ m.UpdatedAt = now
+ s.items[m.ID] = m
+
+ if err := s.saveLocked(); err != nil {
+ return StoredModel{}, err
+ }
+ return m, nil
+}
+
+func (s *ModelStore) Delete(id string) error {
+ if id == "" {
+ return errors.New("id fehlt")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.items, id)
+ return s.saveLocked()
+}
diff --git a/backend/myapp.exe b/backend/myapp.exe
new file mode 100644
index 0000000000000000000000000000000000000000..4ce1ff017b1a3a0b46a5e5cacb0ab15a9d1053d5
GIT binary patch
literal 8680448
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