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.
This commit is contained in:
KhooLy 2026-07-18 23:38:19 +03:00
parent 87f209d7fb
commit fdccb06183
28 changed files with 609 additions and 1171 deletions

View file

@ -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<Meta[]>(() => {
const [localSuggestions, setLocalSuggestions] = useState<Meta[]>([]);
useEffect(() => {
let active = true;
const needle = inputValue.trim().toLowerCase();
if (needle.length < 2) return [];
return rankByNeedle(flattenCategories(state.home.categories), needle);
void coreInvoke<Meta[]>('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<Meta[]>(() => {
const [networkSuggestions, setNetworkSuggestions] = useState<Meta[]>([]);
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<string, unknown> | 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<Meta[]>('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<string>();
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<string>();
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<string, React.CSSProperties> = {
sectionLabel: {
color: 'rgba(255,255,255,0.5)',

View file

@ -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<number | null>(null);
const [nextEpDismissed, setNextEpDismissed] = useState(false);
const [episodes, setEpisodes] = useState<EpisodeInfo[]>([]);
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<string | null>(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<ActiveSkip | null>(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<HTMLButtonElement | null>(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: <Link2 size={15} />, label: t('player.copy_stream_link'), onSelect: () => { void navigator.clipboard.writeText(sourceLink); } }] : []),
...(streamIsTorrent(stream) ? [{ icon: <Magnet size={15} />, label: t('player.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []),
...(streamLinksPlan?.isTorrent ? [{ icon: <Magnet size={15} />, label: t('player.copy_magnet_link'), onSelect: () => { void streamMagnetLink(stream).then((link) => { if (link) void navigator.clipboard.writeText(link); }); } }] : []),
...(meta && downloadLink ? [{ icon: <Download size={15} />, label: t('player.download_this_video'), onSelect: () => { void enqueueOfflineDownload(buildOfflineDownloadRequest(meta, stream, currentEpisode)); } }] : []),
];
})()}

View file

@ -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<string>();
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 (
<>

View file

@ -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<Record<s
const data = await anilistGraphql<AniListCollectionResponse>(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<string, unknown>;
})
.filter((item): item is Record<string, unknown> => item !== null);
return (await coreInvoke<Record<string, unknown>[]>('providerCalendarItems', JSON.stringify({ provider: 'anilist', entries }))) ?? [];
}
export async function pushWatchlistAniList(

View file

@ -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<Meta[]> {
if (source.provider === 'tmdb') return loadTmdbCollectionSource(source, page);
if (!source.traktListId) return [];
const clientId = await invoke<string>('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<string, unknown>, 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<Meta[]> {
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<string>('get_oauth_client_id', { service: 'trakt' }).catch(() => '')
: '';
const plan = await coreInvoke<{
url: string;
params: Record<string, string | number>;
headers: Record<string, string>;
}>('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<Record<string, unknown>>; crew?: Array<Record<string, unknown>> };
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<string, unknown>).media_type !== 'tv');
const series = resolvedItems.filter((item) => (item as Record<string, unknown>).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<Meta[]>('remoteCollectionResponsePlan', JSON.stringify({ plan, data: await response.json() }))) ?? [];
} catch {
return [];
}

View file

@ -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',

View file

@ -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<string, unknown
const currentEpisode = metaObj.lastEpisodeNumber as number | undefined;
if (currentSeason == null || currentEpisode == null) return undefined;
const videos = await fetchVideosForSeries(id, await loadAddons());
const next = await coreResolveNextEpisode(
JSON.stringify(videos),
currentSeason,
currentEpisode,
Date.now(),
false,
) as { id?: string; season?: number; episode?: number; number?: number } | null;
if (!next?.id) return undefined;
return {
return (await coreInvoke<WatchProgressInfo>('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(

View file

@ -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<number | null> {
return coreInvoke('simklLookupIdForType', JSON.stringify({ lookupJson, wantType }));
}
export async function coreTraktPlaybackItemsToLibrary(itemsJson: string): Promise<unknown[] | null> {
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<string, string>;
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<u
return coreInvoke('nuvioMapCollections', JSON.stringify({ collections }));
}
export async function coreNuvioSortAddonsByPriority<T>(addons: T[]): Promise<T[] | null> {
return coreInvoke('nuvioSortAddonsByPriority', JSON.stringify({ addons }));
}
export async function coreFilterEnabledAddons<T>(addons: T[], disabledKeys: string[]): Promise<T[] | null> {
return coreInvoke('filterEnabledAddons', JSON.stringify({ addons, disabledKeys }));
}
export async function coreAirDateRefreshCandidates(items: unknown[], nowMs: number): Promise<string[]> {
return (await coreInvoke<string[]>('airDateRefreshCandidates', JSON.stringify({ items, nowMs }))) ?? [];
}

View file

@ -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<string, Record<string, unknown>> | 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<string, unknown> | 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<string, Record<string, unknown>>;
promotions: Array<{
item: Record<string, unknown>;
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<void> {
if (!profile) return;
const tasks: Promise<void>[] = [];
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<void>[] = [];
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<typeof remote>('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<string, unknown>,
profile: UserProfile | null,
): Promise<void> {
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<void>[] = [];
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<void> {
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<string, unknown>): Promise<void> {
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<string, unknown>): Promise<unknown> {
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<string, unknown>): Promise<unknown> {

View file

@ -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<string, unknown>[] | undefined) ?? [];
const externalContinueWatching = (library.externalContinueWatching as Record<string, unknown>[] | undefined) ?? [];

View file

@ -1,5 +1,4 @@
import {
coreAirDateRefreshCandidates,
coreInvoke,
coreLibraryApplyMarkWatched,
coreLibraryLocalStatePlan,
@ -41,12 +40,10 @@ export async function refreshWatchlistAirDates(): Promise<void> {
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<string, LibraryItem>();
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<LibraryItem[]>('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<void> {
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<WatchProgressInfo>('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<string, unknown>): Promise<void> {

View file

@ -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<string, string>;
descriptors: Array<Record<string, unknown>>;
}> {
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<string, string>();
const manifests = await Promise.allSettled(

View file

@ -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<string, unknown> }>)
: [];
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<string, unknown> = 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,

View file

@ -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<string, unknown>;
const episode = entry.episode as Record<string, unknown> | undefined;
const show = entry.show as Record<string, unknown> | undefined;
const ids = show?.ids as Record<string, unknown> | 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<string, unknown>;
}).filter((item): item is Record<string, unknown> => item !== null);
const movieItems = (Array.isArray(movies) ? movies : []).map((raw) => {
const entry = raw as Record<string, unknown>;
const movie = entry.movie as Record<string, unknown> | undefined;
const ids = movie?.ids as Record<string, unknown> | 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<string, unknown>;
}).filter((item): item is Record<string, unknown> => item !== null);
return [...showItems, ...movieItems];
return (await coreInvoke<Record<string, unknown>[]>('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<string, unknown>[] = [];
const showPayloads: Map<string, Record<string, unknown>> = new Map();
for (const vid of videoIds) {
const parsed = await coreParseVideoId(vid);
if (!parsed.imdb && !parsed.tmdb) continue;
const ids: Record<string, unknown> = 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<string, unknown>[];
let seasonEntry = seasons.find((s) => s.number === parsed.season);
if (!seasonEntry) { seasonEntry = { number: parsed.season, episodes: [] }; seasons.push(seasonEntry); }
(seasonEntry.episodes as Record<string, unknown>[]).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<string, unknown> = {};
if (moviePayloads.length > 0) body.movies = moviePayloads;
if (showPayloads.size > 0) body.shows = [...showPayloads.values()];
const body = await coreInvoke<Record<string, unknown>>('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<void> {
@ -195,11 +130,8 @@ export async function pushWatchlistSimkl(
'simkl-api-key': clientId,
'Content-Type': 'application/json',
};
const ids: Record<string, unknown> = 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<Record<string, unknown>>('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),
});

View file

@ -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 {

View file

@ -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<string, unknown>,
state: Record<string, unknown>,
extra: Record<string, unknown> = {},
): Record<string, unknown> | 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<string, unknown>,
command: 'add' | 'remove',
@ -61,15 +39,8 @@ export async function pushStremioWatchlist(
): Promise<void> {
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<Record<string, unknown>[]>('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<void> {
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<Record<string, unknown>[]>('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<void> {
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<Record<string, unknown>[]>('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<void> {

View file

@ -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<Record<string, unknown>[]> {
const sep = url.includes('?') ? '&' : '?';
const items: Record<string, unknown>[] = [];
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<string, unknown>[]));
type PaginationPlan = { items: Record<string, unknown>[]; done: boolean; page: number; requestUrl?: string | null };
let plan = await coreInvoke<PaginationPlan>('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<PaginationPlan>('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<string, unknown>[]): Promise<void> {
@ -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<string, unknown>;
const episode = entry.episode as Record<string, unknown> | undefined;
const show = entry.show as Record<string, unknown> | undefined;
const ids = show?.ids as Record<string, unknown> | 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<string, unknown>;
}).filter((item): item is Record<string, unknown> => item !== null);
const movieItems = (Array.isArray(movies) ? movies : []).map((raw) => {
const entry = raw as Record<string, unknown>;
const movie = entry.movie as Record<string, unknown> | undefined;
const ids = movie?.ids as Record<string, unknown> | 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<string, unknown>;
}).filter((item): item is Record<string, unknown> => item !== null);
return [...showItems, ...movieItems];
return (await coreInvoke<Record<string, unknown>[]>('providerCalendarItems', JSON.stringify({ provider: 'trakt', shows, movies }))) ?? [];
}
export async function pushMarkWatchedTrakt(

View file

@ -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<void> {
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<string, Record<string, unknown>> | 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<typeof e> => e !== null);
const watchlist = (lib.watchlist as Array<Record<string, unknown>> | 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<string, boolean> | 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(),
]);
}

View file

@ -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<void>;
}
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<string, string> | 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<string | null>(null);
const [playerTitle, setPlayerTitle] = useState<string | undefined>();
@ -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<string, unknown>;
}>('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))) {

View file

@ -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<string, boolean>;
onDispatch: (actionJson: string) => void;
}) {
const seasonWatchedMap = useMemo(() => {
const map: Record<number, boolean> = {};
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<Record<number, boolean>>({});
useEffect(() => {
let active = true;
void coreInvoke<Record<number, boolean>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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 };

View file

@ -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<CalendarItem[]>([]);
useEffect(() => {
let active = true;
void coreInvoke<CalendarItem[]>('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<string>, completedNames: Set<string>): 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;

View file

@ -53,26 +53,6 @@ interface Props {
playbackFailure?: string | null;
}
function orderStreamsByPrefs(streams: Stream[], prefs: Record<string, unknown>): 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<Stream[]>([]);
useEffect(() => {
let active = true;
void coreInvoke<Stream[]>('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,

View file

@ -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: [] },
];

View file

@ -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<string>('movie');
@ -53,10 +43,18 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre
const isGridScrollingRef = useRef(false);
const scrollIdleTimerRef = useRef<number | null>(null);
const hoveredMetaRef = useRef<Meta | null>(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<DiscoverCatalog['extras']>[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<string | null>(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<typeof selectionPlan>('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<string>();
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<Meta[]>([]);
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<string[]>(['movie', 'series']);
useEffect(() => {
void coreInvoke<string[]>('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;

View file

@ -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<string>;
}
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<string>();
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<string, Meta[]>();
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<FolderSourceBatch> {
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<string, unknown>;
}>('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<string, unknown> = {};
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<FolderSourceState>('folderPageState', JSON.stringify({ state, batch: batches[index] }))) ?? state
));
const { items, groups } = (await coreInvoke<FolderItemsResult>('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<FolderSourceBatch>({ 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<FolderSourceState>('folderPageState', JSON.stringify({ state, batch: batches[index] }))) ?? state
));
const { items, groups } = (await coreInvoke<FolderItemsResult>('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<Record<string, Trailer[]>>({});
const [fetchedHeroTrailerIds, setFetchedHeroTrailerIds] = useState<string[]>([]);
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<typeof homePlan>('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<string, () => 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<Record<string, Trailer[]>>({});
const fetchedHeroTrailerIds = useRef<Set<string>>(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<string, string>();
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<T extends Meta | null>(item: T, trailers: Record<string, Trailer[]>): 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<string>();
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 (

View file

@ -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<string, LibraryItem>);
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<string, Meta[]>();
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<UserCollection[]>('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<typeof viewPlan>('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<string>();
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 (
<div style={styles.historyScroll}>

View file

@ -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<typeof screenPlan>('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 (
<div style={styles.screen}>
<div style={styles.content}>
@ -107,7 +119,7 @@ export const SearchScreen = React.memo(function SearchScreen({ state, onDispatch
<p style={styles.eyebrow}>{t('auto.search_results')}</p>
<h1 style={styles.title}>{query.trim() ? query.trim() : t('auto.search')}</h1>
{query.trim().length >= 2 && !isLoading && (
<p style={styles.subtitle}>{t('search.results_across_catalogs', resultCount, categories.length)}</p>
<p style={styles.subtitle}>{t('search.results_across_catalogs', resultCount, screenPlan.categoryCount)}</p>
)}
</div>

View file

@ -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<string, AddonDescriptor>();
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<unknown> {
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<UserProfile>('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<UserProfile>('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<UserProfile>('addonProfileMutationPlan', JSON.stringify({ profile: activeProfile, command: 'toggle', addonKey: key }))) ?? activeProfile;
await saveProfile(updatedProfile);
onProfileUpdated(updatedProfile);
void syncNuvioAddons(updatedProfile, installedAddons);