updated
This commit is contained in:
parent
ce92719ae7
commit
7298e4c91f
@ -23,6 +23,94 @@ type BioContextResp struct {
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Model string `json:"model"`
|
||||
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) {
|
||||
@ -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
|
||||
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("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(BioContextResp{
|
||||
Enabled: enabled,
|
||||
FetchedAt: cachedAt,
|
||||
LastError: "",
|
||||
Model: model,
|
||||
Bio: cachedBio,
|
||||
Enabled: enabled,
|
||||
FetchedAt: cachedAt,
|
||||
LastError: "",
|
||||
Model: model,
|
||||
Bio: cachedBio,
|
||||
RoomStatus: roomStatus,
|
||||
IsOnline: isOnline,
|
||||
ChatRoomURL: chatRoomURL,
|
||||
ImageURL: imageURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -208,18 +303,37 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fetchedAt := time.Now().UTC()
|
||||
|
||||
roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(bio)
|
||||
isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown"
|
||||
|
||||
// 5) Persistieren
|
||||
if cbModelStore != nil {
|
||||
_ = 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("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(BioContextResp{
|
||||
Enabled: enabled,
|
||||
FetchedAt: fetchedAt,
|
||||
LastError: "",
|
||||
Model: model,
|
||||
Bio: bio,
|
||||
Enabled: enabled,
|
||||
FetchedAt: fetchedAt,
|
||||
LastError: "",
|
||||
Model: model,
|
||||
Bio: bio,
|
||||
RoomStatus: roomStatus,
|
||||
IsOnline: isOnline,
|
||||
ChatRoomURL: chatRoomURL,
|
||||
ImageURL: imageURL,
|
||||
})
|
||||
}
|
||||
|
||||
@ -271,6 +271,37 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
|
||||
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) {
|
||||
if store == nil {
|
||||
return
|
||||
@ -319,6 +350,11 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
||||
continue
|
||||
}
|
||||
|
||||
// Solange ein aktiver Recorder für das Model läuft, NICHT auf offline kippen.
|
||||
if hasActiveRecordingJobForModelKey(modelKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = store.SetChaturbateRoomState(
|
||||
"chaturbate.com",
|
||||
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) ---
|
||||
|
||||
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
||||
@ -400,6 +597,10 @@ func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]Chatur
|
||||
continue
|
||||
}
|
||||
|
||||
if hasActiveRecordingJobForModelKey(userLower) {
|
||||
continue
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(prevRm.Username)
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(userLower)
|
||||
@ -690,7 +891,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
|
||||
}
|
||||
|
||||
hc := NewHTTPClient(userAgent)
|
||||
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/")
|
||||
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
|
||||
|
||||
body, err := hc.FetchPage(ctx, pageURL, cookie)
|
||||
if err != nil {
|
||||
|
||||
@ -29,6 +29,13 @@ var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"
|
||||
|
||||
type JobStatus string
|
||||
|
||||
type Playlist struct {
|
||||
PlaylistURL string
|
||||
RootURL string
|
||||
Resolution int
|
||||
Framerate int
|
||||
}
|
||||
|
||||
const (
|
||||
JobRunning JobStatus = "running"
|
||||
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
||||
@ -57,10 +64,6 @@ type RecordJob struct {
|
||||
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
PreviewDir string `json:"-"`
|
||||
previewCmd *exec.Cmd `json:"-"`
|
||||
LiveThumbStarted bool `json:"-"`
|
||||
|
||||
// ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler)
|
||||
PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error"
|
||||
PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano
|
||||
@ -72,14 +75,17 @@ type RecordJob struct {
|
||||
previewJPGAt time.Time `json:"-"`
|
||||
previewGen bool `json:"-"`
|
||||
|
||||
LiveThumbStarted bool `json:"-"`
|
||||
PreviewDir string `json:"-"`
|
||||
previewCmd *exec.Cmd `json:"-"`
|
||||
previewCancel context.CancelFunc `json:"-"`
|
||||
previewLastHit time.Time `json:"-"`
|
||||
previewStartMu sync.Mutex `json:"-"`
|
||||
|
||||
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
|
||||
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
|
||||
PreviewUA string `json:"-"` // user-agent
|
||||
|
||||
previewCancel context.CancelFunc `json:"-"`
|
||||
previewLastHit time.Time `json:"-"`
|
||||
previewStartMu sync.Mutex `json:"-"`
|
||||
|
||||
// ✅ Frontend Progress beim Stop/Finalize
|
||||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||||
Progress int `json:"progress,omitempty"` // 0..100
|
||||
@ -264,43 +270,8 @@ var (
|
||||
jobsMu = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func startPreviewIdleKiller() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
go func() {
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
jobsMu.RLock()
|
||||
list := make([]*RecordJob, 0, len(jobs))
|
||||
for _, j := range jobs {
|
||||
if j != nil {
|
||||
list = append(list, j)
|
||||
}
|
||||
}
|
||||
jobsMu.RUnlock()
|
||||
|
||||
for _, j := range list {
|
||||
jobsMu.RLock()
|
||||
cmdRunning := j.previewCmd != nil
|
||||
last := j.previewLastHit
|
||||
st := j.Status
|
||||
jobsMu.RUnlock()
|
||||
|
||||
if !cmdRunning {
|
||||
continue
|
||||
}
|
||||
// wenn Job nicht mehr läuft oder Hover weg
|
||||
if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) {
|
||||
stopPreview(j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func init() {
|
||||
initFFmpegLimiters()
|
||||
startPreviewIdleKiller()
|
||||
|
||||
initSSE()
|
||||
}
|
||||
|
||||
@ -602,27 +573,18 @@ func removeGeneratedForID(id string) {
|
||||
return
|
||||
}
|
||||
|
||||
// falls jemand "file.mp4" übergibt
|
||||
id = strings.TrimSuffix(id, filepath.Ext(id))
|
||||
|
||||
// HOT Prefix weg
|
||||
id = stripHotPrefix(id)
|
||||
|
||||
// wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!)
|
||||
var err error
|
||||
id, err = sanitizeID(id)
|
||||
if err != nil || id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 1) NEU: generated/meta/<id>/ ...
|
||||
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
||||
_ = os.RemoveAll(filepath.Join(root, id))
|
||||
}
|
||||
|
||||
// 2) Temp Preview Segmente (HLS) wegräumen
|
||||
// (%TEMP%/rec_preview/<assetID>)
|
||||
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))
|
||||
}
|
||||
|
||||
func purgeDurationCacheForPath(p string) {
|
||||
|
||||
@ -368,7 +368,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
return ext == ".mp4" || ext == ".ts"
|
||||
return ext == ".mp4"
|
||||
}
|
||||
|
||||
for _, m := range cands {
|
||||
@ -1326,175 +1326,56 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) {
|
||||
segDir := previewSegmentsDir(previewDir)
|
||||
entries, err := os.ReadDir(segDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) {
|
||||
m3u8URL = strings.TrimSpace(m3u8URL)
|
||||
if m3u8URL == "" {
|
||||
return nil, fmt.Errorf("m3u8 leer")
|
||||
}
|
||||
|
||||
if maxCount <= 0 {
|
||||
maxCount = 4
|
||||
if strings.TrimSpace(userAgent) == "" {
|
||||
userAgent = "Mozilla/5.0"
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.ToLower(e.Name())
|
||||
if !strings.HasPrefix(name, "seg_preview_") {
|
||||
continue
|
||||
}
|
||||
|
||||
names = append(names, e.Name())
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("kein Preview-Segment in %s", segDir)
|
||||
if strings.TrimSpace(userAgent) != "" {
|
||||
args = append(args, "-user_agent", strings.TrimSpace(userAgent))
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
if len(names) > maxCount {
|
||||
names = names[len(names)-maxCount:]
|
||||
if strings.TrimSpace(cookie) != "" {
|
||||
args = append(args, "-headers", fmt.Sprintf("Cookie: %s\r\n", strings.TrimSpace(cookie)))
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, filepath.Join(segDir, name))
|
||||
args = append(args,
|
||||
"-i", m3u8URL,
|
||||
"-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
|
||||
}
|
||||
|
||||
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)
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output")
|
||||
}
|
||||
|
||||
segPaths, err := latestPreviewSegments(previewDir, 4)
|
||||
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)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
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 genErr error
|
||||
|
||||
previewDir := strings.TrimSpace(j.PreviewDir)
|
||||
if previewDir != "" {
|
||||
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
|
||||
previewM3U8 := strings.TrimSpace(j.PreviewM3U8)
|
||||
previewCookie := strings.TrimSpace(j.PreviewCookie)
|
||||
previewUA := strings.TrimSpace(j.PreviewUA)
|
||||
|
||||
if previewM3U8 != "" {
|
||||
img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA)
|
||||
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) {
|
||||
jobsMu.RLock()
|
||||
status := job.Status
|
||||
previewDir := job.PreviewDir
|
||||
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
|
||||
previewCookie := strings.TrimSpace(job.PreviewCookie)
|
||||
previewUA := strings.TrimSpace(job.PreviewUA)
|
||||
out := job.Output
|
||||
jobsMu.RUnlock()
|
||||
|
||||
@ -1876,19 +1769,23 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if len(img) == 0 && out != "" {
|
||||
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
||||
img = b
|
||||
}
|
||||
}
|
||||
|
||||
if len(img) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = atomicWriteFile(thumbPath, img)
|
||||
}
|
||||
|
||||
|
||||
@ -7,38 +7,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 ---
|
||||
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
|
||||
func RecordStream(
|
||||
@ -50,185 +25,132 @@ func RecordStream(
|
||||
httpCookie string,
|
||||
job *RecordJob,
|
||||
) error {
|
||||
username = strings.Trim(strings.TrimSpace(username), "/")
|
||||
base := strings.TrimRight(domain, "/")
|
||||
pageURL := base + "/" + username
|
||||
pageURL := base + "/" + username + "/"
|
||||
|
||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seite laden: %w", err)
|
||||
loadFreshHLS := func() (string, error) {
|
||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||
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 {
|
||||
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 {
|
||||
assetID := assetIDForJob(job)
|
||||
if assetID != "" {
|
||||
if dir, derr := ensureGeneratedDir(assetID); derr == nil {
|
||||
previewDir = strings.TrimSpace(dir)
|
||||
|
||||
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
|
||||
segmentsDir = strings.TrimSpace(sdir)
|
||||
}
|
||||
}
|
||||
_, _ = ensureGeneratedDir(assetID)
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
job.PreviewM3U8 = strings.TrimSpace(hlsURL)
|
||||
job.PreviewM3U8 = hlsURL
|
||||
job.PreviewCookie = httpCookie
|
||||
job.PreviewUA = hc.userAgent
|
||||
if previewDir != "" {
|
||||
job.PreviewDir = previewDir
|
||||
}
|
||||
jobsMu.Unlock()
|
||||
|
||||
if previewDir != "" {
|
||||
if assetID != "" {
|
||||
startLiveThumbJPGLoop(ctx, job)
|
||||
}
|
||||
}
|
||||
|
||||
if segmentsDir != "" {
|
||||
defer func() {
|
||||
if err := os.RemoveAll(segmentsDir); err != nil {
|
||||
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
const maxPlaylistRefreshes = 12
|
||||
refreshes := 0
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("datei erstellen: %w", err)
|
||||
}
|
||||
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 {
|
||||
for {
|
||||
err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := file.Write(b); err != nil {
|
||||
return fmt.Errorf("schreibe segment: %w", err)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if err := file.Sync(); err != nil {
|
||||
//fmt.Println("output sync failed:", outputPath, err)
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
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 != "" {
|
||||
if err := os.MkdirAll(segmentsDir, 0o755); err == nil {
|
||||
ext := ".m4s"
|
||||
if len(b) > 0 && b[0] == 0x47 {
|
||||
ext = ".ts"
|
||||
}
|
||||
refreshes++
|
||||
if refreshes > maxPlaylistRefreshes {
|
||||
return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
|
||||
}
|
||||
|
||||
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
|
||||
segPath := filepath.Join(segmentsDir, segName)
|
||||
fmt.Println("🔄 [cb] refresh playlist:",
|
||||
"user=", username,
|
||||
"try=", refreshes,
|
||||
"reason=", err,
|
||||
)
|
||||
|
||||
if err := os.WriteFile(segPath, b, 0o644); err == nil {
|
||||
trimPreviewSegments(segmentsDir, 12)
|
||||
}
|
||||
select {
|
||||
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 {
|
||||
published = true
|
||||
_ = publishJob(job.ID)
|
||||
}
|
||||
hlsURL = newHLS
|
||||
|
||||
written += int64(len(b))
|
||||
if job != nil {
|
||||
now := time.Now()
|
||||
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
|
||||
jobsMu.Lock()
|
||||
job.SizeBytes = written
|
||||
jobsMu.Unlock()
|
||||
|
||||
lastPush = now
|
||||
lastBytes = written
|
||||
}
|
||||
jobsMu.Lock()
|
||||
job.PreviewM3U8 = hlsURL
|
||||
job.PreviewCookie = httpCookie
|
||||
job.PreviewUA = hc.userAgent
|
||||
jobsMu.Unlock()
|
||||
}
|
||||
|
||||
_ = duration
|
||||
_ = segmentURL
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("watch segments: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
||||
@ -258,289 +180,6 @@ func ParseStream(html string) (string, error) {
|
||||
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)
|
||||
func addCookiesFromString(req *http.Request, cookieStr string) {
|
||||
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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -89,7 +87,7 @@ func RecordStreamMFC(
|
||||
}
|
||||
|
||||
// Aufnahme starten
|
||||
return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
|
||||
return handleM3U8Mode(ctx, m3u8URL, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL())
|
||||
}
|
||||
|
||||
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 handleM3U8Mode(ctx, m3u8URL, outArg, nil)
|
||||
return handleM3U8Mode(ctx, m3u8URL, outArg, nil, "", "", mfc.GetWebsiteURL())
|
||||
}
|
||||
|
||||
func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
||||
@ -310,98 +308,6 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
||||
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
|
||||
─────────────────────────────── */
|
||||
|
||||
@ -262,6 +262,127 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---------------- 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 {
|
||||
if job == nil {
|
||||
return false
|
||||
@ -310,6 +431,11 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
return nil, errors.New("url fehlt")
|
||||
}
|
||||
|
||||
provider := detectProvider(url)
|
||||
if provider != "chaturbate" && provider != "mfc" {
|
||||
return nil, fmt.Errorf("unsupported provider: %q", url)
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
|
||||
@ -345,7 +471,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
provider := detectProvider(url)
|
||||
|
||||
username := ""
|
||||
switch provider {
|
||||
@ -358,7 +483,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
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)
|
||||
recordDir := strings.TrimSpace(recordDirAbs)
|
||||
@ -382,8 +507,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
}
|
||||
|
||||
jobs[jobID] = job
|
||||
|
||||
go runJob(ctx, job, req)
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
@ -429,7 +554,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||
|
||||
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()
|
||||
existingOut := strings.TrimSpace(job.Output)
|
||||
@ -446,6 +571,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
jobsMu.Unlock()
|
||||
}
|
||||
|
||||
markModelPublicWhileRecording(job)
|
||||
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
|
||||
|
||||
case "mfc":
|
||||
@ -458,7 +584,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||
|
||||
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)
|
||||
|
||||
jobsMu.Lock()
|
||||
@ -489,6 +615,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
jobsMu.Lock()
|
||||
job.EndedAt = &end
|
||||
job.EndedAtMs = end.UnixMilli()
|
||||
job.Status = target
|
||||
if errText != "" {
|
||||
job.Error = errText
|
||||
}
|
||||
@ -496,22 +623,41 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
out := strings.TrimSpace(job.Output)
|
||||
jobsMu.Unlock()
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// HARTE SCHRANKE 1: gar kein Output-Pfad
|
||||
// ------------------------------------------------------------
|
||||
if out == "" {
|
||||
jobsMu.Lock()
|
||||
job.Status = target
|
||||
job.Phase = ""
|
||||
job.Progress = 100
|
||||
job.PostWorkKey = ""
|
||||
job.PostWork = nil
|
||||
jobsMu.Unlock()
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
publishJobRemove(job)
|
||||
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 {
|
||||
_ = removeWithRetry(out)
|
||||
purgeDurationCacheForPath(out)
|
||||
@ -521,21 +667,13 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
jobsMu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// pre-queue gate: nur in die Nachbearbeitung, wenn Datei behalten werden soll
|
||||
// ------------------------------------------------------------
|
||||
// AUTO DELETE SMALL DOWNLOADS
|
||||
// ------------------------------------------------------------
|
||||
{
|
||||
fi, serr := os.Stat(out)
|
||||
if serr == nil && fi != nil && !fi.IsDir() {
|
||||
@ -563,10 +701,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
jobsMu.Unlock()
|
||||
|
||||
publishJobRemove(job)
|
||||
|
||||
if shouldLogRecordInfo(req) {
|
||||
fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -576,7 +710,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// postwork queue
|
||||
// ------------------------------------------------------------
|
||||
// POSTWORK QUEUE
|
||||
// ------------------------------------------------------------
|
||||
postOut := out
|
||||
postTarget := target
|
||||
postKey := "postwork:" + job.ID
|
||||
@ -619,24 +755,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
}
|
||||
|
||||
out := strings.TrimSpace(postOut)
|
||||
|
||||
if out == "" {
|
||||
jobsMu.Lock()
|
||||
job.Status = postTarget
|
||||
job.Phase = ""
|
||||
job.Progress = 100
|
||||
job.Status = postTarget
|
||||
job.PostWorkKey = ""
|
||||
job.PostWork = nil
|
||||
jobsMu.Unlock()
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
publishJobRemove(job)
|
||||
|
||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||
File: postFile,
|
||||
AssetID: postAssetID,
|
||||
Queue: "postwork",
|
||||
State: "done",
|
||||
Label: "Primäre Nachbearbeitung fertig",
|
||||
})
|
||||
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)
|
||||
|
||||
// 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)
|
||||
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",
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" {
|
||||
out = strings.TrimSpace(moved)
|
||||
jobsMu.Lock()
|
||||
job.Output = out
|
||||
jobsMu.Unlock()
|
||||
notifyDoneChanged()
|
||||
}
|
||||
|
||||
// 3) Duration
|
||||
// 4) DURATION
|
||||
setPhase("probe", 35)
|
||||
{
|
||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
@ -776,7 +884,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// 4) Video props
|
||||
// 5) VIDEO PROPS
|
||||
setPhase("probe", 75)
|
||||
{
|
||||
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)
|
||||
|
||||
lastPct := -1
|
||||
@ -830,7 +938,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
}
|
||||
setPhase("assets", 100)
|
||||
|
||||
// Finalize primary postwork
|
||||
// FINALIZE PRIMARY POSTWORK
|
||||
finalFile := filepath.Base(out)
|
||||
finalAssetID := assetIDFromVideoPath(out)
|
||||
|
||||
@ -842,6 +950,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
job.PostWork = nil
|
||||
jobsMu.Unlock()
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
|
||||
publishJobRemove(job)
|
||||
|
||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||
@ -896,6 +1008,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
job.PostWork = nil
|
||||
jobsMu.Unlock()
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
|
||||
publishJobRemove(job)
|
||||
|
||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"maxConcurrentDownloads": 80,
|
||||
"useChaturbateApi": true,
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloads": false,
|
||||
"autoDeleteSmallDownloadsBelowMB": 100,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
|
||||
@ -45,6 +45,7 @@ func main() {
|
||||
store := registerRoutes(mux, auth)
|
||||
|
||||
go startChaturbateOnlinePoller(store)
|
||||
go startChaturbateBioContextPoller()
|
||||
go startChaturbateAutoStartWorker(store)
|
||||
go startMyFreeCamsAutoStartWorker(store)
|
||||
go startDiskSpaceGuard()
|
||||
|
||||
@ -124,6 +124,18 @@ type TaskStateEvent = {
|
||||
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 = {
|
||||
recordDir: 'records',
|
||||
doneDir: 'records/done',
|
||||
@ -201,6 +213,39 @@ type AutostartState = {
|
||||
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' {
|
||||
const s = String(v ?? '').trim().toLowerCase()
|
||||
|
||||
@ -1939,64 +1984,6 @@ export default function App() {
|
||||
chaturbateStoreKeysLowerRef.current = 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(() => {
|
||||
selectedTabRef.current = selectedTab
|
||||
}, [selectedTab])
|
||||
@ -2070,17 +2057,14 @@ export default function App() {
|
||||
}
|
||||
}, [authed, recSettings.useChaturbateApi])
|
||||
|
||||
function shouldQueueForRoomStatus(
|
||||
show: string
|
||||
): boolean {
|
||||
function shouldAlwaysQueueForRoomStatus(show: string): boolean {
|
||||
const s = String(show || '').trim().toLowerCase()
|
||||
return (
|
||||
s === 'private' ||
|
||||
s === 'hidden' ||
|
||||
s === 'away' ||
|
||||
s === 'offline' ||
|
||||
s === 'unknown'
|
||||
)
|
||||
return s === 'private' || s === 'hidden' || s === 'away'
|
||||
}
|
||||
|
||||
function shouldQueueForOfflineLikeStatus(show: string): boolean {
|
||||
const s = String(show || '').trim().toLowerCase()
|
||||
return s === 'offline' || s === 'unknown'
|
||||
}
|
||||
|
||||
// ✅ 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
|
||||
|
||||
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status
|
||||
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status
|
||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
|
||||
try {
|
||||
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
|
||||
@ -2130,26 +2114,20 @@ export default function App() {
|
||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
||||
|
||||
if (mkLower) {
|
||||
const upsertPendingRow = (opts?: {
|
||||
show?: unknown
|
||||
imageUrl?: string
|
||||
chatRoomUrl?: string
|
||||
}) => {
|
||||
const model = modelsByKeyRef.current[mkLower] as any
|
||||
const show = normalizePendingShow(opts?.show ?? model?.roomStatus)
|
||||
|
||||
const imageUrl =
|
||||
String(opts?.imageUrl ?? '').trim() ||
|
||||
String(model?.imageUrl ?? '').trim() ||
|
||||
undefined
|
||||
// silent bleibt günstig wie bisher
|
||||
if (silent) {
|
||||
setPendingAutoStartByKey((prev) => {
|
||||
const next = { ...(prev || {}), [mkLower]: norm }
|
||||
pendingAutoStartByKeyRef.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
setPendingWatchedRooms((prev) => {
|
||||
const nextItem: PendingWatchedRoom = {
|
||||
id: mkLower,
|
||||
modelKey: mkLower,
|
||||
url: norm,
|
||||
currentShow: show,
|
||||
imageUrl,
|
||||
currentShow: 'unknown',
|
||||
}
|
||||
|
||||
const idx = prev.findIndex(
|
||||
@ -2164,64 +2142,54 @@ export default function App() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Live current_show prüfen
|
||||
const live = await fetchChaturbateCurrentShow(mkLower)
|
||||
const liveShow = normalizePendingShow(live?.show)
|
||||
|
||||
if (shouldQueueForRoomStatus(liveShow)) {
|
||||
enqueuePending({
|
||||
show: liveShow,
|
||||
imageUrl: live?.imageUrl,
|
||||
chatRoomUrl: live?.chatRoomUrl,
|
||||
// manuell: erst /online
|
||||
try {
|
||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ q: [mkLower] }),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// public -> Snapshot aktualisieren und normal starten
|
||||
applyPendingRoomSnapshot(mkLower, {
|
||||
show: liveShow,
|
||||
imageUrl: live?.imageUrl,
|
||||
chatRoomUrl: live?.chatRoomUrl,
|
||||
})
|
||||
let handled = false
|
||||
|
||||
if (onlineRes.ok) {
|
||||
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||
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 {
|
||||
// Wenn Live-Check fehlschlägt: lieber in Warteschlange statt blind starten
|
||||
try {
|
||||
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 (silent) {
|
||||
const mkLower = providerKeyLowerFromUrl(norm)
|
||||
if (mkLower) {
|
||||
setPendingAutoStartByKey((prev) => {
|
||||
const next = { ...(prev || {}), [mkLower]: norm }
|
||||
@ -2252,8 +2220,6 @@ export default function App() {
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// parse fail -> normal starten
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2298,7 +2264,7 @@ export default function App() {
|
||||
setBusy(false)
|
||||
busyRef.current = false
|
||||
}
|
||||
}, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow])
|
||||
}, [])
|
||||
|
||||
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
|
||||
useEffect(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user