From fdccb0618331e278b67356af005aff573ccd63ea Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:38:19 +0300 Subject: [PATCH] delegate remaining screen and hook policy to fluxa-core Continues the shared-core migration: addon sort/priority ordering and disabled-addon filtering now route through fluxa-core instead of being computed locally, alongside the broader policy delegation already in flight across screens and hooks. --- src/components/GlobalSearchBar.tsx | 62 +++---- src/components/ReactPlayerOverlay.tsx | 41 +++-- src/components/detail/SourcePanel.tsx | 14 +- src/core/anilistExternalSync.ts | 17 +- src/core/collectionSources.ts | 138 +++------------ src/core/coreMethods.ts | 36 ++++ src/core/effectRunner.ts | 23 +-- src/core/engine.ts | 22 +++ src/core/externalSync.ts | 203 +++++++++-------------- src/core/homeEffects.ts | 8 +- src/core/libraryEffects.ts | 45 ++--- src/core/nuvioSync.ts | 3 +- src/core/scrobble.ts | 40 ++--- src/core/simklExternalSync.ts | 80 +-------- src/core/streamLinks.ts | 15 +- src/core/stremioExternalSync.ts | 80 +-------- src/core/traktExternalSync.ts | 67 ++------ src/hooks/useNuvioConnectivity.ts | 59 ++----- src/hooks/usePlayer.ts | 108 ++++++------ src/hooks/useSeasonWatched.ts | 45 ++--- src/screens/CalendarScreen.tsx | 22 +-- src/screens/DetailScreen.tsx | 49 ++---- src/screens/DiscoverScreen.race.test.tsx | 23 ++- src/screens/DiscoverScreen.tsx | 107 ++++++------ src/screens/HomeScreen.tsx | 203 ++++++++--------------- src/screens/LibraryScreen.tsx | 130 ++++----------- src/screens/SearchScreen.tsx | 74 +++++---- src/screens/SettingsScreen.tsx | 66 +------- 28 files changed, 609 insertions(+), 1171 deletions(-) diff --git a/src/components/GlobalSearchBar.tsx b/src/components/GlobalSearchBar.tsx index f19340a..8dc0ff1 100644 --- a/src/components/GlobalSearchBar.tsx +++ b/src/components/GlobalSearchBar.tsx @@ -4,6 +4,7 @@ import { t, getLanguage } from '../i18n'; import { addRecentSearch, loadRecentSearches, clearRecentSearches, removeRecentSearch, type RecentSearch } from '../core/searchHistory'; import { setSearchPartialHandler } from '../core/catalogEffects'; import { appPrefs, prefBool } from '../core/appPrefs'; +import { coreInvoke } from '../core/engine'; import type { AppState, Meta } from '../core/types'; interface Props { @@ -67,21 +68,37 @@ export function GlobalSearchBar({ query, onSearch, onBack, focusSignal, state, o return () => setSearchPartialHandler(null); }, []); - const localSuggestions = useMemo(() => { + const [localSuggestions, setLocalSuggestions] = useState([]); + useEffect(() => { + let active = true; const needle = inputValue.trim().toLowerCase(); - if (needle.length < 2) return []; - return rankByNeedle(flattenCategories(state.home.categories), needle); + void coreInvoke('searchSuggestionsPlan', JSON.stringify({ categories: state.home.categories, needle, limit: MAX_SUGGESTIONS })) + .then((items) => { if (active) setLocalSuggestions(items ?? []); }); + return () => { active = false; }; }, [state.home.categories, inputValue]); - const networkSuggestions = useMemo(() => { + const [networkSuggestions, setNetworkSuggestions] = useState([]); + useEffect(() => { + let active = true; const trimmed = inputValue.trim(); const needle = trimmed.toLowerCase(); - if (needle.length < 2) return []; - if (partialQueryRef.current === trimmed && partialResults.length > 0) { - return rankByNeedle(partialResults, needle); + if (needle.length < 2) { + setNetworkSuggestions([]); + return () => { active = false; }; } - if ((state.search.query ?? '').trim().toLowerCase() !== needle) return []; - return rankByNeedle(flattenCategories(state.search.categories), needle); + let request: Record | null = null; + if (partialQueryRef.current === trimmed && partialResults.length > 0) { + request = { items: partialResults, needle, limit: MAX_SUGGESTIONS }; + } else if ((state.search.query ?? '').trim().toLowerCase() === needle) { + request = { categories: state.search.categories, needle, limit: MAX_SUGGESTIONS }; + } + if (!request) { + setNetworkSuggestions([]); + return () => { active = false; }; + } + void coreInvoke('searchSuggestionsPlan', JSON.stringify(request)) + .then((items) => { if (active) setNetworkSuggestions(items ?? []); }); + return () => { active = false; }; }, [partialResults, state.search.categories, state.search.query, inputValue]); const suggestions = networkSuggestions.length > 0 ? networkSuggestions : localSuggestions; @@ -343,33 +360,6 @@ export function GlobalSearchBar({ query, onSearch, onBack, focusSignal, state, o ); } -function flattenCategories(categories: { items: Meta[] }[] | undefined): Meta[] { - const seen = new Set(); - const items: Meta[] = []; - for (const category of categories ?? []) { - for (const meta of category.items) { - if (seen.has(meta.id)) continue; - seen.add(meta.id); - items.push(meta); - } - } - return items; -} - -function rankByNeedle(items: Meta[], needle: string): Meta[] { - const startsWith: Meta[] = []; - const includes: Meta[] = []; - const seenNames = new Set(); - for (const meta of items) { - const name = (meta.name ?? '').toLowerCase(); - if (!name.includes(needle)) continue; - if (seenNames.has(name)) continue; - seenNames.add(name); - (name.startsWith(needle) ? startsWith : includes).push(meta); - } - return [...startsWith, ...includes].slice(0, MAX_SUGGESTIONS); -} - const dropdownStyles: Record = { sectionLabel: { color: 'rgba(255,255,255,0.5)', diff --git a/src/components/ReactPlayerOverlay.tsx b/src/components/ReactPlayerOverlay.tsx index 869e05a..4b3d0ef 100644 --- a/src/components/ReactPlayerOverlay.tsx +++ b/src/components/ReactPlayerOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { t } from '../i18n'; import { invoke } from '@tauri-apps/api/core'; import { listen, emit } from '@tauri-apps/api/event'; @@ -46,7 +46,7 @@ import { EpisodePanel, epLabel } from './player/EpisodePanel'; import type { EpisodeInfo } from './player/EpisodePanel'; import type { Meta, Stream, Video } from '../core/types'; import { streamMagnetLink, enqueueOfflineDownload } from '../core/engine'; -import { buildOfflineDownloadRequest, streamDownloadLink, streamIsTorrent, streamSourceLink } from '../core/streamLinks'; +import { buildOfflineDownloadRequest, streamShellPlan } from '../core/streamLinks'; import { ContextMenu } from './ui/ContextMenu'; import { TrackPopover, type SubtitleCaptureCue } from './player/TrackPopover'; import { CastPopover } from './player/CastPopover'; @@ -54,7 +54,7 @@ import { TorrentStatsPopover } from './player/TorrentStatsPopover'; import { PlayerSettingsPopover } from './player/PlayerSettingsPopover'; import { SegmentMarkerPanel } from './player/SegmentMarkerPanel'; import { Popover } from './ui/Popover'; -import { corePlaybackIntroLookupContentId } from '../core/engine'; +import { corePlaybackIntroLookupContentId, coreResolveNextEpisode } from '../core/engine'; import { imdbButtonFor, updateDiscordPresence } from '../core/discordPresence'; import { castDisconnect, castPlay, castPause, castSeek, castSetVolume, discoverCastDevices, proxyMediaUrl, resolveCastMediaUrl, startCasting } from '../core/cast'; import type { CastDevice } from '../core/cast'; @@ -180,17 +180,23 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in const [countdown, setCountdown] = useState(null); const [nextEpDismissed, setNextEpDismissed] = useState(false); const [episodes, setEpisodes] = useState([]); - const nextEpThumbnail = useMemo(() => { - if (!currentEpisode) return null; - const sorted = [...episodes].sort((a, b) => { - const sa = a.season ?? 1, sb = b.season ?? 1; - if (sa !== sb) return sa - sb; - return (a.episode ?? a.number ?? 0) - (b.episode ?? b.number ?? 0); + const [nextEpThumbnail, setNextEpThumbnail] = useState(null); + useEffect(() => { + let active = true; + if (!currentEpisode) { + setNextEpThumbnail(null); + return () => { active = false; }; + } + void coreResolveNextEpisode( + JSON.stringify(episodes), + currentEpisode.season ?? 1, + currentEpisode.episode ?? currentEpisode.number ?? 0, + Date.now(), + false, + ).then((next) => { + if (active) setNextEpThumbnail((next as EpisodeInfo | null)?.thumbnail ?? null); }); - const curSeason = currentEpisode.season ?? 1; - const curEp = currentEpisode.episode ?? currentEpisode.number ?? 0; - const curIndex = sorted.findIndex((ep) => (ep.season ?? 1) === curSeason && (ep.episode ?? ep.number ?? 0) === curEp); - return curIndex >= 0 ? sorted[curIndex + 1]?.thumbnail ?? null : null; + return () => { active = false; }; }, [episodes, currentEpisode]); const [showEpisodePanel, setShowEpisodePanel] = useState(false); const [activeSkip, setActiveSkip] = useState(null); @@ -199,6 +205,7 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in const [showNextEpCard, setShowNextEpCard] = useState(false); const [trackPopover, setTrackPopover] = useState<'audio' | 'sub' | 'speed' | null>(null); const [streamLinksMenuPoint, setStreamLinksMenuPoint] = useState<{ x: number; y: number } | null>(null); + const [streamLinksPlan, setStreamLinksPlan] = useState<{ isTorrent: boolean; sourceLink?: string; downloadLink?: string } | null>(null); const streamLinksBtnRef = useRef(null); const [miniPlayerActive, setMiniPlayerActive] = useState(false); const miniPlayerActiveRef = useRef(false); @@ -1632,6 +1639,8 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in e.stopPropagation(); resetActivity(); const rect = streamLinksBtnRef.current?.getBoundingClientRect(); + const stream = streamRef?.current; + if (stream) void streamShellPlan(stream).then(setStreamLinksPlan); setStreamLinksMenuPoint(rect ? { x: Math.max(0, rect.right - 216), y: rect.bottom + 8 } : null); }} className="fluxa-ibtn" @@ -1771,11 +1780,11 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in const stream = streamRef?.current; const meta = metaRef?.current; if (!stream) return []; - const sourceLink = streamSourceLink(stream); - const downloadLink = streamDownloadLink(stream); + const sourceLink = streamLinksPlan?.sourceLink; + const downloadLink = streamLinksPlan?.downloadLink; return [ ...(sourceLink ? [{ icon: , label: t('player.copy_stream_link'), onSelect: () => { void navigator.clipboard.writeText(sourceLink); } }] : []), - ...(streamIsTorrent(stream) ? [{ icon: , label: t('player.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []), + ...(streamLinksPlan?.isTorrent ? [{ icon: , label: t('player.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []), ...(meta && downloadLink ? [{ icon: , label: t('player.download_this_video'), onSelect: () => { void enqueueOfflineDownload(buildOfflineDownloadRequest(meta, stream, currentEpisode)); } }] : []), ]; })()} diff --git a/src/components/detail/SourcePanel.tsx b/src/components/detail/SourcePanel.tsx index ac494ca..0ccc563 100644 --- a/src/components/detail/SourcePanel.tsx +++ b/src/components/detail/SourcePanel.tsx @@ -5,7 +5,7 @@ import { EP, MS, SS, spinnerStyle } from './detailStyles'; import type { Meta, Stream, Video } from '../../core/types'; import { useDragScroll } from '../../hooks/useDragScroll'; import { streamMagnetLink, enqueueOfflineDownload } from '../../core/engine'; -import { buildOfflineDownloadRequest, streamDownloadLink, streamIsTorrent, streamSourceLink } from '../../core/streamLinks'; +import { buildOfflineDownloadRequest, streamShellPlan } from '../../core/streamLinks'; import { ContextMenu } from '../ui/ContextMenu'; export function streamDisplayText(value: string | undefined): string | undefined { @@ -16,6 +16,12 @@ export function streamDisplayText(value: string | undefined): string | undefined export function SourceRow({ stream, onClick, meta, episode }: { stream: Stream; onClick: () => void; meta?: Meta; episode?: Video | null }) { const [hovered, setHovered] = useState(false); const [menuPoint, setMenuPoint] = useState<{ x: number; y: number } | null>(null); + const [linkPlan, setLinkPlan] = useState<{ isTorrent: boolean; sourceLink?: string; downloadLink?: string } | null>(null); + useEffect(() => { + let active = true; + void streamShellPlan(stream).then((plan) => { if (active) setLinkPlan(plan); }); + return () => { active = false; }; + }, [stream]); const heading = streamDisplayText(stream.name) || streamDisplayText(stream.title) || streamDisplayText(stream.description) || t('player.source'); const seenLines = new Set(); const lines = [stream.title, stream.description] @@ -26,9 +32,9 @@ export function SourceRow({ stream, onClick, meta, episode }: { stream: Stream; return true; }); - const isTorrent = streamIsTorrent(stream); - const sourceLink = streamSourceLink(stream); - const downloadLink = streamDownloadLink(stream); + const isTorrent = linkPlan?.isTorrent === true; + const sourceLink = linkPlan?.sourceLink; + const downloadLink = linkPlan?.downloadLink; return ( <> diff --git a/src/core/anilistExternalSync.ts b/src/core/anilistExternalSync.ts index 86f3dfa..ea19a06 100644 --- a/src/core/anilistExternalSync.ts +++ b/src/core/anilistExternalSync.ts @@ -1,7 +1,7 @@ import { platformFetch } from './httpClient'; import { loadLibrary, saveLibrary, buildContinueWatching, persistStatusListMerge, persistWatchedMerge, persistProgressMerge } from './libraryOps'; import { replaceExternalContinueWatching } from './externalSyncUtils'; -import { coreAnilistEntriesToSync, coreMergeLibraryItemsById } from './engine'; +import { coreAnilistEntriesToSync, coreInvoke, coreMergeLibraryItemsById } from './engine'; type AniListEntry = { status?: string | null; @@ -102,20 +102,7 @@ export async function fetchAniListCalendarItems(token: string): Promise(ANILIST_COLLECTION_QUERY, { userId }, token); const entries = (data?.MediaListCollection?.lists ?? []).flatMap((list) => list.entries ?? []); - return entries - .map((entry) => { - const media = entry.media; - const nextEpisode = media?.nextAiringEpisode; - if (!media?.id || !nextEpisode?.airingAt) return null; - return { - id: `anilist:${media.id}:${nextEpisode.episode}`, - title: media.title?.english ?? media.title?.romaji, - dateIso: new Date(nextEpisode.airingAt * 1000).toISOString(), - contentId: `anilist:${media.id}`, - seriesId: `anilist:${media.id}`, - } as Record; - }) - .filter((item): item is Record => item !== null); + return (await coreInvoke[]>('providerCalendarItems', JSON.stringify({ provider: 'anilist', entries }))) ?? []; } export async function pushWatchlistAniList( diff --git a/src/core/collectionSources.ts b/src/core/collectionSources.ts index 9fa5acf..fbc6cea 100644 --- a/src/core/collectionSources.ts +++ b/src/core/collectionSources.ts @@ -3,129 +3,31 @@ import { platformFetch } from './httpClient'; import type { Meta, NuvioRemoteCollectionSource } from './types'; import { loadPrefs } from './libraryOps'; import { prefString } from './appPrefs'; -import { coreTmdbBulkMetas } from './engine'; - -interface TraktItem { - movie?: { title?: string; year?: number; ids?: { imdb?: string; tmdb?: number } }; - show?: { title?: string; year?: number; ids?: { imdb?: string; tmdb?: number } }; -} - -function metaFromTraktItem(item: TraktItem, mediaType: string): Meta | null { - const isSeries = mediaType.toUpperCase() === 'TV'; - const value = isSeries ? item.show : item.movie; - if (!value?.title) return null; - const id = value.ids?.imdb ?? (value.ids?.tmdb ? `tmdb:${value.ids.tmdb}` : null); - if (!id) return null; - return { - id, - type: isSeries ? 'series' : 'movie', - name: value.title, - releaseInfo: value.year ? String(value.year) : undefined, - }; -} - -export function isNuvioCollectionSource(source: unknown): source is NuvioRemoteCollectionSource { - return !!source && typeof source === 'object' && - (((source as NuvioRemoteCollectionSource).provider === 'trakt' && typeof (source as NuvioRemoteCollectionSource).traktListId === 'number') || - (source as NuvioRemoteCollectionSource).provider === 'tmdb'); -} +import { coreInvoke } from './engine'; export async function loadNuvioCollectionSource(source: NuvioRemoteCollectionSource, page = 1): Promise { - if (source.provider === 'tmdb') return loadTmdbCollectionSource(source, page); - if (!source.traktListId) return []; - const clientId = await invoke('get_oauth_client_id', { service: 'trakt' }).catch(() => ''); - if (!clientId) return []; - const type = source.mediaType?.toUpperCase() === 'TV' ? 'show' : 'movie'; - const params = new URLSearchParams({ extended: 'full,images', page: String(page), limit: '50' }); - if (source.sortBy) params.set('sort_by', source.sortBy); - if (source.sortHow) params.set('sort_how', source.sortHow); - try { - const response = await platformFetch( - `https://api.trakt.tv/lists/${encodeURIComponent(String(source.traktListId))}/items/${type}?${params}`, - { headers: { 'trakt-api-version': '2', 'trakt-api-key': clientId } }, - ); - if (!response.ok) return []; - const data = await response.json(); - return Array.isArray(data) - ? data.map((item) => metaFromTraktItem(item as TraktItem, source.mediaType ?? 'MOVIE')).filter((item): item is Meta => !!item) - : []; - } catch { - return []; - } -} - -function tmdbType(source: NuvioRemoteCollectionSource): string { - return source.mediaType?.toUpperCase() === 'TV' ? 'tv' : 'movie'; -} - -function setFilter(params: URLSearchParams, source: Record, input: string, output: string) { - const value = source[input]; - if (typeof value === 'string' || typeof value === 'number') params.set(output, String(value)); -} - -async function loadTmdbCollectionSource(source: NuvioRemoteCollectionSource, page: number): Promise { const prefs = await loadPrefs(); - const apiKey = prefString(prefs, 'tmdbApiKey').trim(); - if (!apiKey) return []; - const type = source.tmdbSourceType === 'NETWORK' ? 'tv' : tmdbType(source); - const language = prefString(prefs, 'language', 'en').replace('_', '-'); - const params = new URLSearchParams({ api_key: apiKey, language, page: String(page) }); - let path: string; - if (source.tmdbSourceType === 'LIST' && source.tmdbId) { - path = `3/list/${source.tmdbId}`; - } else if (source.tmdbSourceType === 'COLLECTION' && source.tmdbId) { - path = `3/collection/${source.tmdbId}`; - params.delete('page'); - } else if ((source.tmdbSourceType === 'PERSON' || source.tmdbSourceType === 'DIRECTOR') && source.tmdbId) { - path = `3/person/${source.tmdbId}/combined_credits`; - params.delete('page'); - } else { - path = `3/discover/${type}`; - params.set('sort_by', source.sortBy ?? 'popularity.desc'); - if (source.tmdbSourceType === 'COMPANY' && source.tmdbId) params.set('with_companies', String(source.tmdbId)); - if (source.tmdbSourceType === 'NETWORK' && source.tmdbId) params.set('with_networks', String(source.tmdbId)); - const filters = source.filters ?? {}; - setFilter(params, filters, 'year', type === 'tv' ? 'first_air_date_year' : 'year'); - setFilter(params, filters, 'withGenres', 'with_genres'); - setFilter(params, filters, 'watchRegion', 'watch_region'); - setFilter(params, filters, 'voteCountGte', 'vote_count.gte'); - setFilter(params, filters, 'withKeywords', 'with_keywords'); - setFilter(params, filters, 'withNetworks', 'with_networks'); - setFilter(params, filters, 'withCompanies', 'with_companies'); - setFilter(params, filters, 'releaseDateGte', type === 'tv' ? 'first_air_date.gte' : 'primary_release_date.gte'); - setFilter(params, filters, 'releaseDateLte', type === 'tv' ? 'first_air_date.lte' : 'primary_release_date.lte'); - setFilter(params, filters, 'voteAverageGte', 'vote_average.gte'); - setFilter(params, filters, 'voteAverageLte', 'vote_average.lte'); - setFilter(params, filters, 'withOriginCountry', 'with_origin_country'); - setFilter(params, filters, 'withWatchProviders', 'with_watch_providers'); - setFilter(params, filters, 'withOriginalLanguage', 'with_original_language'); - } + const clientId = source.provider === 'trakt' + ? await invoke('get_oauth_client_id', { service: 'trakt' }).catch(() => '') + : ''; + const plan = await coreInvoke<{ + url: string; + params: Record; + headers: Record; + }>('remoteCollectionRequestPlan', JSON.stringify({ + source, + page, + clientId, + apiKey: prefString(prefs, 'tmdbApiKey'), + language: prefString(prefs, 'language', 'en'), + })); + if (!plan) return []; try { - const response = await platformFetch(`https://api.themoviedb.org/${path}?${params}`); + const url = new URL(plan.url); + for (const [key, value] of Object.entries(plan.params)) url.searchParams.set(key, String(value)); + const response = await platformFetch(url.toString(), { headers: plan.headers }); if (!response.ok) return []; - const data = await response.json() as { parts?: unknown[]; items?: unknown[]; results?: unknown[]; cast?: Array>; crew?: Array> }; - const mediaType = source.mediaType?.toUpperCase() === 'TV' ? 'tv' : 'movie'; - const credits = source.tmdbSourceType === 'DIRECTOR' - ? data.crew?.filter((credit) => credit.job === 'Director') - : data.cast; - const items = source.tmdbSourceType === 'COLLECTION' - ? data.parts - : source.tmdbSourceType === 'LIST' - ? data.items - : (source.tmdbSourceType === 'PERSON' || source.tmdbSourceType === 'DIRECTOR') - ? credits?.filter((credit) => credit.media_type === mediaType) - : data.results; - const resolvedItems = Array.isArray(items) ? items : []; - if (source.tmdbSourceType === 'LIST') { - const movies = resolvedItems.filter((item) => (item as Record).media_type !== 'tv'); - const series = resolvedItems.filter((item) => (item as Record).media_type === 'tv'); - const [movieMetas, seriesMetas] = await Promise.all([ - coreTmdbBulkMetas(JSON.stringify(movies), 'movie', language), - coreTmdbBulkMetas(JSON.stringify(series), 'series', language), - ]); - return [...((movieMetas ?? []) as Meta[]), ...((seriesMetas ?? []) as Meta[])]; - } - return ((await coreTmdbBulkMetas(JSON.stringify(resolvedItems), type === 'tv' ? 'series' : 'movie', language)) ?? []) as Meta[]; + return (await coreInvoke('remoteCollectionResponsePlan', JSON.stringify({ plan, data: await response.json() }))) ?? []; } catch { return []; } diff --git a/src/core/coreMethods.ts b/src/core/coreMethods.ts index 8842c3c..35d41af 100644 --- a/src/core/coreMethods.ts +++ b/src/core/coreMethods.ts @@ -2,11 +2,13 @@ export const CORE_METHODS = [ 'activeProfilePlan', 'addonCollectionMutationPlan', + 'addonProfileMutationPlan', 'addonResourceRequestPlan', 'addonStoreInputType', 'addonStoreSearchPolicy', 'addonStreamsWithProvider', 'airDateRefreshCandidates', + 'airDateRefreshPlan', 'anilistEntriesToSync', 'anilistMalId', 'anilistSaveMediaListEntryVariables', @@ -14,6 +16,7 @@ export const CORE_METHODS = [ 'app.destroy', 'app.dispatch', 'app.state', + 'applyAirDateUpdates', 'applyPreferenceUpdate', 'baseUrl', 'buildBillboardPool', @@ -32,6 +35,7 @@ export const CORE_METHODS = [ 'calendarReleaseDetection', 'calendarReleaseRows', 'calendarSeasonCandidates', + 'calendarVisibilityPlan', 'calendarWidgetRows', 'canPrefetchNextEpisode', 'catalogHasRequiredExtraExcept', @@ -39,6 +43,8 @@ export const CORE_METHODS = [ 'catalogSupportsExtra', 'classifyMetaLinks', 'clearPlaybackProgressItem', + 'collectionFolderItemsPlan', + 'collectionMergePlan', 'computeContinueWatchingBadges', 'contentMergeKeys', 'contentTraktKeysBatch', @@ -59,6 +65,8 @@ export const CORE_METHODS = [ 'directPlaybackPolicy', 'discoverCatalogCacheKey', 'discoverCatalogOptions', + 'discoverContentTypes', + 'discoverSelectionPlan', 'discoverSortPlan', 'dolbyVisionConvertRpu', 'dolbyVisionRpuInfo', @@ -72,11 +80,16 @@ export const CORE_METHODS = [ 'episodeFilenameCandidate', 'episodeTextMatches', 'exportCollections', + 'externalProviderActionPlan', 'extractAddonManifestUrl', 'filterDiscoverResults', + 'filterEnabledAddons', 'filterHomeContinueWatching', 'findPreferredSubtitleIndex', + 'folderPageState', + 'folderSourcePagePlan', 'formatEpisodeLine', + 'homeHeroPlan', 'homeOverlapRatio', 'homePersonalizationScore', 'identity', @@ -93,15 +106,19 @@ export const CORE_METHODS = [ 'libraryLocalStatePlan', 'libraryOfflineGrouping', 'librarySortPlan', + 'libraryViewPlan', 'libraryWatchlistItems', 'manifestCandidates', 'manifestFetchDecision', 'manifestFetchPlan', + 'markSeasonsActionPlan', 'matchAnimeSkipEpisodeId', 'mergeContinueWatchingDuplicates', 'mergeContinueWatchingLists', + 'mergeDiscoverPages', 'mergeExternalWatched', 'mergeExternalWatchlist', + 'mergeFolderSources', 'mergeIntroSegments', 'mergeLibraryItemsById', 'mergeLiveManifest', @@ -110,6 +127,7 @@ export const CORE_METHODS = [ 'mergeWatchedTimestamped', 'mergeWatchlistTimestamped', 'moveMetadataFeedOrder', + 'nextProgressInfoPlan', 'nextRetrySourcePlan', 'nextUnairedEpisode', 'normalizeAddonDescriptor', @@ -122,12 +140,16 @@ export const CORE_METHODS = [ 'normalizePluginRepositoryUrl', 'normalizeTrailerSubtitleUrl', 'nuvioBuildLocalProfiles', + 'nuvioExportPushPlan', 'nuvioImportMergePlan', + 'nuvioLibraryMutationPlan', 'nuvioLibraryToWatchlist', 'nuvioMapCollections', 'nuvioProgressMetaNeeds', + 'nuvioSortAddonsByPriority', 'offlineDownloadPlan', 'optimizeHomeRows', + 'orderStreamsPlan', 'orderedMetadataFeedKeys', 'parseAddonResourceResult', 'parseAddonStreamResult', @@ -143,6 +165,7 @@ export const CORE_METHODS = [ 'partitionThisWeek', 'playbackClosePlan', 'playbackIntroLookupContentId', + 'playbackPreferencesPlan', 'playbackPreparePlan', 'playbackProgressItem', 'playbackProgressMergePlan', @@ -177,10 +200,15 @@ export const CORE_METHODS = [ 'profilePinMatches', 'profileSafePrefs', 'profileSettingsMigrationPlan', + 'promoteExternalProgressPlan', 'providerAvailabilityPlan', + 'providerCalendarItems', + 'providerPaginationPlan', 'providerSearchTerms', 'recentSearchesPlan', 'rememberLastWatchedEpisodes', + 'remoteCollectionRequestPlan', + 'remoteCollectionResponsePlan', 'replaceExternalContinueWatching', 'repositoryMetaDetailPlan', 'repositorySeasonVideos', @@ -199,12 +227,17 @@ export const CORE_METHODS = [ 'safeStreamSourceSelectionMode', 'samePluginRepositoryUrl', 'sanitizeProfile', + 'scrobbleMediaContext', 'searchResultGrouping', + 'searchScreenPlan', + 'searchSuggestionsPlan', + 'seasonWatchedPlan', 'selectContinueWatchingArtwork', 'selectNextEpisodeStream', 'selectStreamIndex', 'setMetadataFeedGroupEnabled', 'simklLookupIdForType', + 'simklMarkWatchedBody', 'simklMatchEpisode', 'simklRecommendationCandidates', 'simklRecommendationToMeta', @@ -212,6 +245,7 @@ export const CORE_METHODS = [ 'simklScrobbleBody', 'simklWatchedToIds', 'simklWatchingToItems', + 'simklWatchlistBody', 'simklWatchlistToItems', 'stableFeedPart', 'streamDiscoveryCacheKey', @@ -224,6 +258,8 @@ export const CORE_METHODS = [ 'streamRequestHeaders', 'streamRequestIds', 'streamRequestReferer', + 'streamShellPlan', + 'stremioLibraryMutationPlan', 'stremioWatchedToIds', 'stremioWatchlistToItems', 'subtitleLanguageMatches', diff --git a/src/core/effectRunner.ts b/src/core/effectRunner.ts index 59cc818..bd0a982 100644 --- a/src/core/effectRunner.ts +++ b/src/core/effectRunner.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import { completeEffect, coreMergeContinueWatchingLists, coreResolveNextEpisode, dispatchAction, enqueueOfflineDownload, httpExecuteText, libraryContinueWatchingDelete, libraryProgressDelete, registerTrailerProxyUrl } from './engine'; +import { completeEffect, coreInvoke, coreMergeContinueWatchingLists, dispatchAction, enqueueOfflineDownload, httpExecuteText, libraryContinueWatchingDelete, libraryProgressDelete, registerTrailerProxyUrl } from './engine'; import { startTorrentStream, stopTorrentStream } from './mpvPlayer'; import { effectRunnerLibraryKey, loadActiveProfile, loadAddons, loadLibrary, loadPrefs, saveLibrary, buildContinueWatching, persistLastWatchedEpisode } from './libraryOps'; import { readHomeBootstrap, refreshReleasedContinueWatching } from './homeEffects'; @@ -95,24 +95,13 @@ async function deriveNextProgressFromLastWatched(metaObj: Record('nextProgressInfoPlan', JSON.stringify({ contentId: id, contentType: 'series', - videoId: next.id, - positionSeconds: 0, - durationSeconds: 0, - lastWatched: Date.now(), - season: next.season, - episode: next.episode ?? next.number, - }; + videos, + watchedEpisodes: [{ season: currentSeason, episode: currentEpisode }], + nowMs: Date.now(), + }))) ?? undefined; } async function runEffect( diff --git a/src/core/engine.ts b/src/core/engine.ts index d7102b2..ea16475 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -448,6 +448,10 @@ export async function coreSimklScrobbleBody( return coreInvoke('simklScrobbleBody', JSON.stringify({ idsJson, isEpisode, season, epNumber, timePosSec, durationSec })); } +export async function coreSimklLookupIdForType(lookupJson: string, wantType: string): Promise { + return coreInvoke('simklLookupIdForType', JSON.stringify({ lookupJson, wantType })); +} + export async function coreTraktPlaybackItemsToLibrary(itemsJson: string): Promise { return coreInvoke('traktPlaybackItemsToLibrary', itemsJson); } @@ -627,6 +631,16 @@ export async function coreResolveNextEpisode( })); } +export async function coreStreamShellPlan(stream: unknown): Promise<{ + identityKey: string; + isTorrent: boolean; + requestHeaders?: Record; + sourceLink?: string; + downloadLink?: string; +} | null> { + return coreInvoke('streamShellPlan', JSON.stringify(stream)); +} + export async function coreFormatEpisodeLine( lastEpisodeName?: string | null, lastEpisodeSeason?: number | null, @@ -772,6 +786,14 @@ export async function coreNuvioMapCollections(collections: unknown[]): Promise(addons: T[]): Promise { + return coreInvoke('nuvioSortAddonsByPriority', JSON.stringify({ addons })); +} + +export async function coreFilterEnabledAddons(addons: T[], disabledKeys: string[]): Promise { + return coreInvoke('filterEnabledAddons', JSON.stringify({ addons, disabledKeys })); +} + export async function coreAirDateRefreshCandidates(items: unknown[], nowMs: number): Promise { return (await coreInvoke('airDateRefreshCandidates', JSON.stringify({ items, nowMs }))) ?? []; } diff --git a/src/core/externalSync.ts b/src/core/externalSync.ts index 15681fe..e1bec60 100644 --- a/src/core/externalSync.ts +++ b/src/core/externalSync.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { coreParseVideoId } from './engine'; +import { coreInvoke } from './engine'; import { dropTraktPlaybackProgress } from './traktSync'; import { syncTraktNow, pushMarkWatchedTrakt, pushWatchlistTrakt } from './traktExternalSync'; import { syncSimklNow, pushMarkWatchedSimkl, pushWatchlistSimkl } from './simklExternalSync'; @@ -57,42 +57,27 @@ export async function promoteExternalProgress( const lib = await loadLibrary(); const progress = (lib.progress as Record> | undefined) ?? {}; const progressBefore = { ...progress }; - let changed = false; - for (const item of items) { - const id = typeof item.id === 'string' ? item.id : ''; - const videoId = typeof item.lastVideoId === 'string' ? item.lastVideoId : ''; - const duration = Number(item.duration ?? 0); - const offset = Number(item.timeOffset ?? 0); - const savedAt = typeof item.savedAt === 'string' ? item.savedAt : ''; - if (!id || !videoId || duration <= 0 || !savedAt) continue; - const existing = progress[id]; - if (existing?.savedAt && new Date(String(existing.savedAt)).getTime() >= new Date(savedAt).getTime()) continue; - const existingMeta = (existing?.meta as Record | undefined) ?? {}; - const itemFields = Object.fromEntries(Object.entries(item).filter(([, v]) => v !== null && v !== undefined)); - const next = { ...existing, ...itemFields, meta: { ...existingMeta, ...itemFields }, source, savedAt }; - progress[id] = next; - changed = true; - await pushPlaybackProgressExternal({ - contentId: id, - contentType: String(item.type ?? 'movie'), - videoId, - positionSeconds: offset, - durationSeconds: duration, - lastWatched: new Date(savedAt).getTime(), - season: typeof item.lastEpisodeSeason === 'number' ? item.lastEpisodeSeason : undefined, - episode: typeof item.lastEpisodeNumber === 'number' ? item.lastEpisodeNumber : undefined, - }, item, profile); - const meta = { id, type: String(item.type ?? 'movie'), name: String(item.name ?? '') } as import('./types').Meta; - const episode = typeof item.lastEpisodeSeason === 'number' && typeof item.lastEpisodeNumber === 'number' - ? { id: videoId, season: item.lastEpisodeSeason, episode: item.lastEpisodeNumber, number: item.lastEpisodeNumber } as import('./types').Video - : null; - if (source !== 'trakt') traktScrobbleOnClose(profile, meta, episode, offset, duration); - if (source !== 'simkl') simklScrobbleOnClose(profile, meta, episode, offset, duration); + const plan = await coreInvoke<{ + progress: Record>; + promotions: Array<{ + item: Record; + externalProgress: WatchProgressInfo; + meta: import('./types').Meta; + episode: import('./types').Video | null; + scrobbleTrakt: boolean; + scrobbleSimkl: boolean; + }>; + }>('promoteExternalProgressPlan', JSON.stringify({ progress, items, source })); + if (!plan) return; + for (const promotion of plan.promotions) { + await pushPlaybackProgressExternal(promotion.externalProgress, promotion.item, profile); + if (promotion.scrobbleTrakt) traktScrobbleOnClose(profile, promotion.meta, promotion.episode, promotion.externalProgress.positionSeconds, promotion.externalProgress.durationSeconds); + if (promotion.scrobbleSimkl) simklScrobbleOnClose(profile, promotion.meta, promotion.episode, promotion.externalProgress.positionSeconds, promotion.externalProgress.durationSeconds); } - if (changed) { - lib.progress = progress; - lib.continueWatching = await buildContinueWatching(progress); - await persistProgressMerge(progressBefore, progress); + if (plan.promotions.length > 0) { + lib.progress = plan.progress; + lib.continueWatching = await buildContinueWatching(plan.progress); + await persistProgressMerge(progressBefore, plan.progress); await saveLibrary(lib); } } @@ -153,79 +138,60 @@ export async function pushMarkWatchedExternal( ): Promise { if (!profile) return; const tasks: Promise[] = []; + const plan = await coreInvoke<{ + trakt: boolean; simkl: boolean; anilist: boolean; stremio: boolean; nuvio: boolean; + animeEpisode?: WatchedEpisodeInfo; animeProgressEpisode?: number; + episodes: WatchedEpisodeInfo[]; + watchedKeys: Array<{ content_id: string; season?: number; episode?: number }>; + historyItems: Array<{ content_id: string; content_type: string; title?: string; season?: number; episode?: number; watched_at: number }>; + progressEntry?: { content_id: string; content_type: string; video_id: string; position: number; duration: number; last_watched: number; season?: number; episode?: number }; + }>('externalProviderActionPlan', JSON.stringify({ kind: 'markWatched', profile, videoIds, watched, meta, episodeInfo, progressInfo, nowMs: Date.now() })); + if (!plan) return; - if (profile.traktAccessToken && !(profile.traktTokenExpiresAt && Date.now() / 1000 > profile.traktTokenExpiresAt)) { + if (plan.trakt) { tasks.push((async () => { const clientId = await getOAuthClientId('trakt'); await pushMarkWatchedTrakt(videoIds, watched, profile.traktAccessToken!, clientId); })().catch(() => undefined)); } - if (profile.simklAccessToken) { + if (plan.simkl) { tasks.push((async () => { const clientId = await getOAuthClientId('simkl'); await pushMarkWatchedSimkl(videoIds, watched, meta, profile.simklAccessToken!, clientId); })().catch(() => undefined)); } - if (profile.anilistAccessToken && watched) { - const animeEpisodeInfo = Array.isArray(episodeInfo) ? episodeInfo[episodeInfo.length - 1] : episodeInfo; + if (plan.anilist) { tasks.push(pushAnimeTrackingExternal({ meta, - episode: animeEpisodeInfo, - progressEpisode: progressInfo?.episode ?? animeEpisodeInfo?.episode, + episode: plan.animeEpisode, + progressEpisode: plan.animeProgressEpisode, watched, }, profile).catch(() => undefined)); } - const nuvioEpisodes = (Array.isArray(episodeInfo) ? episodeInfo : episodeInfo ? [episodeInfo] : []) - .filter((info) => info.contentId); - if (profile.stremioAuthKey) { - tasks.push(pushStremioWatched(meta, watched, nuvioEpisodes, profile).catch(() => undefined)); + if (plan.stremio) { + tasks.push(pushStremioWatched(meta, watched, plan.episodes, profile).catch(() => undefined)); } - if (profile.nuvioAccessToken) { + if (plan.nuvio) { tasks.push((async () => { let nuvioProfile = await validNuvioProfile(profile); const push = async () => { const token = nuvioProfile.nuvioAccessToken!; const profileIdx = nuvioProfile.nuvioProfileIndex ?? 1; - const watchedKeys = nuvioEpisodes.length > 0 - ? nuvioEpisodes.map((info) => ({ content_id: info.contentId, season: info.season, episode: info.episode })) - : [{ content_id: String(meta?.id ?? videoIds[0] ?? ''), season: undefined, episode: undefined }] - .filter((key) => key.content_id); if (!watched) { - if (watchedKeys.length > 0) await nuvioDeleteWatchHistory(token, profileIdx, watchedKeys); + if (plan.watchedKeys.length > 0) await nuvioDeleteWatchHistory(token, profileIdx, plan.watchedKeys); return; } - if (nuvioEpisodes.length > 0) { - await Promise.all(nuvioEpisodes.map((info) => + if (plan.episodes.length > 0) { + await Promise.all(plan.episodes.map((info) => nuvioDeleteWatchProgress(token, profileIdx, info.contentId, info.season, info.episode).catch(() => undefined), )); - const watchedAt = Date.now(); - await nuvioPushWatchHistory( - token, - profileIdx, - nuvioEpisodes.map((info) => ({ - content_id: info.contentId, - content_type: info.contentType, - title: info.title ?? '', - season: info.season, - episode: info.episode, - watched_at: watchedAt, - })), - ); + await nuvioPushWatchHistory(token, profileIdx, plan.historyItems); } - if (progressInfo?.contentId && progressInfo.videoId && progressInfo.durationSeconds > 0) { - await nuvioPushWatchProgress(token, profileIdx, [{ - content_id: progressInfo.contentId, - content_type: progressInfo.contentType, - video_id: progressInfo.videoId, - position: Math.round(progressInfo.positionSeconds * 1000), - duration: Math.round(progressInfo.durationSeconds * 1000), - last_watched: progressInfo.lastWatched, - season: progressInfo.season, - episode: progressInfo.episode, - }]); + if (plan.progressEntry) { + await nuvioPushWatchProgress(token, profileIdx, [plan.progressEntry]); } }; try { @@ -250,31 +216,32 @@ export async function pushWatchlistExternal( const id = String(item.id ?? ''); const contentType = String(item.type ?? 'movie'); const tasks: Promise[] = []; + const plan = await coreInvoke<{ trakt: boolean; simkl: boolean; anilist: boolean; stremio: boolean; nuvio: boolean }>('externalProviderActionPlan', JSON.stringify({ kind: 'watchlist', profile, item, command, nowMs: Date.now() })); + if (!plan) return; - if (profile.traktAccessToken && !(profile.traktTokenExpiresAt && Date.now() / 1000 > profile.traktTokenExpiresAt)) { + if (plan.trakt) { tasks.push((async () => { const clientId = await getOAuthClientId('trakt'); await pushWatchlistTrakt(id, contentType, command, profile.traktAccessToken!, clientId); })().catch(() => undefined)); } - if (profile.simklAccessToken && command === 'add') { + if (plan.simkl) { tasks.push((async () => { const clientId = await getOAuthClientId('simkl'); - const parsed = await coreParseVideoId(id); - await pushWatchlistSimkl(id, contentType, parsed, profile.simklAccessToken!, clientId); + await pushWatchlistSimkl(id, contentType, profile.simklAccessToken!, clientId); })().catch(() => undefined)); } - if (profile.anilistAccessToken) { - tasks.push(pushWatchlistAniList(id, command, profile.anilistAccessToken).catch(() => undefined)); + if (plan.anilist) { + tasks.push(pushWatchlistAniList(id, command, profile.anilistAccessToken!).catch(() => undefined)); } - if (profile.stremioAuthKey) { + if (plan.stremio) { tasks.push(pushStremioWatchlist(item, command, profile).catch(() => undefined)); } - if (profile.nuvioAccessToken) { + if (plan.nuvio) { const queueKey = `${profile.nuvioUserId ?? profile.id}:${profile.nuvioProfileIndex ?? 1}`; tasks.push(queueNuvioLibraryMutation(queueKey, async () => { let nuvioProfile = await validNuvioProfile(profile); @@ -282,29 +249,8 @@ export async function pushWatchlistExternal( if (!token) return; const profileIdx = nuvioProfile.nuvioProfileIndex ?? 1; const remote = await nuvioPullLibrary(token, profileIdx); - const existingIndex = remote.findIndex((entry) => entry.content_id === id && entry.content_type === contentType); - if (command === 'remove') { - if (existingIndex < 0) return; - remote.splice(existingIndex, 1); - } else { - const entry = { - content_id: id, - content_type: contentType, - name: String(item.name ?? ''), - poster: (item.poster as string | undefined) ?? null, - poster_shape: 'poster', - background: (item.background as string | undefined) ?? null, - description: (item.description as string | undefined) ?? null, - release_info: (item.releaseInfo as string | undefined) ?? null, - imdb_rating: typeof item.imdbRating === 'number' ? item.imdbRating : null, - genres: Array.isArray(item.genres) ? item.genres.filter((genre): genre is string => typeof genre === 'string') : [], - addon_base_url: null, - added_at: Date.now(), - }; - if (existingIndex >= 0) remote[existingIndex] = { ...remote[existingIndex], ...entry }; - else remote.push(entry); - } - await nuvioPushLibrary(token, profileIdx, remote); + const updated = await coreInvoke('nuvioLibraryMutationPlan', JSON.stringify({ remote, item, command, nowMs: Date.now() })); + if (updated) await nuvioPushLibrary(token, profileIdx, updated); }).catch(() => undefined)); } @@ -316,25 +262,21 @@ export async function pushPlaybackProgressExternal( meta: Record, profile: UserProfile | null, ): Promise { - if (!profile || progress.durationSeconds <= 0) return; + if (!profile) return; + const plan = await coreInvoke<{ + stremio: boolean; nuvio: boolean; + progressEntry?: { content_id: string; content_type: string; video_id: string; position: number; duration: number; last_watched: number; season?: number; episode?: number }; + }>('externalProviderActionPlan', JSON.stringify({ kind: 'progress', profile, progress, nowMs: Date.now() })); + if (!plan) return; const tasks: Promise[] = []; - if (profile.stremioAuthKey) { + if (plan.stremio) { tasks.push(pushStremioPlaybackProgress(meta, progress, profile).catch(() => undefined)); } - if (profile.nuvioAccessToken) { + if (plan.nuvio && plan.progressEntry) { tasks.push((async () => { const fresh = await validNuvioProfile(profile); if (!fresh.nuvioAccessToken) return; - await nuvioPushWatchProgress(fresh.nuvioAccessToken, fresh.nuvioProfileIndex ?? 1, [{ - content_id: progress.contentId, - content_type: progress.contentType, - video_id: progress.videoId, - position: Math.round(progress.positionSeconds * 1000), - duration: Math.round(progress.durationSeconds * 1000), - last_watched: progress.lastWatched, - season: progress.season, - episode: progress.episode, - }]); + await nuvioPushWatchProgress(fresh.nuvioAccessToken, fresh.nuvioProfileIndex ?? 1, [plan.progressEntry!]); })().catch(() => undefined)); } await Promise.all(tasks); @@ -346,29 +288,32 @@ export async function pushLibraryStatusExternal( command: 'add' | 'remove', profile: UserProfile | null, ): Promise { - if (!profile?.anilistAccessToken) return; + if (!profile) return; + const plan = await coreInvoke<{ anilist: boolean }>('externalProviderActionPlan', JSON.stringify({ kind: 'status', profile, item, list, command, nowMs: Date.now() })); + if (!plan?.anilist) return; const id = String(item.id ?? ''); - await pushLibraryStatusAniList(id, list, command, profile.anilistAccessToken).catch(() => undefined); + await pushLibraryStatusAniList(id, list, command, profile.anilistAccessToken!).catch(() => undefined); } export async function dropExternalPlaybackProgress(item: Record): Promise { - const reason = String(item.reason ?? '').toLowerCase(); const id = String(item.id ?? ''); if (!id) return; - if (reason === 'trakt') { + const plan = await coreInvoke<{ dropTrakt: boolean }>('externalProviderActionPlan', JSON.stringify({ kind: 'dropProgress', profile: {}, item, nowMs: Date.now() })); + if (plan?.dropTrakt) { await dropTraktPlaybackProgress(id); } - // Simkl: no playback progress API — local removal is sufficient. } export async function syncExternalIntegrationNow(payload: Record): Promise { - const provider = String(payload.provider ?? 'trakt').toLowerCase(); + const plan = await coreInvoke<{ provider: string; supported: boolean; error?: string }>('externalProviderActionPlan', JSON.stringify({ kind: 'sync', provider: payload.provider })); + const provider = plan?.provider ?? ''; + if (!plan?.supported) return { synced: false, error: plan?.error }; if (provider === 'anilist') return syncAniListNow(payload); if (provider === 'simkl') return syncSimklNow(payload); if (provider === 'trakt') return syncTraktNow(payload); if (provider === 'stremio') return syncStremioNow(payload); if (provider === 'nuvio') return syncNuvioNow(payload); - return { synced: false, error: `Unsupported external sync provider: ${provider}` }; + return { synced: false }; } async function syncNuvioNow(payload: Record): Promise { diff --git a/src/core/homeEffects.ts b/src/core/homeEffects.ts index e224686..e9c2fe1 100644 --- a/src/core/homeEffects.ts +++ b/src/core/homeEffects.ts @@ -4,12 +4,12 @@ import { coreComputeContinueWatchingBadges, coreDiscoverCatalogOptions, coreEffectiveMetadataFeedSelection, + coreFilterEnabledAddons, coreMergeContinueWatchingLists, coreResolveFeedOptionGenre, storageRead, storageWrite, } from './engine'; -import { addonKey } from './addons'; import { buildResourceUrl } from './addonManifest'; import { effectRunnerLibraryKey, loadActiveProfile, loadAddons, loadLibrary, loadPrefs } from './libraryOps'; import { fetchBuiltinCatalog, isBuiltinTmdbAddon, withBuiltinTmdbAddon } from './tmdbAddon'; @@ -110,10 +110,8 @@ export async function readHomeBootstrap( const allAddons = await loadAddons(); const library = await loadLibrary(); const prefs = await loadPrefs(); - const addons = await withBuiltinTmdbAddon( - allAddons.filter((addon) => !disabledAddonKeys.includes(addonKey(addon))), - prefs, - ); + const enabledAddons = (await coreFilterEnabledAddons(allAddons, disabledAddonKeys)) ?? allAddons; + const addons = await withBuiltinTmdbAddon(enabledAddons, prefs); const localContinueWatching = (library.continueWatching as Record[] | undefined) ?? []; const externalContinueWatching = (library.externalContinueWatching as Record[] | undefined) ?? []; diff --git a/src/core/libraryEffects.ts b/src/core/libraryEffects.ts index 5fd096a..6a34bc6 100644 --- a/src/core/libraryEffects.ts +++ b/src/core/libraryEffects.ts @@ -1,5 +1,4 @@ import { - coreAirDateRefreshCandidates, coreInvoke, coreLibraryApplyMarkWatched, coreLibraryLocalStatePlan, @@ -41,12 +40,10 @@ export async function refreshWatchlistAirDates(): Promise { const watchlist = (lib.watchlist as LibraryItem[] | undefined) ?? []; const continueWatching = (lib.continueWatching as LibraryItem[] | undefined) ?? []; - const dueIds = new Set(await coreAirDateRefreshCandidates([...watchlist, ...continueWatching], nowMs)); - const byId = new Map(); - for (const item of [...watchlist, ...continueWatching]) { - if (!byId.has(item.id)) byId.set(item.id, item); - } - const candidates = [...byId.values()].filter((item) => dueIds.has(item.id)); + const candidates = (await coreInvoke('airDateRefreshPlan', JSON.stringify({ + items: [...watchlist, ...continueWatching], + nowMs, + }))) ?? []; if (candidates.length === 0) return; const addons = await loadAddons(); @@ -58,15 +55,14 @@ export async function refreshWatchlistAirDates(): Promise { return { id: item.id, nextEpisodeAirDate: next?.released, lastAirDateCheckedAt: nowIso }; }); - const updatesById = new Map(updates.map((update) => [update.id, update])); - const applyUpdate = (item: LibraryItem): LibraryItem => { - const update = updatesById.get(item.id); - return update - ? { ...item, nextEpisodeAirDate: update.nextEpisodeAirDate, lastAirDateCheckedAt: update.lastAirDateCheckedAt } - : item; - }; - lib.watchlist = watchlist.map(applyUpdate); - lib.continueWatching = continueWatching.map(applyUpdate); + const applied = await coreInvoke<{ watchlist: LibraryItem[]; continueWatching: LibraryItem[] }>('applyAirDateUpdates', JSON.stringify({ + watchlist, + continueWatching, + updates, + })); + if (!applied) return; + lib.watchlist = applied.watchlist; + lib.continueWatching = applied.continueWatching; await saveLibrary(lib); invalidateCalendarCache(); @@ -116,22 +112,13 @@ async function deriveNextProgressInfo( if (!seriesId || watchedEpisodes.length === 0) return undefined; const addons = await loadAddons(); const videos = await fetchVideosForSeries(seriesId, addons); - const next = await coreInvoke<{ id?: string; season?: number; episode?: number; number?: number }>('resolveNextAfterWatched', JSON.stringify({ + return (await coreInvoke('nextProgressInfoPlan', JSON.stringify({ + contentId: seriesId, + contentType, videos, watchedEpisodes, nowMs: Date.now(), - })); - if (!next?.id) return undefined; - return { - contentId: seriesId, - contentType, - videoId: next.id, - positionSeconds: 0, - durationSeconds: 0, - lastWatched: Date.now(), - season: next.season, - episode: next.episode ?? next.number, - }; + }))) ?? undefined; } export async function notifyReleasedEpisodes(payload: Record): Promise { diff --git a/src/core/nuvioSync.ts b/src/core/nuvioSync.ts index ef6160e..b13767e 100644 --- a/src/core/nuvioSync.ts +++ b/src/core/nuvioSync.ts @@ -20,6 +20,7 @@ import { coreNuvioLibraryToWatchlist, coreNuvioMapCollections, coreNuvioProgressMetaNeeds, + coreNuvioSortAddonsByPriority, storageRead, storageWrite, } from './engine'; @@ -86,7 +87,7 @@ async function fetchAddonManifests(addons: NuvioAddon[]): Promise<{ manifestIdByUrl: Map; descriptors: Array>; }> { - const sorted = [...addons].sort((a, b) => a.sort_order - b.sort_order); + const sorted = (await coreNuvioSortAddonsByPriority(addons)) ?? addons; const enabled = sorted.filter((a) => a.enabled); const manifestIdByUrl = new Map(); const manifests = await Promise.allSettled( diff --git a/src/core/scrobble.ts b/src/core/scrobble.ts index dce35d5..65d7c6e 100644 --- a/src/core/scrobble.ts +++ b/src/core/scrobble.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; -import { coreParseVideoId, coreSimklMatchEpisode, coreSimklScrobbleAction, coreSimklScrobbleBody, coreTraktScrobblePlan } from './engine'; +import { coreInvoke, coreParseVideoId, coreSimklLookupIdForType, coreSimklMatchEpisode, coreSimklScrobbleAction, coreSimklScrobbleBody, coreTraktScrobblePlan } from './engine'; import { _appVersion } from './httpClient'; import type { UserProfile, Meta, Video } from './types'; @@ -12,16 +12,15 @@ export function traktScrobbleOnClose( durationSec: number, ): void { if (!profile?.traktAccessToken || !meta) return; - if (profile.traktTokenExpiresAt && Date.now() / 1000 > profile.traktTokenExpiresAt) return; - - const isEpisode = meta.type === 'series' && !!episode; void (async () => { + const context = await coreInvoke<{ videoId: string; isEpisode: boolean; season: number; episode: number; traktEnabled: boolean }>('scrobbleMediaContext', JSON.stringify({ meta, episode, profile, nowSeconds: Math.floor(Date.now() / 1000) })); + if (!context?.traktEnabled) return; const plan = await coreTraktScrobblePlan( - meta.id, - isEpisode, - isEpisode ? (episode!.season ?? 1) : null, - isEpisode ? (episode!.episode ?? episode!.number ?? 1) : null, + context.videoId, + context.isEpisode, + context.isEpisode ? context.season : null, + context.isEpisode ? context.episode : null, timePosSec, durationSec, ); @@ -51,12 +50,13 @@ export function simklScrobbleOnClose( ): void { if (!profile?.simklAccessToken || !meta) return; - const isEpisode = meta.type === 'series' && !!episode; const token = profile.simklAccessToken; void (async () => { + const context = await coreInvoke<{ videoId: string; isEpisode: boolean; simklType: string; season: number; episode: number; releaseDate?: string; episodeTitle: string }>('scrobbleMediaContext', JSON.stringify({ meta, episode, profile, nowSeconds: Math.floor(Date.now() / 1000) })); + if (!context) return; const action = await coreSimklScrobbleAction(timePosSec, durationSec); - const parsed = await coreParseVideoId(meta.id); + const parsed = await coreParseVideoId(context.videoId); const baseId = parsed.imdb; if (!baseId) return; @@ -71,29 +71,23 @@ export function simklScrobbleOnClose( `https://api.simkl.com/search/id?imdb=${encodeURIComponent(baseId)}&${simklQuery}`, { headers: authHeaders }, ); - const lookupJson = lookupRes.ok - ? (await lookupRes.json() as Array<{ type?: string; ids?: Record }>) - : []; - const wantType = isEpisode ? 'tv' : 'movie'; - const found = lookupJson.find((item) => item.type === wantType); - const simklId = typeof found?.ids?.simkl === 'number' ? found.ids.simkl : null; + const lookupJson = lookupRes.ok ? await lookupRes.json() : []; + const simklId = await coreSimklLookupIdForType(JSON.stringify(lookupJson), context.simklType); const ids: Record = simklId != null ? { simkl: simklId } : { imdb: baseId }; - let scrobbleSeason = isEpisode ? (episode!.season ?? 1) : 1; - let scrobbleNumber = isEpisode ? (episode!.episode ?? episode!.number ?? 1) : 1; + let scrobbleSeason = context.season; + let scrobbleNumber = context.episode; - if (isEpisode && simklId != null) { + if (context.isEpisode && simklId != null) { const epRes = await tauriFetch( `https://api.simkl.com/tv/${simklId}/episodes?${simklQuery}`, { headers: authHeaders }, ); if (epRes.ok) { const epList = await epRes.json() as Array<{ season?: number; episode?: number; date?: string; title?: string }>; - const releaseDate = episode!.released?.slice(0, 10); - const epName = episode!.name ?? episode!.title ?? ''; const matched = await coreSimklMatchEpisode( JSON.stringify(Array.isArray(epList) ? epList : []), - JSON.stringify({ releaseDate: releaseDate ?? '', title: epName }), + JSON.stringify({ releaseDate: context.releaseDate ?? '', title: context.episodeTitle }), ); if (matched) { scrobbleSeason = matched.season; @@ -104,7 +98,7 @@ export function simklScrobbleOnClose( const body = await coreSimklScrobbleBody( JSON.stringify(ids), - isEpisode, + context.isEpisode, scrobbleSeason, scrobbleNumber, timePosSec, diff --git a/src/core/simklExternalSync.ts b/src/core/simklExternalSync.ts index ffb1f5e..1a75e91 100644 --- a/src/core/simklExternalSync.ts +++ b/src/core/simklExternalSync.ts @@ -1,7 +1,7 @@ import { coreMergeExternalWatched, coreMergeExternalWatchlist, - coreParseVideoId, + coreInvoke, coreSimklWatchedToIds, coreSimklWatchingToItems, coreSimklWatchlistToItems, @@ -95,44 +95,7 @@ export async function fetchSimklCalendarItems(token: string, clientId: string): .then((res) => (res.ok ? res.json() : [])).catch(() => []), ]); - const showItems = (Array.isArray(shows) ? shows : []).map((raw) => { - const entry = raw as Record; - const episode = entry.episode as Record | undefined; - const show = entry.show as Record | undefined; - const ids = show?.ids as Record | undefined; - const imdb = typeof ids?.imdb === 'string' ? ids.imdb : undefined; - const tmdb = ids?.tmdb != null ? `tmdb:${ids.tmdb}` : undefined; - const seriesId = imdb ?? tmdb; - const dateIso = typeof entry.date === 'string' ? entry.date : undefined; - if (!seriesId || !dateIso) return null; - return { - id: `${seriesId}:${episode?.season}:${episode?.episode}`, - title: show?.title, - episodeTitle: episode?.title, - dateIso, - contentId: seriesId, - seriesId, - } as Record; - }).filter((item): item is Record => item !== null); - - const movieItems = (Array.isArray(movies) ? movies : []).map((raw) => { - const entry = raw as Record; - const movie = entry.movie as Record | undefined; - const ids = movie?.ids as Record | undefined; - const imdb = typeof ids?.imdb === 'string' ? ids.imdb : undefined; - const tmdb = ids?.tmdb != null ? `tmdb:${ids.tmdb}` : undefined; - const contentId = imdb ?? tmdb; - const dateIso = typeof entry.date === 'string' ? entry.date : undefined; - if (!contentId || !dateIso) return null; - return { - id: contentId, - title: movie?.title, - dateIso, - contentId, - } as Record; - }).filter((item): item is Record => item !== null); - - return [...showItems, ...movieItems]; + return (await coreInvoke[]>('providerCalendarItems', JSON.stringify({ provider: 'simkl', shows, movies }))) ?? []; } export async function pushMarkWatchedSimkl( @@ -148,35 +111,8 @@ export async function pushMarkWatchedSimkl( 'Content-Type': 'application/json', }; const endpoint = watched ? '/sync/history' : '/sync/history/remove'; - const moviePayloads: Record[] = []; - const showPayloads: Map> = new Map(); - - for (const vid of videoIds) { - const parsed = await coreParseVideoId(vid); - if (!parsed.imdb && !parsed.tmdb) continue; - const ids: Record = parsed.imdb ? { imdb: parsed.imdb } : { tmdb: parsed.tmdb }; - if (parsed.isEpisode) { - const showId = String(parsed.imdb ?? parsed.tmdb ?? ''); - if (!showPayloads.has(showId)) showPayloads.set(showId, { ids, seasons: [] }); - const showEntry = showPayloads.get(showId)!; - const seasons = showEntry.seasons as Record[]; - let seasonEntry = seasons.find((s) => s.number === parsed.season); - if (!seasonEntry) { seasonEntry = { number: parsed.season, episodes: [] }; seasons.push(seasonEntry); } - (seasonEntry.episodes as Record[]).push({ number: parsed.episode }); - } else { - const contentType = (meta?.type ?? 'movie') === 'series' ? 'shows' : 'movies'; - if (contentType === 'movies') { - moviePayloads.push({ ids, watched_at: 'now' }); - } else { - showPayloads.set(String(parsed.imdb ?? parsed.tmdb ?? ''), { ids }); - } - } - } - - if (moviePayloads.length > 0 || showPayloads.size > 0) { - const body: Record = {}; - if (moviePayloads.length > 0) body.movies = moviePayloads; - if (showPayloads.size > 0) body.shows = [...showPayloads.values()]; + const body = await coreInvoke>('simklMarkWatchedBody', JSON.stringify({ videoIds, meta })); + if (body) { await platformFetch(`https://api.simkl.com${endpoint}?client_id=${encodeURIComponent(clientId)}`, { method: 'POST', headers: simklHeaders, body: JSON.stringify(body), }); @@ -186,7 +122,6 @@ export async function pushMarkWatchedSimkl( export async function pushWatchlistSimkl( id: string, contentType: string, - parsed: { imdb?: string; tmdb?: string }, token: string, clientId: string, ): Promise { @@ -195,11 +130,8 @@ export async function pushWatchlistSimkl( 'simkl-api-key': clientId, 'Content-Type': 'application/json', }; - const ids: Record = parsed.imdb ? { imdb: parsed.imdb } : parsed.tmdb ? { tmdb: parsed.tmdb } : {}; - if (Object.keys(ids).length === 0) return; - const body = contentType === 'series' - ? { shows: [{ ids, to: 'plantowatch' }] } - : { movies: [{ ids, to: 'plantowatch' }] }; + const body = await coreInvoke>('simklWatchlistBody', JSON.stringify({ id, contentType })); + if (!body) return; await platformFetch(`https://api.simkl.com/sync/add-to-list?client_id=${encodeURIComponent(clientId)}`, { method: 'POST', headers: simklHeaders, body: JSON.stringify(body), }); diff --git a/src/core/streamLinks.ts b/src/core/streamLinks.ts index 22ec7c4..c6faf77 100644 --- a/src/core/streamLinks.ts +++ b/src/core/streamLinks.ts @@ -1,18 +1,7 @@ import type { Meta, Stream, Video } from './types'; +import { coreStreamShellPlan } from './engine'; -export function streamSourceLink(stream: Stream): string | undefined { - return stream.url ?? stream.infoHash ?? undefined; -} - -export function streamDownloadLink(stream: Stream): string | undefined { - return stream.playableUrl ?? stream.url ?? undefined; -} - -export function streamIsTorrent(stream: Stream): boolean { - if (stream.infoHash || stream.isTorrent) return true; - const link = stream.url?.toLowerCase(); - return !!link && (link.startsWith('magnet:') || link.startsWith('stremio://torrent/') || link.startsWith('infohash:')); -} +export const streamShellPlan = coreStreamShellPlan; export function buildOfflineDownloadRequest(meta: Meta, stream: Stream, video?: Video | null) { return { diff --git a/src/core/stremioExternalSync.ts b/src/core/stremioExternalSync.ts index a9833cc..919a5fb 100644 --- a/src/core/stremioExternalSync.ts +++ b/src/core/stremioExternalSync.ts @@ -1,5 +1,6 @@ import { coreLibraryContinueWatchingItems, + coreInvoke, coreMergeExternalWatched, coreMergeExternalWatchlist, coreStremioWatchedToIds, @@ -31,29 +32,6 @@ type PlaybackProgress = { episode?: number; }; -function stremioTimestamp(value: number): string { - return new Date(Number.isFinite(value) ? value : Date.now()).toISOString(); -} - -function libraryItem( - meta: Record, - state: Record, - extra: Record = {}, -): Record | null { - const id = String(meta.id ?? ''); - if (!id) return null; - return { - _id: id, - name: String(meta.name ?? ''), - type: String(meta.type ?? 'movie'), - poster: meta.poster ?? null, - background: meta.background ?? null, - logo: meta.logo ?? null, - state, - ...extra, - }; -} - export async function pushStremioWatchlist( item: Record, command: 'add' | 'remove', @@ -61,15 +39,8 @@ export async function pushStremioWatchlist( ): Promise { const authKey = profile?.stremioAuthKey; if (!authKey) return; - const change = libraryItem(item, { - lastWatched: null, - timeOffset: 0, - duration: 0, - videoId: null, - timesWatched: 0, - flaggedWatched: 0, - }, command === 'remove' ? { removed: 1 } : { removed: 0 }); - if (change) await stremioPushLibrary(authKey, [change]); + const changes = await coreInvoke[]>('stremioLibraryMutationPlan', JSON.stringify({ kind: 'watchlist', item, command })); + if (changes?.length) await stremioPushLibrary(authKey, changes); } export async function pushStremioPlaybackProgress( @@ -79,13 +50,8 @@ export async function pushStremioPlaybackProgress( ): Promise { const authKey = profile?.stremioAuthKey; if (!authKey || progress.durationSeconds <= 0) return; - const change = libraryItem(meta, { - lastWatched: stremioTimestamp(progress.lastWatched), - timeOffset: Math.max(0, Math.round(progress.positionSeconds)), - duration: Math.max(0, Math.round(progress.durationSeconds)), - videoId: progress.videoId, - }); - if (change) await stremioPushLibrary(authKey, [change]); + const changes = await coreInvoke[]>('stremioLibraryMutationPlan', JSON.stringify({ kind: 'progress', meta, progress })); + if (changes?.length) await stremioPushLibrary(authKey, changes); } export async function pushStremioWatched( @@ -96,40 +62,8 @@ export async function pushStremioWatched( ): Promise { const authKey = profile?.stremioAuthKey; if (!authKey) return; - const watchedAt = watched ? stremioTimestamp(Date.now()) : null; - const changes = episodes.length > 0 - ? episodes.map((episode) => { - const videoId = episode.videoId || `${episode.contentId}:${episode.season ?? 0}:${episode.episode ?? 0}`; - return { - _id: videoId, - name: episode.title ?? String(meta?.name ?? ''), - type: episode.contentType, - poster: meta?.poster ?? null, - background: meta?.background ?? null, - logo: meta?.logo ?? null, - state: { - lastWatched: watchedAt, - timeOffset: 0, - duration: 0, - videoId, - timesWatched: watched ? 1 : 0, - flaggedWatched: watched ? 1 : 0, - }, - lastWatched: watchedAt, - }; - }) - : (() => { - const change = meta ? libraryItem(meta, { - lastWatched: watchedAt, - timeOffset: 0, - duration: 0, - videoId: null, - timesWatched: watched ? 1 : 0, - flaggedWatched: watched ? 1 : 0, - }, { lastWatched: watchedAt }) : null; - return change ? [change] : []; - })(); - await stremioPushLibrary(authKey, changes); + const changes = await coreInvoke[]>('stremioLibraryMutationPlan', JSON.stringify({ kind: 'watched', meta, watched, episodes, nowMs: Date.now() })); + if (changes?.length) await stremioPushLibrary(authKey, changes); } export async function syncStremioAddons(profile: UserProfile, addons: AddonDescriptor[]): Promise { diff --git a/src/core/traktExternalSync.ts b/src/core/traktExternalSync.ts index 4351ac0..be49d10 100644 --- a/src/core/traktExternalSync.ts +++ b/src/core/traktExternalSync.ts @@ -1,5 +1,6 @@ import { coreBuildTraktIds, + coreInvoke, coreMergeExternalWatched, coreMergeExternalWatchlist, coreTraktMarkWatchedBody, @@ -14,19 +15,24 @@ import { traktHeaders } from './traktSync'; import { enrichWithAddonMeta, replaceExternalContinueWatching } from './externalSyncUtils'; async function fetchAllPages(url: string, headers: HeadersInit, limit: number): Promise[]> { - const sep = url.includes('?') ? '&' : '?'; - const items: Record[] = []; - for (let page = 1; page <= 100; page++) { - const res = await platformFetch(`${url}${sep}page=${page}&limit=${limit}`, { headers }); - if (!res.ok) break; - const data = await res.json(); - if (!Array.isArray(data) || data.length === 0) break; - items.push(...(data as Record[])); + type PaginationPlan = { items: Record[]; done: boolean; page: number; requestUrl?: string | null }; + let plan = await coreInvoke('providerPaginationPlan', JSON.stringify({ baseUrl: url, limit })); + while (plan && !plan.done && plan.requestUrl) { + const res = await platformFetch(plan.requestUrl, { headers }); + const data = res.ok ? await res.json().catch(() => []) : []; + const pageItems = Array.isArray(data) ? data : []; const pageCount = Number(res.headers.get('x-pagination-page-count')); - if (Number.isFinite(pageCount) && page >= pageCount) break; - if (data.length < limit) break; + plan = await coreInvoke('providerPaginationPlan', JSON.stringify({ + baseUrl: url, + limit, + page: plan.page, + items: plan.items, + pageItems, + pageCount: Number.isFinite(pageCount) ? pageCount : null, + responseOk: res.ok, + })); } - return items; + return plan?.items ?? []; } async function mergeExternalWatchlist(externalItems: Record[]): Promise { @@ -106,44 +112,7 @@ export async function fetchTraktCalendarItems(token: string, clientId: string): .then((res) => (res.ok ? res.json() : [])).catch(() => []), ]); - const showItems = (Array.isArray(shows) ? shows : []).map((raw) => { - const entry = raw as Record; - const episode = entry.episode as Record | undefined; - const show = entry.show as Record | undefined; - const ids = show?.ids as Record | undefined; - const imdb = typeof ids?.imdb === 'string' ? ids.imdb : undefined; - const tmdb = ids?.tmdb != null ? `tmdb:${ids.tmdb}` : undefined; - const seriesId = imdb ?? tmdb; - const dateIso = typeof entry.first_aired === 'string' ? entry.first_aired : undefined; - if (!seriesId || !dateIso) return null; - return { - id: `${seriesId}:${episode?.season}:${episode?.number}`, - title: show?.title, - episodeTitle: episode?.title, - dateIso, - contentId: seriesId, - seriesId, - } as Record; - }).filter((item): item is Record => item !== null); - - const movieItems = (Array.isArray(movies) ? movies : []).map((raw) => { - const entry = raw as Record; - const movie = entry.movie as Record | undefined; - const ids = movie?.ids as Record | undefined; - const imdb = typeof ids?.imdb === 'string' ? ids.imdb : undefined; - const tmdb = ids?.tmdb != null ? `tmdb:${ids.tmdb}` : undefined; - const contentId = imdb ?? tmdb; - const dateIso = typeof entry.released === 'string' ? entry.released : undefined; - if (!contentId || !dateIso) return null; - return { - id: contentId, - title: movie?.title, - dateIso, - contentId, - } as Record; - }).filter((item): item is Record => item !== null); - - return [...showItems, ...movieItems]; + return (await coreInvoke[]>('providerCalendarItems', JSON.stringify({ provider: 'trakt', shows, movies }))) ?? []; } export async function pushMarkWatchedTrakt( diff --git a/src/hooks/useNuvioConnectivity.ts b/src/hooks/useNuvioConnectivity.ts index 6b78d21..99682ca 100644 --- a/src/hooks/useNuvioConnectivity.ts +++ b/src/hooks/useNuvioConnectivity.ts @@ -3,63 +3,24 @@ import { nuvioHealthCheck, nuvioPushWatchProgress, nuvioPushLibrary, nuvioPushWa import { loadLibrary } from '../core/libraryOps'; import { freshNuvioProfile, importNuvioProfileData, recordNuvioSyncMeta } from '../core/nuvioSync'; import type { UserProfile } from '../core/types'; +import { coreInvoke } from '../core/engine'; async function pushLocalToNuvio(profile: UserProfile): Promise { const freshProfile = await freshNuvioProfile(profile).catch(() => profile); const token = freshProfile.nuvioAccessToken!; const profileIdx = freshProfile.nuvioProfileIndex ?? 1; const lib = await loadLibrary(); - - const progressMap = (lib.progress as Record> | undefined) ?? {}; - const progressEntries = Object.entries(progressMap) - .map(([contentId, e]) => { - const meta = e.meta as { type?: string } | undefined; - const timeOffset = Number(e.timeOffset ?? 0); - const duration = Number(e.duration ?? 0); - if (duration <= 0) return null; - const videoId = e.lastVideoId ? String(e.lastVideoId) : contentId; - return { - content_id: contentId, - content_type: String(meta?.type ?? 'movie'), - video_id: videoId, - position: Math.round(timeOffset * 1000), - duration: Math.round(duration * 1000), - last_watched: e.savedAt ? new Date(String(e.savedAt)).getTime() : Date.now(), - season: e.lastEpisodeSeason != null ? Number(e.lastEpisodeSeason) : undefined, - episode: e.lastEpisodeNumber != null ? Number(e.lastEpisodeNumber) : undefined, - }; - }) - .filter((e): e is NonNullable => e !== null); - - const watchlist = (lib.watchlist as Array> | undefined) ?? []; - const libraryItems = watchlist - .map((item) => ({ - content_id: String(item.id ?? ''), - content_type: String(item.type ?? 'movie'), - name: String(item.name ?? ''), - poster: (item.poster as string | undefined) ?? null, - background: (item.background as string | undefined) ?? null, - })) - .filter((i) => i.content_id); - - const watchedMap = (lib.watched as Record | undefined) ?? {}; - const historyItems = Object.keys(watchedMap).map((videoId) => { - const parts = videoId.split(':'); - const isSeries = parts.length === 3; - return { - content_id: parts[0], - content_type: isSeries ? 'series' : 'movie', - title: '', - season: isSeries ? Number(parts[1]) : undefined, - episode: isSeries ? Number(parts[2]) : undefined, - watched_at: Date.now(), - }; - }); + const plan = await coreInvoke<{ + progressEntries: Array<{ content_id: string; content_type: string; video_id: string; position: number; duration: number; last_watched: number; season?: number; episode?: number }>; + libraryItems: Array<{ content_id: string; content_type: string; name?: string; poster?: string | null; background?: string | null }>; + historyItems: Array<{ content_id: string; content_type: string; title?: string; season?: number; episode?: number; watched_at: number }>; + }>('nuvioExportPushPlan', JSON.stringify({ library: lib, nowMs: Date.now() })); + if (!plan) return; await Promise.allSettled([ - progressEntries.length > 0 ? nuvioPushWatchProgress(token, profileIdx, progressEntries) : Promise.resolve(), - libraryItems.length > 0 ? nuvioPushLibrary(token, profileIdx, libraryItems) : Promise.resolve(), - historyItems.length > 0 ? nuvioPushWatchHistory(token, profileIdx, historyItems) : Promise.resolve(), + plan.progressEntries.length > 0 ? nuvioPushWatchProgress(token, profileIdx, plan.progressEntries) : Promise.resolve(), + plan.libraryItems.length > 0 ? nuvioPushLibrary(token, profileIdx, plan.libraryItems) : Promise.resolve(), + plan.historyItems.length > 0 ? nuvioPushWatchHistory(token, profileIdx, plan.historyItems) : Promise.resolve(), ]); } diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index e1e33ba..d95d2cf 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { invoke } from '@tauri-apps/api/core'; import * as Sentry from '@sentry/react'; -import { dispatchAction, coreDetectAnimePlayback, coreInvoke, corePlaybackIntroLookupContentId, corePlaybackPreparePlan, coreResolveNextEpisode, coreCanPrefetchNextEpisode, coreSelectNextEpisodeStream, coreTorrentStatusInfo, coreTorrentReadyBudget } from '../core/engine'; +import { dispatchAction, coreDetectAnimePlayback, coreInvoke, corePlaybackIntroLookupContentId, corePlaybackPreparePlan, coreResolveNextEpisode, coreCanPrefetchNextEpisode, coreSelectNextEpisodeStream, coreStreamShellPlan, coreTorrentStatusInfo, coreTorrentReadyBudget } from '../core/engine'; function debugLog(msg: string) { void invoke('debug_log', { msg }).catch(() => {}); @@ -97,28 +97,6 @@ interface UsePlayerResult { flushProgressOnQuit: () => Promise; } -function streamKey(stream: Stream | null | undefined): string { - if (!stream) return ''; - return [ - stream.url ?? '', - stream.playableUrl ?? '', - stream.infoHash ?? '', - stream.fileIdx ?? '', - stream.title ?? '', - stream.name ?? '', - ].join('|'); -} - -function streamIsP2P(stream: Stream): boolean { - return !!(stream.isTorrent || stream.infoHash); -} - -function streamRequestHeaders(stream: Stream): Record | undefined { - const headers = stream.behaviorHints?.requestHeaders ?? stream.behaviorHints?.proxyHeaders?.request; - if (!headers || Object.keys(headers).length === 0) return undefined; - return headers; -} - export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdated, onEpisodePlaybackFailed }: UsePlayerOptions): UsePlayerResult { const [playerUrl, setPlayerUrl] = useState(null); const [playerTitle, setPlayerTitle] = useState(); @@ -385,7 +363,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat timePos, duration, streamIndex: stateRef.current.player.currentStreamIndex ?? null, - watchedThresholdPercent: Number(prefString(closePrefs, 'watchedThresholdPercent', '90')) || 90, + prefs: closePrefs, })); if (closePlan?.shouldScrobble) { traktScrobbleOnClose(activeProfileRef.current, captureMeta, captureEpisode, timePos, duration); @@ -439,25 +417,23 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat lastPlaybackStatusRef.current = status; const timePos = parseFloat(status.timePos ?? '0'); const duration = parseFloat(status.duration ?? '0'); - if (!(timePos > 30 && duration > 0)) return; try { - const saveResult = await dispatchAction(JSON.stringify({ - type: 'savePlaybackProgressRequested', + const plan = await coreInvoke<{ + shouldScrobble: boolean; + progressAction: Record; + }>('playbackClosePlan', JSON.stringify({ meta: captureMeta, - timeOffset: Math.floor(timePos), + episode: captureEpisode, + stream: captureStream, + nextEpisode: null, + timePos, duration: Math.floor(duration), - lastVideoId: captureEpisode?.id ?? null, - lastStreamIndex: stateRef.current.player.currentStreamIndex ?? null, - lastEpisodeName: captureEpisode?.name ?? captureEpisode?.title ?? null, - lastEpisodeSeason: captureEpisode?.season ?? null, - lastEpisodeNumber: captureEpisode?.episode ?? captureEpisode?.number ?? null, - lastEpisodeThumbnail: captureEpisode?.thumbnail ?? null, - lastStreamUrl: captureStream?.playableUrl ?? captureStream?.url ?? null, - lastStreamTitle: captureStream?.title ?? captureStream?.name ?? null, - lastAudioLanguage: null, - lastSubtitleLanguage: null, + streamIndex: stateRef.current.player.currentStreamIndex ?? null, + prefs: appPrefs(stateRef.current), scrobbleTraktPause: false, })); + if (!plan?.shouldScrobble || !plan.progressAction) return; + const saveResult = await dispatchAction(JSON.stringify(plan.progressAction)); if (saveResult) { updateState(saveResult.state); if (saveResult.effects.length > 0) await pumpEffects(saveResult.effects, updateState); @@ -488,12 +464,14 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat const isCancelled = () => generation !== playGenerationRef.current; openSourcePickerOnFailureRef.current = openSourcePickerOnFailure; setPlayerUrl(null); - setPlayerUsesTorrent(streamIsP2P(stream)); - const currentStreamKey = streamKey(stream); + const streamPlan = await coreStreamShellPlan(stream); + const currentStreamKey = streamPlan?.identityKey ?? ''; + const candidatePlans = await Promise.all(playingSourceCandidatesRef.current.map(coreStreamShellPlan)); + setPlayerUsesTorrent(streamPlan?.isTorrent === true); if (sourceCandidates?.length) { playingSourceCandidatesRef.current = sourceCandidates; attemptedSourceKeysRef.current = new Set(); - } else if (!playingSourceCandidatesRef.current.some((candidate) => streamKey(candidate) === currentStreamKey)) { + } else if (!candidatePlans.some((candidate) => candidate?.identityKey === currentStreamKey)) { playingSourceCandidatesRef.current = [stream]; attemptedSourceKeysRef.current = new Set(); } @@ -512,7 +490,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat setPlayerPosterUrl(earlyArtwork.background ?? meta?.poster); setPlayerLogoUrl(earlyArtwork.logo ?? undefined); setPlayerMetaId(meta?.id); - setPlayerStreamHeaders(streamRequestHeaders(stream)); + setPlayerStreamHeaders(streamPlan?.requestHeaders); artworkPrefetchRef.current = prefetchPlayerArtwork(earlyArtwork.background, earlyArtwork.logo).catch(() => undefined); let loadingArtworkPromise = showPlayerLoading(generation, earlyTitle, earlyArtwork, stream); @@ -603,9 +581,20 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat await playerClearSkipInfo(); const skipPrefs = appPrefs(stateRef.current); - const skipThreshold = Number(prefString(skipPrefs, 'nextEpisodeThresholdPercent', '85')) || 85; - const skipAutoPlay = prefBool(skipPrefs, 'autoPlayNextEpisode', true); - const skipCountdown = Number(prefString(skipPrefs, 'autoPlayCountdownSecs', '7')) || 7; + const playbackPrefs = await coreInvoke<{ + nextEpisodeThresholdPercent: number; + autoPlayNextEpisode: boolean; + autoPlayCountdownSecs: number; + autoSkipIntro: boolean; + useIntroDb: boolean; + useAniSkip: boolean; + useAnimeSkip: boolean; + animeSkipClientId: string; + }>('playbackPreferencesPlan', JSON.stringify(skipPrefs)); + if (!playbackPrefs) throw new Error(); + const skipThreshold = playbackPrefs.nextEpisodeThresholdPercent; + const skipAutoPlay = playbackPrefs.autoPlayNextEpisode; + const skipCountdown = playbackPrefs.autoPlayCountdownSecs; const playableInitialNextEp = nextEp; await playerSetSkipInfo( '[]', @@ -613,7 +602,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat skipThreshold, skipAutoPlay, skipCountdown, - prefBool(skipPrefs, 'autoSkipIntro', false), + playbackPrefs.autoSkipIntro, ); void playerClearChapters(); const episodeList = meta?.videos ?? []; @@ -626,14 +615,14 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat ); debugLog(`handlePlay:anime detection confidence=${animeDetection.confidence} isAnime=${animeDetection.isAnime} reasons=${animeDetection.reasons.join(', ')}`); const skipSegmentsPromise = (async () => { - const useIntroDb = prefBool(skipPrefs, 'useIntroDb', true); - const useAniSkip = prefBool(skipPrefs, 'useAniSkip', true); - const useAnimeSkip = prefBool(skipPrefs, 'useAnimeSkip', false); + const useIntroDb = playbackPrefs.useIntroDb; + const useAniSkip = playbackPrefs.useAniSkip; + const useAnimeSkip = playbackPrefs.useAnimeSkip; if ((!useIntroDb && !useAniSkip && !useAnimeSkip) || !episode) return []; const imdbId = useIntroDb && meta?.id ? await corePlaybackIntroLookupContentId(meta.id) : ''; const season = episode.season ?? 1; const epNum = episode.episode ?? episode.number ?? 1; - return fetchPlaybackSkipSegments({ imdbId, season, episode: epNum, title: meta?.name ?? '', useIntroDb, useAniSkip, useAnimeSkip, animeSkipClientId: prefString(skipPrefs, 'animeSkipClientId', '') }); + return fetchPlaybackSkipSegments({ imdbId, season, episode: epNum, title: meta?.name ?? '', useIntroDb, useAniSkip, useAnimeSkip, animeSkipClientId: playbackPrefs.animeSkipClientId }); })(); void skipSegmentsPromise.then((segments) => { if (isCancelled() || segments.length === 0) return; @@ -643,7 +632,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat skipThreshold, skipAutoPlay, skipCountdown, - prefBool(skipPrefs, 'autoSkipIntro', false), + playbackPrefs.autoSkipIntro, ); }).catch(() => undefined); @@ -674,7 +663,8 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat if (playbackPlan?.mode === 'torrent') { const budget = await coreTorrentReadyBudget(); - const MAX_PEER_RETRIES = playingSourceCandidatesRef.current.some((candidate) => streamKey(candidate) !== currentStreamKey) + const retryCandidatePlans = await Promise.all(playingSourceCandidatesRef.current.map(coreStreamShellPlan)); + const MAX_PEER_RETRIES = retryCandidatePlans.some((candidate) => candidate?.identityKey !== currentStreamKey) ? budget.maxPeerRetriesWithAlternatives : budget.maxPeerRetriesSingleSource; const TORRENT_READY_FIRST_ATTEMPT_MS = budget.firstAttemptMs; @@ -759,7 +749,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat debugLog('handlePlay:calling playInEmbeddedMpv'); setLoadingStatus(t('player.status_loading_stream')); void pollMpvLoadingStatus(); - await playInEmbeddedMpv(generation, url, title, false, subtitlesPromise, loadingArtworkPromise, resumeAtSeconds, effectiveTotalDuration, streamRequestHeaders(stream), animeDetection.isAnime); + await playInEmbeddedMpv(generation, url, title, false, subtitlesPromise, loadingArtworkPromise, resumeAtSeconds, effectiveTotalDuration, streamPlan?.requestHeaders, animeDetection.isAnime); debugLog('handlePlay:playInEmbeddedMpv resolved'); } catch (err) { loadingStatusPollActive = false; @@ -769,7 +759,6 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat } } - // Background: fetch skip segments + prefetch next episode stream void (async () => { try { const prefs = appPrefs(stateRef.current); @@ -795,16 +784,13 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat const resolvedPlayableNextEp = resolvedNextEp; if (segmentResult.length === 0 && !resolvedPlayableNextEp) return; - const threshold = Number(prefString(prefs, 'nextEpisodeThresholdPercent', '85')) || 85; - const autoPlay = prefBool(prefs, 'autoPlayNextEpisode', true); - const countdown = Number(prefString(prefs, 'autoPlayCountdownSecs', '7')) || 7; await playerSetSkipInfo( JSON.stringify(segmentResult), resolvedPlayableNextEp ? formatNextEpisodeSubtitle(resolvedPlayableNextEp) : undefined, - threshold, - autoPlay, - countdown, - prefBool(prefs, 'autoSkipIntro', false), + playbackPrefs.nextEpisodeThresholdPercent, + playbackPrefs.autoPlayNextEpisode, + playbackPrefs.autoPlayCountdownSecs, + playbackPrefs.autoSkipIntro, ); if (resolvedPlayableNextEp && await coreCanPrefetchNextEpisode(JSON.stringify(prefs), JSON.stringify(stream))) { diff --git a/src/hooks/useSeasonWatched.ts b/src/hooks/useSeasonWatched.ts index 8b20ab2..69b4193 100644 --- a/src/hooks/useSeasonWatched.ts +++ b/src/hooks/useSeasonWatched.ts @@ -1,4 +1,5 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { coreInvoke } from '../core/engine'; import type { Meta, Video } from '../core/types'; export function useSeasonWatched({ @@ -16,42 +17,32 @@ export function useSeasonWatched({ watchedMap: Record; onDispatch: (actionJson: string) => void; }) { - const seasonWatchedMap = useMemo(() => { - const map: Record = {}; - for (const season of seasonNumbers) { - const sEps = episodes.filter((ep) => (ep.season ?? 1) === season); - if (sEps.length > 0) map[season] = sEps.every((ep) => watchedMap[ep.id] === true); - } - return map; + const [seasonWatchedMap, setSeasonWatchedMap] = useState>({}); + useEffect(() => { + let active = true; + void coreInvoke>('seasonWatchedPlan', JSON.stringify({ episodes, seasonNumbers, watchedMap })) + .then((plan) => { if (active) setSeasonWatchedMap(plan ?? {}); }); + return () => { active = false; }; }, [seasonNumbers, episodes, watchedMap]); const dispatchMarkSeason = useCallback((seasons: number[], watched: boolean) => { - const now = Date.now(); - const allEps = episodes.filter((ep) => { - if (!seasons.includes(ep.season ?? 1)) return false; - if (watched && ep.released && new Date(ep.released).getTime() > now) return false; - return true; - }); - if (allEps.length === 0) return; - onDispatch(JSON.stringify({ - type: 'markWatchedRequested', - seriesId: meta.id, - videoIds: allEps.map((ep) => ep.id), + void coreInvoke>('markSeasonsActionPlan', JSON.stringify({ + episodes, + seasons, watched, meta: { id: meta.id, name: displayMeta.name, type: meta.type }, - episodes: allEps.map((ep) => ({ id: ep.id, name: ep.name ?? ep.title, season: ep.season, number: ep.episode ?? ep.number, thumbnail: ep.thumbnail })), - })); + nowMs: Date.now(), + })).then((action) => { if (action) onDispatch(JSON.stringify(action)); }); }, [episodes, meta.id, displayMeta.name, meta.type, onDispatch]); const toggleEpisodeWatched = useCallback((ep: Video, currentlyWatched: boolean) => { - onDispatch(JSON.stringify({ - type: 'markWatchedRequested', - seriesId: meta.id, - videoIds: [ep.id], + void coreInvoke>('markSeasonsActionPlan', JSON.stringify({ + episodes: [ep], + seasons: [ep.season ?? 1], watched: !currentlyWatched, meta: { id: meta.id, name: displayMeta.name, type: meta.type }, - episodes: [{ id: ep.id, name: ep.name ?? ep.title, season: ep.season, number: ep.episode ?? ep.number, thumbnail: ep.thumbnail }], - })); + nowMs: Date.now(), + })).then((action) => { if (action) onDispatch(JSON.stringify(action)); }); }, [meta.id, displayMeta.name, meta.type, onDispatch]); return { seasonWatchedMap, dispatchMarkSeason, toggleEpisodeWatched }; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 5e61cff..44d0273 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Bell, ChevronLeft, ChevronRight, Eye, EyeOff } from 'lucide-react'; import type { AppState, LibraryItem } from '../core/types'; import { refreshExternalCalendarItems, refreshWatchlistAirDates } from '../core/libraryEffects'; +import { coreInvoke } from '../core/engine'; import { t } from '../i18n'; const NAV_RAIL_WIDTH = 6.5; @@ -77,12 +78,13 @@ export const CalendarScreen = React.memo(function CalendarScreen({ state, onDisp [calendarState.items, calendarState.localItems, calendarState.externalItems], ); const completedItems = (state.library.lastWrite?.completed ?? state.library.completed ?? []) as LibraryItem[]; - const completedIds = useMemo(() => new Set(completedItems.map((item) => item.id)), [completedItems]); - const completedNames = useMemo(() => new Set(completedItems.map((item) => item.name.toLowerCase())), [completedItems]); - const visibleItems = useMemo( - () => showCompleted ? items : items.filter((item) => !isCompletedCalendarItem(item, completedIds, completedNames)), - [items, showCompleted, completedIds, completedNames], - ); + const [visibleItems, setVisibleItems] = useState([]); + useEffect(() => { + let active = true; + void coreInvoke('calendarVisibilityPlan', JSON.stringify({ items, completedItems, showCompleted })) + .then((plan) => { if (active) setVisibleItems(plan ?? []); }); + return () => { active = false; }; + }, [items, completedItems, showCompleted]); const itemsByDate = useMemo(() => groupItemsByDate(visibleItems), [visibleItems]); const cells = useMemo(() => buildMonthCells(monthStart), [monthStart]); const selectedDayItems = selectedDateIso ? (itemsByDate[selectedDateIso] ?? []) : []; @@ -271,14 +273,6 @@ function todayIso(): string { return localDateKey(new Date()); } -function isCompletedCalendarItem(item: CalendarItem, completedIds: Set, completedNames: Set): boolean { - const ids = [item.contentId, item.seriesId, item.id].filter((id): id is string => !!id); - if (ids.some((id) => completedIds.has(id))) return true; - if (ids.some((id) => [...completedIds].some((completedId) => id === completedId || id.startsWith(`${completedId}:`)))) return true; - const name = (item.title ?? item.name ?? '').toLowerCase(); - return !!name && completedNames.has(name); -} - function EventBadge({ item }: { item: CalendarItem }) { const date = item.dateIso ? localDateKeyFromIso(item.dateIso) : undefined; if (!date) return null; diff --git a/src/screens/DetailScreen.tsx b/src/screens/DetailScreen.tsx index 3c191a6..a2068a0 100644 --- a/src/screens/DetailScreen.tsx +++ b/src/screens/DetailScreen.tsx @@ -53,26 +53,6 @@ interface Props { playbackFailure?: string | null; } -function orderStreamsByPrefs(streams: Stream[], prefs: Record): Stream[] { - const mode = prefString(prefs, 'streamSourceSelectionMode', 'manual'); - if (mode === 'regex') { - const pattern = prefString(prefs, 'streamSourceRegexPattern'); - if (!pattern) return streams; - try { - const regex = new RegExp(pattern, 'i'); - return [...streams].sort((a, b) => Number(regex.test(streamText(b))) - Number(regex.test(streamText(a)))); - } catch { - return streams; - } - } - return streams; -} - -function streamText(stream: Stream): string { - return [stream.name, stream.title, stream.description, stream.url, stream.playableUrl, stream.infoHash].filter(Boolean).join(' '); -} - - export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail, onNavigateGenre, onBack, initialEpisode, autoShowStreams, playbackFailure }: Props) { const detail = state.detail; const [bgError, setBgError] = useState(false); @@ -160,10 +140,13 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail onDispatch(JSON.stringify({ type: 'detailStreamsRequested', contentType: meta.type, requestIds: [meta.id], language: getLanguage() })); }, [detail.meta, meta.id, meta.type, detail.isLoadingStreams, detail.streams?.length]); - const streams = useMemo( - () => orderStreamsByPrefs((detail.streams ?? []) as Stream[], prefs), - [detail.streams, prefs], - ); + const [streams, setStreams] = useState([]); + useEffect(() => { + let active = true; + void coreInvoke('orderStreamsPlan', JSON.stringify({ streams: detail.streams ?? [], prefs })) + .then((plan) => { if (active) setStreams(plan ?? (detail.streams ?? []) as Stream[]); }); + return () => { active = false; }; + }, [detail.streams, prefs]); const poster = useMemo(() => posterPrefsFromState(state), [state.settings?.values]); useEffect(() => { @@ -185,22 +168,10 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail const fanartArtwork = detail.fanartArtwork; const metaEpisodes = displayMeta.videos ?? []; const episodes = useMemo(() => metaEpisodes, [metaEpisodes]); - const fallbackSeasonNumbers = useMemo( - () => isSeries ? [...new Set(episodes.map((e) => e.season ?? 1))].sort((a, b) => a - b) : [], - [isSeries, episodes], - ); - const fallbackFilteredEps = useMemo( - () => episodes.filter((e) => (e.season ?? 1) === selectedSeason), - [episodes, selectedSeason], - ); - const seasonNumbers = useMemo( - () => [...new Set([...(episodePlan?.seasonNumbers ?? []), ...fallbackSeasonNumbers, selectedSeason])].sort((a, b) => a - b), - [episodePlan?.seasonNumbers, fallbackSeasonNumbers, selectedSeason], - ); + const seasonNumbers = episodePlan?.seasonNumbers ?? []; const filteredEps = useMemo(() => { const plannedSeasonMatches = episodePlan?.selectedSeason == null || episodePlan?.selectedSeason === selectedSeason; - const planned = plannedSeasonMatches && episodePlan?.episodes?.length ? episodePlan.episodes : null; - const computed = planned ?? fallbackFilteredEps; + const computed = plannedSeasonMatches ? (episodePlan?.episodes ?? []) : []; if (computed.length > 0) { prevFilteredEpsRef.current = { metaId: meta.id, season: selectedSeason, episodes: computed }; return computed; @@ -208,7 +179,7 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail const prev = prevFilteredEpsRef.current; if (prev.metaId === meta.id && prev.season === selectedSeason && prev.episodes.length > 0) return prev.episodes; return computed; - }, [episodePlan, selectedSeason, fallbackFilteredEps, meta.id]); + }, [episodePlan, selectedSeason, meta.id]); const { seasonWatchedMap, dispatchMarkSeason, toggleEpisodeWatched } = useSeasonWatched({ meta, diff --git a/src/screens/DiscoverScreen.race.test.tsx b/src/screens/DiscoverScreen.race.test.tsx index 56af132..92ee386 100644 --- a/src/screens/DiscoverScreen.race.test.tsx +++ b/src/screens/DiscoverScreen.race.test.tsx @@ -1,10 +1,31 @@ import React, { useState } from 'react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DiscoverScreen } from './DiscoverScreen'; import type { AppState, DiscoverCatalog } from '../core/types'; +vi.mock('../core/engine', () => ({ + coreInvoke: async (method: string, argsJson: string) => { + const args = JSON.parse(argsJson); + if (method === 'discoverContentTypes') return ['movie', 'series']; + if (method === 'discoverSelectionPlan') { + const catalogs = args.catalogs.filter((catalog: DiscoverCatalog) => catalog.type === args.contentType); + const selectedCatalog = catalogs.find((catalog: DiscoverCatalog) => catalog.key === args.selectedCatalogKey) ?? catalogs[0] ?? null; + return { + catalogs, + selectedCatalogKey: selectedCatalog?.key ?? null, + selectedCatalog, + selectedExtra: null, + extraValue: null, + key: `${selectedCatalog?.key ?? ''}||`, + }; + } + if (method === 'mergeDiscoverPages') return { items: args.baseItems, appendedItems: [], exhausted: true }; + return null; + }, +})); + const MOVIE_CATALOGS: DiscoverCatalog[] = [ { key: 'cinemeta-movie-top', label: 'Cinemeta: Popular', type: 'movie', extras: [] }, ]; diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index ef6ab5a..2caa731 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -6,6 +6,7 @@ import { getLanguage, t } from '../i18n'; import { FilterDropdown } from '../components/FilterDropdown'; import { DiscoverDetailPanel } from '../components/DiscoverDetailPanel'; import { VirtualizedPosterGrid } from '../components/VirtualizedPosterGrid'; +import { coreInvoke } from '../core/engine'; interface Props { state: AppState; @@ -32,17 +33,6 @@ interface DiscoverCatalog { }>; } -function cacheKey(catalogKey: string | null, extraName: string | null, extraValue: string | null): string { - return `${catalogKey ?? ''}|${extraName ?? ''}|${extraValue ?? ''}`; -} - -function isSearchOnlyCatalog(cat: { extra?: Array<{ name?: string; isRequired?: boolean; options?: string[] }> }): boolean { - const extra = cat.extra ?? []; - const requiresSearch = extra.some((e) => e.name === 'search' && e.isRequired); - if (!requiresSearch) return false; - return !extra.some((e) => e.name !== 'search' && e.name !== 'skip' && (e.options?.length ?? 0) > 0); -} - function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre }: Props) { const discover = state.discover; const [contentType, setContentType] = useState('movie'); @@ -53,10 +43,18 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre const isGridScrollingRef = useRef(false); const scrollIdleTimerRef = useRef(null); const hoveredMetaRef = useRef(null); - const catalogs = ((discover.catalogs ?? []) as DiscoverCatalog[]).filter((catalog) => catalog.type === contentType); - const selectedCatalog = catalogs.find((catalog) => catalog.key === selectedCatalogKey) ?? null; - const selectedExtra = selectedCatalog?.extras?.[0] ?? null; - const key = cacheKey(selectedCatalog?.key ?? null, selectedExtra?.name ?? null, extraValue); + const [selectionPlan, setSelectionPlan] = useState<{ + catalogs: DiscoverCatalog[]; + selectedCatalogKey: string | null; + selectedCatalog: DiscoverCatalog | null; + selectedExtra: NonNullable[number] | null; + extraValue: string | null; + key: string; + }>({ catalogs: [], selectedCatalogKey: null, selectedCatalog: null, selectedExtra: null, extraValue: null, key: '||' }); + const catalogs = selectionPlan.catalogs; + const selectedCatalog = selectionPlan.selectedCatalog; + const selectedExtra = selectionPlan.selectedExtra; + const key = selectionPlan.key; const cachedResults = discoverResultsCache.get(key) ?? null; const lastDispatchedKeyRef = useRef(null); const posterPrefs = useMemo(() => posterPrefsFromState(state), [state.settings?.values]); @@ -70,13 +68,17 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre }, [contentType]); useEffect(() => { - if (!selectedCatalogKey && catalogs.length > 0) setSelectedCatalogKey(catalogs[0].key); - }, [catalogs, selectedCatalogKey]); - - useEffect(() => { - if (!selectedCatalog) return; - if (extraValue && !selectedExtra?.options.includes(extraValue)) setExtraValue(null); - }, [selectedCatalog, selectedExtra, extraValue]); + let active = true; + void coreInvoke('discoverSelectionPlan', JSON.stringify({ + catalogs: discover.catalogs ?? [], contentType, selectedCatalogKey, extraValue, + })).then((plan) => { + if (!active || !plan) return; + setSelectionPlan(plan); + if (plan.selectedCatalogKey !== selectedCatalogKey) setSelectedCatalogKey(plan.selectedCatalogKey); + if (plan.extraValue !== extraValue) setExtraValue(plan.extraValue); + }); + return () => { active = false; }; + }, [discover.catalogs, contentType, selectedCatalogKey, extraValue]); useEffect(() => { if (!selectedCatalog || discoverResultsCache.has(key)) return; @@ -115,15 +117,16 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre pendingPagingKeyRef.current = null; }, [key]); - const displayResults = useMemo(() => { - const seen = new Set(); - const merged: Meta[] = []; - for (const item of [...baseResults, ...(pagingExtra[key] ?? [])]) { - if (seen.has(item.id)) continue; - seen.add(item.id); - merged.push(item); - } - return merged; + const [displayResults, setDisplayResults] = useState([]); + useEffect(() => { + let active = true; + void coreInvoke<{ items: Meta[] }>('mergeDiscoverPages', JSON.stringify({ + baseItems: baseResults, + existingItems: pagingExtra[key] ?? [], + incomingItems: [], + })).then((plan) => { if (active) setDisplayResults(plan?.items ?? []); }) + .catch(() => { if (active) setDisplayResults(baseResults); }); + return () => { active = false; }; }, [baseResults, pagingExtra, key]); const handleLoadMore = useCallback(() => { @@ -146,35 +149,37 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre if (!paging || !pendingKey || paging.isLoading) return; pendingPagingKeyRef.current = null; const items = Array.isArray(paging.items) ? paging.items : []; - if (paging.error || items.length === 0) { + if (paging.error) { pagingNoMoreRef.current.add(pendingKey); return; } - setPagingExtra((prev) => { - const existing = prev[pendingKey] ?? []; - const knownIds = new Set([...baseResults.map((m) => m.id), ...existing.map((m) => m.id)]); - const newItems = items.filter((item) => !knownIds.has(item.id)); - if (newItems.length === 0) { - pagingNoMoreRef.current.add(pendingKey); - return prev; - } - return { ...prev, [pendingKey]: [...existing, ...newItems] }; - }); + void (async () => { + const existing = pagingExtra[pendingKey] ?? []; + const plan = await coreInvoke<{ appendedItems: Meta[]; exhausted: boolean }>('mergeDiscoverPages', JSON.stringify({ + baseItems: baseResults, + existingItems: existing, + incomingItems: items, + })); + if (!plan || plan.exhausted) pagingNoMoreRef.current.add(pendingKey); + if (!plan?.appendedItems.length) return; + setPagingExtra((prev) => ({ + ...prev, + [pendingKey]: [...(prev[pendingKey] ?? []), ...plan.appendedItems], + })); + })().catch(() => pagingNoMoreRef.current.add(pendingKey)); }, [discover.paging, baseResults]); + const [contentTypes, setContentTypes] = useState(['movie', 'series']); + useEffect(() => { + void coreInvoke('discoverContentTypes', JSON.stringify(state.addons?.installed ?? [])) + .then((types) => { if (types) setContentTypes(types); }); + }, [state.addons?.installed]); const typeOptions = useMemo(() => { - const types = ['movie', 'series']; - for (const addon of state.addons?.installed ?? []) { - for (const cat of addon.manifest?.catalogs ?? addon.catalogs ?? []) { - if (!cat.type || types.includes(cat.type) || isSearchOnlyCatalog(cat)) continue; - types.push(cat.type); - } - } - return types.map((ty) => ({ + return contentTypes.map((ty) => ({ value: ty, label: ty === 'movie' ? t('auto.movies') : ty === 'series' ? t('auto.series') : ty.charAt(0).toUpperCase() + ty.slice(1), })); - }, [state.addons?.installed]); + }, [contentTypes]); const handleGridScroll = useCallback(() => { isGridScrollingRef.current = true; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index ab0eea2..1f07a61 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -9,15 +9,14 @@ import { CollectionShelfRow } from '../components/CollectionShelfRow'; import { posterPrefsFromState } from '../core/posterPrefs'; import { appPrefs, prefBool, prefString } from '../core/appPrefs'; import { buildResourceUrl } from '../core/addonManifest'; -import { httpFetchText } from '../core/engine'; +import { coreInvoke, httpFetchText } from '../core/engine'; import { prewarmYoutubeTrailerConfig } from '../core/effectRunner'; import { fetchTmdbTrailers } from '../core/detailEffects'; -import { youtubeVideoId } from '../components/detail/TrailerCarousel'; import type { AppState, HomeCategory, Meta, NuvioRemoteCollectionSource, Trailer } from '../core/types'; import { getLanguage, t } from '../i18n'; import { useInViewport } from '../hooks/useInViewport'; -import { isNuvioCollectionSource, loadNuvioCollectionSource } from '../core/collectionSources'; -import { fetchBuiltinCatalog, isBuiltinTmdbAddon } from '../core/tmdbAddon'; +import { loadNuvioCollectionSource } from '../core/collectionSources'; +import { fetchBuiltinCatalog } from '../core/tmdbAddon'; import { loadPrefs } from '../core/libraryOps'; const ROW_PLACEHOLDER_HEIGHT = 340; @@ -63,97 +62,50 @@ type FolderSourceBatch = { type: string; items: Meta[] }; type AddonFolderSource = { transportUrl: string; catalogId: string; type: string; genre?: string }; type FolderSource = AddonFolderSource | NuvioRemoteCollectionSource; -// A source whose page overlaps entirely with what we've already seen from it isn't -// necessarily exhausted (addons can reshuffle/resort between requests) — tolerate a -// few consecutive duplicate-only pages, advancing skip through them, before giving up. -const DUPLICATE_FOLDER_PAGE_LIMIT = 3; - interface FolderSourceState { skip: number; exhausted: boolean; duplicateStreak: number; items: Meta[]; - seen: Set; -} - -function folderItemKey(item: Meta): string { - return `${item.type}:${item.id}`; } function initFolderSourceState(): FolderSourceState { - return { skip: 0, exhausted: false, duplicateStreak: 0, items: [], seen: new Set() }; -} - -function applyFolderPage(state: FolderSourceState, batch: FolderSourceBatch): FolderSourceState { - if (batch.items.length === 0) { - return { ...state, exhausted: true }; - } - const newItems = batch.items.filter((item) => !state.seen.has(folderItemKey(item))); - const seen = state.seen; - for (const item of newItems) seen.add(folderItemKey(item)); - const skip = state.skip + batch.items.length; - if (newItems.length === 0) { - const duplicateStreak = state.duplicateStreak + 1; - return { ...state, skip, duplicateStreak, exhausted: duplicateStreak >= DUPLICATE_FOLDER_PAGE_LIMIT }; - } - return { ...state, skip, duplicateStreak: 0, items: [...state.items, ...newItems] }; -} - -// Interleaves each source's own items round-robin (rather than "all of source A, then -// all of source B") so multi-source folders present a balanced blend, deduping globally -// across sources as it merges. -function mergeFolderSources(perSourceItems: Meta[][]): FolderItemsResult { - const seen = new Set(); - const items: Meta[] = []; - const iterators = perSourceItems.map((list) => list[Symbol.iterator]()); - const active = iterators.map(() => true); - while (active.some(Boolean)) { - iterators.forEach((it, i) => { - if (!active[i]) return; - const next = it.next(); - if (next.done) { active[i] = false; return; } - const key = folderItemKey(next.value); - if (seen.has(key)) return; - seen.add(key); - items.push(next.value); - }); - } - const groupsByType = new Map(); - for (const item of items) { - const list = groupsByType.get(item.type); - if (list) list.push(item); else groupsByType.set(item.type, [item]); - } - return { items, groups: Array.from(groupsByType, ([type, groupItems]) => ({ type, items: groupItems })) }; + return { skip: 0, exhausted: false, duplicateStreak: 0, items: [] }; } async function loadFolderSourcePage( source: FolderSource, skip: number, ): Promise { - if (isNuvioCollectionSource(source)) { - const type = source.mediaType?.toUpperCase() === 'TV' ? 'series' : 'movie'; - return { type, items: await loadNuvioCollectionSource(source, Math.floor(skip / 50) + 1) }; + const plan = await coreInvoke<{ + kind: 'remote' | 'builtinTmdb' | 'addon'; + type: string; + page?: number; + transportUrl?: string; + catalogId?: string; + extra?: Record; + }>('folderSourcePagePlan', JSON.stringify({ source, skip })); + if (!plan) return { type: 'movie', items: [] }; + if (plan.kind === 'remote') { + return { type: plan.type, items: await loadNuvioCollectionSource(source as NuvioRemoteCollectionSource, plan.page) }; } - const extra: Record = {}; - if (source.genre) extra.genre = source.genre; - if (skip > 0) extra.skip = skip; - if (isBuiltinTmdbAddon(source.transportUrl)) { + if (plan.kind === 'builtinTmdb') { const prefs = await loadPrefs(); - const { metas } = await fetchBuiltinCatalog(source.type, extra, String(prefs.tmdbApiKey ?? ''), getLanguage()); - return { type: source.type, items: metas as Meta[] }; + const { metas } = await fetchBuiltinCatalog(plan.type, plan.extra ?? {}, String(prefs.tmdbApiKey ?? ''), getLanguage()); + return { type: plan.type, items: metas as Meta[] }; } - const extraJson = Object.keys(extra).length ? JSON.stringify(extra) : undefined; - const url = await buildResourceUrl(source.transportUrl, 'catalog', source.type, source.catalogId, extraJson); + const extraJson = Object.keys(plan.extra ?? {}).length ? JSON.stringify(plan.extra) : undefined; + const url = await buildResourceUrl(plan.transportUrl!, 'catalog', plan.type, plan.catalogId!, extraJson); try { const res = await httpFetchText(url); if (res.statusCode === 200) { const data = JSON.parse(res.body) as { metas?: unknown }; - return { type: source.type, items: Array.isArray(data?.metas) ? data.metas as Meta[] : [] }; + return { type: plan.type, items: Array.isArray(data?.metas) ? data.metas as Meta[] : [] }; } } catch { /* skip failed source */ } - return { type: source.type, items: [] as Meta[] }; + return { type: plan.type, items: [] as Meta[] }; } export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, onNavigateDetail, onPlay, onResume, onStartOver, onPlayManually, onOpenSettings, isActive, onScrolledChange, resetKey }: Props) { @@ -255,8 +207,10 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on setFolderPaginated(true); try { const batches = await Promise.all(sources.map((source) => loadFolderSourcePage(source, 0))); - folderSourceStatesRef.current = folderSourceStatesRef.current.map((s, i) => applyFolderPage(s, batches[i])); - const { items, groups } = mergeFolderSources(folderSourceStatesRef.current.map((s) => s.items)); + folderSourceStatesRef.current = await Promise.all(folderSourceStatesRef.current.map(async (state, index) => + (await coreInvoke('folderPageState', JSON.stringify({ state, batch: batches[index] }))) ?? state + )); + const { items, groups } = (await coreInvoke('mergeFolderSources', JSON.stringify(folderSourceStatesRef.current.map((state) => state.items)))) ?? { items: [], groups: [] }; setViewAllCategory({ title: folderMeta.name, items, groups }); setFolderError(items.length === 0); } finally { @@ -271,13 +225,16 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on if (states.every((s) => s.exhausted)) return; setFolderLoadingMore(true); try { - const batches = await Promise.all(sources.map((source, i) => ( - states[i].exhausted - ? Promise.resolve({ type: isNuvioCollectionSource(source) && source.mediaType?.toUpperCase() === 'TV' ? 'series' : ('type' in source ? source.type : 'movie'), items: [] }) - : loadFolderSourcePage(source, states[i].skip) - ))); - folderSourceStatesRef.current = states.map((s, i) => (s.exhausted ? s : applyFolderPage(s, batches[i]))); - const { items, groups } = mergeFolderSources(folderSourceStatesRef.current.map((s) => s.items)); + const batches = await Promise.all(sources.map(async (source, i) => { + if (!states[i].exhausted) return loadFolderSourcePage(source, states[i].skip); + const plan = await coreInvoke<{ type: string }>('folderSourcePagePlan', JSON.stringify({ source, skip: states[i].skip })); + return { type: plan?.type ?? 'movie', items: [] }; + })); + folderSourceStatesRef.current = await Promise.all(states.map(async (state, index) => state.exhausted + ? state + : (await coreInvoke('folderPageState', JSON.stringify({ state, batch: batches[index] }))) ?? state + )); + const { items, groups } = (await coreInvoke('mergeFolderSources', JSON.stringify(folderSourceStatesRef.current.map((state) => state.items)))) ?? { items: [], groups: [] }; setViewAllCategory((prev) => (prev ? { ...prev, items, groups } : prev)); } finally { setFolderLoadingMore(false); @@ -301,32 +258,35 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on }, [home.isStale, onDispatch]); const continueWatching = useMemo(() => (home.continueWatching ?? []) as Meta[], [home.continueWatching]); - const categories = useMemo( - () => (home.categories ?? []).map((c) => (Array.isArray(c.items) ? c : { ...c, items: [] })), - [home.categories], - ); - const contentCategories = useMemo( - () => categories.filter((c) => c.type !== 'collection' && c.type !== 'collection_folder'), - [categories], - ); + const posterPrefs = useMemo(() => posterPrefsFromState(state), [state.settings?.values]); + const prefs = useMemo(() => appPrefs(state), [state.settings?.values]); + const [heroTrailers, setHeroTrailers] = useState>({}); + const [fetchedHeroTrailerIds, setFetchedHeroTrailerIds] = useState([]); + const [homePlan, setHomePlan] = useState<{ + categories: HomeCategory[]; + billboard: Meta | null; + slides: Meta[]; + trailerTargets: Meta[]; + showHero: boolean; + autoplayTrailer: boolean; + }>({ categories: [], billboard: null, slides: [], trailerTargets: [], showHero: true, autoplayTrailer: false }); + useEffect(() => { + let active = true; + void coreInvoke('homeHeroPlan', JSON.stringify({ + categories: home.categories ?? [], billboard: home.billboard ?? null, prefs, + fetchedTrailers: heroTrailers, fetchedIds: fetchedHeroTrailerIds, + })).then((plan) => { if (active && plan) setHomePlan(plan); }); + return () => { active = false; }; + }, [home.categories, home.billboard, prefs, heroTrailers, fetchedHeroTrailerIds]); + const categories = homePlan.categories; const nearEndCallbacks = useMemo(() => { const map = new Map void>(); for (const cat of categories) map.set(cat.id, () => handleLoadMoreCategory(cat)); return map; }, [categories, handleLoadMoreCategory]); - const billboard = useMemo( - () => home.billboard ?? contentCategories[0]?.items?.[0] ?? null, - [home.billboard, contentCategories], - ); - const heroSlides = useMemo( - () => buildHeroSlides(billboard, contentCategories.flatMap((c) => c.items)), - [billboard, contentCategories], - ); - const posterPrefs = useMemo(() => posterPrefsFromState(state), [state.settings?.values]); - const prefs = useMemo(() => appPrefs(state), [state.settings?.values]); - const [heroTrailers, setHeroTrailers] = useState>({}); - const fetchedHeroTrailerIds = useRef>(new Set()); - const autoplayTrailerEnabled = prefBool(prefs, 'homeHeroAutoplayTrailer', false); + const billboard = homePlan.billboard; + const heroSlides = homePlan.slides; + const autoplayTrailerEnabled = homePlan.autoplayTrailer; useEffect(() => { if (!autoplayTrailerEnabled) return; @@ -335,12 +295,9 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on useEffect(() => { const apiKey = prefString(prefs, 'tmdbApiKey'); - if (!autoplayTrailerEnabled || !prefBool(prefs, 'tmdbTrailersEnabled', true) || !apiKey) return; - const targets = [billboard, ...heroSlides].filter( - (item): item is Meta => !!item && !hasPlayableTrailer(item) && !fetchedHeroTrailerIds.current.has(item.id), - ); + const targets = homePlan.trailerTargets; if (!targets.length) return; - targets.forEach((item) => fetchedHeroTrailerIds.current.add(item.id)); + setFetchedHeroTrailerIds((current) => Array.from(new Set([...current, ...targets.map((item) => item.id)]))); let cancelled = false; const language = getLanguage(); Promise.all(targets.map(async (item) => { @@ -353,16 +310,10 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on setHeroTrailers((prev) => ({ ...prev, ...Object.fromEntries(found) })); }).catch((err) => console.error('hero trailer fetch failed', err)); return () => { cancelled = true; }; - }, [billboard, heroSlides, autoplayTrailerEnabled, prefs]); + }, [homePlan.trailerTargets, prefs]); - const billboardWithTrailer = useMemo( - () => withHeroTrailer(billboard, heroTrailers), - [billboard, heroTrailers], - ); - const heroSlidesWithTrailers = useMemo( - () => heroSlides.map((item) => withHeroTrailer(item, heroTrailers)), - [heroSlides, heroTrailers], - ); + const billboardWithTrailer = billboard; + const heroSlidesWithTrailers = heroSlides; const addonIconByName = useMemo(() => { const map = new Map(); for (const addon of state.addons.installed ?? []) { @@ -370,7 +321,7 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on } return map; }, [state.addons.installed]); - const showHero = prefBool(prefs, 'showHeroSection', true); + const showHero = homePlan.showHero; const showContinueWatching = prefBool(prefs, 'continueWatchingEnabled', true); const gifAutoplayEnabled = prefBool(prefs, 'gifAutoplayEnabled', false); const topTenFeedKeys = useMemo(() => { @@ -540,28 +491,6 @@ function formatCatalogTitle(name: string, type: string): string { return `${name} - ${label}`; } -function hasPlayableTrailer(item: Meta): boolean { - return (item.trailers ?? []).some((trailer) => !!youtubeVideoId(trailer.url)); -} - -function withHeroTrailer(item: T, trailers: Record): T { - if (!item || hasPlayableTrailer(item) || !trailers[item.id]) return item; - return { ...item, trailers: trailers[item.id] }; -} - -function buildHeroSlides(billboard: Meta | null, items: Meta[]): Meta[] { - const seen = new Set(); - return [billboard, ...items] - .filter((item): item is Meta => !!item && !!(item.background || item.poster)) - .filter((item) => { - const key = item.id || item.name; - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .slice(0, 8); -} - function LoadingSkeleton() { const box: React.CSSProperties = { background: '#12161D', borderRadius: '0.625rem', animation: 'pulse 1.6s ease-in-out infinite' }; return ( diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 4e76727..b5b0068 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -4,17 +4,18 @@ import { VirtualizedPosterGrid } from '../components/VirtualizedPosterGrid'; import { FilterDropdown } from '../components/FilterDropdown'; import { posterPrefsFromState } from '../core/posterPrefs'; import { appPrefs, prefString } from '../core/appPrefs'; -import { effectiveCatalogId, effectiveCatalogType, exportCollectionsJson, importCollectionsJson } from '../core/collections'; +import { exportCollectionsJson, importCollectionsJson } from '../core/collections'; import { getViewPrefs, setViewPref, whenViewPrefsReady } from '../core/viewPrefs'; import { saveProfile } from '../core/profiles'; import { nuvioPushCollections } from '../core/nuvioApi'; import { freshNuvioProfile } from '../core/nuvioSync'; -import type { AppState, CatalogSource, HomeCategory, LibraryItem, Meta, UserCollection, UserCollectionFolder, UserProfile } from '../core/types'; +import type { AppState, HomeCategory, LibraryItem, Meta, NuvioRemoteCollectionSource, UserCollection, UserCollectionFolder, UserProfile } from '../core/types'; import { t } from '../i18n'; import { CategoryGridScreen } from './CategoryGridScreen'; import { CollectionEditorScreen } from './CollectionEditorScreen'; import { CollectionsTab } from '../components/library/CollectionsTab'; -import { isNuvioCollectionSource, loadNuvioCollectionSource } from '../core/collectionSources'; +import { loadNuvioCollectionSource } from '../core/collectionSources'; +import { coreInvoke } from '../core/engine'; type Tab = 'watchlist' | 'watching' | 'completed' | 'dropped' | 'collections' | 'airing' | 'rated' | 'history'; @@ -89,15 +90,6 @@ export const LibraryScreen = React.memo(function LibraryScreen({ const watching = (library.lastWrite?.continueWatching ?? library.continueWatching ?? []) as LibraryItem[]; const rawCompleted = (library.lastWrite?.completed ?? library.completed ?? []) as LibraryItem[]; const rawDropped = (library.lastWrite?.dropped ?? library.dropped ?? []) as LibraryItem[]; - const progressItems = Object.values((library.lastWrite?.progress ?? {}) as Record); - const completed = useMemo( - () => [...rawCompleted].sort((a, b) => (b.statusChangedAt ?? '').localeCompare(a.statusChangedAt ?? '')), - [rawCompleted] - ); - const dropped = useMemo( - () => [...rawDropped].sort((a, b) => (b.statusChangedAt ?? '').localeCompare(a.statusChangedAt ?? '')), - [rawDropped] - ); const posterPrefs = useMemo(() => posterPrefsFromState(state), [state.settings?.values]); const prefs = useMemo(() => appPrefs(state), [state.settings?.values]); const accent = prefString(prefs, 'accentColorArgb', '#FFFFFF'); @@ -105,52 +97,19 @@ export const LibraryScreen = React.memo(function LibraryScreen({ const collections: UserCollection[] = activeProfile?.libraryCollections ?? []; const homeCategories: HomeCategory[] = state.home.categories ?? []; - function getItemsForFolder(folder: UserCollectionFolder): { items: Meta[]; groups: Array<{ type: string; items: Meta[] }> } { - const modernAddonSources: CatalogSource[] = (folder.sources ?? []) - .filter((source) => source.provider === 'addon') - .map((source) => ({ - addonId: source.addonId, - catalogId: source.catalogId, - type: source.type, - genre: source.genre, - })); - const sources = modernAddonSources.length - ? modernAddonSources - : folder.catalogSources?.length - ? folder.catalogSources - : effectiveCatalogId(folder) - ? [{ catalogId: effectiveCatalogId(folder)!, type: effectiveCatalogType(folder) ?? '' }] - : []; - const groupsByType = new Map(); - for (const source of sources) { - const cat = homeCategories.find((c) => c.id === source.catalogId || c.catalogId === source.catalogId); - if (!cat) continue; - const genre = source.genre ?? folder.genre; - const items = genre - ? cat.items.filter((m) => m.genres?.some((g) => g.toLowerCase() === genre.toLowerCase())) - : cat.items; - const existing = groupsByType.get(source.type); - if (existing) existing.push(...items); - else groupsByType.set(source.type, [...items]); - } - const groups = Array.from(groupsByType, ([type, items]) => ({ type, items })); - return { items: groups.flatMap((g) => g.items), groups }; + async function getItemsForFolder(folder: UserCollectionFolder): Promise<{ items: Meta[]; groups: Array<{ type: string; items: Meta[] }>; remoteSources: NuvioRemoteCollectionSource[] }> { + return ((await coreInvoke('collectionFolderItemsPlan', JSON.stringify({ folder, categories: homeCategories }))) as { items: Meta[]; groups: Array<{ type: string; items: Meta[] }>; remoteSources: NuvioRemoteCollectionSource[] } | null) ?? { items: [], groups: [], remoteSources: [] }; } async function openFolder(folder: UserCollectionFolder, title: string) { savedScrollRef.current = collectionsScrollRef.current?.scrollTop ?? 0; - const local = getItemsForFolder(folder); + const local = await getItemsForFolder(folder); setViewAllFolder({ title, ...local }); - const specialSources = (folder.sources ?? []).filter(isNuvioCollectionSource); + const specialSources = local.remoteSources; if (!specialSources.length) return; const batches = await Promise.all(specialSources.map((source) => loadNuvioCollectionSource(source))); - const groupsByType = new Map(local.groups.map((group) => [group.type, [...group.items]])); - for (let index = 0; index < batches.length; index += 1) { - const type = specialSources[index].mediaType?.toUpperCase() === 'TV' ? 'series' : 'movie'; - groupsByType.set(type, [...(groupsByType.get(type) ?? []), ...batches[index]]); - } - const groups = Array.from(groupsByType, ([type, items]) => ({ type, items })); - setViewAllFolder({ title, items: groups.flatMap((group) => group.items), groups }); + const merged = (await coreInvoke<{ items: Meta[]; groups: Array<{ type: string; items: Meta[] }> }>('mergeFolderSources', JSON.stringify([local.items, ...batches]))) ?? local; + setViewAllFolder({ title, ...merged }); } async function saveCollections(next: UserCollection[]) { @@ -186,8 +145,7 @@ export const LibraryScreen = React.memo(function LibraryScreen({ async function handleImportJson(json: string) { const imported = await importCollectionsJson(json); if (!imported.length) return; - const existingIds = new Set(collections.map((c) => c.id)); - const merged = [...collections, ...imported.filter((c) => !existingIds.has(c.id))]; + const merged = (await coreInvoke('collectionMergePlan', JSON.stringify({ existing: collections, incoming: imported }))) ?? collections; await saveCollections(merged); setEditingCollection(null); } @@ -197,28 +155,25 @@ export const LibraryScreen = React.memo(function LibraryScreen({ await navigator.clipboard.writeText(json); } - const smartLists = useMemo(() => { - const all = uniqueLibraryItems([...watchlist, ...watching, ...completed, ...dropped, ...progressItems]); - const airing = uniqueLibraryItems([...watching, ...watchlist]) - .filter((item) => Boolean(item.nextEpisodeAirDate || item.newEpisodeReleasedAt || item.continueWatchingBadge === 'newEpisode' || item.continueWatchingBadge === 'scheduledEpisode')) - .sort((a, b) => itemAirTime(a) - itemAirTime(b)); - const rated = [...all] - .filter((item) => Number((item as unknown as Meta).imdbRating ?? 0) >= 7.5) - .sort((a, b) => Number((b as unknown as Meta).imdbRating ?? 0) - Number((a as unknown as Meta).imdbRating ?? 0)); - const history = [...all] - .filter((item) => itemActivityTime(item) > 0) - .sort((a, b) => itemActivityTime(b) - itemActivityTime(a)); - return { airing, rated, history }; - }, [watchlist, watching, completed, dropped, progressItems]); - - const items = tab === 'watchlist' ? watchlist - : tab === 'watching' ? watching - : tab === 'completed' ? completed - : tab === 'dropped' ? dropped - : tab === 'airing' ? smartLists.airing - : tab === 'rated' ? smartLists.rated - : tab === 'history' ? smartLists.history - : []; + const [viewPlan, setViewPlan] = useState<{ + completed: LibraryItem[]; + dropped: LibraryItem[]; + smartLists: { airing: LibraryItem[]; rated: LibraryItem[]; history: LibraryItem[] }; + tabItems: LibraryItem[]; + items: LibraryItem[]; + }>({ completed: [], dropped: [], smartLists: { airing: [], rated: [], history: [] }, tabItems: [], items: [] }); + useEffect(() => { + let active = true; + void coreInvoke('libraryViewPlan', JSON.stringify({ + watchlist, watching, completed: rawCompleted, dropped: rawDropped, + progress: library.lastWrite?.progress ?? {}, tab, query, sortBy, + })).then((plan) => { if (active && plan) setViewPlan(plan); }); + return () => { active = false; }; + }, [watchlist, watching, rawCompleted, rawDropped, library.lastWrite?.progress, tab, query, sortBy]); + const completed = viewPlan.completed; + const dropped = viewPlan.dropped; + const smartLists = viewPlan.smartLists; + const items = viewPlan.tabItems; useEffect(() => { setSelectedIds((current) => { @@ -229,13 +184,7 @@ export const LibraryScreen = React.memo(function LibraryScreen({ }); }, [items]); - const q = query.trim().toLowerCase(); - const shown = q ? items.filter((it) => it.name.toLowerCase().includes(q)) : items; - const sorted = sortBy === 'title' - ? [...shown].sort((a, b) => a.name.localeCompare(b.name)) - : sortBy === 'rating' - ? [...shown].sort((a, b) => Number((b as Meta).imdbRating ?? 0) - Number((a as Meta).imdbRating ?? 0) || a.name.localeCompare(b.name)) - : shown; + const sorted = viewPlan.items; const subtitle = tab === 'watchlist' ? t('auto.movies_and_shows_you_saved_to_watch_later') : tab === 'watching' ? t('library.subtitle_watching') @@ -500,17 +449,6 @@ export const LibraryScreen = React.memo(function LibraryScreen({ prev.onProfileUpdated === next.onProfileUpdated, ); -function uniqueLibraryItems(items: LibraryItem[]): LibraryItem[] { - const seen = new Set(); - const next: LibraryItem[] = []; - for (const item of items) { - if (!item?.id || seen.has(item.id)) continue; - seen.add(item.id); - next.push(item); - } - return next; -} - function itemActivityTime(item: LibraryItem): number { const raw = (item as LibraryItem & { savedAt?: string; updatedAt?: string; lastWatchedAt?: string }).savedAt ?? (item as LibraryItem & { savedAt?: string; updatedAt?: string; lastWatchedAt?: string }).lastWatchedAt @@ -522,12 +460,6 @@ function itemActivityTime(item: LibraryItem): number { return Number.isFinite(parsed) ? parsed : 0; } -function itemAirTime(item: LibraryItem): number { - const raw = item.nextEpisodeAirDate ?? item.newEpisodeReleasedAt; - const parsed = raw ? Date.parse(raw) : Number.POSITIVE_INFINITY; - return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; -} - function HistoryTimeline({ items, onNavigateDetail }: { items: LibraryItem[]; onNavigateDetail: (meta: Meta) => void }) { return (
diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index a24558e..1a34e55 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -6,6 +6,7 @@ import { posterPrefsFromState, type PosterPrefs } from '../core/posterPrefs'; import { addRecentSearch, clearRecentSearches, loadRecentSearches, removeRecentSearch, type RecentSearch } from '../core/searchHistory'; import type { AppState, HomeCategory, Meta } from '../core/types'; import { getLanguage, t } from '../i18n'; +import { coreInvoke } from '../core/engine'; interface Props { state: AppState; @@ -37,25 +38,53 @@ export const SearchScreen = React.memo(function SearchScreen({ state, onDispatch const search = state.search; const posterPrefs = posterPrefsFromState(state, 0.85); const trimmedQuery = query.trim(); + const lastRecentQueryRef = useRef(''); + const [screenPlan, setScreenPlan] = useState<{ + query: string; + queryEligible: boolean; + shouldDispatch: boolean; + shouldCache: boolean; + categories: HomeCategory[]; + resultCount: number; + categoryCount: number; + isLoading: boolean; + }>({ query: '', queryEligible: false, shouldDispatch: false, shouldCache: false, categories: [], resultCount: 0, categoryCount: 0, isLoading: false }); useEffect(() => { loadRecentSearches().then(setRecentSearches); }, []); - useEffect(() => { - if (trimmedQuery.length < 2) return; - void addRecentSearch(trimmedQuery, recentSearches).then(setRecentSearches); - if (searchResultsCache.has(trimmedQuery)) return; - onDispatch(JSON.stringify({ type: 'searchRequested', query: trimmedQuery, language: getLanguage() })); - }, [trimmedQuery, onDispatch]); - - const resultsMatchCurrentQuery = search.query === trimmedQuery; - if (resultsMatchCurrentQuery && (search.categories?.length ?? 0) > 0) { - searchResultsCache.set(trimmedQuery, search.categories as HomeCategory[]); - } const cachedCategories = searchResultsCache.get(trimmedQuery) ?? null; - const rawCategories = resultsMatchCurrentQuery ? (search.categories ?? cachedCategories ?? []) : (cachedCategories ?? []); - const isLoading = search.isLoading && !cachedCategories; + useEffect(() => { + let active = true; + void coreInvoke('searchScreenPlan', JSON.stringify({ + query, + searchQuery: search.query, + searchCategories: search.categories ?? [], + cachedCategories: cachedCategories ?? [], + hasCache: cachedCategories != null, + searchLoading: search.isLoading, + typeFilter, + })).then((plan) => { + if (!active || !plan) return; + if (plan.shouldCache) searchResultsCache.set(plan.query, search.categories as HomeCategory[]); + setScreenPlan(plan); + }); + return () => { active = false; }; + }, [query, search.query, search.categories, search.isLoading, cachedCategories, typeFilter]); + + useEffect(() => { + if (!screenPlan.queryEligible || screenPlan.query !== trimmedQuery) return; + if (lastRecentQueryRef.current !== screenPlan.query) { + lastRecentQueryRef.current = screenPlan.query; + void addRecentSearch(screenPlan.query, recentSearches).then(setRecentSearches); + } + if (screenPlan.shouldDispatch) onDispatch(JSON.stringify({ type: 'searchRequested', query: screenPlan.query, language: getLanguage() })); + }, [screenPlan.query, screenPlan.queryEligible, screenPlan.shouldDispatch, trimmedQuery, onDispatch]); + + const categories = screenPlan.categories; + const resultCount = screenPlan.resultCount; + const isLoading = screenPlan.isLoading; const handleGenreClick = (genreKey: string) => { onQueryChange(t(genreKey)); @@ -78,23 +107,6 @@ export const SearchScreen = React.memo(function SearchScreen({ state, onDispatch void clearRecentSearches().then(setRecentSearches); }; - const categories = useMemo( - () => - rawCategories - .map((category) => ({ - ...category, - items: typeFilter - ? category.items.filter((meta) => meta.type === typeFilter) - : category.items, - })) - .filter((category) => category.items.length > 0), - [rawCategories, typeFilter], - ); - const resultCount = useMemo( - () => categories.reduce((sum, category) => sum + category.items.length, 0), - [categories], - ); - return (
@@ -107,7 +119,7 @@ export const SearchScreen = React.memo(function SearchScreen({ state, onDispatch

{t('auto.search_results')}

{query.trim() ? query.trim() : t('auto.search')}

{query.trim().length >= 2 && !isLoading && ( -

{t('search.results_across_catalogs', resultCount, categories.length)}

+

{t('search.results_across_catalogs', resultCount, screenPlan.categoryCount)}

)}
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 650c2d9..9d196c7 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { coreApplyPreferenceUpdate, httpFetchText, storageRead, storageWrite } from '../core/engine'; +import { coreApplyPreferenceUpdate, coreInvoke, httpFetchText, storageRead, storageWrite } from '../core/engine'; import { Keyboard, Search } from 'lucide-react'; import { coreAddonCollectionMutationPlan, @@ -44,41 +44,6 @@ import { AddonsSection } from '../components/settings/AddonsSection'; import { DownloadsSection } from '../components/settings/DownloadsSection'; import { AddonAddedDialog } from '../components/AddonAddedDialog'; -function mergeAddons(existing: AddonDescriptor[], incoming: AddonDescriptor[]): AddonDescriptor[] { - const merged = new Map(); - for (const addon of existing) merged.set(addonKey(addon), addon); - for (const addon of incoming) merged.set(addonKey(addon), addon); - return [...merged.values()]; -} - -function addonUrlIdentity(url: string): string { - return url - .trim() - .replace(/\/+$/, '') - .replace(/^https?:\/\//i, '') - .toLowerCase(); -} - -function profileLocalAddons(profile: UserProfile | null): string[] { - return profile?.addonSettings?.localAddons ?? profile?.localAddons ?? []; -} - -function withInstalledLocalAddon(profile: UserProfile, normalizedUrl: string): UserProfile { - const existing = profileLocalAddons(profile); - const next = existing.some((url) => addonUrlIdentity(url) === addonUrlIdentity(normalizedUrl)) - ? existing - : [...existing, normalizedUrl]; - return { - ...profile, - localAddons: next, - addonSettings: { - ...(profile.addonSettings ?? {}), - localAddons: next, - disabledLocalAddons: profile.addonSettings?.disabledLocalAddons ?? profile.disabledLocalAddons ?? [], - }, - }; -} - async function settingsFetchJson(url: string): Promise { const response = await httpFetchText(url); if (response.statusCode < 200 || response.statusCode > 299) { @@ -213,7 +178,7 @@ export function SettingsScreen({ state, onDispatch, activeProfile, onProfileUpda if (engineAddons.length > 0) { loadAddons().then((stored) => { coreAddonCollectionMutationPlan({ existing: stored, incoming: engineAddons }) - .then((plan) => ((plan?.addons as AddonDescriptor[] | undefined) ?? mergeAddons(stored, engineAddons))) + .then((plan) => ((plan?.addons as AddonDescriptor[] | undefined) ?? stored)) .then((merged) => { setInstalledAddons(merged); }); @@ -264,12 +229,12 @@ export function SettingsScreen({ state, onDispatch, activeProfile, onProfileUpda const normalizedAddon = await normalizeAddonDescriptor({ ...addon, transportUrl: normalizedUrl }); const stored = await loadAddons(); const plan = await coreAddonCollectionMutationPlan({ existing: stored, incoming: [normalizedAddon] }); - const updated = await Promise.all(((plan?.addons as AddonDescriptor[] | undefined) ?? mergeAddons(stored, [normalizedAddon])).map(normalizeAddonDescriptor)); + const updated = await Promise.all(((plan?.addons as AddonDescriptor[] | undefined) ?? stored).map(normalizeAddonDescriptor)); await saveAddons(updated); let syncProfile = activeProfile; if (activeProfile) { - const updatedProfile = withInstalledLocalAddon(activeProfile, normalizedUrl); + const updatedProfile = (await coreInvoke('addonProfileMutationPlan', JSON.stringify({ profile: activeProfile, command: 'install', addonKey: normalizedUrl }))) ?? activeProfile; await saveProfile(updatedProfile); onProfileUpdated(updatedProfile); syncProfile = updatedProfile; @@ -295,16 +260,7 @@ export function SettingsScreen({ state, onDispatch, activeProfile, onProfileUpda await saveAddons(updated); setInstalledAddons(updated); if (activeProfile) { - const nextUrls = profileLocalAddons(activeProfile).filter((url) => addonUrlIdentity(url) !== addonUrlIdentity(removeKey)); - const updatedProfile: UserProfile = { - ...activeProfile, - localAddons: nextUrls, - addonSettings: { - ...(activeProfile.addonSettings ?? {}), - localAddons: nextUrls, - disabledLocalAddons: activeProfile.addonSettings?.disabledLocalAddons ?? activeProfile.disabledLocalAddons ?? [], - }, - }; + const updatedProfile = (await coreInvoke('addonProfileMutationPlan', JSON.stringify({ profile: activeProfile, command: 'remove', addonKey: removeKey }))) ?? activeProfile; await saveProfile(updatedProfile); onProfileUpdated(updatedProfile); void syncNuvioAddons(updatedProfile, updated); @@ -322,17 +278,7 @@ export function SettingsScreen({ state, onDispatch, activeProfile, onProfileUpda const handleToggleAddon = async (addon: AddonDescriptor) => { if (!activeProfile) return; const key = addonKey(addon); - const disabled = activeProfile.addonSettings?.disabledLocalAddons ?? activeProfile.disabledLocalAddons ?? []; - const isDisabled = disabled.includes(key); - const nextDisabled = isDisabled ? disabled.filter((k) => k !== key) : [...disabled, key]; - const updatedProfile: UserProfile = { - ...activeProfile, - addonSettings: { - ...(activeProfile.addonSettings ?? {}), - localAddons: profileLocalAddons(activeProfile), - disabledLocalAddons: nextDisabled, - }, - }; + const updatedProfile = (await coreInvoke('addonProfileMutationPlan', JSON.stringify({ profile: activeProfile, command: 'toggle', addonKey: key }))) ?? activeProfile; await saveProfile(updatedProfile); onProfileUpdated(updatedProfile); void syncNuvioAddons(updatedProfile, installedAddons);