updated
This commit is contained in:
parent
32bd7c1e6e
commit
ce92719ae7
@ -115,9 +115,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
// ✅ Autostart nur wenn Feature aktiviert ist
|
|
||||||
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)
|
// Backend-Autostart läuft, wenn
|
||||||
if !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || !s.AutoAddToDownloadList {
|
// - Chaturbate API aktiviert ist
|
||||||
|
// - Autostart NICHT pausiert ist (wird oben schon via isAutostartPaused() geprüft)
|
||||||
|
if !s.UseChaturbateAPI {
|
||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
@ -145,7 +147,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
running := map[string]bool{}
|
running := map[string]bool{}
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
if j == nil || j.Status != JobRunning {
|
if !isActiveRecordingJob(j) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u := chaturbateUserFromURL(j.SourceURL)
|
u := chaturbateUserFromURL(j.SourceURL)
|
||||||
@ -173,9 +175,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if showByUser[it.userKey] == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if running[it.userKey] {
|
if running[it.userKey] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -183,6 +182,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if it.url == "" {
|
if it.url == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist
|
||||||
nextQueue = append(nextQueue, it)
|
nextQueue = append(nextQueue, it)
|
||||||
nextQueued[it.userKey] = true
|
nextQueued[it.userKey] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -135,6 +135,118 @@ type importResult struct {
|
|||||||
Skipped int `json:"skipped"`
|
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) {
|
func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, error) {
|
||||||
cr := csv.NewReader(r)
|
cr := csv.NewReader(r)
|
||||||
cr.Comma = ';'
|
cr.Comma = ';'
|
||||||
@ -293,6 +405,35 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
|||||||
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
|
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) {
|
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||||
"ffmpegPath": "",
|
"ffmpegPath": "",
|
||||||
"autoAddToDownloadList": true,
|
"autoAddToDownloadList": true,
|
||||||
"autoStartAddedDownloads": true,
|
|
||||||
"enableConcurrentDownloadsLimit": true,
|
"enableConcurrentDownloadsLimit": true,
|
||||||
"maxConcurrentDownloads": 80,
|
"maxConcurrentDownloads": 80,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
|
|||||||
@ -23,7 +23,6 @@ type RecorderSettings struct {
|
|||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
|
|
||||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
|
||||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||||
|
|
||||||
@ -55,7 +54,6 @@ var (
|
|||||||
FFmpegPath: "",
|
FFmpegPath: "",
|
||||||
|
|
||||||
AutoAddToDownloadList: false,
|
AutoAddToDownloadList: false,
|
||||||
AutoStartAddedDownloads: false,
|
|
||||||
EnableConcurrentDownloadsLimit: false,
|
EnableConcurrentDownloadsLimit: false,
|
||||||
MaxConcurrentDownloads: 20,
|
MaxConcurrentDownloads: 20,
|
||||||
|
|
||||||
@ -210,7 +208,6 @@ type RecorderSettingsPublic struct {
|
|||||||
HasDBPassword bool `json:"hasDbPassword"`
|
HasDBPassword bool `json:"hasDbPassword"`
|
||||||
|
|
||||||
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
|
||||||
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
|
|
||||||
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
EnableConcurrentDownloadsLimit bool `json:"enableConcurrentDownloadsLimit"`
|
||||||
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
MaxConcurrentDownloads int `json:"maxConcurrentDownloads"`
|
||||||
|
|
||||||
@ -238,7 +235,6 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
|
||||||
|
|
||||||
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
AutoAddToDownloadList: s.AutoAddToDownloadList,
|
||||||
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
|
|
||||||
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
EnableConcurrentDownloadsLimit: s.EnableConcurrentDownloadsLimit,
|
||||||
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
MaxConcurrentDownloads: s.MaxConcurrentDownloads,
|
||||||
|
|
||||||
@ -389,7 +385,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
next.EncryptedDBPassword = in.EncryptedDBPassword
|
next.EncryptedDBPassword = in.EncryptedDBPassword
|
||||||
|
|
||||||
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
next.AutoAddToDownloadList = in.AutoAddToDownloadList
|
||||||
next.AutoStartAddedDownloads = in.AutoStartAddedDownloads
|
|
||||||
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
next.EnableConcurrentDownloadsLimit = in.EnableConcurrentDownloadsLimit
|
||||||
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
next.MaxConcurrentDownloads = in.MaxConcurrentDownloads
|
||||||
|
|
||||||
|
|||||||
@ -589,7 +589,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
|
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>>({})
|
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||||||
|
|
||||||
@ -808,7 +808,6 @@ export default function App() {
|
|||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||||
|
|
||||||
setDoneCount(count)
|
setDoneCount(count)
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@ -823,7 +822,6 @@ export default function App() {
|
|||||||
|
|
||||||
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
||||||
setDoneJobs(prefetched.items)
|
setDoneJobs(prefetched.items)
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
|
|
||||||
donePrefetchRef.current = null
|
donePrefetchRef.current = null
|
||||||
|
|
||||||
@ -852,7 +850,6 @@ export default function App() {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
setDoneJobs(items)
|
setDoneJobs(items)
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
|
|
||||||
const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount)
|
const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount)
|
||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||||
@ -948,7 +945,6 @@ export default function App() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -970,27 +966,11 @@ export default function App() {
|
|||||||
void loadDonePage(donePage, doneSort)
|
void loadDonePage(donePage, doneSort)
|
||||||
}, [authed, donePage, doneSort, loadDonePage])
|
}, [authed, donePage, doneSort, loadDonePage])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setModelsCount(Object.keys(modelsByKey).length)
|
|
||||||
}, [modelsByKey])
|
|
||||||
|
|
||||||
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
|
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
|
||||||
const h = String(m?.host ?? '').toLowerCase()
|
const h = String(m?.host ?? '').toLowerCase()
|
||||||
const input = String(m?.input ?? '').toLowerCase()
|
const input = String(m?.input ?? '').toLowerCase()
|
||||||
return h.includes('chaturbate') || input.includes('chaturbate.com')
|
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 applyJobDelta = useCallback((msg: JobEvent) => {
|
||||||
const jobId = String(msg?.jobId ?? '').trim()
|
const jobId = String(msg?.jobId ?? '').trim()
|
||||||
@ -1043,7 +1023,6 @@ export default function App() {
|
|||||||
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
|
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
|
||||||
)
|
)
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(now)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1080,8 +1059,6 @@ export default function App() {
|
|||||||
if (String((prev as any).id ?? '').trim() !== jobId) return prev
|
if (String((prev as any).id ?? '').trim() !== jobId) return prev
|
||||||
return mergeJob(prev)
|
return mergeJob(prev)
|
||||||
})
|
})
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(now)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ensureModelEventListener = useCallback((name: string) => {
|
const ensureModelEventListener = useCallback((name: string) => {
|
||||||
@ -1194,15 +1171,75 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshModelsByKey = useCallback(async () => {
|
type AppModelsSnapshot = {
|
||||||
try {
|
totalCount: number
|
||||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
|
onlineModelsCount: number
|
||||||
const safeList = Array.isArray(list) ? list : []
|
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))
|
setModelsByKey(buildModelsByKey(safeList))
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
} finally {
|
||||||
|
overviewInFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [buildModelsByKey])
|
}, [buildModelsByKey])
|
||||||
|
|
||||||
@ -1427,59 +1464,6 @@ export default function App() {
|
|||||||
else cur.list.unshift(m)
|
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)
|
const recSettingsRef = useRef(recSettings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recSettingsRef.current = recSettings
|
recSettingsRef.current = recSettings
|
||||||
@ -1653,6 +1637,96 @@ export default function App() {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}, [modelsByKey])
|
}, [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 selectedTabRef = useRef(selectedTab)
|
||||||
|
|
||||||
const applyPendingRoomSnapshot = useCallback(
|
const applyPendingRoomSnapshot = useCallback(
|
||||||
@ -1752,7 +1826,10 @@ export default function App() {
|
|||||||
}, [pendingAutoStartByKey, modelsByKey])
|
}, [pendingAutoStartByKey, modelsByKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recSettings.useChaturbateApi) return
|
if (!recSettings.useChaturbateApi) {
|
||||||
|
setCbOnlineFetchedAt('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let timer: number | null = null
|
let timer: number | null = null
|
||||||
@ -1789,6 +1866,7 @@ export default function App() {
|
|||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||||
|
|
||||||
const rooms = Array.isArray(data?.rooms) ? data!.rooms : []
|
const rooms = Array.isArray(data?.rooms) ? data!.rooms : []
|
||||||
|
|
||||||
const byKey: Record<
|
const byKey: Record<
|
||||||
@ -1892,6 +1970,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||||
|
|
||||||
const room = Array.isArray(data?.rooms)
|
const room = Array.isArray(data?.rooms)
|
||||||
? data!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === keyLower)
|
? data!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === keyLower)
|
||||||
: null
|
: null
|
||||||
@ -1949,6 +2028,48 @@ export default function App() {
|
|||||||
setRoomStatusByModelKey(next)
|
setRoomStatusByModelKey(next)
|
||||||
}, [jobs, modelsByKey, recSettings.useChaturbateApi])
|
}, [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(
|
function shouldQueueForRoomStatus(
|
||||||
show: string
|
show: string
|
||||||
): boolean {
|
): boolean {
|
||||||
@ -2234,37 +2355,10 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ✅ Anzahl Watched Models (aus Store), die online sind
|
// ✅ Anzahl Watched Models (aus Store), die online sind
|
||||||
const onlineWatchedModelsCount = useMemo(() => {
|
const onlineModelsCount = headerStats.onlineModelsCount
|
||||||
let c = 0
|
const onlineWatchedModelsCount = headerStats.onlineWatchedModelsCount
|
||||||
for (const m of Object.values(modelsByKey)) {
|
const onlineFavCount = headerStats.onlineFavCount
|
||||||
if (!m?.watching) continue
|
const onlineLikedCount = headerStats.onlineLikedCount
|
||||||
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 runningTabCount = useMemo(() => {
|
const runningTabCount = useMemo(() => {
|
||||||
const activeDownloads = jobs.filter((j) => {
|
const activeDownloads = jobs.filter((j) => {
|
||||||
@ -3413,14 +3507,20 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
|
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
|
||||||
<div className="sm:hidden mt-1 w-full">
|
<div className="sm:hidden mt-1 w-full">
|
||||||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
|
<LastUpdatedText
|
||||||
|
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||||
|
fetchedAt={cbOnlineFetchedAt}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-stretch gap-2">
|
<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'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
function formatAgoDE(diffMs: number) {
|
function formatAgoDE(diffMs: number) {
|
||||||
const s = Math.max(0, Math.floor(diffMs / 1000))
|
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`
|
if (s < 60) return `vor ${s} Sekunden`
|
||||||
|
|
||||||
const m = Math.floor(s / 60)
|
const m = Math.floor(s / 60)
|
||||||
@ -11,20 +13,41 @@ function formatAgoDE(diffMs: number) {
|
|||||||
|
|
||||||
const h = Math.floor(m / 60)
|
const h = Math.floor(m / 60)
|
||||||
if (h === 1) return 'vor 1 Stunde'
|
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())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
return () => window.clearInterval(t)
|
||||||
}, [])
|
}, [enabled])
|
||||||
|
|
||||||
const text = useMemo(() => {
|
const text = useMemo(() => {
|
||||||
return `(zuletzt aktualisiert: ${formatAgoDE(now - lastAt)})`
|
if (!enabled) return ''
|
||||||
}, [now, lastAt])
|
|
||||||
|
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}</>
|
return <>{text}</>
|
||||||
}
|
}
|
||||||
@ -18,7 +18,6 @@ type RecorderSettings = {
|
|||||||
doneDir: string
|
doneDir: string
|
||||||
ffmpegPath?: string
|
ffmpegPath?: string
|
||||||
autoAddToDownloadList?: boolean
|
autoAddToDownloadList?: boolean
|
||||||
autoStartAddedDownloads?: boolean
|
|
||||||
enableConcurrentDownloadsLimit?: boolean
|
enableConcurrentDownloadsLimit?: boolean
|
||||||
maxConcurrentDownloads?: number
|
maxConcurrentDownloads?: number
|
||||||
useChaturbateApi?: boolean
|
useChaturbateApi?: boolean
|
||||||
@ -188,7 +187,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
|
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
|
||||||
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
|
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
|
||||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
|
||||||
enableConcurrentDownloadsLimit:
|
enableConcurrentDownloadsLimit:
|
||||||
(data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit,
|
(data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit,
|
||||||
maxConcurrentDownloads:
|
maxConcurrentDownloads:
|
||||||
@ -522,7 +520,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||||||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
|
||||||
const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit
|
const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit
|
||||||
const maxConcurrentDownloads = Math.max(
|
const maxConcurrentDownloads = Math.max(
|
||||||
1,
|
1,
|
||||||
@ -557,7 +554,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
doneDir,
|
doneDir,
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
autoAddToDownloadList,
|
autoAddToDownloadList,
|
||||||
autoStartAddedDownloads,
|
|
||||||
enableConcurrentDownloadsLimit,
|
enableConcurrentDownloadsLimit,
|
||||||
maxConcurrentDownloads,
|
maxConcurrentDownloads,
|
||||||
useChaturbateApi,
|
useChaturbateApi,
|
||||||
@ -1111,21 +1107,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setValue((v) => ({
|
setValue((v) => ({
|
||||||
...v,
|
...v,
|
||||||
autoAddToDownloadList: checked,
|
autoAddToDownloadList: checked,
|
||||||
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
label="Automatisch zur Downloadliste hinzufügen"
|
label="Automatisch zur Downloadliste hinzufügen"
|
||||||
description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen."
|
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
|
<LabeledSwitch
|
||||||
checked={!!value.useChaturbateApi}
|
checked={!!value.useChaturbateApi}
|
||||||
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
|
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user