Preserve Nuvio collection sources in desktop sync

This commit is contained in:
KhooLy 2026-07-12 18:49:06 +03:00
parent 54371cb489
commit ebf8a2adf6
6 changed files with 116 additions and 35 deletions

View file

@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { platformFetch } from './httpClient';
import type { Meta, NuvioCollectionSource } from './types';
import type { Meta, NuvioRemoteCollectionSource } from './types';
import { loadPrefs } from './libraryOps';
import { prefString } from './appPrefs';
import { coreTmdbBulkMetas } from './engine';
@ -24,13 +24,13 @@ function metaFromTraktItem(item: TraktItem, mediaType: string): Meta | null {
};
}
export function isNuvioCollectionSource(source: unknown): source is NuvioCollectionSource {
export function isNuvioCollectionSource(source: unknown): source is NuvioRemoteCollectionSource {
return !!source && typeof source === 'object' &&
(((source as NuvioCollectionSource).provider === 'trakt' && typeof (source as NuvioCollectionSource).traktListId === 'number') ||
(source as NuvioCollectionSource).provider === 'tmdb');
(((source as NuvioRemoteCollectionSource).provider === 'trakt' && typeof (source as NuvioRemoteCollectionSource).traktListId === 'number') ||
(source as NuvioRemoteCollectionSource).provider === 'tmdb');
}
export async function loadNuvioCollectionSource(source: NuvioCollectionSource, page = 1): Promise<Meta[]> {
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(() => '');
@ -54,7 +54,7 @@ export async function loadNuvioCollectionSource(source: NuvioCollectionSource, p
}
}
function tmdbType(source: NuvioCollectionSource): string {
function tmdbType(source: NuvioRemoteCollectionSource): string {
return source.mediaType?.toUpperCase() === 'TV' ? 'tv' : 'movie';
}
@ -63,21 +63,27 @@ function setFilter(params: URLSearchParams, source: Record<string, unknown>, inp
if (typeof value === 'string' || typeof value === 'number') params.set(output, String(value));
}
async function loadTmdbCollectionSource(source: NuvioCollectionSource, page: number): Promise<Meta[]> {
async function loadTmdbCollectionSource(source: NuvioRemoteCollectionSource, page: number): Promise<Meta[]> {
const prefs = await loadPrefs();
const apiKey = prefString(prefs, 'tmdbApiKey').trim();
if (!apiKey) return [];
const type = tmdbType(source);
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 === 'COLLECTION' && source.tmdbId) {
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');
@ -97,9 +103,29 @@ async function loadTmdbCollectionSource(source: NuvioCollectionSource, page: num
try {
const response = await platformFetch(`https://api.themoviedb.org/${path}?${params}`);
if (!response.ok) return [];
const data = await response.json() as { parts?: unknown[]; results?: unknown[] };
const items = source.tmdbSourceType === 'COLLECTION' ? data.parts : data.results;
return ((await coreTmdbBulkMetas(JSON.stringify(Array.isArray(items) ? items : []), type === 'tv' ? 'series' : 'movie', language)) ?? []) as Meta[];
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[];
} catch {
return [];
}

View file

@ -28,11 +28,16 @@ export function effectiveFolderShape(folder: UserCollectionFolder): string {
}
export function effectiveCatalogId(folder: UserCollectionFolder): string | null {
return folder.catalogSources?.[0]?.catalogId ?? folder.catalogId ?? null;
return folder.sources?.find((source) => source.provider === 'addon')?.catalogId
?? folder.catalogSources?.[0]?.catalogId
?? folder.catalogId
?? null;
}
export function effectiveCatalogType(folder: UserCollectionFolder): string | null {
return folder.catalogSources?.[0]?.type ?? null;
return folder.sources?.find((source) => source.provider === 'addon')?.type
?? folder.catalogSources?.[0]?.type
?? null;
}
export async function importCollectionsJson(rawJson: string): Promise<UserCollection[]> {

View file

@ -186,14 +186,10 @@ export async function importNuvioProfileData(
const errors: Partial<Record<NuvioImportStep, string>> = {};
const activeRemoteProgressIds = new Set<string>();
let addonList: NuvioAddon[] = [];
let manifestIdByUrl = new Map<string, string>();
let addonDescriptors: Array<Record<string, unknown>> = [];
try {
const addons = await nuvioPullAddons(token, profileIdx);
const fetched = await fetchAddonManifests(addons);
addonList = fetched.addonList;
manifestIdByUrl = fetched.manifestIdByUrl;
addonDescriptors = fetched.descriptors;
await storageWrite(`addons_${suffix}`, fetched.descriptors);
onStep?.('addons', true);
@ -380,14 +376,17 @@ export async function importNuvioProfileData(
if (collections.length > 0) {
const raw = collections[0]?.collections_json ?? [];
const mapped = (raw as Array<Record<string, unknown>>).map((c) => ({
...c,
id: String(c.id ?? ''),
title: String(c.title ?? ''),
imageUrl: (c.backdropImageUrl as string | undefined) ?? undefined,
showOnHome: Boolean(c.pinToTop),
backdropImageUrl: (c.backdropImageUrl as string | undefined) ?? undefined,
showOnHome: true,
viewMode: (c.viewMode as string | undefined) ?? 'ROWS',
showAllTab: Boolean(c.showAllTab),
pinToTop: Boolean(c.pinToTop),
folders: ((c.folders as Array<Record<string, unknown>>) ?? []).map((f) => ({
...f,
id: String(f.id ?? ''),
title: String(f.title ?? ''),
coverImageUrl: (f.coverImageUrl as string | undefined) ?? undefined,
@ -395,24 +394,35 @@ export async function importNuvioProfileData(
focusGifUrl: (f.focusGifUrl as string | undefined) ?? undefined,
focusGifEnabled: f.focusGifEnabled !== false,
titleLogoUrl: (f.titleLogoUrl as string | undefined) ?? undefined,
heroBackdropUrl: (f.heroBackdropUrl as string | undefined) ?? undefined,
heroVideoUrl: (f.heroVideoUrl as string | undefined) ?? undefined,
shape: normalizeTileShape(f.tileShape as string | undefined),
hideTitle: Boolean(f.hideTitle),
catalogSources: ((f.catalogSources as Array<Record<string, unknown>>) ?? []).map((s) => {
catalogSources: ((f.sources as Array<Record<string, unknown>>) ?? []).length > 0
? ((f.sources as Array<Record<string, unknown>>) ?? []).flatMap((s) => {
if (String(s.provider ?? 'addon').toLowerCase() !== 'addon') return [];
const addonId = String(s.addonId ?? '');
return [{
addonId,
catalogId: String(s.catalogId ?? ''),
type: String(s.type ?? 'movie'),
genre: typeof s.genre === 'string' ? s.genre : undefined,
}];
})
: ((f.catalogSources as Array<Record<string, unknown>>) ?? []).map((s) => {
const addonId = String(s.addonId ?? '');
const matched = addonList.find((a) => {
const manifestId = manifestIdByUrl.get(a.url);
return manifestId === addonId || a.url === addonId;
});
return {
transportUrl: matched ? matched.url : addonId,
addonId,
catalogId: String(s.catalogId ?? ''),
type: String(s.type ?? 'movie'),
genre: typeof s.genre === 'string' ? s.genre : undefined,
};
}),
sources: ((f.sources as Array<Record<string, unknown>>) ?? []).flatMap((s): NuvioCollectionSource[] => {
const provider = String(s.provider ?? '').toLowerCase();
const provider = String(s.provider ?? 'addon').toLowerCase();
if (provider === 'trakt' && typeof s.traktListId === 'number') {
return [{
...s,
provider: 'trakt',
title: typeof s.title === 'string' ? s.title : undefined,
mediaType: typeof s.mediaType === 'string' ? s.mediaType : undefined,
@ -423,6 +433,7 @@ export async function importNuvioProfileData(
}
if (provider === 'tmdb' && typeof s.tmdbSourceType === 'string') {
return [{
...s,
provider: 'tmdb',
title: typeof s.title === 'string' ? s.title : undefined,
mediaType: typeof s.mediaType === 'string' ? s.mediaType : undefined,
@ -433,6 +444,16 @@ export async function importNuvioProfileData(
filters: s.filters && typeof s.filters === 'object' && !Array.isArray(s.filters) ? s.filters as Record<string, unknown> : undefined,
}];
}
if (provider === 'addon' && typeof s.addonId === 'string' && typeof s.type === 'string' && typeof s.catalogId === 'string') {
return [{
...s,
provider: 'addon',
addonId: s.addonId,
type: s.type,
catalogId: s.catalogId,
genre: typeof s.genre === 'string' ? s.genre : undefined,
}];
}
return [];
}),
})),

View file

@ -198,9 +198,18 @@ export interface CatalogSource {
addonId?: string;
catalogId: string;
type: string;
genre?: string;
}
export interface NuvioCollectionSource {
export interface NuvioAddonCollectionSource {
provider: 'addon';
addonId: string;
type: string;
catalogId: string;
genre?: string;
}
export interface NuvioRemoteCollectionSource {
provider: 'trakt' | 'tmdb';
title?: string;
mediaType?: string;
@ -212,6 +221,8 @@ export interface NuvioCollectionSource {
filters?: Record<string, unknown>;
}
export type NuvioCollectionSource = NuvioAddonCollectionSource | NuvioRemoteCollectionSource;
export interface UserCollectionFolder {
id: string;
title: string;
@ -229,6 +240,7 @@ export interface UserCollectionFolder {
focusGifUrl?: string;
titleLogoUrl?: string;
heroBackdropUrl?: string;
heroVideoUrl?: string;
}
export interface UserCollection {
@ -236,6 +248,7 @@ export interface UserCollection {
title: string;
itemIds?: string[];
imageUrl?: string;
backdropImageUrl?: string;
showOnHome?: boolean;
folders?: UserCollectionFolder[];
showAllTab?: boolean;

View file

@ -11,7 +11,8 @@ import { appPrefs, prefBool, prefString } from '../core/appPrefs';
import { buildResourceUrl } from '../core/addonManifest';
import { httpFetchText, prewarmYoutubeTrailerConfig } from '../core/engine';
import { fetchTmdbTrailers } from '../core/detailEffects';
import type { AppState, HomeCategory, Meta, NuvioCollectionSource, Trailer } from '../core/types';
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';
@ -59,7 +60,7 @@ interface FolderItemsResult {
type FolderSourceBatch = { type: string; items: Meta[] };
type AddonFolderSource = { transportUrl: string; catalogId: string; type: string; genre?: string };
type FolderSource = AddonFolderSource | NuvioCollectionSource;
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
@ -335,7 +336,7 @@ export const HomeScreen = React.memo(function HomeScreen({ state, onDispatch, on
const apiKey = prefString(prefs, 'tmdbApiKey');
if (!autoplayTrailerEnabled || !prefBool(prefs, 'tmdbTrailersEnabled', true) || !apiKey) return;
const targets = [billboard, ...heroSlides].filter(
(item): item is Meta => !!item && !item.trailers?.length && !fetchedHeroTrailerIds.current.has(item.id),
(item): item is Meta => !!item && !hasPlayableTrailer(item) && !fetchedHeroTrailerIds.current.has(item.id),
);
if (!targets.length) return;
targets.forEach((item) => fetchedHeroTrailerIds.current.add(item.id));
@ -532,8 +533,12 @@ 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 || item.trailers?.length || !trailers[item.id]) return item;
if (!item || hasPlayableTrailer(item) || !trailers[item.id]) return item;
return { ...item, trailers: trailers[item.id] };
}

View file

@ -9,7 +9,7 @@ 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, HomeCategory, LibraryItem, Meta, UserCollection, UserCollectionFolder, UserProfile } from '../core/types';
import type { AppState, CatalogSource, HomeCategory, LibraryItem, Meta, UserCollection, UserCollectionFolder, UserProfile } from '../core/types';
import { t } from '../i18n';
import { CategoryGridScreen } from './CategoryGridScreen';
import { CollectionEditorScreen } from './CollectionEditorScreen';
@ -106,7 +106,17 @@ export const LibraryScreen = React.memo(function LibraryScreen({
const homeCategories: HomeCategory[] = state.home.categories ?? [];
function getItemsForFolder(folder: UserCollectionFolder): { items: Meta[]; groups: Array<{ type: string; items: Meta[] }> } {
const sources = folder.catalogSources?.length
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) ?? '' }]
@ -115,8 +125,9 @@ export const LibraryScreen = React.memo(function LibraryScreen({
for (const source of sources) {
const cat = homeCategories.find((c) => c.id === source.catalogId || c.catalogId === source.catalogId);
if (!cat) continue;
const items = folder.genre
? cat.items.filter((m) => m.genres?.some((g) => g.toLowerCase() === folder.genre!.toLowerCase()))
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);