diff --git a/src/hooks/useSettingsExport.ts b/src/hooks/useSettingsExport.ts new file mode 100644 index 00000000..6caaad9e --- /dev/null +++ b/src/hooks/useSettingsExport.ts @@ -0,0 +1,103 @@ +import { useCallback } from "react"; + +import { Settings } from "@/hooks/useSettingsImport"; +import { useAuthStore } from "@/stores/auth"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useLanguageStore } from "@/stores/language"; +import { usePreferencesStore } from "@/stores/preferences"; +import { useProgressStore } from "@/stores/progress"; +import { useQualityStore } from "@/stores/quality"; +import { useSubtitleStore } from "@/stores/subtitles"; +import { useThemeStore } from "@/stores/theme"; +import { useVolumeStore } from "@/stores/volume"; + +export function useSettingsExport() { + const authStore = useAuthStore(); + const bookmarksStore = useBookmarkStore(); + const languageStore = useLanguageStore(); + const preferencesStore = usePreferencesStore(); + const progressStore = useProgressStore(); + const qualityStore = useQualityStore(); + const subtitleStore = useSubtitleStore(); + const themeStore = useThemeStore(); + const volumeStore = useVolumeStore(); + + const collect = useCallback( + (includeAuth: boolean): Settings => { + return { + auth: { + account: includeAuth ? authStore.account : undefined, + backendUrl: authStore.backendUrl, + proxySet: authStore.proxySet, + }, + bookmarks: { + bookmarks: bookmarksStore.bookmarks, + }, + language: { + language: languageStore.language, + }, + preferences: { + enableThumbnails: preferencesStore.enableThumbnails, + }, + progress: { + items: progressStore.items, + }, + quality: { + quality: { + automaticQuality: qualityStore.quality.automaticQuality, + lastChosenQuality: qualityStore.quality.lastChosenQuality, + }, + }, + subtitles: { + lastSelectedLanguage: subtitleStore.lastSelectedLanguage, + styling: { + backgroundBlur: subtitleStore.styling.backgroundBlur, + backgroundOpacity: subtitleStore.styling.backgroundOpacity, + color: subtitleStore.styling.color, + size: subtitleStore.styling.size, + }, + overrideCasing: subtitleStore.overrideCasing, + delay: subtitleStore.delay, + }, + theme: { + theme: themeStore.theme, + }, + volume: { + volume: volumeStore.volume, + }, + }; + }, + [ + authStore, + bookmarksStore, + languageStore, + preferencesStore, + progressStore, + qualityStore, + subtitleStore, + themeStore, + volumeStore, + ], + ); + + const exportSettings = useCallback( + (includeAuth: boolean) => { + const output = JSON.stringify(collect(includeAuth), null, 2); + + const blob = new Blob([output], { type: "application/json" }); + const elem = window.document.createElement("a"); + elem.href = window.URL.createObjectURL(blob); + + const date = new Date(); + elem.download = `movie-web settings - ${ + date.toISOString().split("T")[0] + }.json`; + document.body.appendChild(elem); + elem.click(); + document.body.removeChild(elem); + }, + [collect], + ); + + return exportSettings; +} diff --git a/src/hooks/useSettingsImport.ts b/src/hooks/useSettingsImport.ts new file mode 100644 index 00000000..3267640f --- /dev/null +++ b/src/hooks/useSettingsImport.ts @@ -0,0 +1,234 @@ +import { useCallback } from "react"; +import { z } from "zod"; + +import { useAuthStore } from "@/stores/auth"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useLanguageStore } from "@/stores/language"; +import { usePreferencesStore } from "@/stores/preferences"; +import { useProgressStore } from "@/stores/progress"; +import { useQualityStore } from "@/stores/quality"; +import { useSubtitleStore } from "@/stores/subtitles"; +import { useThemeStore } from "@/stores/theme"; +import { useVolumeStore } from "@/stores/volume"; + +const settingsSchema = z.object({ + auth: z.object({ + account: z + .object({ + profile: z.object({ + colorA: z.string(), + colorB: z.string(), + icon: z.string(), + }), + sessionId: z.string(), + userId: z.string(), + token: z.string(), + seed: z.string(), + deviceName: z.string(), + }) + .nullish(), + backendUrl: z.string().nullable(), + proxySet: z.array(z.string()).nullable(), + }), + bookmarks: z.object({ + bookmarks: z.record( + z.object({ + title: z.string(), + year: z.number().optional(), + poster: z.string().optional(), + type: z.enum(["show", "movie"]), + updatedAt: z.number(), + }), + ), + }), + language: z.object({ + language: z.string(), + }), + preferences: z.object({ + enableThumbnails: z.boolean(), + }), + progress: z.object({ + items: z.record( + z.object({ + title: z.string(), + year: z.number().optional(), + poster: z.string().optional(), + type: z.enum(["show", "movie"]), + updatedAt: z.number(), + progress: z + .object({ + watched: z.number(), + duration: z.number(), + }) + .optional(), + seasons: z.record( + z.object({ + title: z.string(), + number: z.number(), + id: z.string(), + }), + ), + episodes: z.record( + z.object({ + title: z.string(), + number: z.number(), + id: z.string(), + seasonId: z.string(), + updatedAt: z.number(), + progress: z.object({ + watched: z.number(), + duration: z.number(), + }), + }), + ), + }), + ), + }), + quality: z.object({ + quality: z.object({ + automaticQuality: z.boolean(), + lastChosenQuality: z + .enum(["unknown", "360", "480", "720", "1080", "4k"]) + .nullable(), + }), + }), + subtitles: z.object({ + lastSelectedLanguage: z.string().nullable(), + styling: z.object({ + backgroundBlur: z.number(), + backgroundOpacity: z.number(), + color: z.string(), + size: z.number(), + }), + overrideCasing: z.boolean(), + delay: z.number(), + }), + theme: z.object({ + theme: z.string().nullable(), + }), + volume: z.object({ + volume: z.number(), + }), +}); + +const settingsPartialSchema = settingsSchema.partial(); + +export type Settings = z.infer; + +export function useSettingsImport() { + const authStore = useAuthStore(); + const bookmarksStore = useBookmarkStore(); + const languageStore = useLanguageStore(); + const preferencesStore = usePreferencesStore(); + const progressStore = useProgressStore(); + const qualityStore = useQualityStore(); + const subtitleStore = useSubtitleStore(); + const themeStore = useThemeStore(); + const volumeStore = useVolumeStore(); + + const importSettings = useCallback( + async (file: File) => { + const text = await file.text(); + + const data = settingsPartialSchema.parse(JSON.parse(text)); + if (data.auth?.account) authStore.setAccount(data.auth.account); + if (data.auth?.backendUrl) authStore.setBackendUrl(data.auth.backendUrl); + if (data.auth?.proxySet) authStore.setProxySet(data.auth.proxySet); + if (data.bookmarks) { + for (const [id, item] of Object.entries(data.bookmarks.bookmarks)) { + bookmarksStore.setBookmark(id, { + title: item.title, + type: item.type, + year: item.year, + poster: item.poster, + updatedAt: item.updatedAt, + }); + } + } + if (data.language) languageStore.setLanguage(data.language.language); + if (data.preferences) { + preferencesStore.setEnableThumbnails(data.preferences.enableThumbnails); + } + if (data.quality) { + qualityStore.setAutomaticQuality(data.quality.quality.automaticQuality); + qualityStore.setLastChosenQuality( + data.quality.quality.lastChosenQuality, + ); + } + if (data.subtitles) { + subtitleStore.setLanguage(data.subtitles.lastSelectedLanguage); + subtitleStore.updateStyling(data.subtitles.styling); + subtitleStore.setOverrideCasing(data.subtitles.overrideCasing); + subtitleStore.setDelay(data.subtitles.delay); + } + if (data.theme) themeStore.setTheme(data.theme.theme); + if (data.volume) volumeStore.setVolume(data.volume.volume); + + if (data.progress) { + for (const [id, item] of Object.entries(data.progress.items)) { + if (!progressStore.items[id]) { + progressStore.setItem(id, item); + } + + // We want to preserve existing progress so we take the max of the updatedAt and the progress + const storeItem = progressStore.items[id]; + storeItem.updatedAt = Math.max(storeItem.updatedAt, item.updatedAt); + storeItem.title = item.title; + storeItem.year = item.year; + storeItem.poster = item.poster; + storeItem.type = item.type; + storeItem.progress = item.progress + ? { + duration: item.progress.duration, + watched: Math.max( + storeItem.progress?.watched ?? 0, + item.progress.watched, + ), + } + : undefined; + + for (const [seasonId, season] of Object.entries(item.seasons)) { + storeItem.seasons[seasonId] = season; + } + + for (const [episodeId, episode] of Object.entries(item.episodes)) { + if (!storeItem.episodes[episodeId]) { + storeItem.episodes[episodeId] = episode; + } + + const storeEpisode = storeItem.episodes[episodeId]; + storeEpisode.updatedAt = Math.max( + storeEpisode.updatedAt, + episode.updatedAt, + ); + storeEpisode.title = episode.title; + storeEpisode.number = episode.number; + storeEpisode.seasonId = episode.seasonId; + storeEpisode.progress = { + duration: episode.progress.duration, + watched: Math.max( + storeEpisode.progress.watched, + episode.progress.watched, + ), + }; + } + + progressStore.setItem(id, storeItem); + } + } + }, + [ + authStore, + bookmarksStore, + languageStore, + preferencesStore, + progressStore, + qualityStore, + subtitleStore, + themeStore, + volumeStore, + ], + ); + + return importSettings; +} diff --git a/src/pages/migration/MigrationDirect.tsx b/src/pages/migration/MigrationDirect.tsx index 9b3a3702..3fe48f41 100644 --- a/src/pages/migration/MigrationDirect.tsx +++ b/src/pages/migration/MigrationDirect.tsx @@ -1,12 +1,26 @@ +import { useCallback } from "react"; + import { CenterContainer } from "@/components/layout/ThinContainer"; +import { useSettingsExport } from "@/hooks/useSettingsExport"; import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout"; import { PageTitle } from "@/pages/parts/util/PageTitle"; export function MigrationDirectPage() { + const exportSettings = useSettingsExport(); + + const doDownload = useCallback(() => { + const data = exportSettings(false); + console.log(data); + }, [exportSettings]); + return ( - Hi + + + ); }