updated
This commit is contained in:
parent
ce92719ae7
commit
7298e4c91f
@ -23,6 +23,94 @@ type BioContextResp struct {
|
|||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Bio any `json:"bio,omitempty"`
|
Bio any `json:"bio,omitempty"`
|
||||||
|
|
||||||
|
// neu
|
||||||
|
RoomStatus string `json:"roomStatus,omitempty"` // public/private/hidden/away/offline/unknown
|
||||||
|
IsOnline bool `json:"isOnline,omitempty"`
|
||||||
|
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCBShow(v string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
switch s {
|
||||||
|
case "public", "private", "hidden", "away", "offline":
|
||||||
|
return s
|
||||||
|
case "online", "live":
|
||||||
|
return "public"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asMap(v any) map[string]any {
|
||||||
|
m, _ := v.(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k]; ok {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferRoomStatusFromBio(bio any) (show string, chatRoomURL string, imageURL string, ok bool) {
|
||||||
|
root := asMap(bio)
|
||||||
|
if root == nil {
|
||||||
|
return "unknown", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// häufige Kandidaten
|
||||||
|
candidates := []map[string]any{
|
||||||
|
root,
|
||||||
|
asMap(root["room_status"]),
|
||||||
|
asMap(root["room"]),
|
||||||
|
asMap(root["status"]),
|
||||||
|
asMap(root["user"]),
|
||||||
|
asMap(root["broadcaster"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range candidates {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := firstString(
|
||||||
|
m,
|
||||||
|
"current_show",
|
||||||
|
"room_status",
|
||||||
|
"status",
|
||||||
|
"show",
|
||||||
|
"chat_status",
|
||||||
|
)
|
||||||
|
|
||||||
|
if raw != "" {
|
||||||
|
show = normalizeCBShow(raw)
|
||||||
|
chatRoomURL = firstString(m, "chat_room_url", "room_url", "url")
|
||||||
|
imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url")
|
||||||
|
return show, chatRoomURL, imageURL, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: rekursiv ein paar bekannte Unterobjekte durchsuchen
|
||||||
|
for _, key := range []string{"room", "user", "broadcaster", "data"} {
|
||||||
|
if m := asMap(root[key]); m != nil {
|
||||||
|
raw := firstString(m, "current_show", "room_status", "status", "show", "chat_status")
|
||||||
|
if raw != "" {
|
||||||
|
show = normalizeCBShow(raw)
|
||||||
|
chatRoomURL = firstString(m, "chat_room_url", "room_url", "url")
|
||||||
|
imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url")
|
||||||
|
return show, chatRoomURL, imageURL, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown", "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -65,14 +153,21 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück
|
// 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück
|
||||||
if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge {
|
if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge {
|
||||||
|
roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(cachedBio)
|
||||||
|
isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown"
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(BioContextResp{
|
_ = json.NewEncoder(w).Encode(BioContextResp{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
FetchedAt: cachedAt,
|
FetchedAt: cachedAt,
|
||||||
LastError: "",
|
LastError: "",
|
||||||
Model: model,
|
Model: model,
|
||||||
Bio: cachedBio,
|
Bio: cachedBio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -208,18 +303,37 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
fetchedAt := time.Now().UTC()
|
fetchedAt := time.Now().UTC()
|
||||||
|
|
||||||
|
roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(bio)
|
||||||
|
isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown"
|
||||||
|
|
||||||
// 5) Persistieren
|
// 5) Persistieren
|
||||||
if cbModelStore != nil {
|
if cbModelStore != nil {
|
||||||
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
|
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
|
||||||
|
|
||||||
|
if hasStatus {
|
||||||
|
_ = cbModelStore.SetChaturbateRoomState(
|
||||||
|
"chaturbate.com",
|
||||||
|
model,
|
||||||
|
roomStatus,
|
||||||
|
isOnline,
|
||||||
|
chatRoomURL,
|
||||||
|
imageURL,
|
||||||
|
fetchedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(BioContextResp{
|
_ = json.NewEncoder(w).Encode(BioContextResp{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
LastError: "",
|
LastError: "",
|
||||||
Model: model,
|
Model: model,
|
||||||
Bio: bio,
|
Bio: bio,
|
||||||
|
RoomStatus: roomStatus,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ChatRoomURL: chatRoomURL,
|
||||||
|
ImageURL: imageURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -271,6 +271,37 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasActiveRecordingJobForModelKey(modelKey string) bool {
|
||||||
|
mk := strings.ToLower(strings.TrimSpace(modelKey))
|
||||||
|
if mk == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.RLock()
|
||||||
|
defer jobsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isActiveRecordingJob(j) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL)))
|
||||||
|
if u == mk {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
|
func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return
|
return
|
||||||
@ -319,6 +350,11 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solange ein aktiver Recorder für das Model läuft, NICHT auf offline kippen.
|
||||||
|
if hasActiveRecordingJobForModelKey(modelKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
_ = store.SetChaturbateRoomState(
|
_ = store.SetChaturbateRoomState(
|
||||||
"chaturbate.com",
|
"chaturbate.com",
|
||||||
modelKey,
|
modelKey,
|
||||||
@ -331,6 +367,167 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cbBioRefreshMu sync.Mutex
|
||||||
|
var cbBioRefreshAt = map[string]time.Time{} // key=userLower -> last biocontext refresh
|
||||||
|
|
||||||
|
func shouldRefreshBio(userLower string, minInterval time.Duration) bool {
|
||||||
|
if userLower == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cbBioRefreshMu.Lock()
|
||||||
|
defer cbBioRefreshMu.Unlock()
|
||||||
|
|
||||||
|
last := cbBioRefreshAt[userLower]
|
||||||
|
if !last.IsZero() && time.Since(last) < minInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cbBioRefreshAt[userLower] = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeChaturbateJobsMissingFromOnlineSnapshot() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
|
||||||
|
cbMu.RLock()
|
||||||
|
lite := cb.LiteByUser
|
||||||
|
cbMu.RUnlock()
|
||||||
|
|
||||||
|
jobsMu.RLock()
|
||||||
|
defer jobsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isActiveRecordingJob(j) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
userLower := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL)))
|
||||||
|
if userLower == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if lite != nil {
|
||||||
|
if _, ok := lite[userLower]; ok {
|
||||||
|
continue // schon im /online Snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := strings.TrimSpace(j.PreviewCookie)
|
||||||
|
out[userLower] = cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChaturbateStatusFromBioContext(ctx context.Context, model string, cookieHeader string) error {
|
||||||
|
model = sanitizeModelKey(model)
|
||||||
|
if model == "" {
|
||||||
|
return fmt.Errorf("empty model")
|
||||||
|
}
|
||||||
|
|
||||||
|
u := fmt.Sprintf(chaturbateBioContextURLFmt, model)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("Referer", "https://chaturbate.com/"+model+"/")
|
||||||
|
|
||||||
|
if ck := strings.TrimSpace(cookieHeader); ck != "" {
|
||||||
|
req.Header.Set("Cookie", ck)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cbHTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("biocontext HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var bio any
|
||||||
|
if err := json.Unmarshal(raw, &bio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedAt := time.Now().UTC()
|
||||||
|
|
||||||
|
if cbModelStore != nil {
|
||||||
|
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
|
||||||
|
show, chatRoomURL, imageURL, ok := inferRoomStatusFromBio(bio)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnline := show != "offline" && show != "unknown"
|
||||||
|
|
||||||
|
if cbModelStore != nil {
|
||||||
|
_ = cbModelStore.SetChaturbateRoomState(
|
||||||
|
"chaturbate.com",
|
||||||
|
model,
|
||||||
|
show,
|
||||||
|
isOnline,
|
||||||
|
chatRoomURL,
|
||||||
|
imageURL,
|
||||||
|
fetchedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startChaturbateBioContextPoller() {
|
||||||
|
const scanEvery = 15 * time.Second
|
||||||
|
const minPerModel = 1 * time.Minute
|
||||||
|
|
||||||
|
ticker := time.NewTicker(scanEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if !getSettings().UseChaturbateAPI {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := activeChaturbateJobsMissingFromOnlineSnapshot()
|
||||||
|
if len(missing) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for userLower, cookieHeader := range missing {
|
||||||
|
if !shouldRefreshBio(userLower, minPerModel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(model, cookie string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := refreshChaturbateStatusFromBioContext(ctx, model, cookie); err != nil {
|
||||||
|
fmt.Println("⚠️ [chaturbate] biocontext refresh failed:", model, err)
|
||||||
|
}
|
||||||
|
}(userLower, cookieHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Profilbild Download + Persist (online -> offline) ---
|
// --- Profilbild Download + Persist (online -> offline) ---
|
||||||
|
|
||||||
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
||||||
@ -400,6 +597,10 @@ func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]Chatur
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasActiveRecordingJobForModelKey(userLower) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
username := strings.TrimSpace(prevRm.Username)
|
username := strings.TrimSpace(prevRm.Username)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
username = strings.TrimSpace(userLower)
|
username = strings.TrimSpace(userLower)
|
||||||
@ -690,7 +891,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
|
|||||||
}
|
}
|
||||||
|
|
||||||
hc := NewHTTPClient(userAgent)
|
hc := NewHTTPClient(userAgent)
|
||||||
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/")
|
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
|
||||||
|
|
||||||
body, err := hc.FetchPage(ctx, pageURL, cookie)
|
body, err := hc.FetchPage(ctx, pageURL, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -29,6 +29,13 @@ var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"
|
|||||||
|
|
||||||
type JobStatus string
|
type JobStatus string
|
||||||
|
|
||||||
|
type Playlist struct {
|
||||||
|
PlaylistURL string
|
||||||
|
RootURL string
|
||||||
|
Resolution int
|
||||||
|
Framerate int
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
JobRunning JobStatus = "running"
|
JobRunning JobStatus = "running"
|
||||||
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
||||||
@ -57,10 +64,6 @@ type RecordJob struct {
|
|||||||
|
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
PreviewDir string `json:"-"`
|
|
||||||
previewCmd *exec.Cmd `json:"-"`
|
|
||||||
LiveThumbStarted bool `json:"-"`
|
|
||||||
|
|
||||||
// ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler)
|
// ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler)
|
||||||
PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error"
|
PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error"
|
||||||
PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano
|
PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano
|
||||||
@ -72,14 +75,17 @@ type RecordJob struct {
|
|||||||
previewJPGAt time.Time `json:"-"`
|
previewJPGAt time.Time `json:"-"`
|
||||||
previewGen bool `json:"-"`
|
previewGen bool `json:"-"`
|
||||||
|
|
||||||
|
LiveThumbStarted bool `json:"-"`
|
||||||
|
PreviewDir string `json:"-"`
|
||||||
|
previewCmd *exec.Cmd `json:"-"`
|
||||||
|
previewCancel context.CancelFunc `json:"-"`
|
||||||
|
previewLastHit time.Time `json:"-"`
|
||||||
|
previewStartMu sync.Mutex `json:"-"`
|
||||||
|
|
||||||
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
|
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
|
||||||
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
|
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
|
||||||
PreviewUA string `json:"-"` // user-agent
|
PreviewUA string `json:"-"` // user-agent
|
||||||
|
|
||||||
previewCancel context.CancelFunc `json:"-"`
|
|
||||||
previewLastHit time.Time `json:"-"`
|
|
||||||
previewStartMu sync.Mutex `json:"-"`
|
|
||||||
|
|
||||||
// ✅ Frontend Progress beim Stop/Finalize
|
// ✅ Frontend Progress beim Stop/Finalize
|
||||||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||||||
Progress int `json:"progress,omitempty"` // 0..100
|
Progress int `json:"progress,omitempty"` // 0..100
|
||||||
@ -264,43 +270,8 @@ var (
|
|||||||
jobsMu = sync.RWMutex{}
|
jobsMu = sync.RWMutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func startPreviewIdleKiller() {
|
|
||||||
t := time.NewTicker(5 * time.Second)
|
|
||||||
go func() {
|
|
||||||
defer t.Stop()
|
|
||||||
for range t.C {
|
|
||||||
jobsMu.RLock()
|
|
||||||
list := make([]*RecordJob, 0, len(jobs))
|
|
||||||
for _, j := range jobs {
|
|
||||||
if j != nil {
|
|
||||||
list = append(list, j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobsMu.RUnlock()
|
|
||||||
|
|
||||||
for _, j := range list {
|
|
||||||
jobsMu.RLock()
|
|
||||||
cmdRunning := j.previewCmd != nil
|
|
||||||
last := j.previewLastHit
|
|
||||||
st := j.Status
|
|
||||||
jobsMu.RUnlock()
|
|
||||||
|
|
||||||
if !cmdRunning {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// wenn Job nicht mehr läuft oder Hover weg
|
|
||||||
if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) {
|
|
||||||
stopPreview(j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
initFFmpegLimiters()
|
initFFmpegLimiters()
|
||||||
startPreviewIdleKiller()
|
|
||||||
|
|
||||||
initSSE()
|
initSSE()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -602,27 +573,18 @@ func removeGeneratedForID(id string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// falls jemand "file.mp4" übergibt
|
|
||||||
id = strings.TrimSuffix(id, filepath.Ext(id))
|
id = strings.TrimSuffix(id, filepath.Ext(id))
|
||||||
|
|
||||||
// HOT Prefix weg
|
|
||||||
id = stripHotPrefix(id)
|
id = stripHotPrefix(id)
|
||||||
|
|
||||||
// wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!)
|
|
||||||
var err error
|
var err error
|
||||||
id, err = sanitizeID(id)
|
id, err = sanitizeID(id)
|
||||||
if err != nil || id == "" {
|
if err != nil || id == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) NEU: generated/meta/<id>/ ...
|
|
||||||
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
||||||
_ = os.RemoveAll(filepath.Join(root, id))
|
_ = os.RemoveAll(filepath.Join(root, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Temp Preview Segmente (HLS) wegräumen
|
|
||||||
// (%TEMP%/rec_preview/<assetID>)
|
|
||||||
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func purgeDurationCacheForPath(p string) {
|
func purgeDurationCacheForPath(p string) {
|
||||||
|
|||||||
@ -368,7 +368,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
return ext == ".mp4" || ext == ".ts"
|
return ext == ".mp4"
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range cands {
|
for _, m := range cands {
|
||||||
@ -1326,175 +1326,56 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
|||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) {
|
func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) {
|
||||||
segDir := previewSegmentsDir(previewDir)
|
m3u8URL = strings.TrimSpace(m3u8URL)
|
||||||
entries, err := os.ReadDir(segDir)
|
if m3u8URL == "" {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("m3u8 leer")
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxCount <= 0 {
|
if strings.TrimSpace(userAgent) == "" {
|
||||||
maxCount = 4
|
userAgent = "Mozilla/5.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
args := []string{
|
||||||
for _, e := range entries {
|
"-hide_banner",
|
||||||
if e.IsDir() {
|
"-loglevel", "error",
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.ToLower(e.Name())
|
|
||||||
if !strings.HasPrefix(name, "seg_preview_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
names = append(names, e.Name())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(names) == 0 {
|
if strings.TrimSpace(userAgent) != "" {
|
||||||
return nil, fmt.Errorf("kein Preview-Segment in %s", segDir)
|
args = append(args, "-user_agent", strings.TrimSpace(userAgent))
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(names)
|
if strings.TrimSpace(cookie) != "" {
|
||||||
|
args = append(args, "-headers", fmt.Sprintf("Cookie: %s\r\n", strings.TrimSpace(cookie)))
|
||||||
if len(names) > maxCount {
|
|
||||||
names = names[len(names)-maxCount:]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]string, 0, len(names))
|
args = append(args,
|
||||||
for _, name := range names {
|
"-i", m3u8URL,
|
||||||
out = append(out, filepath.Join(segDir, name))
|
"-frames:v", "1",
|
||||||
|
"-vf", "scale=320:-2",
|
||||||
|
"-vcodec", "mjpeg",
|
||||||
|
"-q:v", "6",
|
||||||
|
"-f", "image2pipe",
|
||||||
|
"pipe:1",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
|
||||||
|
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 live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
b := out.Bytes()
|
||||||
}
|
if len(b) == 0 {
|
||||||
|
return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output")
|
||||||
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
|
||||||
segDir := previewSegmentsDir(previewDir)
|
|
||||||
if strings.TrimSpace(segDir) == "" {
|
|
||||||
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
segPaths, err := latestPreviewSegments(previewDir, 4)
|
return b, nil
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(segPaths[len(segPaths)-1]))
|
|
||||||
|
|
||||||
// Bei TS: ebenfalls lieber die letzten paar Segmente zusammenkleben,
|
|
||||||
// nicht nur das letzte einzelne Segment.
|
|
||||||
if ext == ".ts" {
|
|
||||||
tmp, err := os.CreateTemp(segDir, "preview_input_*.ts")
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_ = tmp.Close()
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, segPath := range segPaths {
|
|
||||||
b, err := os.ReadFile(segPath)
|
|
||||||
if err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
if len(b) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := tmp.Write(b); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tmpPath, cleanup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fMP4/CMAF: init.mp4 + die letzten N Segmente
|
|
||||||
initPath := filepath.Join(segDir, "init.mp4")
|
|
||||||
if _, err := os.Stat(initPath); err != nil {
|
|
||||||
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
initBytes, err := os.ReadFile(initPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_ = tmp.Close()
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tmp.Write(initBytes); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, segPath := range segPaths {
|
|
||||||
segBytes, err := os.ReadFile(segPath)
|
|
||||||
if err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
if len(segBytes) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := tmp.Write(segBytes); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
cleanup()
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tmpPath, cleanup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
|
|
||||||
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
img, err := extractLastFrameJPGScaled(inputPath, 320, 70)
|
|
||||||
if err == nil && len(img) > 0 {
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
return extractFirstFrameJPGScaled(inputPath, 320, 70)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
|
|
||||||
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
img, err := extractLastFrameJPG(inputPath)
|
|
||||||
if err == nil && len(img) > 0 {
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
return extractFirstFrameJPGScaled(inputPath, 720, 75)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
|
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
@ -1796,11 +1677,21 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
var img []byte
|
var img []byte
|
||||||
var genErr error
|
var genErr error
|
||||||
|
|
||||||
previewDir := strings.TrimSpace(j.PreviewDir)
|
previewM3U8 := strings.TrimSpace(j.PreviewM3U8)
|
||||||
if previewDir != "" {
|
previewCookie := strings.TrimSpace(j.PreviewCookie)
|
||||||
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
|
previewUA := strings.TrimSpace(j.PreviewUA)
|
||||||
|
|
||||||
|
if previewM3U8 != "" {
|
||||||
|
img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
//fmt.Println("preview from previewDir failed:", previewDir, genErr)
|
// optional loggen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 {
|
||||||
|
outPath := strings.TrimSpace(j.Output)
|
||||||
|
if outPath != "" {
|
||||||
|
img, _ = extractLastFrameJPGScaled(outPath, 720, 75)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1845,7 +1736,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
status := job.Status
|
status := job.Status
|
||||||
previewDir := job.PreviewDir
|
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
|
||||||
|
previewCookie := strings.TrimSpace(job.PreviewCookie)
|
||||||
|
previewUA := strings.TrimSpace(job.PreviewUA)
|
||||||
out := job.Output
|
out := job.Output
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
@ -1876,19 +1769,23 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var img []byte
|
var img []byte
|
||||||
if previewDir != "" {
|
|
||||||
if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 {
|
if previewM3U8 != "" {
|
||||||
|
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
|
||||||
img = b
|
img = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(img) == 0 && out != "" {
|
if len(img) == 0 && out != "" {
|
||||||
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
||||||
img = b
|
img = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(img) == 0 {
|
if len(img) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = atomicWriteFile(thumbPath, img)
|
_ = atomicWriteFile(thumbPath, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,38 +7,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func previewSegmentsDir(previewDir string) string {
|
|
||||||
previewDir = strings.TrimSpace(previewDir)
|
|
||||||
if previewDir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(previewDir, "segments")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePreviewSegmentsDir(previewDir string) (string, error) {
|
|
||||||
dir := previewSegmentsDir(previewDir)
|
|
||||||
if strings.TrimSpace(dir) == "" {
|
|
||||||
return "", fmt.Errorf("previewDir leer")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return dir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DVR-ähnlicher Recorder-Ablauf ---
|
// --- DVR-ähnlicher Recorder-Ablauf ---
|
||||||
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
|
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
|
||||||
func RecordStream(
|
func RecordStream(
|
||||||
@ -50,185 +25,132 @@ func RecordStream(
|
|||||||
httpCookie string,
|
httpCookie string,
|
||||||
job *RecordJob,
|
job *RecordJob,
|
||||||
) error {
|
) error {
|
||||||
|
username = strings.Trim(strings.TrimSpace(username), "/")
|
||||||
base := strings.TrimRight(domain, "/")
|
base := strings.TrimRight(domain, "/")
|
||||||
pageURL := base + "/" + username
|
pageURL := base + "/" + username + "/"
|
||||||
|
|
||||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
loadFreshHLS := func() (string, error) {
|
||||||
if err != nil {
|
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||||
return fmt.Errorf("seite laden: %w", err)
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("seite laden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hlsURL, err := ParseStream(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stream-parsing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hlsURL = strings.TrimSpace(hlsURL)
|
||||||
|
if hlsURL == "" {
|
||||||
|
return "", errors.New("leere hls url")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalURL, err := getWantedResolutionPlaylistWithHeaders(
|
||||||
|
ctx,
|
||||||
|
hlsURL,
|
||||||
|
httpCookie,
|
||||||
|
hc.userAgent,
|
||||||
|
pageURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("variant-playlist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(finalURL), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL, err := ParseStream(body)
|
hlsURL, err := loadFreshHLS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stream-parsing: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("playlist abrufen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var previewDir string
|
|
||||||
var segmentsDir string
|
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
assetID := assetIDForJob(job)
|
assetID := assetIDForJob(job)
|
||||||
if assetID != "" {
|
if assetID != "" {
|
||||||
if dir, derr := ensureGeneratedDir(assetID); derr == nil {
|
_, _ = ensureGeneratedDir(assetID)
|
||||||
previewDir = strings.TrimSpace(dir)
|
|
||||||
|
|
||||||
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
|
|
||||||
segmentsDir = strings.TrimSpace(sdir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PreviewM3U8 = strings.TrimSpace(hlsURL)
|
job.PreviewM3U8 = hlsURL
|
||||||
job.PreviewCookie = httpCookie
|
job.PreviewCookie = httpCookie
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
if previewDir != "" {
|
|
||||||
job.PreviewDir = previewDir
|
|
||||||
}
|
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
if previewDir != "" {
|
if assetID != "" {
|
||||||
startLiveThumbJPGLoop(ctx, job)
|
startLiveThumbJPGLoop(ctx, job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if segmentsDir != "" {
|
const maxPlaylistRefreshes = 12
|
||||||
defer func() {
|
refreshes := 0
|
||||||
if err := os.RemoveAll(segmentsDir); err != nil {
|
|
||||||
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Create(outputPath)
|
for {
|
||||||
if err != nil {
|
err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL)
|
||||||
return fmt.Errorf("datei erstellen: %w", err)
|
if err == nil {
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = file.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
var lastPush time.Time
|
|
||||||
var lastBytes int64
|
|
||||||
published := false
|
|
||||||
|
|
||||||
trimPreviewSegments := func(dir string, keep int) {
|
|
||||||
if strings.TrimSpace(dir) == "" || keep <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type item struct {
|
|
||||||
name string
|
|
||||||
mod time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]item, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := e.Name()
|
|
||||||
lower := strings.ToLower(name)
|
|
||||||
|
|
||||||
if lower == "init.mp4" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(lower, "seg_preview_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, item{
|
|
||||||
name: name,
|
|
||||||
mod: info.ModTime(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) <= keep {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
return items[i].mod.Before(items[j].mod)
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := 0; i < len(items)-keep; i++ {
|
|
||||||
_ = os.Remove(filepath.Join(dir, items[i].name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error {
|
|
||||||
if len(b) == 0 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := file.Write(b); err != nil {
|
if ctx.Err() != nil {
|
||||||
return fmt.Errorf("schreibe segment: %w", err)
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := file.Sync(); err != nil {
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
//fmt.Println("output sync failed:", outputPath, err)
|
shouldRefresh :=
|
||||||
|
strings.Contains(msg, "403") ||
|
||||||
|
strings.Contains(msg, "401") ||
|
||||||
|
strings.Contains(msg, "invalid data found") ||
|
||||||
|
strings.Contains(msg, "error opening input") ||
|
||||||
|
strings.Contains(msg, "stream-parsing") ||
|
||||||
|
strings.Contains(msg, "kein hls-quell-url") ||
|
||||||
|
strings.Contains(msg, "http ")
|
||||||
|
|
||||||
|
if !shouldRefresh {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if segmentsDir != "" {
|
refreshes++
|
||||||
if err := os.MkdirAll(segmentsDir, 0o755); err == nil {
|
if refreshes > maxPlaylistRefreshes {
|
||||||
ext := ".m4s"
|
return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
|
||||||
if len(b) > 0 && b[0] == 0x47 {
|
}
|
||||||
ext = ".ts"
|
|
||||||
}
|
|
||||||
|
|
||||||
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
|
fmt.Println("🔄 [cb] refresh playlist:",
|
||||||
segPath := filepath.Join(segmentsDir, segName)
|
"user=", username,
|
||||||
|
"try=", refreshes,
|
||||||
|
"reason=", err,
|
||||||
|
)
|
||||||
|
|
||||||
if err := os.WriteFile(segPath, b, 0o644); err == nil {
|
select {
|
||||||
trimPreviewSegments(segmentsDir, 12)
|
case <-ctx.Done():
|
||||||
}
|
return ctx.Err()
|
||||||
|
case <-time.After(1500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
newHLS, rerr := loadFreshHLS()
|
||||||
|
if rerr != nil {
|
||||||
|
fmt.Println("⚠️ [cb] refresh failed:",
|
||||||
|
"user=", username,
|
||||||
|
"try=", refreshes,
|
||||||
|
"err=", rerr,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if job != nil && !published {
|
hlsURL = newHLS
|
||||||
published = true
|
|
||||||
_ = publishJob(job.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
written += int64(len(b))
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
now := time.Now()
|
jobsMu.Lock()
|
||||||
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
|
job.PreviewM3U8 = hlsURL
|
||||||
jobsMu.Lock()
|
job.PreviewCookie = httpCookie
|
||||||
job.SizeBytes = written
|
job.PreviewUA = hc.userAgent
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
lastPush = now
|
|
||||||
lastBytes = written
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = duration
|
|
||||||
_ = segmentURL
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("watch segments: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
||||||
@ -258,289 +180,6 @@ func ParseStream(html string) (string, error) {
|
|||||||
return rd.HLSSource, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// nutzt ebenfalls *HTTPClient
|
|
||||||
func (p *Playlist) WatchSegments(
|
|
||||||
ctx context.Context,
|
|
||||||
hc *HTTPClient,
|
|
||||||
httpCookie string,
|
|
||||||
previewDir string,
|
|
||||||
handler func(segmentURL string, b []byte, duration float64) error,
|
|
||||||
) error {
|
|
||||||
var lastSeq int64 = -1
|
|
||||||
emptyRounds := 0
|
|
||||||
const maxEmptyRounds = 60
|
|
||||||
|
|
||||||
playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL))
|
|
||||||
if err != nil || playlistBaseURL == nil {
|
|
||||||
return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastMapURL string
|
|
||||||
|
|
||||||
writeInitIfNeeded := func(mapURL string) {
|
|
||||||
if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
segDir, err := ensurePreviewSegmentsDir(previewDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
initPath := filepath.Join(segDir, "init.mp4")
|
|
||||||
|
|
||||||
// schon vorhanden und gleiches URL-Mapping -> nichts tun
|
|
||||||
if mapURL == lastMapURL {
|
|
||||||
if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := hc.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(b) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(initPath, b, 0o644); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMapURL = mapURL
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(p.PlaylistURL) == "" {
|
|
||||||
return errors.New("playlist url fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
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++
|
|
||||||
//fmt.Println("playlist download failed:", p.PlaylistURL, err)
|
|
||||||
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
//fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL)
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
emptyRounds++
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return fmt.Errorf("❌ playlist liefert status %d – stream vermutlich offline", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
emptyRounds++
|
|
||||||
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if listType != m3u8.MEDIA {
|
|
||||||
emptyRounds++
|
|
||||||
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
media, ok := playlist.(*m3u8.MediaPlaylist)
|
|
||||||
if !ok || media == nil {
|
|
||||||
emptyRounds++
|
|
||||||
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("❌ Fehlerhafte Media-Playlist – möglicherweise offline")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if media.Map != nil {
|
|
||||||
mapURI := strings.TrimSpace(media.Map.URI)
|
|
||||||
if mapURI != "" {
|
|
||||||
mapRef, err := url.Parse(mapURI)
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Println("map uri parse failed:", mapURI, err)
|
|
||||||
} else {
|
|
||||||
mapURL := playlistBaseURL.ResolveReference(mapRef).String()
|
|
||||||
writeInitIfNeeded(mapURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newSegment := false
|
|
||||||
successfulSegment := false
|
|
||||||
|
|
||||||
for _, segment := range media.Segments {
|
|
||||||
if segment == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if int64(segment.SeqId) <= lastSeq {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSeq = int64(segment.SeqId)
|
|
||||||
newSegment = true
|
|
||||||
|
|
||||||
segURI := strings.TrimSpace(segment.URI)
|
|
||||||
if segURI == "" {
|
|
||||||
//fmt.Println("segment uri empty in playlist:", p.PlaylistURL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
segRef, err := url.Parse(segURI)
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Println("segment uri parse failed:", segURI, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
segmentURL := playlistBaseURL.ResolveReference(segRef).String()
|
|
||||||
|
|
||||||
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Println("segment request build failed:", segmentURL, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
segResp, err := hc.client.Do(segReq)
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Println("segment download failed:", segmentURL, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if segResp.StatusCode < 200 || segResp.StatusCode >= 300 {
|
|
||||||
//fmt.Println("segment bad status:", segResp.StatusCode, segmentURL)
|
|
||||||
_, _ = io.Copy(io.Discard, segResp.Body)
|
|
||||||
segResp.Body.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(segResp.Body)
|
|
||||||
segResp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
//fmt.Println("segment read failed:", segmentURL, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
//fmt.Println("segment empty:", segmentURL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := handler(segmentURL, data, segment.Duration); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
successfulSegment = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if successfulSegment {
|
|
||||||
emptyRounds = 0
|
|
||||||
} else if newSegment {
|
|
||||||
emptyRounds++
|
|
||||||
//fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL)
|
|
||||||
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden – Stream vermutlich geschützt, fehlerhaft oder offline.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptyRounds++
|
|
||||||
if emptyRounds >= maxEmptyRounds {
|
|
||||||
return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(1 * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
|
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
|
||||||
func addCookiesFromString(req *http.Request, cookieStr string) {
|
func addCookiesFromString(req *http.Request, cookieStr string) {
|
||||||
if cookieStr == "" {
|
if cookieStr == "" {
|
||||||
|
|||||||
274
backend/record_stream_hls.go
Normal file
274
backend/record_stream_hls.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
// backend\record_stream_hls.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafov/m3u8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getWantedResolutionPlaylistWithHeaders(
|
||||||
|
ctx context.Context,
|
||||||
|
playlistURL string,
|
||||||
|
httpCookie string,
|
||||||
|
userAgent string,
|
||||||
|
referer string,
|
||||||
|
) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(userAgent) != "" {
|
||||||
|
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(httpCookie) != "" {
|
||||||
|
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(referer) != "" {
|
||||||
|
ref := strings.TrimSpace(referer)
|
||||||
|
req.Header.Set("Referer", ref)
|
||||||
|
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
|
||||||
|
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
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 bestHeight int
|
||||||
|
var bestFramerate float64
|
||||||
|
|
||||||
|
for _, v := range master.Variants {
|
||||||
|
if v == nil || strings.TrimSpace(v.URI) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h := 0
|
||||||
|
if v.Resolution != "" {
|
||||||
|
parts := strings.Split(v.Resolution, "x")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if hh, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
h = hh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fr := 30.0
|
||||||
|
if v.FrameRate > 0 {
|
||||||
|
fr = v.FrameRate
|
||||||
|
}
|
||||||
|
|
||||||
|
if h > bestHeight || (h == bestHeight && fr > bestFramerate) {
|
||||||
|
bestHeight = h
|
||||||
|
bestFramerate = fr
|
||||||
|
bestURI = strings.TrimSpace(v.URI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestURI == "" {
|
||||||
|
return "", errors.New("Master-Playlist ohne gültige Varianten")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(playlistURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
refURL, err := url.Parse(bestURI)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseURL.ResolveReference(refURL).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
|
||||||
|
lines := make([]string, 0, 6)
|
||||||
|
|
||||||
|
if strings.TrimSpace(httpCookie) != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Cookie: %s", strings.TrimSpace(httpCookie)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(referer) != "" {
|
||||||
|
ref := strings.TrimSpace(referer)
|
||||||
|
lines = append(lines, fmt.Sprintf("Referer: %s", ref))
|
||||||
|
|
||||||
|
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("Origin: %s://%s", ru.Scheme, ru.Host))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(userAgent) != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("User-Agent: %s", strings.TrimSpace(userAgent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\r\n") + "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleM3U8Mode(
|
||||||
|
ctx context.Context,
|
||||||
|
m3u8URL string,
|
||||||
|
outFile string,
|
||||||
|
job *RecordJob,
|
||||||
|
httpCookie string,
|
||||||
|
userAgent string,
|
||||||
|
referer string,
|
||||||
|
) error {
|
||||||
|
u, err := url.Parse(m3u8URL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return fmt.Errorf("ungültige URL: %q", m3u8URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m3u8URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(userAgent) != "" {
|
||||||
|
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(httpCookie) != "" {
|
||||||
|
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(referer) != "" {
|
||||||
|
req.Header.Set("Referer", strings.TrimSpace(referer))
|
||||||
|
if ru, err := url.Parse(strings.TrimSpace(referer)); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
|
||||||
|
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(outFile) == "" {
|
||||||
|
return errors.New("output file path leer")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-nostats",
|
||||||
|
"-loglevel", "warning",
|
||||||
|
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
|
||||||
|
"-http_persistent", "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := strings.TrimSpace(userAgent)
|
||||||
|
ref := strings.TrimSpace(referer)
|
||||||
|
ck := strings.TrimSpace(httpCookie)
|
||||||
|
|
||||||
|
if ua != "" {
|
||||||
|
args = append(args, "-user_agent", ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref != "" {
|
||||||
|
args = append(args, "-referer", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ck != "" {
|
||||||
|
args = append(args, "-cookies", ck)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" {
|
||||||
|
args = append(args, "-headers", hdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-i", m3u8URL,
|
||||||
|
"-c", "copy",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
outFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
stopStat := make(chan struct{})
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(1 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
var last int64
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-stopStat:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
fi, err := os.Stat(outFile)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sz := fi.Size()
|
||||||
|
if sz > 0 && sz != last {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.SizeBytes = sz
|
||||||
|
jobsMu.Unlock()
|
||||||
|
last = sz
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
close(stopStat)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg != "" {
|
||||||
|
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -10,8 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -89,7 +87,7 @@ func RecordStreamMFC(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Aufnahme starten
|
// Aufnahme starten
|
||||||
return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
|
return handleM3U8Mode(ctx, m3u8URL, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
type MyFreeCams struct {
|
type MyFreeCams struct {
|
||||||
@ -245,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
|
|||||||
return errors.New("keine m3u8 URL gefunden")
|
return errors.New("keine m3u8 URL gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleM3U8Mode(ctx, m3u8URL, outArg, nil)
|
return handleM3U8Mode(ctx, m3u8URL, outArg, nil, "", "", mfc.GetWebsiteURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
||||||
@ -310,98 +308,6 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
|||||||
return root + bestURI, nil
|
return root + bestURI, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob) 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,
|
|
||||||
ffmpegPath,
|
|
||||||
"-y",
|
|
||||||
"-hide_banner",
|
|
||||||
"-nostats",
|
|
||||||
"-loglevel", "warning",
|
|
||||||
"-i", m3u8URL,
|
|
||||||
"-c", "copy",
|
|
||||||
outFile,
|
|
||||||
)
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stdout = io.Discard
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
// ✅ live size polling während ffmpeg läuft
|
|
||||||
stopStat := make(chan struct{})
|
|
||||||
|
|
||||||
if job != nil {
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(1 * time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
|
|
||||||
var last int64
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-stopStat:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
fi, err := os.Stat(outFile)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sz := fi.Size()
|
|
||||||
if sz > 0 && sz != last {
|
|
||||||
jobsMu.Lock()
|
|
||||||
job.SizeBytes = sz
|
|
||||||
jobsMu.Unlock()
|
|
||||||
last = sz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ WICHTIG: ffmpeg wirklich laufen lassen
|
|
||||||
err = cmd.Run()
|
|
||||||
|
|
||||||
close(stopStat)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
msg := strings.TrimSpace(stderr.String())
|
|
||||||
if msg != "" {
|
|
||||||
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────────────────────────
|
/* ───────────────────────────────
|
||||||
Kleine Helper für MFC
|
Kleine Helper für MFC
|
||||||
─────────────────────────────── */
|
─────────────────────────────── */
|
||||||
|
|||||||
@ -262,6 +262,127 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ---------------- Start + run job ----------------
|
// ---------------- Start + run job ----------------
|
||||||
|
|
||||||
|
func markModelPublicWhileRecording(job *RecordJob) {
|
||||||
|
if job == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawURL := strings.TrimSpace(job.SourceURL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if detectProvider(rawURL) != "chaturbate" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelKey := strings.TrimSpace(extractUsername(rawURL))
|
||||||
|
if modelKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := getModelStore()
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
_ = store.SetChaturbateRoomState(
|
||||||
|
"chaturbate.com",
|
||||||
|
modelKey,
|
||||||
|
"public",
|
||||||
|
true,
|
||||||
|
rawURL,
|
||||||
|
"",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFileSize(path string) int64 {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
func statOutputForLog(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return "path-empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return "stat-error=" + err.Error()
|
||||||
|
}
|
||||||
|
if fi == nil {
|
||||||
|
return "stat-nil"
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
return "is-dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("exists size=%d mod=%s", fi.Size(), fi.ModTime().Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("path empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
return fi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else if fi == nil {
|
||||||
|
lastErr = fmt.Errorf("stat returned nil")
|
||||||
|
} else if fi.IsDir() {
|
||||||
|
lastErr = fmt.Errorf("path is dir")
|
||||||
|
} else {
|
||||||
|
lastErr = fmt.Errorf("file size is 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("usable output not ready in time")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldKeepStoppedJobForInspection(target JobStatus, out string) bool {
|
||||||
|
if target != JobStopped {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(strings.TrimSpace(out))
|
||||||
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// auch kleine Fragmente sichtbar behalten, damit du sie debuggen kannst
|
||||||
|
return fi.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
func isActiveRecordingJob(job *RecordJob) bool {
|
func isActiveRecordingJob(job *RecordJob) bool {
|
||||||
if job == nil {
|
if job == nil {
|
||||||
return false
|
return false
|
||||||
@ -310,6 +431,11 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
return nil, errors.New("url fehlt")
|
return nil, errors.New("url fehlt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
provider := detectProvider(url)
|
||||||
|
if provider != "chaturbate" && provider != "mfc" {
|
||||||
|
return nil, fmt.Errorf("unsupported provider: %q", url)
|
||||||
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
defer jobsMu.Unlock()
|
defer jobsMu.Unlock()
|
||||||
|
|
||||||
@ -345,7 +471,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
provider := detectProvider(url)
|
|
||||||
|
|
||||||
username := ""
|
username := ""
|
||||||
switch provider {
|
switch provider {
|
||||||
@ -358,7 +483,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
username = "unknown"
|
username = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.mp4", username, startedAt.Format("01_02_2006__15-04-05"))
|
||||||
|
|
||||||
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||||||
recordDir := strings.TrimSpace(recordDirAbs)
|
recordDir := strings.TrimSpace(recordDirAbs)
|
||||||
@ -382,8 +507,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jobs[jobID] = job
|
jobs[jobID] = job
|
||||||
|
|
||||||
go runJob(ctx, job, req)
|
go runJob(ctx, job, req)
|
||||||
|
|
||||||
return job, nil
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,7 +554,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||||
|
|
||||||
username := extractUsername(req.URL)
|
username := extractUsername(req.URL)
|
||||||
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
existingOut := strings.TrimSpace(job.Output)
|
existingOut := strings.TrimSpace(job.Output)
|
||||||
@ -446,6 +571,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markModelPublicWhileRecording(job)
|
||||||
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
|
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
|
||||||
|
|
||||||
case "mfc":
|
case "mfc":
|
||||||
@ -458,7 +584,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||||
|
|
||||||
username := extractMFCUsername(req.URL)
|
username := extractMFCUsername(req.URL)
|
||||||
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
|
||||||
outPath := filepath.Join(recordDirAbs, filename)
|
outPath := filepath.Join(recordDirAbs, filename)
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
@ -489,6 +615,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.EndedAt = &end
|
job.EndedAt = &end
|
||||||
job.EndedAtMs = end.UnixMilli()
|
job.EndedAtMs = end.UnixMilli()
|
||||||
|
job.Status = target
|
||||||
if errText != "" {
|
if errText != "" {
|
||||||
job.Error = errText
|
job.Error = errText
|
||||||
}
|
}
|
||||||
@ -496,22 +623,41 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
out := strings.TrimSpace(job.Output)
|
out := strings.TrimSpace(job.Output)
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// HARTE SCHRANKE 1: gar kein Output-Pfad
|
||||||
|
// ------------------------------------------------------------
|
||||||
if out == "" {
|
if out == "" {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.Status = target
|
|
||||||
job.Phase = ""
|
job.Phase = ""
|
||||||
job.Progress = 100
|
job.Progress = 100
|
||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken
|
// ------------------------------------------------------------
|
||||||
|
// HARTE SCHRANKE 2: Output ungültig / leer vor Postwork
|
||||||
|
// WICHTIG: bei JobStopped kurz warten, damit Windows/File-Flush
|
||||||
|
// nach context canceled noch fertig werden kann
|
||||||
|
// ------------------------------------------------------------
|
||||||
{
|
{
|
||||||
fi, serr := os.Stat(out)
|
var (
|
||||||
|
fi os.FileInfo
|
||||||
|
serr error
|
||||||
|
)
|
||||||
|
|
||||||
|
if target == JobStopped {
|
||||||
|
fi, serr = waitForUsableOutput(out, 2*time.Second)
|
||||||
|
} else {
|
||||||
|
fi, serr = os.Stat(out)
|
||||||
|
}
|
||||||
|
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
_ = removeWithRetry(out)
|
_ = removeWithRetry(out)
|
||||||
purgeDurationCacheForPath(out)
|
purgeDurationCacheForPath(out)
|
||||||
@ -521,21 +667,13 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
|
||||||
if shouldLogRecordInfo(req) {
|
|
||||||
if serr != nil {
|
|
||||||
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(stat error:", serr, ")")
|
|
||||||
} else if fi == nil || fi.IsDir() {
|
|
||||||
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(not a regular file)")
|
|
||||||
} else {
|
|
||||||
fmt.Println("🧹 removed empty output before postwork:", filepath.Base(out), "(0 bytes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pre-queue gate: nur in die Nachbearbeitung, wenn Datei behalten werden soll
|
// ------------------------------------------------------------
|
||||||
|
// AUTO DELETE SMALL DOWNLOADS
|
||||||
|
// ------------------------------------------------------------
|
||||||
{
|
{
|
||||||
fi, serr := os.Stat(out)
|
fi, serr := os.Stat(out)
|
||||||
if serr == nil && fi != nil && !fi.IsDir() {
|
if serr == nil && fi != nil && !fi.IsDir() {
|
||||||
@ -563,10 +701,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
|
||||||
if shouldLogRecordInfo(req) {
|
|
||||||
fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,7 +710,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// postwork queue
|
// ------------------------------------------------------------
|
||||||
|
// POSTWORK QUEUE
|
||||||
|
// ------------------------------------------------------------
|
||||||
postOut := out
|
postOut := out
|
||||||
postTarget := target
|
postTarget := target
|
||||||
postKey := "postwork:" + job.ID
|
postKey := "postwork:" + job.ID
|
||||||
@ -619,24 +755,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := strings.TrimSpace(postOut)
|
out := strings.TrimSpace(postOut)
|
||||||
|
|
||||||
if out == "" {
|
if out == "" {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
|
job.Status = postTarget
|
||||||
job.Phase = ""
|
job.Phase = ""
|
||||||
job.Progress = 100
|
job.Progress = 100
|
||||||
job.Status = postTarget
|
|
||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
|
||||||
File: postFile,
|
|
||||||
AssetID: postAssetID,
|
|
||||||
Queue: "postwork",
|
|
||||||
State: "done",
|
|
||||||
Label: "Primäre Nachbearbeitung fertig",
|
|
||||||
})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -676,52 +808,46 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Remux
|
|
||||||
// 1) Remux
|
|
||||||
if strings.EqualFold(filepath.Ext(out), ".ts") {
|
|
||||||
setPhase("remuxing", 10)
|
|
||||||
|
|
||||||
newOut, err2 := maybeRemuxTSForJob(job, out)
|
|
||||||
if err2 != nil || strings.TrimSpace(newOut) == "" {
|
|
||||||
finalFile := filepath.Base(out)
|
|
||||||
finalAssetID := assetIDFromVideoPath(out)
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
|
||||||
job.Status = JobFailed
|
|
||||||
job.Error = "TS->MP4 Remux fehlgeschlagen"
|
|
||||||
if err2 != nil {
|
|
||||||
job.Error = "TS->MP4 Remux fehlgeschlagen: " + err2.Error()
|
|
||||||
}
|
|
||||||
job.Phase = ""
|
|
||||||
job.Progress = 100
|
|
||||||
job.PostWorkKey = ""
|
|
||||||
job.PostWork = nil
|
|
||||||
jobsMu.Unlock()
|
|
||||||
|
|
||||||
publishJobRemove(job)
|
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
|
||||||
File: finalFile,
|
|
||||||
AssetID: finalAssetID,
|
|
||||||
Queue: "postwork",
|
|
||||||
State: "error",
|
|
||||||
Phase: "remuxing",
|
|
||||||
Label: "Remux fehlgeschlagen",
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out = strings.TrimSpace(newOut)
|
|
||||||
jobsMu.Lock()
|
|
||||||
job.Output = out
|
|
||||||
jobsMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Move to done
|
|
||||||
setPhase("moving", 10)
|
setPhase("moving", 10)
|
||||||
|
|
||||||
// auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben
|
moved, err2 := moveToDoneDir(out)
|
||||||
|
if err2 != nil || strings.TrimSpace(moved) == "" {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Status = JobFailed
|
||||||
|
if err2 != nil {
|
||||||
|
job.Error = "moveToDoneDir failed: " + err2.Error()
|
||||||
|
} else {
|
||||||
|
job.Error = "moveToDoneDir returned empty path"
|
||||||
|
}
|
||||||
|
job.Phase = ""
|
||||||
|
job.Progress = 100
|
||||||
|
job.PostWorkKey = ""
|
||||||
|
job.PostWork = nil
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
publishJobRemove(job)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "moving",
|
||||||
|
Label: "Verschieben nach done fehlgeschlagen",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out = strings.TrimSpace(moved)
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Output = out
|
||||||
|
jobsMu.Unlock()
|
||||||
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
// 3) Datei nach move trotzdem kaputt
|
||||||
{
|
{
|
||||||
fi, serr := os.Stat(out)
|
fi, serr := os.Stat(out)
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
@ -742,29 +868,11 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
Phase: "moving",
|
Phase: "moving",
|
||||||
Label: "Nachbearbeitung fehlgeschlagen",
|
Label: "Nachbearbeitung fehlgeschlagen",
|
||||||
})
|
})
|
||||||
|
|
||||||
if shouldLogRecordInfo(req) {
|
|
||||||
if serr != nil {
|
|
||||||
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")")
|
|
||||||
} else if fi == nil || fi.IsDir() {
|
|
||||||
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(not a regular file)")
|
|
||||||
} else {
|
|
||||||
fmt.Println("🧹 removed empty post-remux output:", filepath.Base(out), "(0 bytes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" {
|
// 4) DURATION
|
||||||
out = strings.TrimSpace(moved)
|
|
||||||
jobsMu.Lock()
|
|
||||||
job.Output = out
|
|
||||||
jobsMu.Unlock()
|
|
||||||
notifyDoneChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Duration
|
|
||||||
setPhase("probe", 35)
|
setPhase("probe", 35)
|
||||||
{
|
{
|
||||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
@ -776,7 +884,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Video props
|
// 5) VIDEO PROPS
|
||||||
setPhase("probe", 75)
|
setPhase("probe", 75)
|
||||||
{
|
{
|
||||||
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
@ -791,7 +899,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Nur primäre Assets synchron: preview.jpg + preview.mp4
|
// 6) PRIMARY ASSETS
|
||||||
setPhase("assets", 0)
|
setPhase("assets", 0)
|
||||||
|
|
||||||
lastPct := -1
|
lastPct := -1
|
||||||
@ -830,7 +938,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
setPhase("assets", 100)
|
setPhase("assets", 100)
|
||||||
|
|
||||||
// Finalize primary postwork
|
// FINALIZE PRIMARY POSTWORK
|
||||||
finalFile := filepath.Base(out)
|
finalFile := filepath.Base(out)
|
||||||
finalAssetID := assetIDFromVideoPath(out)
|
finalAssetID := assetIDFromVideoPath(out)
|
||||||
|
|
||||||
@ -842,6 +950,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
@ -896,6 +1008,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"maxConcurrentDownloads": 80,
|
"maxConcurrentDownloads": 80,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": false,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 100,
|
"autoDeleteSmallDownloadsBelowMB": 100,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ func main() {
|
|||||||
store := registerRoutes(mux, auth)
|
store := registerRoutes(mux, auth)
|
||||||
|
|
||||||
go startChaturbateOnlinePoller(store)
|
go startChaturbateOnlinePoller(store)
|
||||||
|
go startChaturbateBioContextPoller()
|
||||||
go startChaturbateAutoStartWorker(store)
|
go startChaturbateAutoStartWorker(store)
|
||||||
go startMyFreeCamsAutoStartWorker(store)
|
go startMyFreeCamsAutoStartWorker(store)
|
||||||
go startDiskSpaceGuard()
|
go startDiskSpaceGuard()
|
||||||
|
|||||||
@ -124,6 +124,18 @@ type TaskStateEvent = {
|
|||||||
ts?: number
|
ts?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChaturbateBioContextResponse = {
|
||||||
|
enabled?: boolean
|
||||||
|
fetchedAt?: string
|
||||||
|
lastError?: string
|
||||||
|
model?: string
|
||||||
|
roomStatus?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
chatRoomUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
|
bio?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||||
recordDir: 'records',
|
recordDir: 'records',
|
||||||
doneDir: 'records/done',
|
doneDir: 'records/done',
|
||||||
@ -201,6 +213,39 @@ type AutostartState = {
|
|||||||
pausedByDisk?: boolean
|
pausedByDisk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchChaturbateBioContextStatus(
|
||||||
|
modelKey: string,
|
||||||
|
cookiesObj: Record<string, string>
|
||||||
|
): Promise<{
|
||||||
|
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
|
||||||
|
imageUrl?: string
|
||||||
|
chatRoomUrl?: string
|
||||||
|
}> {
|
||||||
|
const cookieHeader = Object.entries(cookiesObj)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ')
|
||||||
|
|
||||||
|
const res = await fetch(`/api/chaturbate/biocontext?model=${encodeURIComponent(modelKey)}&refresh=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: cookieHeader
|
||||||
|
? { 'X-Chaturbate-Cookie': cookieHeader }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { show: 'unknown' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => null)) as ChaturbateBioContextResponse | null
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: normalizePendingShow(data?.roomStatus),
|
||||||
|
imageUrl: String(data?.imageUrl ?? '').trim() || undefined,
|
||||||
|
chatRoomUrl: String(data?.chatRoomUrl ?? '').trim() || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||||||
const s = String(v ?? '').trim().toLowerCase()
|
const s = String(v ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
@ -1939,64 +1984,6 @@ export default function App() {
|
|||||||
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
|
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
|
||||||
}, [chaturbateStoreKeysLower])
|
}, [chaturbateStoreKeysLower])
|
||||||
|
|
||||||
const fetchChaturbateCurrentShow = useCallback(async (modelKey: string) => {
|
|
||||||
const keyLower = String(modelKey ?? '').trim().toLowerCase()
|
|
||||||
if (!keyLower) {
|
|
||||||
return {
|
|
||||||
show: 'unknown' as const,
|
|
||||||
imageUrl: '',
|
|
||||||
chatRoomUrl: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/chaturbate/online', {
|
|
||||||
method: 'POST',
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
q: [keyLower],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return {
|
|
||||||
show: 'unknown' as const,
|
|
||||||
imageUrl: '',
|
|
||||||
chatRoomUrl: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
|
||||||
|
|
||||||
const room = Array.isArray(data?.rooms)
|
|
||||||
? data!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === keyLower)
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return {
|
|
||||||
show: 'offline' as const,
|
|
||||||
imageUrl: '',
|
|
||||||
chatRoomUrl: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: normalizePendingShow(room.current_show),
|
|
||||||
imageUrl: String(room.image_url ?? '').trim(),
|
|
||||||
chatRoomUrl: String(room.chat_room_url ?? '').trim(),
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
show: 'unknown' as const,
|
|
||||||
imageUrl: '',
|
|
||||||
chatRoomUrl: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTabRef.current = selectedTab
|
selectedTabRef.current = selectedTab
|
||||||
}, [selectedTab])
|
}, [selectedTab])
|
||||||
@ -2070,17 +2057,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [authed, recSettings.useChaturbateApi])
|
}, [authed, recSettings.useChaturbateApi])
|
||||||
|
|
||||||
function shouldQueueForRoomStatus(
|
function shouldAlwaysQueueForRoomStatus(show: string): boolean {
|
||||||
show: string
|
|
||||||
): boolean {
|
|
||||||
const s = String(show || '').trim().toLowerCase()
|
const s = String(show || '').trim().toLowerCase()
|
||||||
return (
|
return s === 'private' || s === 'hidden' || s === 'away'
|
||||||
s === 'private' ||
|
}
|
||||||
s === 'hidden' ||
|
|
||||||
s === 'away' ||
|
function shouldQueueForOfflineLikeStatus(show: string): boolean {
|
||||||
s === 'offline' ||
|
const s = String(show || '').trim().toLowerCase()
|
||||||
s === 'unknown'
|
return s === 'offline' || s === 'unknown'
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
|
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
|
||||||
@ -2118,7 +2102,7 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
if (alreadyRunning) return true
|
if (alreadyRunning) return true
|
||||||
|
|
||||||
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status
|
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status
|
||||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
|
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
|
||||||
try {
|
try {
|
||||||
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
||||||
@ -2130,26 +2114,20 @@ export default function App() {
|
|||||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
const upsertPendingRow = (opts?: {
|
// silent bleibt günstig wie bisher
|
||||||
show?: unknown
|
if (silent) {
|
||||||
imageUrl?: string
|
setPendingAutoStartByKey((prev) => {
|
||||||
chatRoomUrl?: string
|
const next = { ...(prev || {}), [mkLower]: norm }
|
||||||
}) => {
|
pendingAutoStartByKeyRef.current = next
|
||||||
const model = modelsByKeyRef.current[mkLower] as any
|
return next
|
||||||
const show = normalizePendingShow(opts?.show ?? model?.roomStatus)
|
})
|
||||||
|
|
||||||
const imageUrl =
|
|
||||||
String(opts?.imageUrl ?? '').trim() ||
|
|
||||||
String(model?.imageUrl ?? '').trim() ||
|
|
||||||
undefined
|
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) => {
|
setPendingWatchedRooms((prev) => {
|
||||||
const nextItem: PendingWatchedRoom = {
|
const nextItem: PendingWatchedRoom = {
|
||||||
id: mkLower,
|
id: mkLower,
|
||||||
modelKey: mkLower,
|
modelKey: mkLower,
|
||||||
url: norm,
|
url: norm,
|
||||||
currentShow: show,
|
currentShow: 'unknown',
|
||||||
imageUrl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx = prev.findIndex(
|
const idx = prev.findIndex(
|
||||||
@ -2164,64 +2142,54 @@ export default function App() {
|
|||||||
|
|
||||||
return [nextItem, ...prev]
|
return [nextItem, ...prev]
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const enqueuePending = (opts?: {
|
|
||||||
show?: unknown
|
|
||||||
imageUrl?: string
|
|
||||||
chatRoomUrl?: string
|
|
||||||
}) => {
|
|
||||||
setPendingAutoStartByKey((prev) => {
|
|
||||||
const next = { ...(prev || {}), [mkLower]: norm }
|
|
||||||
pendingAutoStartByKeyRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
upsertPendingRow(opts)
|
|
||||||
|
|
||||||
applyPendingRoomSnapshot(mkLower, {
|
|
||||||
show: normalizePendingShow(opts?.show),
|
|
||||||
imageUrl: String(opts?.imageUrl ?? '').trim() || undefined,
|
|
||||||
chatRoomUrl: String(opts?.chatRoomUrl ?? '').trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wenn gerade andere Starts laufen -> direkt in Warteschlange
|
|
||||||
if (busyRef.current) {
|
|
||||||
enqueuePending({ show: 'unknown' })
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live current_show prüfen
|
// manuell: erst /online
|
||||||
const live = await fetchChaturbateCurrentShow(mkLower)
|
try {
|
||||||
const liveShow = normalizePendingShow(live?.show)
|
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||||
|
method: 'POST',
|
||||||
if (shouldQueueForRoomStatus(liveShow)) {
|
cache: 'no-store',
|
||||||
enqueuePending({
|
headers: {
|
||||||
show: liveShow,
|
'Content-Type': 'application/json',
|
||||||
imageUrl: live?.imageUrl,
|
},
|
||||||
chatRoomUrl: live?.chatRoomUrl,
|
body: JSON.stringify({ q: [mkLower] }),
|
||||||
})
|
})
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// public -> Snapshot aktualisieren und normal starten
|
let handled = false
|
||||||
applyPendingRoomSnapshot(mkLower, {
|
|
||||||
show: liveShow,
|
if (onlineRes.ok) {
|
||||||
imageUrl: live?.imageUrl,
|
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||||
chatRoomUrl: live?.chatRoomUrl,
|
const room = Array.isArray(onlineData?.rooms)
|
||||||
})
|
? onlineData!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
applyPendingRoomSnapshot(mkLower, {
|
||||||
|
show: normalizePendingShow(room.current_show),
|
||||||
|
imageUrl: String(room.image_url ?? '').trim() || undefined,
|
||||||
|
chatRoomUrl: String(room.chat_room_url ?? '').trim() || undefined,
|
||||||
|
})
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handled) {
|
||||||
|
const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies)
|
||||||
|
applyPendingRoomSnapshot(mkLower, {
|
||||||
|
show: bio.show,
|
||||||
|
imageUrl: bio.imageUrl,
|
||||||
|
chatRoomUrl: bio.chatRoomUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Wenn Live-Check fehlschlägt: lieber in Warteschlange statt blind starten
|
if (silent) {
|
||||||
try {
|
const mkLower = providerKeyLowerFromUrl(norm)
|
||||||
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ input: norm }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
setPendingAutoStartByKey((prev) => {
|
setPendingAutoStartByKey((prev) => {
|
||||||
const next = { ...(prev || {}), [mkLower]: norm }
|
const next = { ...(prev || {}), [mkLower]: norm }
|
||||||
@ -2252,8 +2220,6 @@ export default function App() {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// parse fail -> normal starten
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2298,7 +2264,7 @@ export default function App() {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
}
|
}
|
||||||
}, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow])
|
}, [])
|
||||||
|
|
||||||
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
|
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user