updated
This commit is contained in:
parent
32bd7c1e6e
commit
ce92719ae7
@ -115,9 +115,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
// ✅ Autostart nur wenn Feature aktiviert ist
|
||||
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)
|
||||
if !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || !s.AutoAddToDownloadList {
|
||||
|
||||
// Backend-Autostart läuft, wenn
|
||||
// - Chaturbate API aktiviert ist
|
||||
// - Autostart NICHT pausiert ist (wird oben schon via isAutostartPaused() geprüft)
|
||||
if !s.UseChaturbateAPI {
|
||||
queue = queue[:0]
|
||||
queued = map[string]bool{}
|
||||
time.Sleep(2 * time.Second)
|
||||
@ -145,7 +147,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
running := map[string]bool{}
|
||||
jobsMu.RLock()
|
||||
for _, j := range jobs {
|
||||
if j == nil || j.Status != JobRunning {
|
||||
if !isActiveRecordingJob(j) {
|
||||
continue
|
||||
}
|
||||
u := chaturbateUserFromURL(j.SourceURL)
|
||||
@ -173,9 +175,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if showByUser[it.userKey] == "" {
|
||||
continue
|
||||
}
|
||||
if running[it.userKey] {
|
||||
continue
|
||||
}
|
||||
@ -183,6 +182,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if it.url == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist
|
||||
nextQueue = append(nextQueue, it)
|
||||
nextQueued[it.userKey] = true
|
||||
}
|
||||
|
||||
@ -135,6 +135,118 @@ type importResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
type modelsOverviewRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
IncludeWatched bool `json:"includeWatched"`
|
||||
}
|
||||
|
||||
type modelsOverviewModel struct {
|
||||
ID string `json:"id"`
|
||||
Input string `json:"input"`
|
||||
Host string `json:"host,omitempty"`
|
||||
ModelKey string `json:"modelKey"`
|
||||
Watching bool `json:"watching"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
Liked *bool `json:"liked,omitempty"`
|
||||
IsURL bool `json:"isUrl,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
RoomStatus string `json:"roomStatus,omitempty"`
|
||||
IsOnline bool `json:"isOnline,omitempty"`
|
||||
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
LastOnlineAt string `json:"lastOnlineAt,omitempty"`
|
||||
LastOfflineAt string `json:"lastOfflineAt,omitempty"`
|
||||
LastRoomSyncAt string `json:"lastRoomSyncAt,omitempty"`
|
||||
}
|
||||
|
||||
type modelsOverviewResponse struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
OnlineModelsCount int `json:"onlineModelsCount"`
|
||||
OnlineWatchedModelsCount int `json:"onlineWatchedModelsCount"`
|
||||
OnlineFavCount int `json:"onlineFavCount"`
|
||||
OnlineLikedCount int `json:"onlineLikedCount"`
|
||||
Models []modelsOverviewModel `json:"models"`
|
||||
}
|
||||
|
||||
func normalizeModelKeySet(in []string) map[string]bool {
|
||||
out := make(map[string]bool, len(in))
|
||||
for _, s := range in {
|
||||
k := strings.ToLower(strings.TrimSpace(s))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isOnlineRoomStatus(status string, isOnline bool) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(status))
|
||||
if s == "offline" {
|
||||
return false
|
||||
}
|
||||
if isOnline {
|
||||
return true
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverviewResponse, error) {
|
||||
var list []modelsOverviewModel
|
||||
|
||||
// absichtlich per JSON-Roundtrip:
|
||||
// dadurch müssen wir den exakten Store-Typ hier nicht kennen
|
||||
b, err := json.Marshal(rawList)
|
||||
if err != nil {
|
||||
return modelsOverviewResponse{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return modelsOverviewResponse{}, err
|
||||
}
|
||||
|
||||
keySet := normalizeModelKeySet(req.Keys)
|
||||
|
||||
resp := modelsOverviewResponse{
|
||||
TotalCount: len(list),
|
||||
Models: make([]modelsOverviewModel, 0, len(keySet)+32),
|
||||
}
|
||||
|
||||
for _, m := range list {
|
||||
key := strings.ToLower(strings.TrimSpace(m.ModelKey))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline)
|
||||
|
||||
if strings.Contains(strings.ToLower(m.Host), "chaturbate") {
|
||||
if online {
|
||||
resp.OnlineModelsCount++
|
||||
}
|
||||
if m.Watching && online {
|
||||
resp.OnlineWatchedModelsCount++
|
||||
}
|
||||
}
|
||||
|
||||
if m.Favorite && online {
|
||||
resp.OnlineFavCount++
|
||||
}
|
||||
if m.Liked != nil && *m.Liked && online {
|
||||
resp.OnlineLikedCount++
|
||||
}
|
||||
|
||||
needModel :=
|
||||
keySet[key] ||
|
||||
(req.IncludeWatched && m.Watching)
|
||||
|
||||
if needModel {
|
||||
resp.Models = append(resp.Models, m)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.Comma = ';'
|
||||
@ -293,6 +405,35 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models/overview", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req modelsOverviewRequest
|
||||
if err := modelsReadJSON(r, &req); err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store := getModelStore()
|
||||
if store == nil {
|
||||
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
|
||||
return
|
||||
}
|
||||
|
||||
list := store.List()
|
||||
|
||||
resp, err := buildModelsOverview(list, req)
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
modelsWriteJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||
"ffmpegPath": "",
|
||||
"autoAddToDownloadList": true,
|
||||
"autoStartAddedDownloads": true,
|
||||
"enableConcurrentDownloadsLimit": true,
|
||||
"maxConcurrentDownloads": 80,
|
||||
"useChaturbateApi": true,
|
||||
|
||||
@ -23,7 +23,6 @@ type RecorderSettings struct {
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||
|
||||
@ -55,7 +54,6 @@ var (
|
||||
FFmpegPath: "",
|
||||
|
||||
AutoAddToDownloadList: false,
|
||||
AutoStartAddedDownloads: false,
|
||||
EnableConcurrentDownloadsLimit: false,
|
||||
MaxConcurrentDownloads: 20,
|
||||
|
||||
@ -210,7 +208,6 @@ type RecorderSettingsPublic struct {
|
||||
HasDBPassword bool `json:"hasDbPassword"`
|
||||
|
||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||
|
||||
@ -238,7 +235,6 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||
|
||||
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
||||
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
|
||||
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
||||
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
||||
|
||||
@ -389,7 +385,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||
|
||||
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
||||
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
|
||||
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
||||
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
||||
|
||||
|
||||
@ -589,7 +589,7 @@ export default function App() {
|
||||
|
||||
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
|
||||
|
||||
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
|
||||
const [cbOnlineFetchedAt, setCbOnlineFetchedAt] = useState<string>('')
|
||||
|
||||
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||||
|
||||
@ -808,7 +808,6 @@ export default function App() {
|
||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||
|
||||
setDoneCount(count)
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@ -823,7 +822,6 @@ export default function App() {
|
||||
|
||||
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
||||
setDoneJobs(prefetched.items)
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
|
||||
donePrefetchRef.current = null
|
||||
|
||||
@ -852,7 +850,6 @@ export default function App() {
|
||||
: []
|
||||
|
||||
setDoneJobs(items)
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
|
||||
const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount)
|
||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||
@ -948,7 +945,6 @@ export default function App() {
|
||||
return next
|
||||
})
|
||||
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -970,27 +966,11 @@ export default function App() {
|
||||
void loadDonePage(donePage, doneSort)
|
||||
}, [authed, donePage, doneSort, loadDonePage])
|
||||
|
||||
useEffect(() => {
|
||||
setModelsCount(Object.keys(modelsByKey).length)
|
||||
}, [modelsByKey])
|
||||
|
||||
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
|
||||
const h = String(m?.host ?? '').toLowerCase()
|
||||
const input = String(m?.input ?? '').toLowerCase()
|
||||
return h.includes('chaturbate') || input.includes('chaturbate.com')
|
||||
}, [])
|
||||
|
||||
const onlineModelsCount = useMemo(() => {
|
||||
let c = 0
|
||||
for (const m of Object.values(modelsByKey)) {
|
||||
if (!isChaturbateStoreModel(m)) continue
|
||||
if (String((m as any)?.roomStatus ?? '').trim().toLowerCase() === 'offline') continue
|
||||
if ((m as any)?.isOnline === true || String((m as any)?.roomStatus ?? '').trim() !== '') {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}, [modelsByKey, isChaturbateStoreModel])
|
||||
|
||||
const applyJobDelta = useCallback((msg: JobEvent) => {
|
||||
const jobId = String(msg?.jobId ?? '').trim()
|
||||
@ -1043,7 +1023,6 @@ export default function App() {
|
||||
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
|
||||
)
|
||||
|
||||
setLastHeaderUpdateAtMs(now)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1080,8 +1059,6 @@ export default function App() {
|
||||
if (String((prev as any).id ?? '').trim() !== jobId) return prev
|
||||
return mergeJob(prev)
|
||||
})
|
||||
|
||||
setLastHeaderUpdateAtMs(now)
|
||||
}, [])
|
||||
|
||||
const ensureModelEventListener = useCallback((name: string) => {
|
||||
@ -1194,15 +1171,75 @@ export default function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshModelsByKey = useCallback(async () => {
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
|
||||
const safeList = Array.isArray(list) ? list : []
|
||||
type AppModelsSnapshot = {
|
||||
totalCount: number
|
||||
onlineModelsCount: number
|
||||
onlineWatchedModelsCount: number
|
||||
onlineFavCount: number
|
||||
onlineLikedCount: number
|
||||
models: StoredModel[]
|
||||
}
|
||||
|
||||
const [headerStats, setHeaderStats] = useState({
|
||||
totalCount: 0,
|
||||
onlineModelsCount: 0,
|
||||
onlineWatchedModelsCount: 0,
|
||||
onlineFavCount: 0,
|
||||
onlineLikedCount: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setModelsCount(headerStats.totalCount)
|
||||
}, [headerStats.totalCount])
|
||||
|
||||
const overviewInFlightRef = useRef(false)
|
||||
const overviewLastSigRef = useRef('')
|
||||
const overviewLastAtRef = useRef(0)
|
||||
|
||||
const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
|
||||
const uniqKeys = Array.from(
|
||||
new Set(
|
||||
keys
|
||||
.map((x) => String(x ?? '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
const sig = uniqKeys.join('|')
|
||||
const now = Date.now()
|
||||
|
||||
if (overviewInFlightRef.current) return
|
||||
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return
|
||||
|
||||
overviewInFlightRef.current = true
|
||||
overviewLastSigRef.current = sig
|
||||
overviewLastAtRef.current = now
|
||||
|
||||
try {
|
||||
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store' as any,
|
||||
body: JSON.stringify({
|
||||
keys: uniqKeys,
|
||||
includeWatched: true,
|
||||
}),
|
||||
})
|
||||
|
||||
setHeaderStats({
|
||||
totalCount: Number(data?.totalCount ?? 0),
|
||||
onlineModelsCount: Number(data?.onlineModelsCount ?? 0),
|
||||
onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0),
|
||||
onlineFavCount: Number(data?.onlineFavCount ?? 0),
|
||||
onlineLikedCount: Number(data?.onlineLikedCount ?? 0),
|
||||
})
|
||||
|
||||
const safeList = Array.isArray(data?.models) ? data.models : []
|
||||
setModelsByKey(buildModelsByKey(safeList))
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
overviewInFlightRef.current = false
|
||||
}
|
||||
}, [buildModelsByKey])
|
||||
|
||||
@ -1427,59 +1464,6 @@ export default function App() {
|
||||
else cur.list.unshift(m)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// initial laden
|
||||
void refreshModelsByKey()
|
||||
|
||||
const onChanged = (ev: Event) => {
|
||||
const e = ev as CustomEvent<any>
|
||||
const detail = e?.detail ?? {}
|
||||
const updated = detail?.model
|
||||
|
||||
// ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models)
|
||||
if (updated && typeof updated === 'object') {
|
||||
const k = String(updated.modelKey ?? '').toLowerCase().trim()
|
||||
if (k) {
|
||||
setModelsByKey((prev) => ({ ...prev, [k]: updated }))
|
||||
}
|
||||
|
||||
try {
|
||||
upsertModelCache(updated)
|
||||
} catch {}
|
||||
|
||||
setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 2) Delete-Event (optional): ebenfalls ohne Voll-Refresh
|
||||
if (detail?.removed) {
|
||||
const removedId = String(detail?.id ?? '').trim()
|
||||
const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim()
|
||||
|
||||
if (removedKey) {
|
||||
setModelsByKey((prev) => {
|
||||
const { [removedKey]: _drop, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
if (removedId) {
|
||||
setPlayerModel((prev) => (prev?.id === removedId ? null : prev))
|
||||
}
|
||||
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 3) Nur wenn kein Model/keine Delete-Info mitkommt: kompletter Refresh
|
||||
void refreshModelsByKey()
|
||||
}
|
||||
|
||||
window.addEventListener('models-changed', onChanged as any)
|
||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||
}, [refreshModelsByKey, upsertModelCache])
|
||||
|
||||
const recSettingsRef = useRef(recSettings)
|
||||
useEffect(() => {
|
||||
recSettingsRef.current = recSettings
|
||||
@ -1653,6 +1637,96 @@ export default function App() {
|
||||
return Array.from(set)
|
||||
}, [modelsByKey])
|
||||
|
||||
const requiredModelKeys = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
|
||||
for (const j of jobs) {
|
||||
const k = modelEventKeyFromJob(j)
|
||||
if (k) set.add(k)
|
||||
}
|
||||
|
||||
for (const j of doneJobs) {
|
||||
const k = modelEventKeyFromDoneJob(j)
|
||||
if (k) set.add(k)
|
||||
}
|
||||
|
||||
for (const p of pendingWatchedRooms) {
|
||||
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||
if (k) set.add(k)
|
||||
}
|
||||
|
||||
if (playerJob) {
|
||||
const k = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
|
||||
if (k) set.add(k)
|
||||
}
|
||||
|
||||
if (detailsModelKey) {
|
||||
const k = String(detailsModelKey ?? '').trim().toLowerCase()
|
||||
if (k) set.add(k)
|
||||
}
|
||||
|
||||
return Array.from(set).sort()
|
||||
}, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey])
|
||||
|
||||
const requiredModelKeysSig = useMemo(
|
||||
() => requiredModelKeys.join('|'),
|
||||
[requiredModelKeys]
|
||||
)
|
||||
|
||||
|
||||
|
||||
const lastOverviewSigRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
|
||||
lastOverviewSigRef.current = requiredModelKeysSig
|
||||
void loadAppModelsSnapshot(requiredModelKeys)
|
||||
}
|
||||
|
||||
const onChanged = (ev: Event) => {
|
||||
const e = ev as CustomEvent<any>
|
||||
const detail = e?.detail ?? {}
|
||||
const updated = detail?.model
|
||||
|
||||
if (updated && typeof updated === 'object') {
|
||||
const k = String(updated.modelKey ?? '').toLowerCase().trim()
|
||||
if (k) {
|
||||
setModelsByKey((prev) => ({ ...prev, [k]: updated }))
|
||||
}
|
||||
|
||||
try {
|
||||
upsertModelCache(updated)
|
||||
} catch {}
|
||||
|
||||
setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))
|
||||
return
|
||||
}
|
||||
|
||||
if (detail?.removed) {
|
||||
const removedId = String(detail?.id ?? '').trim()
|
||||
const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim()
|
||||
|
||||
if (removedKey) {
|
||||
setModelsByKey((prev) => {
|
||||
const { [removedKey]: _drop, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
if (removedId) {
|
||||
setPlayerModel((prev) => (prev?.id === removedId ? null : prev))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void loadAppModelsSnapshot(requiredModelKeys)
|
||||
}
|
||||
|
||||
window.addEventListener('models-changed', onChanged as any)
|
||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
||||
|
||||
const selectedTabRef = useRef(selectedTab)
|
||||
|
||||
const applyPendingRoomSnapshot = useCallback(
|
||||
@ -1752,7 +1826,10 @@ export default function App() {
|
||||
}, [pendingAutoStartByKey, modelsByKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recSettings.useChaturbateApi) return
|
||||
if (!recSettings.useChaturbateApi) {
|
||||
setCbOnlineFetchedAt('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timer: number | null = null
|
||||
@ -1789,6 +1866,7 @@ export default function App() {
|
||||
if (!res.ok) return
|
||||
|
||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||
|
||||
const rooms = Array.isArray(data?.rooms) ? data!.rooms : []
|
||||
|
||||
const byKey: Record<
|
||||
@ -1892,6 +1970,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
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
|
||||
@ -1949,6 +2028,48 @@ export default function App() {
|
||||
setRoomStatusByModelKey(next)
|
||||
}, [jobs, modelsByKey, recSettings.useChaturbateApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || !recSettings.useChaturbateApi) {
|
||||
setCbOnlineFetchedAt('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timer: number | null = null
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!onlineRes.ok) return
|
||||
|
||||
const data = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||
if (cancelled || !data) return
|
||||
|
||||
const fetchedAt = String(data.fetchedAt ?? '').trim()
|
||||
if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') {
|
||||
setCbOnlineFetchedAt(fetchedAt)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void tick()
|
||||
|
||||
timer = window.setInterval(() => {
|
||||
void tick()
|
||||
}, 10_000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer != null) window.clearInterval(timer)
|
||||
}
|
||||
}, [authed, recSettings.useChaturbateApi])
|
||||
|
||||
function shouldQueueForRoomStatus(
|
||||
show: string
|
||||
): boolean {
|
||||
@ -2234,37 +2355,10 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
// ✅ Anzahl Watched Models (aus Store), die online sind
|
||||
const onlineWatchedModelsCount = useMemo(() => {
|
||||
let c = 0
|
||||
for (const m of Object.values(modelsByKey)) {
|
||||
if (!m?.watching) continue
|
||||
if (!isChaturbateStoreModel(m)) continue
|
||||
|
||||
const k = lower(String(m?.modelKey ?? ''))
|
||||
if (!k) continue
|
||||
|
||||
const status = String((m as any)?.roomStatus ?? '').trim().toLowerCase()
|
||||
if (status && status !== 'offline') c++
|
||||
}
|
||||
return c
|
||||
}, [modelsByKey, isChaturbateStoreModel])
|
||||
|
||||
const { onlineFavCount, onlineLikedCount } = useMemo(() => {
|
||||
let fav = 0
|
||||
let liked = 0
|
||||
|
||||
for (const m of Object.values(modelsByKey)) {
|
||||
const k = lower(String(m?.modelKey ?? ''))
|
||||
if (!k) continue
|
||||
const status = String((m as any)?.roomStatus ?? '').trim().toLowerCase()
|
||||
if (!status || status === 'offline') continue
|
||||
|
||||
if (m?.favorite) fav++
|
||||
if (m?.liked === true) liked++
|
||||
}
|
||||
|
||||
return { onlineFavCount: fav, onlineLikedCount: liked }
|
||||
}, [modelsByKey])
|
||||
const onlineModelsCount = headerStats.onlineModelsCount
|
||||
const onlineWatchedModelsCount = headerStats.onlineWatchedModelsCount
|
||||
const onlineFavCount = headerStats.onlineFavCount
|
||||
const onlineLikedCount = headerStats.onlineLikedCount
|
||||
|
||||
const runningTabCount = useMemo(() => {
|
||||
const activeDownloads = jobs.filter((j) => {
|
||||
@ -3413,14 +3507,20 @@ export default function App() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
|
||||
<LastUpdatedText
|
||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||
fetchedAt={cbOnlineFetchedAt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
|
||||
<div className="sm:hidden mt-1 w-full">
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
|
||||
<LastUpdatedText
|
||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||
fetchedAt={cbOnlineFetchedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-stretch gap-2">
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
// frontend\src\components\ui\LastUpdatedText.tsx
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
function formatAgoDE(diffMs: number) {
|
||||
const s = Math.max(0, Math.floor(diffMs / 1000))
|
||||
if (s < 2) return 'gerade eben'
|
||||
if (s < 5) return 'gerade eben'
|
||||
if (s < 60) return `vor ${s} Sekunden`
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
@ -11,20 +13,41 @@ function formatAgoDE(diffMs: number) {
|
||||
|
||||
const h = Math.floor(m / 60)
|
||||
if (h === 1) return 'vor 1 Stunde'
|
||||
return `vor ${h} Stunden`
|
||||
if (h < 24) return `vor ${h} Stunden`
|
||||
|
||||
const d = Math.floor(h / 24)
|
||||
if (d === 1) return 'vor 1 Tag'
|
||||
return `vor ${d} Tagen`
|
||||
}
|
||||
|
||||
export default function LastUpdatedText({ lastAt }: { lastAt: number }) {
|
||||
type Props = {
|
||||
fetchedAt?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export default function LastUpdatedText({ fetchedAt, enabled = true }: Props) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
if (!enabled) return
|
||||
|
||||
const t = window.setInterval(() => setNow(Date.now()), 10_000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [])
|
||||
}, [enabled])
|
||||
|
||||
const text = useMemo(() => {
|
||||
return `(zuletzt aktualisiert: ${formatAgoDE(now - lastAt)})`
|
||||
}, [now, lastAt])
|
||||
if (!enabled) return ''
|
||||
|
||||
const raw = String(fetchedAt ?? '').trim()
|
||||
if (!raw) return '(zuletzt aktualisiert: noch kein erfolgreicher Pull)'
|
||||
|
||||
const ts = Date.parse(raw)
|
||||
if (!Number.isFinite(ts)) {
|
||||
return '(zuletzt aktualisiert: unbekannt)'
|
||||
}
|
||||
|
||||
return `(zuletzt aktualisiert: ${formatAgoDE(now - ts)})`
|
||||
}, [enabled, fetchedAt, now])
|
||||
|
||||
return <>{text}</>
|
||||
}
|
||||
@ -18,7 +18,6 @@ type RecorderSettings = {
|
||||
doneDir: string
|
||||
ffmpegPath?: string
|
||||
autoAddToDownloadList?: boolean
|
||||
autoStartAddedDownloads?: boolean
|
||||
enableConcurrentDownloadsLimit?: boolean
|
||||
maxConcurrentDownloads?: number
|
||||
useChaturbateApi?: boolean
|
||||
@ -188,7 +187,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
|
||||
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
|
||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||||
enableConcurrentDownloadsLimit:
|
||||
(data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit,
|
||||
maxConcurrentDownloads:
|
||||
@ -522,7 +520,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
||||
const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit
|
||||
const maxConcurrentDownloads = Math.max(
|
||||
1,
|
||||
@ -557,7 +554,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
doneDir,
|
||||
ffmpegPath,
|
||||
autoAddToDownloadList,
|
||||
autoStartAddedDownloads,
|
||||
enableConcurrentDownloadsLimit,
|
||||
maxConcurrentDownloads,
|
||||
useChaturbateApi,
|
||||
@ -1111,21 +1107,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
autoAddToDownloadList: checked,
|
||||
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
||||
}))
|
||||
}
|
||||
label="Automatisch zur Downloadliste hinzufügen"
|
||||
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoStartAddedDownloads}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, autoStartAddedDownloads: checked }))}
|
||||
disabled={!value.autoAddToDownloadList}
|
||||
label="Hinzugefügte Downloads automatisch starten"
|
||||
description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.useChaturbateApi}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user