updated finished download
This commit is contained in:
parent
bd6b2a50a6
commit
821fe0fef1
219
backend/chaturbate_autostart.go
Normal file
219
backend/chaturbate_autostart.go
Normal file
@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type autoStartItem struct {
|
||||
userKey string
|
||||
url string
|
||||
}
|
||||
|
||||
func normUser(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func chaturbateUserFromURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if !strings.Contains(host, "chaturbate.com") {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(u.Path, "/")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
return normUser(p)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cookieHeaderFromSettings(s RecorderSettings) string {
|
||||
m, err := decryptCookieMap(s.EncryptedCookies)
|
||||
if err != nil || len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for i, k := range keys {
|
||||
v := strings.TrimSpace(m[k])
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString("=")
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func resolveChaturbateURL(m WatchedModelLite) string {
|
||||
in := strings.TrimSpace(m.Input)
|
||||
if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
|
||||
return in
|
||||
}
|
||||
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://chaturbate.com/%s/", key)
|
||||
}
|
||||
|
||||
// Startet watched+online(public) automatisch – unabhängig vom Frontend
|
||||
func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if store == nil {
|
||||
fmt.Println("⚠️ [autostart] model store is nil")
|
||||
return
|
||||
}
|
||||
|
||||
const pollInterval = 5 * time.Second
|
||||
const startGap = 5 * time.Second
|
||||
const retryCooldown = 25 * time.Second
|
||||
|
||||
queue := make([]autoStartItem, 0, 64)
|
||||
queued := map[string]bool{}
|
||||
lastTry := map[string]time.Time{}
|
||||
var lastStart time.Time
|
||||
|
||||
for {
|
||||
s := getSettings()
|
||||
// ✅ Autostart nur wenn Feature aktiviert ist
|
||||
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)
|
||||
if !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || !s.AutoAddToDownloadList {
|
||||
queue = queue[:0]
|
||||
queued = map[string]bool{}
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
cookieHdr := cookieHeaderFromSettings(s)
|
||||
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
|
||||
if !hasChaturbateCookies(cookieHdr) {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// online snapshot aus cache
|
||||
cbMu.RLock()
|
||||
rooms := append([]ChaturbateRoom(nil), cb.Rooms...)
|
||||
cbMu.RUnlock()
|
||||
|
||||
showByUser := map[string]string{}
|
||||
for _, r := range rooms {
|
||||
showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
||||
}
|
||||
|
||||
// running users (damit wir nicht doppelt starten)
|
||||
running := map[string]bool{}
|
||||
jobsMu.Lock()
|
||||
for _, j := range jobs {
|
||||
if j == nil || j.Status != JobRunning {
|
||||
continue
|
||||
}
|
||||
u := chaturbateUserFromURL(j.SourceURL)
|
||||
if u != "" {
|
||||
running[u] = true
|
||||
}
|
||||
}
|
||||
jobsMu.Unlock()
|
||||
|
||||
// watched list aus DB
|
||||
watched := store.ListWatchedLite("chaturbate.com")
|
||||
watchedByUser := map[string]WatchedModelLite{}
|
||||
for _, m := range watched {
|
||||
key := normUser(m.ModelKey)
|
||||
if key != "" && m.Watching {
|
||||
watchedByUser[key] = m
|
||||
}
|
||||
}
|
||||
|
||||
// queue prune
|
||||
nextQueue := queue[:0]
|
||||
nextQueued := map[string]bool{}
|
||||
for _, it := range queue {
|
||||
m, ok := watchedByUser[it.userKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if showByUser[it.userKey] == "" {
|
||||
continue
|
||||
}
|
||||
if running[it.userKey] {
|
||||
continue
|
||||
}
|
||||
it.url = resolveChaturbateURL(m)
|
||||
if it.url == "" {
|
||||
continue
|
||||
}
|
||||
nextQueue = append(nextQueue, it)
|
||||
nextQueued[it.userKey] = true
|
||||
}
|
||||
queue = nextQueue
|
||||
queued = nextQueued
|
||||
|
||||
// enqueue new public watched
|
||||
now := time.Now()
|
||||
for user, m := range watchedByUser {
|
||||
if showByUser[user] != "public" {
|
||||
continue
|
||||
}
|
||||
if running[user] {
|
||||
continue
|
||||
}
|
||||
if queued[user] {
|
||||
continue
|
||||
}
|
||||
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
|
||||
continue
|
||||
}
|
||||
|
||||
u := resolveChaturbateURL(m)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
queue = append(queue, autoStartItem{userKey: user, url: u})
|
||||
queued[user] = true
|
||||
}
|
||||
|
||||
// starte max. einen Job pro Loop (mit Abstand)
|
||||
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
|
||||
it := queue[0]
|
||||
queue = queue[1:]
|
||||
delete(queued, it.userKey)
|
||||
lastTry[it.userKey] = time.Now()
|
||||
|
||||
_, err := startRecordingInternal(RecordRequest{
|
||||
URL: it.url,
|
||||
Cookie: cookieHdr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("❌ [autostart] start failed:", it.url, err)
|
||||
} else {
|
||||
fmt.Println("▶️ [autostart] started:", it.url)
|
||||
lastStart = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
@ -93,7 +93,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
|
||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||
func startChaturbateOnlinePoller() {
|
||||
const interval = 5 * time.Second
|
||||
const interval = 10 * time.Second
|
||||
|
||||
// nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s)
|
||||
lastLoggedCount := -1
|
||||
@ -143,10 +143,6 @@ func startChaturbateOnlinePoller() {
|
||||
cb.FetchedAt = time.Now()
|
||||
cbMu.Unlock()
|
||||
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cbMu.Unlock()
|
||||
|
||||
// success logging only on changes
|
||||
if lastLoggedErr != "" {
|
||||
fmt.Println("✅ [chaturbate] online rooms fetch recovered")
|
||||
|
||||
320
backend/main.go
320
backend/main.go
@ -50,7 +50,9 @@ type RecordJob struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
PreviewDir string `json:"-"`
|
||||
PreviewImage string `json:"-"`
|
||||
@ -84,7 +86,7 @@ var durCache = struct {
|
||||
m map[string]durEntry
|
||||
}{m: map[string]durEntry{}}
|
||||
|
||||
func durationSecondsCached(path string) (float64, error) {
|
||||
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@ -98,7 +100,7 @@ func durationSecondsCached(path string) (float64, error) {
|
||||
durCache.mu.Unlock()
|
||||
|
||||
// ffprobe (oder notfalls ffmpeg -i parsen)
|
||||
cmd := exec.Command("ffprobe",
|
||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
@ -133,6 +135,7 @@ type RecorderSettings struct {
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
|
||||
|
||||
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
|
||||
BlurPreviews bool `json:"blurPreviews,omitempty"`
|
||||
|
||||
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
||||
EncryptedCookies string `json:"encryptedCookies,omitempty"`
|
||||
@ -149,6 +152,7 @@ var (
|
||||
AutoStartAddedDownloads: false,
|
||||
|
||||
UseChaturbateAPI: false,
|
||||
BlurPreviews: false,
|
||||
EncryptedCookies: "",
|
||||
}
|
||||
settingsFile = "recorder_settings.json"
|
||||
@ -1139,7 +1143,9 @@ func registerFrontend(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
// routes.go (package main)
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
func registerRoutes(mux *http.ServeMux) *ModelStore {
|
||||
mux.HandleFunc("/api/cookies", cookiesHandler)
|
||||
|
||||
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
||||
mux.HandleFunc("/api/settings/browse", settingsBrowse)
|
||||
|
||||
@ -1150,9 +1156,11 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/record/list", recordList)
|
||||
mux.HandleFunc("/api/record/video", recordVideo)
|
||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
||||
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||
mux.HandleFunc("/api/record/duration", recordDuration)
|
||||
|
||||
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
|
||||
@ -1169,6 +1177,8 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ✅ Frontend (SPA) ausliefern
|
||||
registerFrontend(mux)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// --- main ---
|
||||
@ -1176,7 +1186,10 @@ func main() {
|
||||
loadSettings()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(mux)
|
||||
store := registerRoutes(mux)
|
||||
|
||||
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
|
||||
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
|
||||
|
||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||
if err := http.ListenAndServe(":9999", mux); err != nil {
|
||||
@ -1191,6 +1204,40 @@ type RecordRequest struct {
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
||||
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
url := strings.TrimSpace(req.URL)
|
||||
if url == "" {
|
||||
return nil, errors.New("url fehlt")
|
||||
}
|
||||
|
||||
// Duplicate-running guard (identische URL)
|
||||
jobsMu.Lock()
|
||||
for _, j := range jobs {
|
||||
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
|
||||
jobsMu.Unlock()
|
||||
return j, nil
|
||||
}
|
||||
}
|
||||
|
||||
jobID := uuid.NewString()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
job := &RecordJob{
|
||||
ID: jobID,
|
||||
SourceURL: url,
|
||||
Status: JobRunning,
|
||||
StartedAt: time.Now(),
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
jobs[jobID] = job
|
||||
jobsMu.Unlock()
|
||||
|
||||
go runJob(ctx, job, req)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -1203,30 +1250,14 @@ func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
http.Error(w, "url fehlt", http.StatusBadRequest)
|
||||
job, err := startRecordingInternal(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 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)
|
||||
_ = json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
func parseCookieString(cookieStr string) map[string]string {
|
||||
@ -1429,8 +1460,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeFile(w, r, outPath)
|
||||
serveVideoFile(w, r, outPath)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1490,9 +1520,18 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeFile(w, r, outPath)
|
||||
serveVideoFile(w, r, outPath)
|
||||
}
|
||||
|
||||
func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
|
||||
durCache.mu.Lock()
|
||||
e, ok := durCache.m[path]
|
||||
durCache.mu.Unlock()
|
||||
|
||||
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
|
||||
return e.sec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1550,7 +1589,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
t := fi.ModTime()
|
||||
|
||||
dur, _ := durationSecondsCached(full)
|
||||
dur := durationSecondsCacheOnly(full, fi)
|
||||
|
||||
list = append(list, &RecordJob{
|
||||
ID: base,
|
||||
@ -1559,6 +1598,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
StartedAt: t,
|
||||
EndedAt: &t,
|
||||
DurationSeconds: dur,
|
||||
SizeBytes: fi.Size(),
|
||||
})
|
||||
|
||||
}
|
||||
@ -1572,6 +1612,155 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
type doneMetaResp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(doneAbs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
|
||||
return
|
||||
}
|
||||
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cnt := 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
|
||||
if ext != ".mp4" && ext != ".ts" {
|
||||
continue
|
||||
}
|
||||
cnt++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
|
||||
}
|
||||
|
||||
type durationReq struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type durationItem struct {
|
||||
File string `json:"file"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func recordDuration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req durationReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hard limit, damit niemand dir 5000 files schickt
|
||||
if len(req.Files) > 200 {
|
||||
http.Error(w, "too many files", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve done dir", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// De-dupe
|
||||
seen := make(map[string]struct{}, len(req.Files))
|
||||
files := make([]string, 0, len(req.Files))
|
||||
for _, f := range req.Files {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
// Server-side Concurrency Limit (z.B. 2-4)
|
||||
sem := make(chan struct{}, 3)
|
||||
|
||||
out := make([]durationItem, len(files))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, file := range files {
|
||||
wg.Add(1)
|
||||
go func(i int, file string) {
|
||||
defer wg.Done()
|
||||
|
||||
// ✅ sanitize: nur basename erlauben
|
||||
if filepath.Base(file) != file || strings.Contains(file, "/") || strings.Contains(file, "\\") {
|
||||
out[i] = durationItem{File: file, Error: "invalid file"}
|
||||
return
|
||||
}
|
||||
|
||||
full := filepath.Join(doneAbs, file)
|
||||
|
||||
// Existiert?
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil || fi.IsDir() {
|
||||
out[i] = durationItem{File: file, Error: "not found"}
|
||||
return
|
||||
}
|
||||
|
||||
// Cache-hit? (spart ffprobe)
|
||||
if sec := durationSecondsCacheOnly(full, fi); sec > 0 {
|
||||
out[i] = durationItem{File: file, DurationSeconds: sec}
|
||||
return
|
||||
}
|
||||
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
sec, err := durationSecondsCached(r.Context(), full) // ctx-fähig, siehe unten
|
||||
if err != nil || sec <= 0 {
|
||||
out[i] = durationItem{File: file, Error: "ffprobe failed"}
|
||||
return
|
||||
}
|
||||
out[i] = durationItem{File: file, DurationSeconds: sec}
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
@ -1636,8 +1825,8 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := removeWithRetry(target); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||
if isSharingViolation(err) {
|
||||
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
@ -1652,6 +1841,27 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||
f, err := openForReadShareDelete(path)
|
||||
if err != nil {
|
||||
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil || fi.IsDir() || fi.Size() == 0 {
|
||||
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
|
||||
// ServeContent unterstützt Range Requests (wichtig für Video)
|
||||
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
|
||||
}
|
||||
|
||||
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -1890,33 +2100,31 @@ func moveFile(src, dst string) error {
|
||||
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
|
||||
|
||||
func isSharingViolation(err error) bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
if errno, ok := pe.Err.(syscall.Errno); ok {
|
||||
return errno == windowsSharingViolation
|
||||
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
|
||||
}
|
||||
return errors.Is(pe.Err, windowsSharingViolation)
|
||||
}
|
||||
|
||||
var le *os.LinkError
|
||||
if errors.As(err, &le) {
|
||||
if errno, ok := le.Err.(syscall.Errno); ok {
|
||||
return errno == windowsSharingViolation
|
||||
}
|
||||
return errors.Is(le.Err, windowsSharingViolation)
|
||||
}
|
||||
|
||||
return errors.Is(err, windowsSharingViolation)
|
||||
// Fallback über Text
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "sharing violation") ||
|
||||
strings.Contains(s, "used by another process") ||
|
||||
strings.Contains(s, "wird von einem anderen prozess verwendet")
|
||||
}
|
||||
|
||||
func renameWithRetry(src, dst string) error {
|
||||
func removeWithRetry(path string) error {
|
||||
var err error
|
||||
for i := 0; i < 15; i++ { // ~1.5s
|
||||
err = os.Rename(src, dst)
|
||||
for i := 0; i < 40; i++ { // ~4s bei 100ms
|
||||
err = os.Remove(path)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
if isSharingViolation(err) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@ -1925,14 +2133,14 @@ func renameWithRetry(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func removeWithRetry(path string) error {
|
||||
func renameWithRetry(oldPath, newPath string) error {
|
||||
var err error
|
||||
for i := 0; i < 15; i++ { // ~1.5s
|
||||
err = os.Remove(path)
|
||||
for i := 0; i < 40; i++ {
|
||||
err = os.Rename(oldPath, newPath)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
if isSharingViolation(err) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@ -2029,8 +2237,6 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
|
||||
|
||||
w.Write([]byte(`{"ok":"stopped"}`))
|
||||
}
|
||||
|
||||
@ -2076,8 +2282,6 @@ func RecordStream(
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
|
||||
|
||||
// 4) Datei öffnen
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
@ -2087,8 +2291,6 @@ func RecordStream(
|
||||
_ = 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
|
||||
|
||||
Binary file not shown.
9
backend/sharedelete_other.go
Normal file
9
backend/sharedelete_other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func openForReadShareDelete(path string) (*os.File, error) {
|
||||
return os.Open(path)
|
||||
}
|
||||
31
backend/sharedelete_windows.go
Normal file
31
backend/sharedelete_windows.go
Normal file
@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func openForReadShareDelete(path string) (*os.File, error) {
|
||||
p, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wichtig: FILE_SHARE_DELETE erlaubt Rename/Move/Delete während Lesen/Streaming
|
||||
h, err := syscall.CreateFile(
|
||||
p,
|
||||
syscall.GENERIC_READ,
|
||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
syscall.OPEN_EXISTING,
|
||||
syscall.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return os.NewFile(uintptr(h), path), nil
|
||||
}
|
||||
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
Normal file
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
|
||||
<script type="module" crossorigin src="/assets/index-wVqrTYvi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CIN0UidG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -38,6 +38,7 @@ type RecorderSettings = {
|
||||
autoAddToDownloadList?: boolean
|
||||
autoStartAddedDownloads?: boolean
|
||||
useChaturbateApi?: boolean
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
@ -47,6 +48,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
autoAddToDownloadList: false,
|
||||
autoStartAddedDownloads: false,
|
||||
useChaturbateApi: false,
|
||||
blurPreviews: false,
|
||||
}
|
||||
|
||||
type StoredModel = {
|
||||
@ -59,29 +61,6 @@ type StoredModel = {
|
||||
liked?: boolean | null
|
||||
}
|
||||
|
||||
|
||||
type ChaturbateRoom = {
|
||||
username: string
|
||||
current_show?: 'public' | 'private' | 'hidden' | 'away' | string
|
||||
}
|
||||
|
||||
type ChaturbateOnlineResponse = {
|
||||
enabled: boolean
|
||||
fetchedAt?: string
|
||||
lastError?: string
|
||||
count?: number
|
||||
rooms: ChaturbateRoom[]
|
||||
}
|
||||
|
||||
type PendingWatchedRoom = {
|
||||
id: string
|
||||
modelKey: string
|
||||
url: string
|
||||
currentShow: string
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms))
|
||||
|
||||
function extractFirstHttpUrl(text: string): string | null {
|
||||
const t = (text ?? '').trim()
|
||||
if (!t) return null
|
||||
@ -132,11 +111,11 @@ export default function App() {
|
||||
const [, setParseError] = useState<string | null>(null)
|
||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||||
const [doneCount, setDoneCount] = useState<number>(0)
|
||||
const [modelsCount, setModelsCount] = useState(0)
|
||||
|
||||
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
||||
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||||
const watchedModelsRef = useRef<StoredModel[]>([])
|
||||
const [, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [cookieModalOpen, setCookieModalOpen] = useState(false)
|
||||
@ -148,12 +127,6 @@ export default function App() {
|
||||
|
||||
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
|
||||
|
||||
// ✅ Watched+Online (wartend) + Autostart-Queue
|
||||
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
|
||||
const autoStartQueueRef = useRef<Array<{ userKey: string; url: string }>>([])
|
||||
const autoStartQueuedUsersRef = useRef<Set<string>>(new Set())
|
||||
const autoStartWorkerRef = useRef(false)
|
||||
|
||||
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
|
||||
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
|
||||
|
||||
@ -223,39 +196,6 @@ export default function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ✅ 2) Watched-Chaturbate-Models (kleine Payload) – nur für den Online-Abgleich/Autostart
|
||||
useEffect(() => {
|
||||
if (!recSettings.useChaturbateApi) {
|
||||
watchedModelsRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const load = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/watched?host=chaturbate.com', { cache: 'no-store' })
|
||||
if (cancelled) return
|
||||
watchedModelsRef.current = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
if (!cancelled) watchedModelsRef.current = []
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const t = window.setInterval(load, document.hidden ? 30000 : 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
}
|
||||
}, [recSettings.useChaturbateApi])
|
||||
|
||||
|
||||
const initialCookies = useMemo(
|
||||
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
|
||||
[cookies]
|
||||
@ -269,8 +209,8 @@ export default function App() {
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running')
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length },
|
||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length },
|
||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
||||
{ id: 'models', label: 'Models', count: modelsCount },
|
||||
{ id: 'settings', label: 'Einstellungen' },
|
||||
]
|
||||
@ -332,6 +272,41 @@ export default function App() {
|
||||
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
||||
}, [cookies, cookiesLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let t: number | undefined
|
||||
|
||||
const loadDoneMeta = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const meta = (await res.json()) as { count?: number }
|
||||
if (!cancelled) setDoneCount(meta.count ?? 0)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
// wenn Tab nicht aktiv/Seite im Hintergrund: weniger oft
|
||||
const ms = document.hidden ? 60_000 : 30_000
|
||||
t = window.setTimeout(loadDoneMeta, ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onVis = () => {
|
||||
if (!document.hidden) void loadDoneMeta()
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
void loadDoneMeta()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (t) window.clearTimeout(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceUrl.trim() === '') {
|
||||
setParsed(null)
|
||||
@ -386,19 +361,47 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ nur pollen, wenn Finished-Tab aktiv ist
|
||||
if (selectedTab !== 'finished') return
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const loadDone = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const list = await apiJSON<RecordJob[]>('/api/record/done')
|
||||
setDoneJobs(Array.isArray(list) ? list : [])
|
||||
const list = await apiJSON<RecordJob[]>('/api/record/done', { cache: 'no-store' as any })
|
||||
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
setDoneJobs([])
|
||||
// optional: bei Fehler nicht leeren, wenn du den letzten Stand behalten willst
|
||||
if (!cancelled) setDoneJobs([])
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
// beim Betreten des Tabs einmal sofort laden
|
||||
loadDone()
|
||||
const t = setInterval(loadDone, 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// ✅ weniger aggressiv pollen
|
||||
const baseMs = 20000 // 20s
|
||||
const tickMs = document.hidden ? 60000 : baseMs
|
||||
const t = window.setInterval(loadDone, tickMs)
|
||||
|
||||
// ✅ wenn Tab wieder sichtbar wird: direkt refresh
|
||||
const onVis = () => {
|
||||
if (!document.hidden) void loadDone()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [selectedTab])
|
||||
|
||||
|
||||
function isChaturbate(url: string): boolean {
|
||||
try {
|
||||
@ -602,209 +605,48 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
|
||||
let m = playerModel
|
||||
if (!m) {
|
||||
m = await resolveModelForJob(job)
|
||||
setPlayerModel(m)
|
||||
}
|
||||
if (!m) return
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (job: RecordJob) => {
|
||||
const file = baseName(job.output || '')
|
||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||
|
||||
const next = !Boolean(m.favorite)
|
||||
const updated = await patchModelFlags({ id: m.id, favorite: next })
|
||||
let m = sameAsPlayer ? playerModel : null
|
||||
if (!m) m = await resolveModelForJob(job)
|
||||
if (!m) return
|
||||
|
||||
setPlayerModel(updated)
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
}, [playerModel])
|
||||
const next = !Boolean(m.favorite)
|
||||
|
||||
const handleToggleLike = useCallback(async (job: RecordJob) => {
|
||||
let m = playerModel
|
||||
if (!m) {
|
||||
m = await resolveModelForJob(job)
|
||||
setPlayerModel(m)
|
||||
}
|
||||
if (!m) return
|
||||
const updated = await patchModelFlags({
|
||||
id: m.id,
|
||||
favorite: next,
|
||||
...(next ? { clearLiked: true } : {}), // ✅ wie ModelsTab
|
||||
})
|
||||
|
||||
const next = !(m.liked === true)
|
||||
const updated = await patchModelFlags({ id: m.id, liked: next })
|
||||
if (sameAsPlayer) setPlayerModel(updated)
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
},
|
||||
[playerJob, playerModel]
|
||||
)
|
||||
|
||||
setPlayerModel(updated)
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
}, [playerModel])
|
||||
const handleToggleLike = useCallback(
|
||||
async (job: RecordJob) => {
|
||||
const file = baseName(job.output || '')
|
||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||
|
||||
let m = sameAsPlayer ? playerModel : null
|
||||
if (!m) m = await resolveModelForJob(job)
|
||||
if (!m) return
|
||||
|
||||
const normUser = (s: string) => (s || '').trim().toLowerCase()
|
||||
const curLiked = m.liked === true
|
||||
const updated = curLiked
|
||||
? await patchModelFlags({ id: m.id, clearLiked: true }) // ✅ aus
|
||||
: await patchModelFlags({ id: m.id, liked: true, favorite: false }) // ✅ an + fav aus
|
||||
|
||||
const chaturbateUserFromUrl = (u: string): string | null => {
|
||||
try {
|
||||
const url = new URL(u)
|
||||
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
return parts[0] ? normUser(parts[0]) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 1) Poll: alle watched+online Models als "wartend" anzeigen (public/private/hidden/away)
|
||||
// und public-Models in eine Start-Queue legen
|
||||
useEffect(() => {
|
||||
if (!recSettings.useChaturbateApi) {
|
||||
setPendingWatchedRooms([])
|
||||
autoStartQueueRef.current = []
|
||||
autoStartQueuedUsersRef.current = new Set()
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const canAutoStart = hasRequiredChaturbateCookies(cookiesRef.current)
|
||||
|
||||
const modelsList = watchedModelsRef.current
|
||||
|
||||
const online = await apiJSON<ChaturbateOnlineResponse>('/api/chaturbate/online', { cache: 'no-store' })
|
||||
|
||||
if (!online?.enabled) return
|
||||
|
||||
// online username -> show
|
||||
const showByUser = new Map<string, string>()
|
||||
for (const r of online.rooms ?? []) {
|
||||
showByUser.set(normUser(r.username), String(r.current_show || 'unknown').toLowerCase())
|
||||
}
|
||||
|
||||
// running username set (damit wir nichts doppelt starten/anzeigen)
|
||||
const runningUsers = new Set(
|
||||
jobsRef.current
|
||||
.filter((j) => j.status === 'running')
|
||||
.map((j) => chaturbateUserFromUrl(String(j.sourceUrl || '')))
|
||||
.filter(Boolean) as string[]
|
||||
)
|
||||
|
||||
// watched username set
|
||||
const watchedModels = (modelsList ?? []).filter(
|
||||
(m) => Boolean(m?.watching) && (
|
||||
String(m?.host || '').toLowerCase().includes('chaturbate.com') || isChaturbate(String(m?.input || ''))
|
||||
)
|
||||
)
|
||||
|
||||
const watchedUsers = new Set(watchedModels.map((m) => normUser(m.modelKey)).filter(Boolean))
|
||||
|
||||
// ✅ Queue aufräumen: raus, wenn nicht mehr watched, offline oder schon running
|
||||
{
|
||||
const nextQueue: Array<{ userKey: string; url: string }> = []
|
||||
for (const q of autoStartQueueRef.current) {
|
||||
if (!watchedUsers.has(q.userKey)) continue
|
||||
if (!showByUser.has(q.userKey)) continue
|
||||
if (runningUsers.has(q.userKey)) continue
|
||||
nextQueue.push(q)
|
||||
}
|
||||
autoStartQueueRef.current = nextQueue
|
||||
autoStartQueuedUsersRef.current = new Set(nextQueue.map((q) => q.userKey))
|
||||
}
|
||||
|
||||
// ✅ Pending Map: alle watched+online, die NICHT running sind
|
||||
const pendingMap = new Map<string, PendingWatchedRoom>()
|
||||
|
||||
for (const m of watchedModels) {
|
||||
const key = normUser(m.modelKey)
|
||||
if (!key) continue
|
||||
|
||||
const currentShow = showByUser.get(key)
|
||||
if (!currentShow) continue // offline -> nicht pending
|
||||
|
||||
// running -> nicht pending (steht ja in Jobs)
|
||||
if (runningUsers.has(key)) continue
|
||||
|
||||
const url = /^https?:\/\//i.test(m.input || '')
|
||||
? String(m.input).trim()
|
||||
: `https://chaturbate.com/${m.modelKey}/`
|
||||
|
||||
// ✅ erst mal ALLE watched+online als wartend anzeigen (auch public)
|
||||
if (!pendingMap.has(key)) {
|
||||
pendingMap.set(key, { id: m.id, modelKey: m.modelKey, url, currentShow })
|
||||
}
|
||||
|
||||
// ✅ public in Queue (wenn Cookies da), aber ohne Duplikate
|
||||
if (currentShow === 'public' && canAutoStart && !autoStartQueuedUsersRef.current.has(key)) {
|
||||
autoStartQueueRef.current.push({ userKey: key, url })
|
||||
autoStartQueuedUsersRef.current.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setPendingWatchedRooms([...pendingMap.values()])
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
const t = window.setInterval(poll, document.hidden ? 15000 : 5000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(t)
|
||||
}
|
||||
}, [recSettings.useChaturbateApi])
|
||||
|
||||
// ✅ 2) Worker: startet Queue nacheinander (5s Pause nach jedem Start)
|
||||
useEffect(() => {
|
||||
if (!recSettings.useChaturbateApi) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const loop = async () => {
|
||||
if (autoStartWorkerRef.current) return
|
||||
autoStartWorkerRef.current = true
|
||||
|
||||
try {
|
||||
while (!cancelled) {
|
||||
// wenn UI gerade manuell startet -> warten
|
||||
if (busyRef.current) {
|
||||
await sleep(500)
|
||||
continue
|
||||
}
|
||||
|
||||
const next = autoStartQueueRef.current.shift()
|
||||
if (!next) {
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// aus queued-set entfernen (damit Poll ggf. neu einreihen kann, falls Start nicht klappt)
|
||||
autoStartQueuedUsersRef.current.delete(next.userKey)
|
||||
|
||||
// start attempt (silent)
|
||||
const ok = await startUrl(next.url, { silent: true })
|
||||
if (ok) {
|
||||
// pending sofort rausnehmen, damit UI direkt "running" zeigt
|
||||
setPendingWatchedRooms((prev) => prev.filter((p) => normUser(p.modelKey) !== next.userKey))
|
||||
}
|
||||
|
||||
// ✅ 5s Abstand nach (erfolgreichem) Starten – ich warte auch bei failure,
|
||||
// damit wir nicht in eine schnelle Retry-Schleife laufen.
|
||||
if (ok) {
|
||||
await sleep(5000)
|
||||
} else {
|
||||
await sleep(5000)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
autoStartWorkerRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
void loop()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [recSettings.useChaturbateApi, startUrl])
|
||||
if (sameAsPlayer) setPlayerModel(updated)
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
},
|
||||
[playerJob, playerModel]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoAddEnabled && !autoStartEnabled) return
|
||||
@ -933,9 +775,9 @@ export default function App() {
|
||||
{selectedTab === 'running' && (
|
||||
<RunningDownloads
|
||||
jobs={runningJobs}
|
||||
pending={pendingWatchedRooms}
|
||||
onOpenPlayer={openPlayer}
|
||||
onStopJob={stopJob}
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -944,6 +786,11 @@ export default function App() {
|
||||
jobs={jobs}
|
||||
doneJobs={doneJobs}
|
||||
onOpenPlayer={openPlayer}
|
||||
onDeleteJob={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleLike={handleToggleLike}
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -977,13 +824,11 @@ export default function App() {
|
||||
<Player
|
||||
job={playerJob}
|
||||
expanded={playerExpanded}
|
||||
onToggleExpand={() => setPlayerExpanded((v) => !v)}
|
||||
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||||
onClose={() => setPlayerJob(null)}
|
||||
|
||||
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
||||
isFavorite={Boolean(playerModel?.favorite)}
|
||||
isLiked={playerModel?.liked === true}
|
||||
|
||||
onDelete={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,30 +6,49 @@ import HoverPopover from './HoverPopover'
|
||||
|
||||
type Variant = 'thumb' | 'fill'
|
||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||
type AnimatedMode = 'frames' | 'clips'
|
||||
type AnimatedTrigger = 'always' | 'hover'
|
||||
|
||||
type Props = {
|
||||
export type FinishedVideoPreviewProps = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
durationSeconds?: number
|
||||
onDuration?: (job: RecordJob, seconds: number) => void
|
||||
|
||||
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips */
|
||||
animated?: boolean
|
||||
animatedMode?: AnimatedMode
|
||||
animatedTrigger?: AnimatedTrigger
|
||||
|
||||
/** nur für frames */
|
||||
autoTickMs?: number
|
||||
thumbStepSec?: number
|
||||
thumbSpread?: boolean
|
||||
thumbSamples?: number
|
||||
|
||||
/** nur für clips */
|
||||
clipSeconds?: number
|
||||
|
||||
/** neu: thumb = w-20 h-16, fill = w-full h-full */
|
||||
variant?: Variant
|
||||
|
||||
/** optionales Zusatz-Styling */
|
||||
className?: string
|
||||
|
||||
showPopover?: boolean
|
||||
|
||||
blur?: boolean
|
||||
|
||||
/**
|
||||
* inline video:
|
||||
* - false: nur Bild
|
||||
* - false: nur Bild/Teaser
|
||||
* - true/'always': immer inline abspielen (wenn inView)
|
||||
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
|
||||
* - 'hover': nur bei Hover/Focus abspielen, sonst Bild
|
||||
*/
|
||||
inlineVideo?: InlineVideoMode
|
||||
/** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */
|
||||
inlineNonce?: number
|
||||
/** Inline-Playback: Controls anzeigen? */
|
||||
inlineControls?: boolean
|
||||
/** Inline-Playback: loopen? */
|
||||
inlineLoop?: boolean
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -37,46 +56,51 @@ export default function FinishedVideoPreview({
|
||||
getFileName,
|
||||
durationSeconds,
|
||||
onDuration,
|
||||
|
||||
animated = false,
|
||||
animatedMode = 'frames',
|
||||
animatedTrigger = 'always',
|
||||
|
||||
autoTickMs = 15000,
|
||||
thumbStepSec,
|
||||
thumbSpread,
|
||||
thumbSamples,
|
||||
|
||||
clipSeconds = 1,
|
||||
|
||||
variant = 'thumb',
|
||||
className,
|
||||
showPopover = true,
|
||||
blur = false,
|
||||
|
||||
inlineVideo = false,
|
||||
}: Props) {
|
||||
inlineNonce = 0,
|
||||
inlineControls = false,
|
||||
inlineLoop = true,
|
||||
}: FinishedVideoPreviewProps) {
|
||||
const file = getFileName(job.output || '')
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
|
||||
const [thumbOk, setThumbOk] = useState(true)
|
||||
const [videoOk, setVideoOk] = useState(true)
|
||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||
|
||||
// ✅ nur animieren, wenn sichtbar (Viewport)
|
||||
// inView (Viewport)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
// Tick nur für frames-Mode
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
|
||||
// ✅ für hover-play
|
||||
// Hover-State (für inline hover ODER teaser hover)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) return
|
||||
if (!inView || document.hidden) return
|
||||
|
||||
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||
return () => window.clearInterval(id)
|
||||
}, [animated, inView, autoTickMs])
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
const previewId = useMemo(() => {
|
||||
if (!file) return ''
|
||||
@ -92,25 +116,62 @@ export default function FinishedVideoPreview({
|
||||
const hasDuration =
|
||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
|
||||
// --- IntersectionObserver: nur Teaser/Inline spielen wenn sichtbar
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
// --- Tick für "frames"
|
||||
useEffect(() => {
|
||||
if (!animated) return
|
||||
if (animatedMode !== 'frames') return
|
||||
if (!inView || document.hidden) return
|
||||
|
||||
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||
return () => window.clearInterval(id)
|
||||
}, [animated, animatedMode, inView, autoTickMs])
|
||||
|
||||
// --- Thumbnail time (nur frames!)
|
||||
const thumbTimeSec = useMemo(() => {
|
||||
if (!animated) return null
|
||||
if (animatedMode !== 'frames') return null
|
||||
if (!hasDuration) return null
|
||||
const step = 3
|
||||
const total = Math.max(durationSeconds! - 0.1, step)
|
||||
return (localTick * step) % total
|
||||
}, [animated, hasDuration, durationSeconds, localTick])
|
||||
|
||||
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
|
||||
const dur = durationSeconds!
|
||||
const step = Math.max(0.25, thumbStepSec ?? 3)
|
||||
|
||||
if (thumbSpread) {
|
||||
const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur)))
|
||||
const idx = localTick % count
|
||||
const span = Math.max(0.1, dur - step)
|
||||
const base = Math.min(0.25, span * 0.02)
|
||||
const t = (idx / count) * span + base
|
||||
return Math.min(dur - 0.05, Math.max(0.05, t))
|
||||
}
|
||||
|
||||
const total = Math.max(dur - 0.1, step)
|
||||
const t = (localTick * step) % total
|
||||
return Math.min(dur - 0.05, Math.max(0.05, t))
|
||||
}, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples])
|
||||
|
||||
const thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
if (thumbTimeSec == null) {
|
||||
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||
}
|
||||
// static thumb (oder frames: mit t=...)
|
||||
if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
|
||||
thumbTimeSec.toFixed(2)
|
||||
)}`
|
||||
}, [previewId, thumbTimeSec])
|
||||
)}&v=${encodeURIComponent(String(localTick))}`
|
||||
}, [previewId, thumbTimeSec, localTick])
|
||||
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
setMetaLoaded(true)
|
||||
@ -120,24 +181,105 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
if (!videoSrc) {
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
}
|
||||
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
// --- Inline Video sichtbar?
|
||||
const showingInlineVideo =
|
||||
inlineMode !== 'never' &&
|
||||
inView &&
|
||||
videoOk &&
|
||||
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
// --- Teaser Clip Zeiten (nur clips)
|
||||
const clipTimes = useMemo(() => {
|
||||
if (!animated) return []
|
||||
if (animatedMode !== 'clips') return []
|
||||
if (!hasDuration) return []
|
||||
|
||||
const dur = durationSeconds!
|
||||
const clipLen = Math.max(0.25, clipSeconds)
|
||||
const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
|
||||
|
||||
const span = Math.max(0.1, dur - clipLen)
|
||||
const base = Math.min(0.25, span * 0.02)
|
||||
|
||||
const times: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = (i / count) * span + base
|
||||
times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
|
||||
}
|
||||
return times
|
||||
}, [animated, animatedMode, hasDuration, durationSeconds, thumbSamples, clipSeconds])
|
||||
|
||||
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
|
||||
|
||||
// --- Teaser aktiv? (nur inView, nicht inline, optional nur hover)
|
||||
const teaserActive =
|
||||
animated &&
|
||||
animatedMode === 'clips' &&
|
||||
inView &&
|
||||
!document.hidden &&
|
||||
videoOk &&
|
||||
clipTimes.length > 0 &&
|
||||
!showingInlineVideo &&
|
||||
(animatedTrigger === 'always' || hovered)
|
||||
|
||||
// --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover
|
||||
const wantsHover = inlineMode === 'hover' || (animated && animatedMode === 'clips' && animatedTrigger === 'hover')
|
||||
|
||||
// --- Teaser-Video Logik: spielt 1s Segmente nacheinander (Loop)
|
||||
const teaserRef = useRef<HTMLVideoElement | null>(null)
|
||||
const clipIdxRef = useRef(0)
|
||||
const clipStartRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const v = teaserRef.current
|
||||
if (!v) return
|
||||
|
||||
if (!teaserActive) {
|
||||
v.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (!clipTimes.length) return
|
||||
|
||||
clipIdxRef.current = clipIdxRef.current % clipTimes.length
|
||||
clipStartRef.current = clipTimes[clipIdxRef.current]
|
||||
|
||||
const start = () => {
|
||||
try {
|
||||
v.currentTime = clipStartRef.current
|
||||
} catch {}
|
||||
const p = v.play()
|
||||
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
|
||||
}
|
||||
|
||||
const onLoaded = () => start()
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!clipTimes.length) return
|
||||
if (v.currentTime - clipStartRef.current >= clipSeconds) {
|
||||
clipIdxRef.current = (clipIdxRef.current + 1) % clipTimes.length
|
||||
clipStartRef.current = clipTimes[clipIdxRef.current]
|
||||
try {
|
||||
v.currentTime = clipStartRef.current + 0.01
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
v.addEventListener('loadedmetadata', onLoaded)
|
||||
v.addEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
// Wenn metadata schon da ist:
|
||||
if (v.readyState >= 1) start()
|
||||
|
||||
return () => {
|
||||
v.removeEventListener('loadedmetadata', onLoaded)
|
||||
v.removeEventListener('timeupdate', onTimeUpdate)
|
||||
v.pause()
|
||||
}
|
||||
}, [teaserActive, clipTimesKey, clipSeconds])
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
@ -147,39 +289,59 @@ export default function FinishedVideoPreview({
|
||||
sizeClass,
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
// ✅ hover only relevant for inlineMode==='hover'
|
||||
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
|
||||
onFocus={wantsHover ? () => setHovered(true) : undefined}
|
||||
onBlur={wantsHover ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
|
||||
{/* 1) Inline Full Video (mit Controls) */}
|
||||
{showingInlineVideo ? (
|
||||
<video
|
||||
key={`inline-${previewId}-${inlineNonce}`}
|
||||
src={videoSrc}
|
||||
className="w-full h-full object-cover bg-black pointer-events-none"
|
||||
className={[
|
||||
'w-full h-full object-cover bg-black',
|
||||
blurCls,
|
||||
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
autoPlay
|
||||
loop
|
||||
controls={inlineControls}
|
||||
loop={inlineLoop}
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : teaserActive ? (
|
||||
/* 2) Teaser Clips (1s Segmente) */
|
||||
<video
|
||||
ref={teaserRef}
|
||||
key={`teaser-${previewId}-${clipTimesKey}`}
|
||||
src={videoSrc}
|
||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : thumbSrc && thumbOk ? (
|
||||
/* 3) Statisches Bild / Frames */
|
||||
<img
|
||||
src={thumbSrc}
|
||||
loading="lazy"
|
||||
alt={file}
|
||||
className="w-full h-full object-cover"
|
||||
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
|
||||
onError={() => setThumbOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
|
||||
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
@ -193,7 +355,7 @@ export default function FinishedVideoPreview({
|
||||
</div>
|
||||
)
|
||||
|
||||
// ✅ Gallery: kein HoverPopover
|
||||
// Gallery: kein HoverPopover
|
||||
if (!showPopover) return previewNode
|
||||
|
||||
return (
|
||||
@ -204,7 +366,7 @@ export default function FinishedVideoPreview({
|
||||
<div className="aspect-video">
|
||||
<video
|
||||
src={videoSrc}
|
||||
className="w-full h-full bg-black"
|
||||
className={['w-full h-full bg-black', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
|
||||
@ -13,10 +13,13 @@ import Card from './Card'
|
||||
|
||||
type Pos = { left: number; top: number }
|
||||
|
||||
type HoverPopoverAPI = { close: () => void }
|
||||
|
||||
type HoverPopoverProps = PropsWithChildren<{
|
||||
// Entweder direkt ein ReactNode
|
||||
// oder eine Renderfunktion, die den Open-Status bekommt
|
||||
content: ReactNode | ((open: boolean) => ReactNode)
|
||||
// (2. Param erlaubt z.B. Close-Button im Popover)
|
||||
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
|
||||
}>
|
||||
|
||||
export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
@ -53,6 +56,11 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
clearCloseTimeout()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const computePos = () => {
|
||||
const trigger = triggerRef.current
|
||||
const pop = popoverRef.current
|
||||
@ -116,7 +124,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
// Hilfsfunktion: content normalisieren
|
||||
const renderContent = () =>
|
||||
typeof content === 'function'
|
||||
? (content as (open: boolean) => ReactNode)(open)
|
||||
? (content as any)(open, { close })
|
||||
: content
|
||||
|
||||
return (
|
||||
@ -144,7 +152,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
<Card
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]"
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 max-w-[calc(100vw-16px)]"
|
||||
noBodyPadding
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import HoverPopover from './HoverPopover'
|
||||
import LiveHlsVideo from './LiveHlsVideo'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type Props = {
|
||||
jobId: string
|
||||
@ -12,9 +13,11 @@ type Props = {
|
||||
thumbTick?: number
|
||||
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
|
||||
autoTickMs?: number
|
||||
blur?: boolean
|
||||
}
|
||||
|
||||
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: Props) {
|
||||
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000, blur = false }: Props) {
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
@ -75,15 +78,32 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={(open) =>
|
||||
content={(open, { close }) =>
|
||||
open && (
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<LiveHlsVideo
|
||||
src={hq}
|
||||
muted={false}
|
||||
className="w-full h-full bg-black"
|
||||
/>
|
||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} />
|
||||
|
||||
{/* LIVE badge */}
|
||||
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||
Live
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70"
|
||||
aria-label="Live-Vorschau schließen"
|
||||
title="Vorschau schließen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -98,7 +118,7 @@ export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: P
|
||||
src={thumb}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
|
||||
onError={() => setImgError(true)}
|
||||
onLoad={() => setImgError(false)}
|
||||
/>
|
||||
|
||||
@ -199,12 +199,50 @@ export default function Player({
|
||||
|
||||
try {
|
||||
p.pause()
|
||||
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
|
||||
// video.js hat reset() -> stoppt Tech + Requests oft zuverlässiger
|
||||
;(p as any).reset?.()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// Source leeren, damit Browser/Tech die HTTP-Verbindung abbricht
|
||||
p.src({ src: '', type: 'video/mp4' } as any)
|
||||
;(p as any).load?.()
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onRelease = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||
const file = (detail?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
const current = baseName(job.output?.trim() || '')
|
||||
if (current && current === file) {
|
||||
releaseMedia()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('player:release', onRelease as EventListener)
|
||||
return () => window.removeEventListener('player:release', onRelease as EventListener)
|
||||
}, [job.output, releaseMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onCloseIfFile = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||
const file = (detail?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
const current = baseName(job.output?.trim() || '')
|
||||
if (current && current === file) {
|
||||
releaseMedia()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('player:close', onCloseIfFile as EventListener)
|
||||
return () => window.removeEventListener('player:close', onCloseIfFile as EventListener)
|
||||
}, [job.output, releaseMedia, onClose])
|
||||
|
||||
const mini = !expanded
|
||||
|
||||
const [miniHover, setMiniHover] = React.useState(false)
|
||||
|
||||
@ -17,6 +17,7 @@ type RecorderSettings = {
|
||||
|
||||
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
|
||||
useChaturbateApi?: boolean
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const DEFAULTS: RecorderSettings = {
|
||||
@ -30,6 +31,7 @@ const DEFAULTS: RecorderSettings = {
|
||||
autoStartAddedDownloads: true,
|
||||
|
||||
useChaturbateApi: false,
|
||||
blurPreviews: false,
|
||||
}
|
||||
|
||||
export default function RecorderSettings() {
|
||||
@ -59,6 +61,7 @@ export default function RecorderSettings() {
|
||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||||
|
||||
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
||||
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@ -117,6 +120,7 @@ export default function RecorderSettings() {
|
||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
||||
|
||||
const useChaturbateApi = !!value.useChaturbateApi
|
||||
const blurPreviews = !!value.blurPreviews
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
@ -131,6 +135,7 @@ export default function RecorderSettings() {
|
||||
autoStartAddedDownloads,
|
||||
|
||||
useChaturbateApi,
|
||||
blurPreviews,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
@ -256,6 +261,13 @@ export default function RecorderSettings() {
|
||||
label="Chaturbate API"
|
||||
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.blurPreviews}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
|
||||
label="Vorschaubilder blurren"
|
||||
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ type Props = {
|
||||
pending?: PendingWatchedRoom[]
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
onStopJob: (id: string) => void
|
||||
blurPreviews?: boolean
|
||||
}
|
||||
|
||||
const baseName = (p: string) =>
|
||||
@ -55,13 +56,13 @@ const runtimeOf = (j: RecordJob) => {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob }: Props) {
|
||||
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
|
||||
const columns = useMemo<Column<RecordJob>[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <ModelPreview jobId={j.id} />,
|
||||
cell: (j) => <ModelPreview jobId={j.id} blur={blurPreviews} />,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
@ -199,7 +200,7 @@ export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onS
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<ModelPreview jobId={j.id} />
|
||||
<ModelPreview jobId={j.id} blur={blurPreviews} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@ -45,6 +45,19 @@ export type SwipeCardProps = {
|
||||
/** Animation timings */
|
||||
snapMs?: number
|
||||
commitMs?: number
|
||||
/**
|
||||
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
|
||||
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
|
||||
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
|
||||
*/
|
||||
ignoreFromBottomPx?: number
|
||||
|
||||
/**
|
||||
* Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird.
|
||||
* (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben)
|
||||
*/
|
||||
ignoreSelector?: string
|
||||
|
||||
}
|
||||
|
||||
export type SwipeCardHandle = {
|
||||
@ -82,6 +95,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
},
|
||||
thresholdPx = 120,
|
||||
thresholdRatio = 0.35,
|
||||
ignoreFromBottomPx = 72,
|
||||
ignoreSelector = '[data-swipe-ignore]',
|
||||
snapMs = 180,
|
||||
commitMs = 180,
|
||||
},
|
||||
@ -95,7 +110,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
x: number
|
||||
y: number
|
||||
dragging: boolean
|
||||
}>({ id: null, x: 0, y: 0, dragging: false })
|
||||
captured: boolean
|
||||
}>({ id: null, x: 0, y: 0, dragging: false, captured: false })
|
||||
|
||||
const [dx, setDx] = React.useState(0)
|
||||
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
||||
@ -190,9 +206,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
|
||||
const target = e.target as HTMLElement | null
|
||||
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
||||
|
||||
// ✅ 2) Ignoriere Start im unteren Bereich (z.B. Video-Controls/Progressbar)
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const fromBottom = rect.bottom - e.clientY
|
||||
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
|
||||
|
||||
pointer.current = {
|
||||
id: e.pointerId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
dragging: false,
|
||||
captured: false,
|
||||
}
|
||||
}}
|
||||
|
||||
onPointerMove={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
@ -200,12 +232,27 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const ddx = e.clientX - pointer.current.x
|
||||
const ddy = e.clientY - pointer.current.y
|
||||
|
||||
// Erst entscheiden ob wir überhaupt "draggen"
|
||||
// Erst entscheiden ob wir überhaupt draggen
|
||||
if (!pointer.current.dragging) {
|
||||
// wenn Nutzer vertikal scrollt, nicht hijacken
|
||||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
|
||||
if (Math.abs(ddx) < 6) return
|
||||
// wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken
|
||||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
|
||||
pointer.current.id = null
|
||||
return
|
||||
}
|
||||
|
||||
// "Dead zone" bis wirklich horizontal gedrückt wird
|
||||
if (Math.abs(ddx) < 12) return
|
||||
|
||||
// ✅ jetzt erst beginnen wir zu swipen
|
||||
pointer.current.dragging = true
|
||||
|
||||
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
pointer.current.captured = true
|
||||
} catch {
|
||||
pointer.current.captured = false
|
||||
}
|
||||
}
|
||||
|
||||
setAnimMs(0)
|
||||
@ -214,9 +261,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
|
||||
}}
|
||||
|
||||
onPointerUp={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
@ -226,7 +273,18 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
const wasDragging = pointer.current.dragging
|
||||
const wasCaptured = pointer.current.captured
|
||||
|
||||
pointer.current.id = null
|
||||
pointer.current.dragging = false
|
||||
pointer.current.captured = false
|
||||
|
||||
// Capture sauber lösen (falls gesetzt)
|
||||
if (wasCaptured) {
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!wasDragging) {
|
||||
reset()
|
||||
@ -235,15 +293,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}
|
||||
|
||||
if (dx > threshold) {
|
||||
void commit('right', true) // keep
|
||||
void commit('right', true)
|
||||
} else if (dx < -threshold) {
|
||||
void commit('left', true) // delete
|
||||
void commit('left', true)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
onPointerCancel={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.captured && pointer.current.id != null) {
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
||||
} catch {}
|
||||
}
|
||||
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false }
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
|
||||
@ -15,58 +15,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
|
||||
.vjs-mini .video-js .vjs-control-bar{
|
||||
z-index: 40; /* über Overlays */
|
||||
background: rgba(0,0,0,.65);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Progressbar deutlich höher */
|
||||
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-play-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(99,102,241,.95);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-load-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
|
||||
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
|
||||
.vjs-controls-on-activity .video-js .vjs-control-bar{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
|
||||
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
|
||||
.vjs-controls-on-activity .player-ui{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .player-ui,
|
||||
.vjs-controls-on-activity:focus-within .player-ui{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user