mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-03 23:46:26 +00:00
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:
parent
87f209d7fb
commit
fdccb06183
28 changed files with 609 additions and 1171 deletions
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)); } }] : []),
|
||||
];
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }))) ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue