diff --git a/docs/web-nuvio-migration/screenshots/02-review.png b/docs/web-nuvio-migration/screenshots/02-review.png new file mode 100644 index 000000000..d8a1cc4cb Binary files /dev/null and b/docs/web-nuvio-migration/screenshots/02-review.png differ diff --git a/docs/web-nuvio-migration/screenshots/03-finished.png b/docs/web-nuvio-migration/screenshots/03-finished.png new file mode 100644 index 000000000..ccd5a9937 Binary files /dev/null and b/docs/web-nuvio-migration/screenshots/03-finished.png differ diff --git a/web/app/migrate/page.tsx b/web/app/migrate/page.tsx new file mode 100644 index 000000000..1899e2bd1 --- /dev/null +++ b/web/app/migrate/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { MigrateApp } from "@/components/migrate/MigrateApp"; +import "./setup.css"; + +export const metadata: Metadata = { + title: "ARVIO setup", + description: "Install addons and move a Nuvio account into ARVIO" +}; + +/** + * Deliberately outside the subscription-gated app shell: someone who only uses + * the free Android or TV app can set their account up here. + */ +export default function MigratePage() { + return ; +} diff --git a/web/app/migrate/setup.css b/web/app/migrate/setup.css new file mode 100644 index 000000000..1f5610534 --- /dev/null +++ b/web/app/migrate/setup.css @@ -0,0 +1,225 @@ +/* Standalone setup page. No app shell, so it carries its own layout, but every + colour, radius and control shape comes from the app's tokens in globals.css + so it reads as the same product. */ + +.setup { + min-height: 100vh; + background: + radial-gradient(1100px 520px at 50% -8%, rgba(0, 213, 136, 0.1), transparent 70%), + radial-gradient(760px 420px at 12% 8%, rgba(255, 205, 60, 0.05), transparent 65%), + var(--bg); +} + +.setup__inner { + max-width: 760px; + margin: 0 auto; + padding: 56px 24px 96px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.setup__brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; } + +.setup__brand img { width: 40px; height: 40px; border-radius: 10px; } + +.setup__brand span { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.setup__header-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; +} + +.setup__header h1 { + margin: 0 0 10px; + font-size: clamp(30px, 4vw, 40px); + line-height: 1.1; + font-weight: 700; +} + +.setup__header p { + margin: 0; + color: var(--muted); + line-height: 1.6; + max-width: 62ch; +} + +.setup__language { display: grid; gap: 6px; min-width: 190px; } + +.setup__language span { color: var(--soft); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; } + +/* Same card geometry as .settings-panel-card in the app. */ +.setup__card { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + border-radius: 18px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--shadow); +} + +.setup__card h2 { margin: 0; font-size: 22px; font-weight: 650; } + +.setup label { display: grid; gap: 7px; font-size: 14px; } + +.setup label > span { font-weight: 600; color: var(--text); } + +.setup input, .setup select { + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: 12px; + color: var(--text); + padding: 12px 14px; + font-size: 15px; + min-height: 48px; + width: 100%; +} + +.setup input::placeholder { color: var(--soft); } + +.setup input:focus-visible, .setup select:focus-visible, .setup button:focus-visible { + outline: none; + border-color: var(--green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 30%, transparent); +} + +.setup small { + display: flex; + align-items: center; + gap: 6px; + color: var(--soft); + font-size: 12px; + line-height: 1.5; +} + +.setup__row { display: flex; gap: 12px; flex-wrap: wrap; align-items: stretch; } + +.setup__row input { flex: 1 1 260px; } + +.setup__primary, .setup__ghost, .setup__icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 12px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + min-height: 48px; + padding: 0 20px; + transition: transform 120ms ease, filter 120ms ease; +} + +.setup__primary { background: var(--text); color: #000; border: 1px solid transparent; } + +.setup__primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.05); } + +.setup__primary:disabled { opacity: 0.4; cursor: not-allowed; } + +.setup__ghost { background: var(--chip); color: var(--text); border: 1px solid var(--line); } + +.setup__icon { + background: var(--chip); + color: var(--text); + border: 1px solid var(--line); + min-height: 38px; + padding: 0 11px; +} + +.setup__icon:hover { background: var(--chip-focus); } + +.setup__profiles { display: flex; gap: 10px; flex-wrap: wrap; } + +.setup__profile { + background: var(--chip); + border: 1px solid var(--line); + border-radius: 999px; + color: var(--text); + cursor: pointer; + font-size: 14px; + min-height: 42px; + padding: 0 18px; +} + +.setup__profile.is-active { background: var(--text); color: #000; border-color: var(--text); } + +.setup__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } + +.setup__list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px 16px; + font-size: 14px; +} + +.setup__list li > span { display: grid; gap: 4px; } + +.setup__list li em { color: var(--soft); font-style: normal; font-size: 13px; } + +.setup__list select { width: auto; min-width: 230px; } + +.setup__results > p:first-child { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; color: var(--green); } + +.setup__muted { color: var(--soft); font-size: 13px; margin: 0; line-height: 1.55; } + +.setup__progress { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; } + +.setup__notice { color: var(--green); font-size: 14px; } + +.setup__error { color: var(--red); font-size: 14px; } + +.setup__warning { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--yellow, #ffcd3c); + font-size: 13px; + line-height: 1.5; +} + +.setup .spin { animation: setup-spin 1s linear infinite; } + +@keyframes setup-spin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + .setup .spin { animation: none; } + .setup__primary:hover:not(:disabled) { transform: none; } +} + +@media (max-width: 560px) { + .setup__inner { padding: 32px 16px 72px; } + .setup__card { padding: 18px; border-radius: 16px; } + .setup__list li { flex-direction: column; align-items: stretch; } + .setup__list select, .setup__row .setup__primary { width: 100%; } +} + +.setup__link { + align-self: flex-start; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 13px; + padding: 4px 0; + text-decoration: underline; + text-underline-offset: 3px; +} + +.setup__link:hover { color: var(--text); } diff --git a/web/components/migrate/MigrateApp.tsx b/web/components/migrate/MigrateApp.tsx new file mode 100644 index 000000000..91f367a9b --- /dev/null +++ b/web/components/migrate/MigrateApp.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, ArrowRightLeft, Check, Download, Loader2, LogIn, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"; +import { authClient } from "@/lib/store"; +import { installAddon, normalizeAddons } from "@/lib/addons"; +import { pullCloudPayload, pullCloudProfiles, saveCloudAddons } from "@/lib/cloud"; +import { applyNuvioImport } from "@/lib/nuvioImportRunner"; +import { + defaultChoices, discoverNuvioBackend, fetchNuvioSnapshot, reconcileChoices, signInToNuvio, summarizePlan, OFFICIAL_NUVIO_URL, + type MigrationChoice, type NuvioSnapshot, type ProfileImportSummary +} from "@/lib/nuvioMigration"; +import { defaultSettings } from "@/lib/store"; +import { LanguageProvider, useTranslation } from "@/lib/i18n"; +import { CONTENT_LANGUAGE_OPTIONS } from "@/lib/i18n/languageOptions"; +import { loadStored, saveStored } from "@/lib/storage"; +import type { InstalledAddon, Profile } from "@/lib/types"; + +const LANGUAGE_KEY = "arvio.web.setupLanguage"; + +/** + * Free setup page for ARVIO accounts, outside the web app's subscription: sign + * in, install addons, and bring a Nuvio account over. Everything is written to + * the ARVIO cloud account, which the Android and TV apps sync for free. + */ +export function MigrateApp() { + // English by default; the choice is remembered on this device. + const [language, setLanguage] = useState("en-US"); + // The language list is built with Intl.DisplayNames, which resolves names + // from the host's locale data — the server's and the browser's disagree, so + // the picker is rendered after mount instead of being hydrated. + const [mounted, setMounted] = useState(false); + useEffect(() => { + setLanguage(loadStored(LANGUAGE_KEY, "en-US")); + setMounted(true); + }, []); + const chooseLanguage = (next: string) => { setLanguage(next); saveStored(LANGUAGE_KEY, next); }; + return + + ; +} + +function SetupTools({ language, onLanguageChange, showLanguagePicker }: { + language: string; + onLanguageChange: (value: string) => void; + showLanguagePicker: boolean; +}) { + // Named `text`, not `translateUi`, so this standalone page reuses the shared + // dictionaries without enrolling its own wording in the translated corpus. + const text = useTranslation(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [signedIn, setSignedIn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const [profiles, setProfiles] = useState([]); + const [accountReady, setAccountReady] = useState(false); + const [accountLoading, setAccountLoading] = useState(false); + const [profileId, setProfileId] = useState(null); + const [addons, setAddons] = useState([]); + const [addonUrl, setAddonUrl] = useState(""); + + const [nuvioServer, setNuvioServer] = useState(""); + const [selfHosted, setSelfHosted] = useState(false); + const [nuvioEmail, setNuvioEmail] = useState(""); + const [nuvioPassword, setNuvioPassword] = useState(""); + const [snapshot, setSnapshot] = useState(null); + const [choices, setChoices] = useState([]); + const [results, setResults] = useState(null); + const [progress, setProgress] = useState(""); + + const loadAccount = useCallback(async (preferredProfileId?: string | null) => { + setAccountReady(false); + setAccountLoading(true); + try { + const cloud = await pullCloudProfiles(authClient); + setProfiles(cloud.profiles); + const nextId = preferredProfileId ?? cloud.activeProfileId ?? cloud.profiles[0]?.id ?? null; + setProfileId(nextId); + if (nextId) { + const payload = await pullCloudPayload(authClient, nextId).catch(() => null); + setAddons(normalizeAddons(payload?.addons ?? [])); + } + setAccountReady(true); + } finally { + setAccountLoading(false); + } + }, []); + + useEffect(() => { + if (!authClient.session) return; + setSignedIn(true); + setEmail(authClient.session.email); + void loadAccount().catch(() => setError("Could not read your ARVIO account")); + }, [loadAccount]); + + const run = async (work: () => Promise) => { + setBusy(true); + setError(null); + try { + await work(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : "Something went wrong"); + } finally { + setBusy(false); + setProgress(""); + } + }; + + const signIn = () => run(async () => { + await authClient.signIn(email.trim(), password); + setPassword(""); + setSignedIn(true); + await loadAccount(); + }); + + const selectProfile = (id: string) => run(async () => { + setProfileId(id); + const payload = await pullCloudPayload(authClient, id).catch(() => null); + setAddons(normalizeAddons(payload?.addons ?? [])); + }); + + const addAddon = () => run(async () => { + if (!profileId) throw new Error("Choose a profile first"); + const installed = await installAddon(addonUrl.trim()); + const next = normalizeAddons([...addons, installed]); + await saveCloudAddons(authClient, next, profileId, { changes: undefined }); + setAddons(next); + setAddonUrl(""); + setNotice(`Installed ${installed.name}`); + }); + + const removeAddon = (addon: InstalledAddon) => run(async () => { + if (!profileId) return; + const next = addons.filter(entry => entry.id !== addon.id); + await saveCloudAddons(authClient, next, profileId, { removedIds: [addon.id] }); + setAddons(next); + }); + + /** Keeps a copy of what Nuvio returned, for people without an ARVIO account. */ + const downloadSnapshot = () => { + if (!snapshot) return; + const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "nuvio-setup.json"; + link.click(); + URL.revokeObjectURL(url); + }; + + const connectNuvio = () => run(async () => { + // The normal case is the public Nuvio cloud, which publishes its own + // settings; only a self-hoster has to name a server. + const discovery = await discoverNuvioBackend(selfHosted ? nuvioServer : OFFICIAL_NUVIO_URL); + const session = await signInToNuvio(discovery, nuvioEmail, nuvioPassword); + const pulled = await fetchNuvioSnapshot(discovery, session); + setNuvioPassword(""); + setSnapshot(pulled); + setChoices(defaultChoices(pulled, profiles)); + setResults(null); + }); + + // The Nuvio step runs before the ARVIO sign-in, so the first plan is drawn up + // against an empty profile list and proposes creating everything. Redo it the + // moment real profiles arrive — without touching a choice already made by hand. + useEffect(() => { + if (!snapshot) return; + setChoices(current => reconcileChoices(snapshot, profiles, current)); + }, [snapshot, profiles]); + + const setTarget = (nuvioProfileId: number, value: string) => { + setChoices(current => current.map(choice => choice.nuvioProfileId !== nuvioProfileId ? choice : { + ...choice, + userSet: true, + target: value === "__skip" ? { kind: "skip" } : value === "__create" ? { kind: "create" } : { kind: "existing", profileId: value } + })); + }; + + const runImport = () => run(async () => { + if (!snapshot) return; + if (!accountReady) throw new Error("Load your ARVIO account successfully before importing"); + const outcome = await applyNuvioImport({ + auth: authClient, profiles, snapshot, choices, + baseSettings: defaultSettings, onProgress: setProgress + }); + setResults(outcome.summaries); + await loadAccount(profileId); + }); + + const planned = snapshot ? summarizePlan(snapshot, choices) : []; + + return ( +
+
+
+ + ARVIO +
+
+
+

{text("ARVIO setup")}

+ {showLanguagePicker && } +
+

Install addons and bring your Nuvio setup over. Everything is saved to your ARVIO account, + so the free Android and TV apps pick it up on their next sync — no web subscription needed.

+
+ + {/* Nuvio comes first: reading a Nuvio account needs no ARVIO account, so + someone who has not signed up yet can still pull their setup out. */} +
+

Move from Nuvio

+ {!snapshot && <> +

Sign in with your Nuvio account to read its profiles, addons and collections. + Nothing in Nuvio is changed, and you do not need an ARVIO account for this step.

+ + + {selfHosted + ? + : } + + } + + {snapshot && snapshot.warnings.length > 0 && <> + {snapshot.warnings.map(warning =>

+

)} + + } + + {snapshot && !results && <> +

Found {snapshot.profiles.length} profiles in {snapshot.email}.

+
    + {snapshot.profiles.map(profile => { + const choice = choices.find(entry => entry.nuvioProfileId === profile.profileId); + const value = choice?.target.kind === "existing" ? choice.target.profileId + : choice?.target.kind === "skip" ? "__skip" : "__create"; + const collections = Array.isArray(profile.collectionsJson) ? profile.collectionsJson.length : 0; + const disabled = profile.addons.filter(addon => !addon.enabled).length; + return
  • + + {profile.name} + {profile.addons.length} addons · {collections} collections + {disabled > 0 && ` · ${disabled} switched off in Nuvio`} + {profile.plugins.length > 0 && ` · ${profile.plugins.length} plugins stay in Nuvio`} + + +
  • ; + })} +
+
+ + +
+ {!signedIn &&

Sign in to ARVIO below to copy this into an account, or download it and keep it for later.

} + } + + {results &&
+

+
    + {results.map(result =>
  • + {result.failed + // Nothing was written for this profile, so say so plainly rather + // than letting a zero count read as "there was nothing to bring". + ? {result.profileName} + Not imported — {result.failed}. Nothing was changed on this profile. + : {result.profileName} + {result.addons} addons · {result.collections} collections{result.created ? " · new profile" : ""} + {result.addonsDisabled > 0 && ` · ${result.addonsDisabled} left switched off`} + {result.addonsFailed > 0 && ` · ${result.addonsFailed} could not be reached`}} +
  • )} +
+

Open ARVIO on your TV or phone and sign in with this account to see them.

+
} +
+ + {!signedIn &&
+

Sign in to ARVIO

+

Needed to save addons, or to copy a Nuvio account into ARVIO.

+ + + +
} + + {signedIn && <> +
+

Profile

+ {!accountReady &&

{accountLoading ? "Loading your ARVIO account..." : "Your ARVIO account could not be loaded. Retry before importing."}

} + {!accountReady && } + {accountReady && profiles.length === 0 &&

This account has no profiles yet. Importing from Nuvio will create them.

} +
+ {profiles.map(profile => )} +
+
+ +
+

Addons

+

Paste a Stremio addon manifest URL to install it for this profile.

+
+ setAddonUrl(event.target.value)} + placeholder="https://example.com/manifest.json" spellCheck={false} /> + +
+
    + {addons.map(addon =>
  • + {addon.name} + +
  • )} + {addons.length === 0 &&
  • No addons on this profile yet.
  • } +
+
+ + } + + {busy && progress &&

} + {error &&

{error}

} + {notice && !error &&

{notice}

} +
+
+ ); +} diff --git a/web/lib/cloud.ts b/web/lib/cloud.ts index 5487f6a7a..17caee3c4 100644 --- a/web/lib/cloud.ts +++ b/web/lib/cloud.ts @@ -1454,6 +1454,29 @@ export async function saveCloudProfiles(auth: AuthClient, profiles: Profile[], a }); } +/** Add imported profiles without asserting a stale copy of the account's list. */ +export async function addCloudProfiles(auth: AuthClient, additions: Profile[]): Promise { + if (!auth.session) throw new Error("Sign in to your ARVIO account first"); + let profiles: Profile[] = []; + await mutateCloudPayload(auth, (root) => { + if (root.profiles != null && !Array.isArray(root.profiles)) { + throw new Error("Could not read your ARVIO profiles"); + } + profiles = [...arrayValue(root.profiles)]; + const ids = new Set(profiles.map(profile => profile.id)); + for (const profile of additions) { + if (ids.has(profile.id)) continue; + profiles.push(profile); + ids.add(profile.id); + } + root.profiles = profiles; + if (!profiles.some(profile => profile.id === root.activeProfileId)) { + root.activeProfileId = profiles[0]?.id ?? null; + } + }); + return profiles; +} + const liveAddonIdMarkers = [ "livetv", "live_tv", diff --git a/web/lib/nuvioImportRunner.ts b/web/lib/nuvioImportRunner.ts new file mode 100644 index 000000000..5c57359ff --- /dev/null +++ b/web/lib/nuvioImportRunner.ts @@ -0,0 +1,141 @@ +import { installAddon, normalizeAddon, normalizeAddons } from "./addons"; +import type { AuthClient } from "./auth"; +import { defaultCatalogs } from "./catalogs"; +import { addCloudProfiles, invalidateRawPayloadCache, pullCloudPayload, pullCloudProfiles, saveCloudAddons, saveCloudSettings } from "./cloud"; +import { mergeImportedCollections, parseCustomCollections } from "./customCollections"; +import { + addonsToInstall, applyHomeCatalogSettings, newProfileFrom, + type MigrationChoice, type NuvioSnapshot, type ProfileImportSummary +} from "./nuvioMigration"; +import { profileColors } from "./profiles"; +import type { AppSettings, InstalledAddon, Profile } from "./types"; + +/** + * Writes a Nuvio snapshot into an ARVIO account. + * + * Everything goes through the cloud payload, which is what the free Android and + * TV apps sync from — so a user who never pays for the web interface still ends + * up with their addons, collections and profiles on their devices. + * + * The import owns exactly one field: the profile's catalog list. Everything + * else in the account — theme, AI subtitle settings, IPTV playlists — is read + * first and handed straight back as the baseline, so `saveCloudSettings` can + * see that this session changed nothing there and leaves those fields alone. + * A profile whose current state cannot be read is therefore not importable: + * writing without that baseline would assert defaults over real data. + */ +export async function applyNuvioImport(options: { + auth: AuthClient; + profiles: Profile[]; + snapshot: NuvioSnapshot; + choices: MigrationChoice[]; + baseSettings: AppSettings; + onProgress?: (message: string) => void; +}): Promise<{ summaries: ProfileImportSummary[]; profiles: Profile[] }> { + const { auth, snapshot, choices, baseSettings, onProgress } = options; + if (!auth.session) throw new Error("Sign in to your ARVIO account first"); + + if (!choices.some(choice => choice.target.kind !== "skip")) return { summaries: [], profiles: options.profiles }; + invalidateRawPayloadCache(); + let profiles = [...(await pullCloudProfiles(auth)).profiles]; + const additions: Profile[] = []; + const targets = new Map(); + + // Profiles first: a Nuvio profile with no ARVIO counterpart is created, so a + // multi-profile Nuvio account arrives complete instead of collapsing into one. + for (const profile of snapshot.profiles) { + const choice = choices.find(entry => entry.nuvioProfileId === profile.profileId); + if (!choice || choice.target.kind === "skip") continue; + if (choice.target.kind === "existing") { + const profileId = choice.target.profileId; + if (!profiles.some(entry => entry.id === profileId)) { + throw new Error("An ARVIO profile is no longer available. Reload your account and choose again."); + } + targets.set(profile.profileId, { id: profileId, created: false }); + continue; + } + onProgress?.(`Creating profile ${profile.name}`); + const id = globalThis.crypto?.randomUUID?.() ?? `p_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const color = profileColors[profiles.length % profileColors.length]; + const created = newProfileFrom(profile, id, color); + profiles = [...profiles, created]; + additions.push(created); + targets.set(profile.profileId, { id, created: true }); + } + if (!targets.size) return { summaries: [], profiles }; + if (additions.length) profiles = await addCloudProfiles(auth, additions); + + const summaries: ProfileImportSummary[] = []; + for (const profile of snapshot.profiles) { + const target = targets.get(profile.profileId); + if (!target) continue; + const summary: ProfileImportSummary = { + profileName: profile.name, created: target.created, addons: 0, addonsFailed: 0, + addonsDisabled: 0, plugins: profile.plugins.length, collections: 0, catalogSettings: false + }; + + onProgress?.(`${profile.name}: reading what is already there`); + // A read failure is not an empty profile. Stop here and write nothing: the + // alternative is asserting default settings over an account we cannot see. + // A newly created profile is read too — the account-wide settings behind it + // (theme, AI key, IPTV) are shared and must survive the same way. + let existing; + try { + invalidateRawPayloadCache(); + existing = await pullCloudPayload(auth, target.id); + } catch (failure) { + const message = (failure as { message?: unknown } | null)?.message; + summary.failed = typeof message === "string" && message ? message : "Could not read this profile from your account"; + summaries.push(summary); + continue; + } + const currentAddons = normalizeAddons(existing.addons ?? []); + // `base` is the account exactly as it stands. It is both what the write is + // built from and the baseline it is compared against, which is what keeps + // every field except `catalogs` out of the write. + const base: AppSettings = { ...baseSettings, ...(existing.settings ?? {}) }; + if (!Array.isArray(base.catalogs)) base.catalogs = []; + const currentCatalogs = base.catalogs.length ? base.catalogs : defaultCatalogs; + + // Addons are stored as resolved manifests, so each new URL is fetched once. + const resolved: InstalledAddon[] = []; + for (const addon of addonsToInstall(currentAddons.map(entry => entry.manifestUrl ?? entry.id), profile.addons)) { + onProgress?.(`${profile.name}: installing ${addon.url}`); + try { + const installed = await installAddon(addon.url); + // An addon switched off in Nuvio arrives switched off, rather than + // turning itself back on in the middle of someone's home screen. + resolved.push(addon.enabled ? installed : normalizeAddon({ ...installed, enabled: false })!); + summary.addons += 1; + if (!addon.enabled) summary.addonsDisabled += 1; + } catch { + // An addon whose server is down must not abort the rest of the import. + summary.addonsFailed += 1; + } + } + const nextAddons = normalizeAddons([...currentAddons, ...resolved]); + + let nextCatalogs = currentCatalogs; + if (Array.isArray(profile.collectionsJson) && profile.collectionsJson.length) { + onProgress?.(`${profile.name}: importing collections`); + try { + const imported = await parseCustomCollections(JSON.stringify(profile.collectionsJson)); + nextCatalogs = mergeImportedCollections(nextCatalogs, imported); + summary.collections = profile.collectionsJson.length; + } catch { + summary.collections = 0; + } + } + const orderedCatalogs = applyHomeCatalogSettings(nextCatalogs, profile.homeCatalogSettings); + summary.catalogSettings = orderedCatalogs !== nextCatalogs; + + onProgress?.(`${profile.name}: saving to your ARVIO account`); + const settings: AppSettings = { ...base, catalogs: orderedCatalogs }; + if (resolved.length) await saveCloudAddons(auth, nextAddons, target.id, { changes: undefined }); + // Profile creation has its own additive write; settings must not reassert + // the list if another device creates or edits a profile during the import. + await saveCloudSettings(auth, settings, nextAddons, target.id, [], base); + summaries.push(summary); + } + return { summaries, profiles }; +} diff --git a/web/lib/nuvioMigration.ts b/web/lib/nuvioMigration.ts new file mode 100644 index 000000000..bf45ea55b --- /dev/null +++ b/web/lib/nuvioMigration.ts @@ -0,0 +1,509 @@ +import type { CatalogConfig, Profile } from "./types"; + +/** + * Migration from a Nuvio account into ARVIO. + * + * Nuvio stores a user's setup server side (Supabase): `profiles`, `addons`, + * `plugins`, `collections` and `home_catalog_settings`, all keyed by an integer + * `profile_id` (1-6). Nuvio Web keeps nothing importable in the browser, so the + * only way to carry a setup over is to read it from the user's own account. + * + * Deployments publish their connection settings at `/.well-known/nuvio`, which + * is how a self-hosted server hands clients its publishable key. Nothing here is + * hard-coded: the user names their server and signs in with their own account, + * and the access token lives in memory for the length of the import only. + */ + +export interface NuvioDiscovery { + backendUrl: string; + publishableKey: string; + selfHosted: boolean; + emailPasswordAuth: boolean; +} + +export interface NuvioSession { + accessToken: string; + userId: string; + email: string; +} + +/** One Nuvio profile plus everything that belongs to it. */ +export interface NuvioProfileData { + profileId: number; + name: string; + avatarColorHex: string; + avatarId: string | null; + pinEnabled: boolean; + addons: NuvioAddon[]; + plugins: NuvioPlugin[]; + collectionsJson: unknown; + homeCatalogSettings: unknown; +} + +export interface NuvioAddon { + url: string; + name: string | null; + enabled: boolean; + sortOrder: number; +} + +export interface NuvioPlugin extends NuvioAddon { + repoType: string | null; +} + +export interface NuvioSnapshot { + email: string; + profiles: NuvioProfileData[]; + /** Tables that could not be read, so the UI can say what is missing rather than implying nothing was there. */ + warnings: string[]; +} + +const trimSlash = (value: string) => value.trim().replace(/\/+$/, ""); + +/** Accepts "example.com", "https://example.com" or a full well-known URL. */ +export function normalizeBackendUrl(raw: string): string { + const input = raw.trim(); + if (!input) throw new Error("Enter your Nuvio server address"); + // A local server is reached over plain HTTP, so a scheme-less "localhost:8000" + // must not be turned into an https URL nothing is listening on. + const isLocal = /^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(input); + const withScheme = /^https?:\/\//i.test(input) ? input : `${isLocal ? "http" : "https"}://${input}`; + const url = new URL(withScheme); + if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { + throw new Error("The Nuvio server address must use HTTPS"); + } + return trimSlash(url.origin + url.pathname.replace(/\/\.well-known\/nuvio\/?$/, "")); +} + +export function parseDiscovery(raw: unknown, fallbackUrl: string): NuvioDiscovery { + const doc = (raw && typeof raw === "object" ? raw : {}) as Record; + const publishableKey = typeof doc.publishable_key === "string" ? doc.publishable_key.trim() : ""; + if (!publishableKey) throw new Error("That server did not return a Nuvio publishable key"); + const capabilities = (doc.capabilities && typeof doc.capabilities === "object" ? doc.capabilities : {}) as Record; + return { + backendUrl: typeof doc.backend_url === "string" && doc.backend_url.trim() ? trimSlash(doc.backend_url) : fallbackUrl, + publishableKey, + selfHosted: doc.self_hosted === true, + // Older deployments omit the capability block; assume the standard login works. + emailPasswordAuth: capabilities.email_password_auth !== false + }; +} + +/** + * The public Nuvio cloud. It publishes its own `/.well-known/nuvio`, so the + * page only needs the address: the publishable key is read at connect time and + * nothing has to be embedded here (and a rotated key keeps working). + */ +export const OFFICIAL_NUVIO_URL = "https://api.nuvio.tv"; + +type Fetcher = typeof fetch; + +export async function discoverNuvioBackend(rawUrl: string, fetcher: Fetcher = fetch): Promise { + const backendUrl = normalizeBackendUrl(rawUrl); + const response = await fetcher(`${backendUrl}/.well-known/nuvio`, { headers: { accept: "application/json" } }); + if (!response.ok) { + throw new Error(`Could not read the Nuvio server settings (HTTP ${response.status})`); + } + return parseDiscovery(await response.json(), backendUrl); +} + +export async function signInToNuvio( + discovery: NuvioDiscovery, + email: string, + password: string, + fetcher: Fetcher = fetch +): Promise { + if (!discovery.emailPasswordAuth) throw new Error("This Nuvio server has email sign-in disabled"); + const response = await fetcher(`${discovery.backendUrl}/auth/v1/token?grant_type=password`, { + method: "POST", + headers: { "content-type": "application/json", apikey: discovery.publishableKey }, + body: JSON.stringify({ email: email.trim(), password }) + }); + const payload = await response.json().catch(() => ({})) as Record; + if (!response.ok) { + const message = typeof payload.error_description === "string" ? payload.error_description + : typeof payload.msg === "string" ? payload.msg + : "Nuvio rejected that email or password"; + throw new Error(message); + } + const accessToken = typeof payload.access_token === "string" ? payload.access_token : ""; + const user = (payload.user && typeof payload.user === "object" ? payload.user : {}) as Record; + if (!accessToken) throw new Error("Nuvio did not return an access token"); + return { + accessToken, + userId: typeof user.id === "string" ? user.id : "", + email: typeof user.email === "string" ? user.email : email.trim() + }; +} + +/** + * A table read. `ok: false` means the request itself did not succeed, which is + * not the same thing as a table that answered with nothing — conflating the two + * is how an import ends up deciding an account is empty when it is unreachable. + */ +export interface TableRead { + table: string; + ok: boolean; + rows: Record[]; +} + +const objectRows = (value: unknown): Record[] => + Array.isArray(value) ? value.filter((row): row is Record => Boolean(row) && typeof row === "object") : []; + +export async function selectRows( + discovery: NuvioDiscovery, + session: NuvioSession, + table: string, + query: string, + fetcher: Fetcher +): Promise { + const response = await fetcher(`${discovery.backendUrl}/rest/v1/${table}?${query}`, { + headers: { + apikey: discovery.publishableKey, + authorization: `Bearer ${session.accessToken}`, + accept: "application/json" + } + }).catch(() => null); + // A table a deployment does not have (or does not expose) must not fail the + // whole import — the user still gets everything else, and the failure is + // reported instead of passing for an empty table. + if (!response?.ok) return { table, ok: false, rows: [] }; + const rows: unknown = await response.json().catch(() => null); + if (!Array.isArray(rows)) return { table, ok: false, rows: [] }; + return { table, ok: true, rows: objectRows(rows) }; +} + +/** + * Row-level security on a Nuvio deployment can leave a plain table read empty + * even though the data is there; the sync RPCs are the supported way in. They + * take one profile at a time, so they are only used to fill a gap. + */ +async function callSyncRpc( + discovery: NuvioDiscovery, + session: NuvioSession, + name: string, + profileIds: number[], + fetcher: Fetcher +): Promise<{ rows: Record[]; failedProfileIds: number[] }> { + const rows: Record[] = []; + const failedProfileIds: number[] = []; + for (const profileId of profileIds) { + const response = await fetcher(`${discovery.backendUrl}/rest/v1/rpc/${name}`, { + method: "POST", + headers: { + apikey: discovery.publishableKey, + authorization: `Bearer ${session.accessToken}`, + "content-type": "application/json", + accept: "application/json" + }, + body: JSON.stringify({ p_profile_id: profileId }) + }).catch(() => null); + if (!response?.ok) { + failedProfileIds.push(profileId); + continue; + } + const payload: unknown = await response.json().catch(() => undefined); + if (payload === undefined || (payload !== null && typeof payload !== "object")) { + failedProfileIds.push(profileId); + continue; + } + const list = Array.isArray(payload) ? payload : payload ? [payload] : []; + for (const row of list) { + if (row && typeof row === "object") { + rows.push({ profile_id: profileId, ...(row as Record) }); + } + } + } + return { rows, failedProfileIds }; +} + +const text = (value: unknown): string => typeof value === "string" ? value.trim() : ""; +const int = (value: unknown, fallback: number): number => { + const parsed = typeof value === "number" ? value : Number.parseInt(text(value), 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +export function mapAddonRows(rows: Record[]): NuvioAddon[] { + return rows + .map(row => ({ + url: text(row.url), + name: text(row.name) || null, + enabled: row.enabled !== false, + sortOrder: int(row.sort_order, 0) + })) + .filter(addon => /^https?:\/\//i.test(addon.url)) + .sort((a, b) => a.sortOrder - b.sortOrder); +} + +export function mapPluginRows(rows: Record[]): NuvioPlugin[] { + return mapAddonRows(rows).map((addon, index) => ({ + ...addon, + repoType: text(rows[index]?.repo_type) || null + })); +} + +/** Groups every table by Nuvio's integer profile id. */ +export function buildSnapshot( + email: string, + profileRows: Record[], + addonRows: Record[], + pluginRows: Record[], + collectionRows: Record[], + catalogSettingRows: Record[], + warnings: string[] = [] +): NuvioSnapshot { + const byProfile = >(rows: T[], profileId: number) => + rows.filter(row => int(row.profile_id, 1) === profileId); + const profiles = profileRows + .map(row => { + const profileId = int(row.profile_id, int(row.profile_index, 1)); + const addons = byProfile(addonRows, profileId); + const plugins = byProfile(pluginRows, profileId); + return { + profileId, + name: text(row.name) || `Profile ${profileId}`, + avatarColorHex: text(row.avatar_color_hex) || "#1E88E5", + avatarId: text(row.avatar_id) || null, + pinEnabled: row.pin_enabled === true, + addons: mapAddonRows(addons), + plugins: mapPluginRows(plugins), + collectionsJson: byProfile(collectionRows, profileId)[0]?.collections_json ?? null, + homeCatalogSettings: byProfile(catalogSettingRows, profileId)[0]?.settings_json ?? null + }; + }) + .sort((a, b) => a.profileId - b.profileId); + return { email, profiles, warnings }; +} + +/** Nuvio's profile ids are 1-6; a gap-free scan is what the fallback probes. */ +const PROBE_PROFILE_IDS = [1, 2, 3, 4, 5, 6]; + +/** + * Recovers the profile ids present in an account when the `profiles` table + * itself is unavailable. Every other table carries `profile_id`, so a profile + * is known to exist as soon as any of its rows comes back — building only + * profile 1 silently dropped everything that belonged to the others. + */ +export function profileIdsFromRows(...rowSets: Record[][]): number[] { + const ids = new Set(); + for (const rows of rowSets) { + for (const row of rows) { + const id = int(row.profile_id, int(row.profile_index, 0)); + if (id >= 1) ids.add(id); + } + } + return [...ids].sort((a, b) => a - b); +} + +export async function fetchNuvioSnapshot( + discovery: NuvioDiscovery, + session: NuvioSession, + fetcher: Fetcher = fetch +): Promise { + const [profiles, addons, plugins, collections, catalogSettings] = await Promise.all([ + selectRows(discovery, session, "profiles", "select=*&order=profile_id.asc", fetcher), + selectRows(discovery, session, "addons", "select=*&order=sort_order.asc", fetcher), + selectRows(discovery, session, "plugins", "select=*&order=sort_order.asc", fetcher), + selectRows(discovery, session, "collections", "select=*", fetcher), + selectRows(discovery, session, "home_catalog_settings", "select=*", fetcher) + ]); + + const warnings: string[] = []; + // A failed read is reported; a read that succeeded with no rows is simply an + // empty table and says nothing is there to bring over. + for (const read of [addons, plugins]) { + if (!read.ok) warnings.push(`Nuvio did not return your ${read.table} — none were imported`); + } + + // The RPCs only answer one profile at a time, so they are probed across + // Nuvio's whole 1-6 range whenever the table read did not produce rows. + const knownIds = profiles.ok && profiles.rows.length + ? profileIdsFromRows(profiles.rows) + : profileIdsFromRows(addons.rows, plugins.rows, collections.rows, catalogSettings.rows); + const probeIds = profiles.ok && profiles.rows.length ? knownIds : PROBE_PROFILE_IDS; + + const readWithFallback = async (read: TableRead, rpc: string) => { + if (read.ok && read.rows.length) return read.rows; + const result = await callSyncRpc(discovery, session, rpc, probeIds, fetcher); + if (result.failedProfileIds.length) { + warnings.push(`Nuvio did not return ${read.table} for profiles ${result.failedProfileIds.join(", ")} - this part of their setup could not be imported. Reconnect to Nuvio to retry reading this data.`); + } + return result.rows; + }; + const effectiveCollections = await readWithFallback(collections, "sync_pull_collections"); + const effectiveCatalogSettings = await readWithFallback(catalogSettings, "sync_pull_home_catalog_settings"); + + let effectiveProfiles = profiles.rows; + if (!profiles.ok || !profiles.rows.length) { + if (!profiles.ok) warnings.push("Nuvio's profile list could not be read, so profiles were recovered from their content"); + // Every id any table answered for is a profile that exists. Falling back to + // profile 1 alone used to drop the rest of the account on the floor. + const recovered = profileIdsFromRows( + addons.rows, plugins.rows, effectiveCollections, effectiveCatalogSettings + ); + const ids = (recovered.length ? recovered : knownIds.length ? knownIds : [1]); + effectiveProfiles = ids.map(id => ({ profile_id: id, name: `Profile ${id}` })); + } + + const snapshot = buildSnapshot( + session.email, effectiveProfiles, addons.rows, plugins.rows, + effectiveCollections, effectiveCatalogSettings, warnings + ); + if (!snapshot.profiles.length) throw new Error("That Nuvio account has no profiles to import"); + return snapshot; +} + +// ── Mapping Nuvio profiles onto ARVIO profiles ────────────────────────────── + +/** Nuvio stores "#RRGGBB"; ARVIO stores the Android ARGB long. */ +export function hexToArgb(hex: string, fallback: number): number { + const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return fallback; + return (0xff000000 | Number.parseInt(match[1], 16)) >>> 0; +} + +/** Nuvio avatar ids are free text; ARVIO uses 0 (initial) or an avatar 1-84. */ +export function avatarIdToArvio(avatarId: string | null): number { + const digits = /(\d{1,3})/.exec(avatarId ?? ""); + if (!digits) return 0; + const parsed = Number.parseInt(digits[1], 10); + return parsed >= 1 && parsed <= 84 ? parsed : 0; +} + +export type ProfileTarget = + | { kind: "existing"; profileId: string } + | { kind: "create" } + | { kind: "skip" }; + +export interface MigrationChoice { + nuvioProfileId: number; + target: ProfileTarget; + /** Set once the user picks a target by hand, so a later recalculation leaves it alone. */ + userSet?: boolean; +} + +const normalizedName = (value: string) => value.trim().toLocaleLowerCase("en"); + +/** + * Default plan: a Nuvio profile lands on the ARVIO profile with the same name, + * otherwise it is created. That keeps a repeated import idempotent instead of + * growing a second copy of every profile. + */ +export function defaultChoices(snapshot: NuvioSnapshot, existing: Pick[]): MigrationChoice[] { + const taken = new Set(); + return snapshot.profiles.map(profile => { + const match = existing.find(candidate => + normalizedName(candidate.name) === normalizedName(profile.name) && !taken.has(candidate.id)); + if (match) taken.add(match.id); + return { nuvioProfileId: profile.profileId, target: match ? { kind: "existing", profileId: match.id } : { kind: "create" } }; + }); +} + +/** + * Recomputes the default plan against a profile list that arrived later — the + * Nuvio step runs before the ARVIO sign-in, so the first pass necessarily saw + * no profiles and proposed creating all of them. A choice the user already + * changed by hand is kept exactly as they set it. + */ +export function reconcileChoices( + snapshot: NuvioSnapshot, + existing: Pick[], + previous: MigrationChoice[] +): MigrationChoice[] { + const kept = new Map(previous.filter(choice => choice.userSet).map(choice => [choice.nuvioProfileId, choice])); + // Names the user has already claimed by hand must not be matched again. + const claimed = new Set( + [...kept.values()].flatMap(choice => choice.target.kind === "existing" ? [choice.target.profileId] : []) + ); + const available = existing.filter(profile => !claimed.has(profile.id)); + const recomputed = defaultChoices(snapshot, available); + return recomputed.map(choice => kept.get(choice.nuvioProfileId) ?? choice); +} + +export function newProfileFrom(profile: NuvioProfileData, id: string, fallbackColor: number): Profile { + return { + id, + name: profile.name.slice(0, 40), + avatarColor: hexToArgb(profile.avatarColorHex, fallbackColor), + avatarId: avatarIdToArvio(profile.avatarId), + createdAt: Date.now(), + lastUsedAt: 0, + // A Nuvio PIN hash cannot be carried over, so the copy starts unlocked and + // the user sets a new PIN in ARVIO rather than being locked out of it. + isLocked: false, + pin: null + }; +} + +export interface ProfileImportSummary { + profileName: string; + created: boolean; + addons: number; + addonsFailed: number; + /** Installed but left switched off, because they were switched off in Nuvio. */ + addonsDisabled: number; + plugins: number; + collections: number; + catalogSettings: boolean; + /** Why nothing was written for this profile. Set only when the import stopped. */ + failed?: string; +} + +/** Counts what a plan would bring over, for the confirmation step. */ +export function summarizePlan(snapshot: NuvioSnapshot, choices: MigrationChoice[]): ProfileImportSummary[] { + return snapshot.profiles.flatMap(profile => { + const choice = choices.find(entry => entry.nuvioProfileId === profile.profileId); + if (!choice || choice.target.kind === "skip") return []; + const collections = Array.isArray(profile.collectionsJson) ? profile.collectionsJson.length : 0; + return [{ + profileName: profile.name, + created: choice.target.kind === "create", + addons: profile.addons.length, + addonsFailed: 0, + addonsDisabled: profile.addons.filter(addon => !addon.enabled).length, + plugins: profile.plugins.length, + collections, + catalogSettings: Boolean(profile.homeCatalogSettings) + }]; + }); +} + +/** + * The Nuvio addons that are not on the profile yet. Whole entries rather than + * URLs, so the import can carry each one's `enabled` state across instead of + * switching on an addon the user had deliberately switched off. + */ +export function addonsToInstall(existing: string[], incoming: NuvioAddon[]): NuvioAddon[] { + const seen = new Set(existing.map(url => url.trim().toLowerCase())); + const added: NuvioAddon[] = []; + for (const addon of incoming) { + const key = addon.url.trim().toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + added.push(addon); + } + return added; +} + +/** + * Nuvio's home catalog settings describe row order and visibility by catalog id. + * ARVIO keeps the same two ideas, so the parts that line up are applied and the + * rest is ignored rather than guessed at. + */ +export function applyHomeCatalogSettings(catalogs: CatalogConfig[], settings: unknown): CatalogConfig[] { + const doc = (settings && typeof settings === "object" ? settings : {}) as Record; + const order = Array.isArray(doc.order) ? doc.order.map(text).filter(Boolean) + : Array.isArray(doc.catalogOrder) ? doc.catalogOrder.map(text).filter(Boolean) : []; + const hidden = new Set( + (Array.isArray(doc.hidden) ? doc.hidden : Array.isArray(doc.hiddenCatalogIds) ? doc.hiddenCatalogIds : []) + .map(text).filter(Boolean) + ); + if (!order.length && !hidden.size) return catalogs; + const ranked = catalogs.map(catalog => ({ + catalog: hidden.has(catalog.id) ? { ...catalog, enabled: false } : catalog, + rank: order.indexOf(catalog.id) + })); + return ranked + .sort((a, b) => (a.rank < 0 ? Number.MAX_SAFE_INTEGER : a.rank) - (b.rank < 0 ? Number.MAX_SAFE_INTEGER : b.rank)) + .map(entry => entry.catalog); +} diff --git a/web/scripts/check-nuvio-ui.cjs b/web/scripts/check-nuvio-ui.cjs new file mode 100644 index 000000000..cfe5480bb --- /dev/null +++ b/web/scripts/check-nuvio-ui.cjs @@ -0,0 +1,84 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const assert = require('node:assert/strict'); +const esbuild = require('esbuild'); +const { chromium } = require('@playwright/test'); + +(async () => { + const root = path.resolve(__dirname, '..'); + const output = path.resolve(root, '../artifacts/nuvio-import'); + fs.mkdirSync(output, { recursive: true }); + const stub = path.join(root, 'tests/nuvio-ui/stubs.tsx'); + await esbuild.build({ + entryPoints: [path.join(root, 'tests/nuvio-ui/entry.tsx')], bundle: true, + outfile: path.join(output, 'app.js'), jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"test"' }, + plugins: [{ name: 'offline-account', setup(build) { + build.onResolve({ filter: /^@\/lib\// }, () => ({ path: stub })); + } }] + }); + const server = http.createServer((req, res) => { + if (req.url === '/app.js') { + res.setHeader('content-type', 'text/javascript'); + fs.createReadStream(path.join(output, 'app.js')).pipe(res); + } else if (req.url === '/setup.css') { + res.setHeader('content-type', 'text/css'); + fs.createReadStream(path.join(root, 'app/migrate/setup.css')).pipe(res); + } else if (req.url === '/globals.css') { + res.setHeader('content-type', 'text/css'); + fs.createReadStream(path.join(root, 'app/globals.css')).pipe(res); + } else if (req.url.startsWith('/logos/')) { + const file = path.join(root, 'public/logos', path.basename(req.url)); + if (!fs.existsSync(file)) { res.writeHead(404).end(); return; } + res.setHeader('content-type', file.endsWith('.svg') ? 'image/svg+xml' : 'image/png'); + fs.createReadStream(file).pipe(res); + } else if (req.url === '/arvio-icon-192.png') { + res.setHeader('content-type', 'image/png'); + fs.createReadStream(path.join(root, 'public/arvio-icon-192.png')).pipe(res); + } else { + res.setHeader('content-type', 'text/html'); + res.end('
'); + } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let browser; + try { + browser = await chromium.launch({ channel: 'msedge', headless: true }); + for (const [name, width, height] of [['desktop', 1440, 1000], ['mobile', 390, 844]]) { + const page = await browser.newPage({ viewport: { width, height } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.getByText('Could not read your ARVIO account', { exact: true }).waitFor(); + const nuvio = page.locator('section').filter({ has: page.getByRole('heading', { name: /Nuvio/ }) }); + await nuvio.locator('input[type=email]').fill('demo@nuvio.example'); + await nuvio.locator('input[type=password]').fill('test-password'); + await nuvio.locator('button.setup__primary').first().click(); + const copy = page.getByRole('button', { name: /Copy 1 profiles into ARVIO/ }); + await copy.waitFor(); + assert.equal(await copy.isDisabled(), true, 'failed account load must block importing'); + assert.equal(await page.getByText('This account has no profiles yet.', { exact: false }).count(), 0); + await page.getByRole('button', { name: 'Retry loading account' }).click(); + await page.waitForFunction(() => window.nuvioTest.resolveProfiles !== null); + assert.equal(await copy.isDisabled(), true, 'pending retry must block importing'); + await page.evaluate(() => window.nuvioTest.resolveProfiles()); + await page.waitForFunction(() => ![...document.querySelectorAll('button')].find(button => button.textContent.includes('Copy 1 profiles')).disabled); + await copy.click(); + await page.getByText('Import finished with warnings', { exact: true }).waitFor(); + await page.getByRole('status').filter({ hasText: 'Nuvio did not return collections' }).waitFor(); + assert.equal(await page.evaluate(() => window.nuvioTest.imports), 1); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + assert.deepEqual(errors, []); + assert.equal(await page.locator('.setup__brand img').evaluate(image => image.complete && image.naturalWidth > 0), true); + await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: true }); + await page.getByRole('button', { name: 'Reconnect to Nuvio' }).click(); + await page.getByRole('button', { name: 'Connect to Nuvio', exact: true }).waitFor(); + await page.close(); + console.log(`${name}: failed/pending account load blocked; retry enabled import; warnings visible`); + } + } finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/web/tests/nuvio-import-runner.test.cjs b/web/tests/nuvio-import-runner.test.cjs new file mode 100644 index 000000000..ba6057f02 --- /dev/null +++ b/web/tests/nuvio-import-runner.test.cjs @@ -0,0 +1,194 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { load } = require('./load.cjs'); +// Values produced inside the sandbox carry that realm's prototypes. +const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected); + +const snapshot = { + email: 'demo@nuvio.example', + warnings: [], + profiles: [ + { + profileId: 1, name: 'Main', avatarColorHex: '#E50914', avatarId: 'avatar_3', pinEnabled: false, + addons: [{ url: 'https://a.example/manifest.json', name: 'A', enabled: true, sortOrder: 0 }, + { url: 'https://down.example/manifest.json', name: 'Down', enabled: true, sortOrder: 1 }, + { url: 'https://off.example/manifest.json', name: 'Off', enabled: false, sortOrder: 2 }], + plugins: [{ url: 'https://p.example/repo.json', name: 'P', enabled: true, sortOrder: 0, repoType: 'github' }], + collectionsJson: [{ id: 'studios', title: 'Studios', folders: [ + { id: 'pixar', title: 'Pixar', sources: [{ provider: 'tmdb', tmdbSourceType: 'COMPANY', tmdbId: 3, mediaType: 'MOVIE' }] } + ] }], + homeCatalogSettings: null + }, + { + profileId: 2, name: 'Kids', avatarColorHex: '#1DB954', avatarId: 'avatar_9', pinEnabled: true, + addons: [{ url: 'https://a.example/manifest.json', name: 'A', enabled: true, sortOrder: 0 }], + plugins: [], collectionsJson: null, homeCatalogSettings: null + } + ] +}; + +function setup({ existingProfiles = [{ id: 'p1', name: 'Main' }, { id: 'p2', name: 'Kids' }], existingAddons = [], pull } = {}) { + const calls = { addons: [], settings: [], profiles: [], installed: [], saved: [] }; + const runner = load('lib/nuvioImportRunner.ts', { + './addons': { + installAddon: async (url) => { + calls.installed.push(url); + if (url.includes('down.example')) throw new Error('unreachable'); + return { id: url, name: `Addon ${calls.installed.length}`, manifestUrl: url, enabled: true }; + }, + normalizeAddons: (list) => list.filter(Boolean), + normalizeAddon: (addon) => ({ ...addon, isEnabled: addon.enabled !== false }) + }, + './auth': {}, + './catalogs': { defaultCatalogs: [{ id: 'trending', enabled: true }] }, + './cloud': { + invalidateRawPayloadCache: () => {}, + pullCloudProfiles: async () => ({ profiles: existingProfiles, activeProfileId: existingProfiles[0]?.id ?? null }), + pullCloudPayload: pull ?? (async () => ({ addons: existingAddons, settings: { catalogs: [{ id: 'trending', enabled: true }] } })), + saveCloudAddons: async (_auth, addons, profileId) => calls.addons.push({ profileId, count: addons.length, addons }), + addCloudProfiles: async (_auth, additions) => { + existingProfiles = [...existingProfiles, ...additions]; + calls.profiles.push(existingProfiles.map(p => p.name)); + return existingProfiles; + }, + saveCloudSettings: async (_auth, settings, _addons, profileId, _profiles, baseline) => + calls.settings.push({ profileId, catalogs: settings.catalogs.length, settings, baseline, profiles: _profiles }) + }, + './customCollections': load('lib/customCollections.ts'), + './nuvioMigration': load('lib/nuvioMigration.ts'), + './profiles': { profileColors: [1, 2, 3] }, + './types': {} + }); + return { runner, calls }; +} + +const auth = { session: { userId: 'u1', email: 'me@arvio.example' } }; +const baseSettings = { catalogs: [] }; + +test('creates the missing profiles and writes each one to the account', async () => { + const { runner, calls } = setup({ existingProfiles: [{ id: 'p1', name: 'Main' }] }); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [ + { nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }, + { nuvioProfileId: 2, target: { kind: 'create' } } + ], + baseSettings + }); + // Kids had no ARVIO profile, so it was created before anything was written. + same(calls.profiles, [['Main', 'Kids']]); + assert.equal(result.profiles.length, 2); + assert.equal(result.summaries.length, 2); + + const main = result.summaries[0]; + assert.equal(main.addons, 2); // two installed + assert.equal(main.addonsFailed, 1); // the unreachable one is reported, not fatal + assert.equal(main.collections, 1); + assert.equal(main.plugins, 1); // counted so the UI can say they stay behind + assert.equal(result.summaries[1].created, true); + + // Settings (catalogs) are written for both profiles, addons only where new ones resolved. + same(calls.settings.map(entry => entry.profileId).sort(), ['p1', result.profiles[1].id].sort()); + assert.equal(calls.addons.length, 2); +}); + +test('skips profiles the user did not choose and never writes them', async () => { + const { runner, calls } = setup(); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'skip' } }, { nuvioProfileId: 2, target: { kind: 'skip' } }], + baseSettings + }); + same(result.summaries, []); + same(calls.settings, []); + same(calls.profiles, []); +}); + +test('an addon already on the profile is not installed twice', async () => { + const { runner, calls } = setup({ existingAddons: [{ id: 'x', name: 'A', manifestUrl: 'https://a.example/manifest.json' }] }); + await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }], + baseSettings + }); + same(calls.installed, ['https://down.example/manifest.json', 'https://off.example/manifest.json']); +}); + +test('refuses to run without an ARVIO session', async () => { + const { runner } = setup(); + await assert.rejects( + runner.applyNuvioImport({ auth: { session: null }, profiles: [], snapshot, choices: [], baseSettings }), + /Sign in to your ARVIO account/ + ); +}); + +// ── Regression: the five findings on PR #774 ──────────────────────────────── + +test('a profile whose current state cannot be read is skipped, not overwritten', async () => { + const { runner, calls } = setup({ + pull: async (_auth, profileId) => { + if (profileId === 'p1') throw new Error('network down'); + return { addons: [], settings: { catalogs: [{ id: 'trending', enabled: true }] } }; + } + }); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }, { id: 'p2', name: 'Kids' }], snapshot, + choices: [ + { nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }, + { nuvioProfileId: 2, target: { kind: 'existing', profileId: 'p2' } } + ], + baseSettings + }); + // Nothing at all is written for the profile that could not be read — not + // settings, not addons — and the failure is reported instead of swallowed. + assert.match(result.summaries[0].failed, /network down/); + same(calls.settings.map(entry => entry.profileId), ['p2']); + same(calls.addons.map(entry => entry.profileId), ['p2']); + // The other profile still imports. + assert.equal(result.summaries[1].failed, undefined); +}); + +test('the write is limited to catalogs and carries the account as its baseline', async () => { + const cloudSettings = { + catalogs: [{ id: 'trending', enabled: true }], + accentColor: '#00d588', aiSubtitlesEnabled: true, aiApiKey: 'secret-key', + iptvPlaylists: [{ id: 'a', m3uUrl: 'https://example.invalid/a' }] + }; + const { runner, calls } = setup({ pull: async () => ({ addons: [], settings: cloudSettings }) }); + await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }], + // The defaults the page carries must never reach the account. + baseSettings: { ...baseSettings, accentColor: '#ffffff', aiSubtitlesEnabled: false, aiApiKey: '', iptvPlaylists: [] } + }); + const write = calls.settings[0]; + // Everything the account already had is handed back untouched… + assert.equal(write.settings.accentColor, '#00d588'); + assert.equal(write.settings.aiApiKey, 'secret-key'); + assert.equal(write.settings.aiSubtitlesEnabled, true); + same(write.settings.iptvPlaylists, cloudSettings.iptvPlaylists); + // …and the baseline matches it field for field, so saveCloudSettings asserts + // nothing but the catalogs this import actually changed. + const { catalogs: _written, ...writtenRest } = write.settings; + const { catalogs: _base, ...baselineRest } = write.baseline; + same(writtenRest, JSON.parse(JSON.stringify(baselineRest))); + same(write.baseline.catalogs, cloudSettings.catalogs); + assert.ok(write.settings.catalogs.length > cloudSettings.catalogs.length); + same(write.profiles, []); + same(calls.profiles, []); +}); + +test('an addon switched off in Nuvio arrives switched off', async () => { + const { runner, calls } = setup(); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }], + baseSettings + }); + const written = calls.addons[0].addons; + const off = written.find(addon => addon.manifestUrl === 'https://off.example/manifest.json'); + assert.equal(off.enabled, false); + assert.equal(off.isEnabled, false); + assert.equal(written.find(addon => addon.manifestUrl === 'https://a.example/manifest.json').enabled, true); + assert.equal(result.summaries[0].addonsDisabled, 1); +}); diff --git a/web/tests/nuvio-import-safety.test.cjs b/web/tests/nuvio-import-safety.test.cjs new file mode 100644 index 000000000..66a0a4e97 --- /dev/null +++ b/web/tests/nuvio-import-safety.test.cjs @@ -0,0 +1,222 @@ +// The Nuvio import, run against the REAL cloud helpers with a stubbed backend. +// +// The unit tests above stub `saveCloudSettings`, which is exactly the layer the +// first two findings on PR #774 lived in: the import looked correct and the +// damage happened inside the helper. These tests assert on the payload that +// actually leaves the browser. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { load, storage } = require('./load.cjs'); + +const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected); + +// What the account already contains: a theme, AI subtitle settings with a saved +// key, an IPTV playlist, and one catalog. None of it is the import's business. +const account = () => ({ + version: 2, + accentColor: '#00d588', + oledBlackBackground: true, + subtitleAiEnabled: true, + subtitleAiApiKey: 'the-users-saved-key', + subtitleAiModel: 'groq', + profiles: [{ id: 'p1', name: 'Main' }], + activeProfileId: 'p1', + catalogsByProfile: { p1: [{ id: 'trending_movies', name: 'Trending in Movies', enabled: true, isPreinstalled: true }] }, + iptvByProfile: { p1: { playlists: [{ id: 'a', name: 'Home', m3uUrl: 'https://example.invalid/a.m3u' }], favoriteChannels: ['a:1'] } }, + addonsByProfile: { p1: [] }, + addons: [] +}); + +const snapshot = { + email: 'demo@nuvio.example', + warnings: [], + profiles: [{ + profileId: 1, name: 'Main', avatarColorHex: '#E50914', avatarId: 'avatar_3', pinEnabled: false, + addons: [{ url: 'https://a.example/manifest.json', name: 'A', enabled: true, sortOrder: 0 }], + plugins: [], + collectionsJson: [{ id: 'studios', title: 'Studios', folders: [ + { id: 'pixar', title: 'Pixar', sources: [{ provider: 'tmdb', tmdbSourceType: 'COMPANY', tmdbId: 3, mediaType: 'MOVIE' }] } + ] }], + homeCatalogSettings: null + }] +}; + +// Only the fields saveCloudSettings reads; the rest of AppSettings is irrelevant +// here and the page passes its own defaults for them. +const defaults = { + catalogs: [], hiddenCatalogIds: [], hiddenHomeServerCatalogIds: [], + accentColor: '#ffffff', oledBlack: false, aiSubtitlesEnabled: false, aiApiKey: '', + aiSubtitleModel: 'off', aiAutoSelect: false, removeHearingImpaired: false, + customUserAgent: '', skipProfileSelection: false, dnsProvider: 'system', + iptvPlaylists: [], iptvStalkerUrl: '', iptvStalkerMac: '', + favoriteChannelIds: [], favoriteGroupIds: [], hiddenGroupIds: [], groupOrder: [], + iptvSortOrder: 'provider', homeServers: [], subtitleStyle: 'outline', subtitleSize: 100, + subtitleOffset: 0, subtitleColorName: 'white', frameRateMatchingMode: 'off', + autoPlayMinQuality: 'any', defaultSubtitle: 'Off', audioLanguage: 'Auto (Original)' +}; + +// `failRead` picks which account reads fail, counted in order: 1 is the read +// of the profile list, 2 is the import's own read of the profile. +function setup({ failRead = () => false, beforeRead = () => {} } = {}) { + let stored = account(); + let reads = 0; + let writes = 0; + const cloud = load('lib/cloud.ts', { + './config': { config: { netlifyBackendUrl: 'https://backend.invalid' }, hasNetlifyBackendUrl: () => true }, + './homeserver': { serializeHomeServerConnectionJson: () => '[]', parseHomeServerConnectionJson: () => [] }, + './iptv': load('lib/iptv.ts', { './http': {}, './storage': storage() }), + './mediaImages': { tmdbImageUrl: () => '' }, + './http': { + jsonRequest: async (_url, options) => { + if (options?.method === 'POST') { writes += 1; stored = JSON.parse(options.body).payload; return { accepted: true }; } + reads += 1; + if (failRead(reads)) throw new Error('backend unreachable'); + beforeRead(stored, reads); + return { payload: structuredClone(stored) }; + } + } + }); + const runner = load('lib/nuvioImportRunner.ts', { + './addons': { + ...load('lib/addons.ts', { + './http': {}, './config': { hasResolverConfig: () => false }, './resolver': {}, + './storage': storage(), './streamCompatibility': {}, './addonStreamInfo': {} + }), + installAddon: async (url) => ({ id: url, name: 'Imported addon', version: '1.0.0', manifestUrl: url, catalogs: [], resources: [], enabled: true, isEnabled: true }) + }, + './auth': {}, './types': {}, + './catalogs': load('lib/catalogs.ts'), + './cloud': cloud, + './customCollections': load('lib/customCollections.ts'), + './nuvioMigration': load('lib/nuvioMigration.ts'), + './profiles': load('lib/profiles.ts') + }); + return { runner, cloud, read: () => stored, writes: () => writes }; +} + +const auth = { + session: { userId: 'u1', email: 'me@arvio.example', accessToken: 'test' }, + isNetlifySession: true, accessToken: async () => 'test' +}; +const choices = [{ nuvioProfileId: 1, target: { kind: 'existing', profileId: 'p1' } }]; + +test('an import leaves every settings field it does not own untouched', async () => { + const { runner, read } = setup(); + const before = account(); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, choices, baseSettings: defaults + }); + assert.equal(result.summaries[0].failed, undefined); + + const after = read(); + // Theme, AI subtitles and the saved key are exactly as the account had them — + // this is the regression that reset them to the page's defaults. + assert.equal(after.accentColor, before.accentColor); + assert.equal(after.oledBlackBackground, true); + assert.equal(after.subtitleAiEnabled, true); + assert.equal(after.subtitleAiApiKey, 'the-users-saved-key'); + assert.equal(after.subtitleAiModel, 'groq'); + // …and so is the IPTV setup, which the same write used to clear. + same(after.iptvByProfile.p1.playlists, before.iptvByProfile.p1.playlists); + same(after.iptvByProfile.p1.favoriteChannels, before.iptvByProfile.p1.favoriteChannels); + + // The one thing the import does own did change: the catalog the account had + // is still there, with the imported collection added alongside it. + const catalogs = after.catalogsByProfile.p1; + assert.ok(catalogs.some(catalog => catalog.id === 'trending_movies')); + assert.ok(catalogs.length > before.catalogsByProfile.p1.length); + assert.equal(after.addonsByProfile.p1.length, 1); +}); + +test('a failed read writes nothing at all', async () => { + // One read fails — the import's own read of the profile — while everything + // else keeps working. That is the exact shape of the bug: the failure passed + // for an empty profile and the write went ahead with the page's defaults. + const { runner, read, writes } = setup({ failRead: (n) => n === 2 }); + const before = account(); + const result = await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, choices, baseSettings: defaults + }); + assert.ok(result.summaries[0].failed, 'the profile is reported as not imported'); + + const after = read(); + // Not one field of the profile's state was written: no defaults over the + // theme or the saved key, no replaced catalogs, no cleared IPTV playlists. + assert.equal(after.accentColor, before.accentColor); + assert.equal(after.subtitleAiApiKey, before.subtitleAiApiKey); + same(after.catalogsByProfile, before.catalogsByProfile); + same(after.iptvByProfile, before.iptvByProfile); + same(after.addonsByProfile, before.addonsByProfile); + assert.equal(after.settings, undefined); + assert.equal(writes(), 0); +}); + +test('an account that cannot be read at all is never written to', async () => { + const { runner, read, writes } = setup({ failRead: () => true }); + await assert.rejects(runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, choices, baseSettings: defaults + })); + assert.equal(writes(), 0); + same(read(), account()); +}); + +test('an unloaded profile list cannot replace existing cloud profiles', async () => { + const { runner, read } = setup(); + await runner.applyNuvioImport({ + auth, profiles: [], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'create' } }], baseSettings: defaults + }); + assert.ok(read().profiles.some(profile => profile.id === 'p1')); + assert.equal(read().profiles.length, 2); + assert.equal(read().activeProfileId, 'p1'); +}); + +test('profiles added by another device survive creation and later settings writes', async () => { + const { runner, read } = setup({ beforeRead: (stored, n) => { + if (n === 2) stored.profiles.push({ id: 'p2', name: 'Kids' }); + if (n === 4) { + stored.profiles.push({ id: 'p3', name: 'Guest' }); + stored.profiles.find(profile => profile.id === 'p1').name = 'Renamed'; + } + } }); + await runner.applyNuvioImport({ + auth, profiles: [{ id: 'p1', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'create' } }], baseSettings: defaults + }); + const profiles = read().profiles; + assert.equal(profiles.length, 4); + assert.equal(profiles.find(profile => profile.id === 'p1').name, 'Renamed'); + assert.ok(profiles.some(profile => profile.id === 'p2')); + assert.ok(profiles.some(profile => profile.id === 'p3')); + assert.equal(read().activeProfileId, 'p1'); +}); + +test('a deleted target profile stops the import before any writes', async () => { + const { runner, read, writes } = setup(); + await assert.rejects(runner.applyNuvioImport({ + auth, profiles: [{ id: 'deleted', name: 'Main' }], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'existing', profileId: 'deleted' } }], baseSettings: defaults + }), /no longer available/); + assert.equal(writes(), 0); + same(read(), account()); +}); + +test('a failed profile creation read never writes a partial list', async () => { + const { runner, read, writes } = setup({ failRead: n => n === 2 }); + await assert.rejects(runner.applyNuvioImport({ + auth, profiles: [], snapshot, + choices: [{ nuvioProfileId: 1, target: { kind: 'create' } }], baseSettings: defaults + })); + assert.equal(writes(), 0); + same(read(), account()); +}); + +test('additive profile writes never replace a matching remote profile', async () => { + const { cloud, read } = setup(); + const profiles = await cloud.addCloudProfiles(auth, [ + { id: 'p1', name: 'Stale name' }, { id: 'p2', name: 'Kids' }, { id: 'p2', name: 'Duplicate' } + ]); + same(profiles, [{ id: 'p1', name: 'Main' }, { id: 'p2', name: 'Kids' }]); + same(read().profiles, [{ id: 'p1', name: 'Main' }, { id: 'p2', name: 'Kids' }]); + assert.equal(read().activeProfileId, 'p1'); +}); diff --git a/web/tests/nuvio-migration.test.cjs b/web/tests/nuvio-migration.test.cjs new file mode 100644 index 000000000..4d757bb0e --- /dev/null +++ b/web/tests/nuvio-migration.test.cjs @@ -0,0 +1,332 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const ts = require('typescript'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +const code = ts.transpileModule(fs.readFileSync(require.resolve('../lib/nuvioMigration.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } +}).outputText; +const sandbox = { exports: {}, URL, console }; +vm.runInNewContext(code, sandbox); +const { + normalizeBackendUrl, parseDiscovery, discoverNuvioBackend, signInToNuvio, fetchNuvioSnapshot, + buildSnapshot, mapAddonRows, hexToArgb, avatarIdToArvio, defaultChoices, newProfileFrom, + summarizePlan, addonsToInstall, applyHomeCatalogSettings, profileIdsFromRows, reconcileChoices +} = sandbox.exports; + +const jsonResponse = (body, ok = true, status = 200) => ({ ok, status, json: async () => body }); +// Values built inside the VM sandbox carry that realm's prototypes, so compare +// them structurally rather than by reference. +const plain = (value) => JSON.parse(JSON.stringify(value)); +const same = (actual, expected) => assert.deepEqual(plain(actual), expected); + +test('accepts the server address in the forms a user types', () => { + assert.equal(normalizeBackendUrl('nuvio.example.com'), 'https://nuvio.example.com'); + assert.equal(normalizeBackendUrl('https://nuvio.example.com/'), 'https://nuvio.example.com'); + assert.equal(normalizeBackendUrl('https://nuvio.example.com/.well-known/nuvio'), 'https://nuvio.example.com'); + assert.equal(normalizeBackendUrl('http://localhost:8000'), 'http://localhost:8000'); + // A local server speaks plain HTTP, so a scheme-less local address keeps it. + assert.equal(normalizeBackendUrl('localhost:4599'), 'http://localhost:4599'); + assert.equal(normalizeBackendUrl('127.0.0.1:8000'), 'http://127.0.0.1:8000'); + // Plaintext to a remote host would leak the password. + assert.throws(() => normalizeBackendUrl('http://nuvio.example.com'), /HTTPS/); + assert.throws(() => normalizeBackendUrl(' '), /server address/); +}); + +test('reads the publishable key from the discovery document', () => { + const discovery = parseDiscovery({ + backend_url: 'https://api.example.com', publishable_key: 'pk_test', self_hosted: true, + capabilities: { email_password_auth: true } + }, 'https://nuvio.example.com'); + assert.equal(discovery.backendUrl, 'https://api.example.com'); + assert.equal(discovery.publishableKey, 'pk_test'); + assert.equal(discovery.selfHosted, true); + assert.equal(discovery.emailPasswordAuth, true); + // A server that answers without a key cannot be signed in to. + assert.throws(() => parseDiscovery({ backend_url: 'https://api.example.com' }, 'https://x.example'), /publishable key/); + // Older deployments omit capabilities entirely. + assert.equal(parseDiscovery({ publishable_key: 'pk' }, 'https://x.example').emailPasswordAuth, true); +}); + +test('discovery falls back to the typed address when the document omits backend_url', async () => { + const discovery = await discoverNuvioBackend('nuvio.example.com', async (url) => { + assert.equal(url, 'https://nuvio.example.com/.well-known/nuvio'); + return jsonResponse({ publishable_key: 'pk_test' }); + }); + assert.equal(discovery.backendUrl, 'https://nuvio.example.com'); +}); + +test('sign-in surfaces the server message instead of a generic failure', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk', emailPasswordAuth: true }; + const session = await signInToNuvio(discovery, ' user@example.com ', 'secret', async (url, init) => { + assert.equal(url, 'https://api.example.com/auth/v1/token?grant_type=password'); + assert.equal(init.headers.apikey, 'pk'); + same(JSON.parse(init.body), { email: 'user@example.com', password: 'secret' }); + return jsonResponse({ access_token: 'token', user: { id: 'u1', email: 'user@example.com' } }); + }); + assert.equal(session.accessToken, 'token'); + assert.equal(session.email, 'user@example.com'); + + await assert.rejects( + signInToNuvio(discovery, 'user@example.com', 'wrong', async () => + jsonResponse({ error_description: 'Invalid login credentials' }, false, 400)), + /Invalid login credentials/ + ); + await assert.rejects( + signInToNuvio({ ...discovery, emailPasswordAuth: false }, 'a@b.c', 'x', async () => jsonResponse({})), + /email sign-in disabled/ + ); +}); + +test('groups addons, plugins and collections by their Nuvio profile', () => { + const snapshot = buildSnapshot( + 'user@example.com', + [ + { profile_id: 2, name: 'Kids', avatar_color_hex: '#1DB954', avatar_id: 'avatar_7', pin_enabled: true }, + { profile_id: 1, name: 'Main', avatar_color_hex: '#E50914' } + ], + [ + { profile_id: 1, url: 'https://a.example/manifest.json', name: 'A', sort_order: 2 }, + { profile_id: 1, url: 'https://b.example/manifest.json', name: 'B', sort_order: 1, enabled: false }, + { profile_id: 2, url: 'https://kids.example/manifest.json' }, + { profile_id: 1, url: 'not-a-url' } + ], + [{ profile_id: 1, url: 'https://plug.example/repo.json', repo_type: 'github' }], + [{ profile_id: 1, collections_json: [{ title: 'Studios', folders: [] }] }], + [{ profile_id: 1, settings_json: { order: ['b', 'a'] } }] + ); + same(snapshot.profiles.map(p => p.profileId), [1, 2]); + const main = snapshot.profiles[0]; + // sort_order decides the order, and a malformed URL is dropped. + same(main.addons.map(a => a.name), ['B', 'A']); + assert.equal(main.addons[0].enabled, false); + assert.equal(main.plugins[0].repoType, 'github'); + assert.equal(main.collectionsJson.length, 1); + same(main.homeCatalogSettings, { order: ['b', 'a'] }); + assert.equal(snapshot.profiles[1].addons.length, 1); + assert.equal(snapshot.profiles[1].pinEnabled, true); +}); + +test('a missing table does not fail the whole import', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url) => { + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }]); + if (url.includes('/plugins')) return jsonResponse({ message: 'relation does not exist' }, false, 404); + return jsonResponse([]); + }); + assert.equal(snapshot.profiles.length, 1); + same(snapshot.profiles[0].plugins, []); + + // A deployment that exposes nothing directly still yields the default profile, + // so the sync RPCs below have something to fill. + const bare = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async () => jsonResponse([])); + assert.equal(bare.profiles.length, 1); + assert.equal(bare.profiles[0].profileId, 1); +}); + +test('falls back to the sync RPCs when a direct table read is empty', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const called = []; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url, init) => { + if (url.includes('/rpc/')) { + called.push({ url, profileId: JSON.parse(init.body).p_profile_id }); + return url.includes('sync_pull_collections') + ? jsonResponse([{ collections_json: [{ title: 'Studios', folders: [] }] }]) + : jsonResponse([{ settings_json: { order: ['a'] } }]); + } + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }]); + return jsonResponse([]); + }); + // Row-level security can hide the tables while the RPCs still answer. + assert.equal(snapshot.profiles[0].collectionsJson.length, 1); + same(snapshot.profiles[0].homeCatalogSettings, { order: ['a'] }); + assert.deepEqual(called.map(entry => entry.profileId), [1, 1]); +}); + +test('converts Nuvio profile looks into ARVIO ones', () => { + assert.equal(hexToArgb('#E50914', 0), 0xffe50914); + assert.equal(hexToArgb('E50914', 0), 0xffe50914); + assert.equal(hexToArgb('nonsense', 0xff123456), 0xff123456); + assert.equal(avatarIdToArvio('avatar_12'), 12); + assert.equal(avatarIdToArvio('85'), 0); + assert.equal(avatarIdToArvio(null), 0); + + const profile = newProfileFrom( + { name: 'Kids', avatarColorHex: '#1DB954', avatarId: 'avatar_7', pinEnabled: true }, + 'new-id', 0xff000000 + ); + assert.equal(profile.avatarColor, 0xff1db954); + assert.equal(profile.avatarId, 7); + // A Nuvio PIN hash is not portable, so the copy must not arrive locked. + assert.equal(profile.isLocked, false); + assert.equal(profile.pin, null); +}); + +test('matches profiles by name and creates the missing ones', () => { + const snapshot = { + email: 'u@e.c', + profiles: [ + { profileId: 1, name: 'Main', addons: [], plugins: [], collectionsJson: null, homeCatalogSettings: null }, + { profileId: 2, name: 'Kids', addons: [{ url: 'https://a.example/manifest.json' }], plugins: [], collectionsJson: [{}, {}], homeCatalogSettings: { order: [] } } + ] + }; + const choices = defaultChoices(snapshot, [{ id: 'p1', name: ' main ' }]); + same(choices[0].target, { kind: 'existing', profileId: 'p1' }); + same(choices[1].target, { kind: 'create' }); + + const summary = summarizePlan(snapshot, choices); + assert.equal(summary.length, 2); + assert.equal(summary[1].created, true); + assert.equal(summary[1].addons, 1); + assert.equal(summary[1].collections, 2); + assert.equal(summary[1].catalogSettings, true); + + // An explicit skip, or a profile the user never chose a target for, is left out. + assert.equal(summarizePlan(snapshot, [{ nuvioProfileId: 1, target: { kind: 'skip' } }]).length, 0); + assert.equal(summarizePlan(snapshot, [{ nuvioProfileId: 2, target: { kind: 'create' } }]).length, 1); +}); + +test('only adds addons that are not installed yet, keeping their Nuvio state', () => { + const added = addonsToInstall( + ['https://a.example/manifest.json'], + [{ url: 'https://A.example/manifest.json', enabled: true }, + { url: 'https://b.example/manifest.json', enabled: false }, + { url: 'https://b.example/manifest.json', enabled: true }] + ); + // Whole entries, not URLs: the import needs the enabled flag to carry over. + same(added, [{ url: 'https://b.example/manifest.json', enabled: false }]); +}); + +test('applies Nuvio row order and hidden rows onto ARVIO catalogs', () => { + const catalogs = [ + { id: 'a', enabled: true }, { id: 'b', enabled: true }, { id: 'c', enabled: true } + ]; + const result = applyHomeCatalogSettings(catalogs, { order: ['c', 'a'], hidden: ['b'] }); + same(result.map(c => c.id), ['c', 'a', 'b']); + assert.equal(result.find(c => c.id === 'b').enabled, false); + // Nothing recognisable means the user's existing order is left alone. + same(applyHomeCatalogSettings(catalogs, { unrelated: true }), catalogs); +}); + +// ── Regression: a failed read is not an empty account (PR #774 review, 4) ── + +test('a profile table that fails to load is reported, not treated as empty', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url) => { + if (url.includes('/profiles')) return jsonResponse({ message: 'permission denied' }, false, 403); + if (url.includes('/addons')) return jsonResponse([ + { profile_id: 1, url: 'https://a.example/manifest.json' }, + { profile_id: 3, url: 'https://c.example/manifest.json' } + ]); + return jsonResponse([]); + }); + // Profiles 1 and 3 both have content, so both must be offered — building only + // profile 1 used to drop everything that belonged to the others. + same(snapshot.profiles.map(p => p.profileId), [1, 3]); + assert.match(snapshot.warnings.join(' '), /profile list could not be read/); + + // A plugins table that errors is called out rather than passing for "no plugins". + const partial = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url) => { + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }]); + if (url.includes('/plugins')) return jsonResponse({ message: 'nope' }, false, 500); + return jsonResponse([]); + }); + assert.match(partial.warnings.join(' '), /plugins/); + // A table that answered with nothing is simply empty and says nothing. + assert.equal(partial.warnings.some(entry => entry.includes('addons')), false); +}); + +test('recovers every profile the sync RPCs answer for when the table is hidden', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url, init) => { + if (url.includes('sync_pull_collections')) { + const profileId = JSON.parse(init.body).p_profile_id; + // Only profiles 2 and 4 exist on this account. + return profileId === 2 || profileId === 4 + ? jsonResponse([{ collections_json: [{ title: `Set ${profileId}`, folders: [] }] }]) + : jsonResponse([]); + } + return jsonResponse([]); + }); + same(snapshot.profiles.map(p => p.profileId), [2, 4]); + assert.equal(snapshot.profiles[0].collectionsJson.length, 1); + assert.equal(snapshot.profiles[1].collectionsJson.length, 1); +}); + +test('profile ids are recovered from any table that carries them', () => { + same(profileIdsFromRows([{ profile_id: 3 }], [{ profile_index: 1 }], [{ profile_id: 'nonsense' }, { profile_id: 3 }]), [1, 3]); + same(profileIdsFromRows([]), []); +}); + +// ── Regression: choices recalculated after a late sign-in (PR #774 review, 3) ── + +test('matches are recalculated once ARVIO profiles arrive, keeping manual picks', () => { + const snapshot = { + email: 'u@e.c', warnings: [], + profiles: [ + { profileId: 1, name: 'Main', addons: [], plugins: [], collectionsJson: null, homeCatalogSettings: null }, + { profileId: 2, name: 'Kids', addons: [], plugins: [], collectionsJson: null, homeCatalogSettings: null } + ] + }; + // Nuvio is connected before the ARVIO sign-in, so nothing can be matched yet. + const first = defaultChoices(snapshot, []); + same(first.map(choice => choice.target.kind), ['create', 'create']); + + // Signing in afterwards must not leave both profiles set to "create". + const profiles = [{ id: 'p1', name: 'Main' }, { id: 'p2', name: 'Kids' }]; + const after = reconcileChoices(snapshot, profiles, first); + same(after.map(choice => choice.target), [{ kind: 'existing', profileId: 'p1' }, { kind: 'existing', profileId: 'p2' }]); + + // A target the user picked by hand survives the next recalculation, and the + // profile they claimed is not handed to someone else by name matching. + const edited = after.map(choice => choice.nuvioProfileId === 2 ? { ...choice, userSet: true, target: { kind: 'skip' } } : choice); + const again = reconcileChoices(snapshot, profiles, edited); + same(again[1].target, { kind: 'skip' }); + same(again[0].target, { kind: 'existing', profileId: 'p1' }); + + const claimed = reconcileChoices(snapshot, profiles, + [{ nuvioProfileId: 1, userSet: true, target: { kind: 'existing', profileId: 'p2' } }, { nuvioProfileId: 2, target: { kind: 'create' } }]); + same(claimed[0].target, { kind: 'existing', profileId: 'p2' }); + // 'Kids' is taken by the manual choice above, so it cannot be matched again. + same(claimed[1].target, { kind: 'create' }); +}); + +test('failed collections and home settings reads are reported, not treated as empty', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url) => { + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }]); + if (url.includes('/collections') || url.includes('/home_catalog_settings') || url.includes('/rpc/')) { + return jsonResponse({ message: 'unreachable' }, false, 503); + } + return jsonResponse([]); + }); + assert.match(snapshot.warnings.join(' '), /collections for profiles 1/); + assert.match(snapshot.warnings.join(' '), /home_catalog_settings for profiles 1/); +}); + +test('a successful empty fallback is empty without a warning', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url) => { + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }]); + if (url.includes('/collections') || url.includes('/home_catalog_settings')) return jsonResponse({}, false, 404); + return jsonResponse([]); + }); + same(snapshot.warnings, []); +}); + +test('a partial fallback retains successful profiles and identifies the failed profile', async () => { + const discovery = { backendUrl: 'https://api.example.com', publishableKey: 'pk' }; + const snapshot = await fetchNuvioSnapshot(discovery, { accessToken: 't', userId: 'u', email: 'u@e.c' }, async (url, init) => { + if (url.includes('/profiles')) return jsonResponse([{ profile_id: 1, name: 'Main' }, { profile_id: 2, name: 'Kids' }]); + if (url.includes('sync_pull_collections')) { + return JSON.parse(init.body).p_profile_id === 1 + ? jsonResponse([{ collections_json: [{ title: 'Studios', folders: [] }] }]) + : { ok: true, json: async () => { throw new Error('bad JSON'); } }; + } + return jsonResponse([]); + }); + assert.equal(snapshot.profiles[0].collectionsJson.length, 1); + assert.match(snapshot.warnings.join(' '), /collections for profiles 2/); + assert.equal(snapshot.warnings.some(warning => warning.includes('home_catalog_settings')), false); +}); diff --git a/web/tests/nuvio-ui/entry.tsx b/web/tests/nuvio-ui/entry.tsx new file mode 100644 index 000000000..e08c2d815 --- /dev/null +++ b/web/tests/nuvio-ui/entry.tsx @@ -0,0 +1,4 @@ +import { createRoot } from "react-dom/client"; +import { MigrateApp } from "../../components/migrate/MigrateApp"; + +createRoot(document.getElementById("root")!).render(); diff --git a/web/tests/nuvio-ui/stubs.tsx b/web/tests/nuvio-ui/stubs.tsx new file mode 100644 index 000000000..a546e5ce6 --- /dev/null +++ b/web/tests/nuvio-ui/stubs.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; + +const snapshot = { + email: "demo@nuvio.example", + warnings: ["Nuvio did not return collections for profiles 1 - retry the import."], + profiles: [{ profileId: 1, name: "Main", addons: [], plugins: [], collectionsJson: null, homeCatalogSettings: null }] +}; + +export const authClient = { session: { email: "demo@arvio.example" } }; +export const defaultSettings = {}; +export const CONTENT_LANGUAGE_OPTIONS = [["en-US", "English"]]; +export const LanguageProvider = ({ children }: { children: ReactNode }) => children; +export const useTranslation = () => (value: string) => value; +export const loadStored = (_key: string, fallback: unknown) => fallback; +export const saveStored = () => {}; +export const installAddon = async () => { throw new Error("Unexpected addon installation"); }; +export const normalizeAddons = (value: unknown) => value; +export const saveCloudAddons = async () => { throw new Error("Unexpected addon write"); }; +export const OFFICIAL_NUVIO_URL = "https://nuvio.invalid"; +export const discoverNuvioBackend = async () => ({}); +export const signInToNuvio = async () => ({}); +export const fetchNuvioSnapshot = async () => snapshot; +export const defaultChoices = () => [{ nuvioProfileId: 1, target: { kind: "create" } }]; +export const reconcileChoices = (_snapshot: unknown, _profiles: unknown, choices: unknown) => choices; +export const summarizePlan = () => snapshot.profiles; + +let reads = 0; +const controls = { + imports: 0, + resolveProfiles: null as null | (() => void) +}; +Object.assign(window, { nuvioTest: controls }); +export const pullCloudProfiles = async () => { + reads += 1; + if (reads === 1) throw new Error("Account unavailable"); + if (reads === 2) await new Promise(resolve => { controls.resolveProfiles = resolve; }); + return { profiles: [{ id: "p1", name: "Main" }], activeProfileId: "p1" }; +}; +export const pullCloudPayload = async () => ({ addons: [] }); +export const applyNuvioImport = async () => { + controls.imports += 1; + return { + profiles: [{ id: "p1", name: "Main" }], + summaries: [{ profileName: "Main", created: false, addons: 0, addonsFailed: 0, addonsDisabled: 0, collections: 0, plugins: 0 }] + }; +};