mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-19 05:25:49 +00:00
Preserve Nuvio collection sources in desktop sync
This commit is contained in:
parent
54371cb489
commit
ebf8a2adf6
6 changed files with 116 additions and 35 deletions
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}),
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue