diff --git a/src/backend/accounts/bookmarks.ts b/src/backend/accounts/bookmarks.ts index c83a366c..84f8a459 100644 --- a/src/backend/accounts/bookmarks.ts +++ b/src/backend/accounts/bookmarks.ts @@ -3,13 +3,33 @@ import { ofetch } from "ofetch"; import { getAuthHeaders } from "@/backend/accounts/auth"; import { BookmarkResponse } from "@/backend/accounts/user"; import { AccountWithToken } from "@/stores/auth"; +import { BookmarkMediaItem } from "@/stores/bookmarks"; -export interface BookmarkInput { +export interface BookmarkMetaInput { title: string; year: number; poster?: string; type: string; +} + +export interface BookmarkInput { tmdbId: string; + meta: BookmarkMetaInput; +} + +export function bookmarkMediaToInput( + tmdbId: string, + item: BookmarkMediaItem +): BookmarkInput { + return { + meta: { + title: item.title, + type: item.type, + poster: item.poster, + year: item.year ?? 0, + }, + tmdbId, + }; } export async function addBookmark( @@ -23,10 +43,7 @@ export async function addBookmark( method: "POST", headers: getAuthHeaders(account.token), baseURL: url, - body: { - meta: input, - tmdbId: input.tmdbId, - }, + body: input, } ); } diff --git a/src/backend/accounts/import.ts b/src/backend/accounts/import.ts new file mode 100644 index 00000000..c09123cb --- /dev/null +++ b/src/backend/accounts/import.ts @@ -0,0 +1,33 @@ +import { ofetch } from "ofetch"; + +import { getAuthHeaders } from "@/backend/accounts/auth"; +import { AccountWithToken } from "@/stores/auth"; + +import { BookmarkInput } from "./bookmarks"; +import { ProgressInput } from "./progress"; + +export function importProgress( + url: string, + account: AccountWithToken, + progressItems: ProgressInput[] +) { + return ofetch(`/users/${account.userId}/progress/import`, { + method: "PUT", + body: progressItems, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} + +export function importBookmarks( + url: string, + account: AccountWithToken, + bookmarks: BookmarkInput[] +) { + return ofetch(`/users/${account.userId}/bookmarks`, { + method: "PUT", + body: bookmarks, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} diff --git a/src/backend/accounts/progress.ts b/src/backend/accounts/progress.ts index a05ed517..a5ad5022 100644 --- a/src/backend/accounts/progress.ts +++ b/src/backend/accounts/progress.ts @@ -3,6 +3,7 @@ import { ofetch } from "ofetch"; import { getAuthHeaders } from "@/backend/accounts/auth"; import { ProgressResponse } from "@/backend/accounts/user"; import { AccountWithToken } from "@/stores/auth"; +import { ProgressMediaItem, ProgressUpdateItem } from "@/stores/progress"; export interface ProgressInput { meta?: { @@ -12,14 +13,70 @@ export interface ProgressInput { type: string; }; tmdbId: string; - watched?: number; - duration?: number; + watched: number; + duration: number; seasonId?: string; episodeId?: string; seasonNumber?: number; episodeNumber?: number; } +export function progressUpdateItemToInput( + item: ProgressUpdateItem +): ProgressInput { + return { + duration: item.progress?.duration ?? 0, + watched: item.progress?.watched ?? 0, + tmdbId: item.tmdbId, + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + episodeId: item.episodeId, + seasonId: item.seasonId, + episodeNumber: item.episodeNumber, + seasonNumber: item.seasonNumber, + }; +} + +export function progressMediaItemToInputs( + tmdbId: string, + item: ProgressMediaItem +): ProgressInput[] { + if (item.type === "show") { + return Object.entries(item.episodes).flatMap(([_, episode]) => ({ + duration: item.progress?.duration ?? episode.progress.duration, + watched: item.progress?.watched ?? episode.progress.watched, + tmdbId, + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + episodeId: episode.id, + seasonId: episode.seasonId, + episodeNumber: episode.number, + seasonNumber: item.seasons[episode.seasonId].number, + })); + } + return [ + { + duration: item.progress?.duration ?? 0, + watched: item.progress?.watched ?? 0, + tmdbId, + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + }, + ]; +} + export async function setProgress( url: string, account: AccountWithToken, diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts new file mode 100644 index 00000000..09405a40 --- /dev/null +++ b/src/backend/accounts/settings.ts @@ -0,0 +1,37 @@ +import { ofetch } from "ofetch"; + +import { getAuthHeaders } from "@/backend/accounts/auth"; +import { AccountWithToken } from "@/stores/auth"; + +export interface SettingsInput { + applicationLanguage?: string; + applicationTheme?: string; + defaultSubtitleLanguage?: string; +} + +export interface SettingsResponse { + applicationTheme?: string | null; + applicationLanguage?: string | null; + defaultSubtitleLanguage?: string | null; +} + +export function updateSettings( + url: string, + account: AccountWithToken, + settings: SettingsInput +) { + return ofetch(`/users/${account.userId}/settings`, { + method: "PUT", + body: settings, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} + +export function getSettings(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/settings`, { + method: "GET", + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 5f455545..ddc44a2a 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; +import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks"; import { bytesToBase64, bytesToBase64Url, @@ -7,7 +8,9 @@ import { keysFromMnemonic, signChallenge, } from "@/backend/accounts/crypto"; +import { importBookmarks, importProgress } from "@/backend/accounts/import"; import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login"; +import { progressMediaItemToInputs } from "@/backend/accounts/progress"; import { getRegisterChallengeToken, registerAccount, @@ -16,7 +19,9 @@ import { removeSession } from "@/backend/accounts/sessions"; import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user"; import { useAuthData } from "@/hooks/auth/useAuthData"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; -import { useAuthStore } from "@/stores/auth"; +import { AccountWithToken, useAuthStore } from "@/stores/auth"; +import { BookmarkMediaItem } from "@/stores/bookmarks"; +import { ProgressMediaItem } from "@/stores/progress"; export interface RegistrationData { recaptchaToken?: string; @@ -69,7 +74,7 @@ export function useAuth() { const user = await getUser(backendUrl, loginResult.token); const seedBase64 = bytesToBase64(keys.seed); - await userDataLogin(loginResult, user.user, user.session, seedBase64); + return userDataLogin(loginResult, user.user, user.session, seedBase64); }, [userDataLogin, backendUrl] ); @@ -106,7 +111,7 @@ export function useAuth() { profile: registerData.userData.profile, }); - await userDataLogin( + return userDataLogin( registerResult, registerResult.user, registerResult.session, @@ -116,6 +121,33 @@ export function useAuth() { [backendUrl, userDataLogin] ); + const importData = useCallback( + async ( + account: AccountWithToken, + progressItems: Record, + bookmarks: Record + ) => { + if ( + Object.keys(progressItems).length === 0 && + Object.keys(bookmarks).length === 0 + ) { + return; + } + + const progressInputs = Object.entries(progressItems).flatMap( + ([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item) + ); + + const bookmarkInputs = Object.entries(bookmarks).map(([tmdbId, item]) => + bookmarkMediaToInput(tmdbId, item) + ); + + await importProgress(backendUrl, account, progressInputs); + await importBookmarks(backendUrl, account, bookmarkInputs); + }, + [backendUrl] + ); + const restore = useCallback(async () => { if (!currentAccount) { return; @@ -136,5 +168,6 @@ export function useAuth() { logout, register, restore, + importData, }; } diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 39c64220..9fef5839 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -24,19 +24,21 @@ export function useAuthData() { const login = useCallback( async ( - account: LoginResponse, + loginResponse: LoginResponse, user: UserResponse, session: SessionResponse, seed: string ) => { - setAccount({ - token: account.token, + const account = { + token: loginResponse.token, userId: user.id, - sessionId: account.session.id, + sessionId: loginResponse.session.id, deviceName: session.device, profile: user.profile, seed, - }); + }; + setAccount(account); + return account; }, [setAccount] ); diff --git a/src/pages/parts/auth/LoginFormPart.tsx b/src/pages/parts/auth/LoginFormPart.tsx index e9c4fc5d..e3ad71d2 100644 --- a/src/pages/parts/auth/LoginFormPart.tsx +++ b/src/pages/parts/auth/LoginFormPart.tsx @@ -11,6 +11,8 @@ import { } from "@/components/layout/LargeCard"; import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { useAuth } from "@/hooks/auth/useAuth"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useProgressStore } from "@/stores/progress"; interface LoginFormPartProps { onLogin?: () => void; @@ -19,7 +21,9 @@ interface LoginFormPartProps { export function LoginFormPart(props: LoginFormPartProps) { const [mnemonic, setMnemonic] = useState(""); const [device, setDevice] = useState(""); - const { login, restore } = useAuth(); + const { login, restore, importData } = useAuth(); + const progressItems = useProgressStore((store) => store.items); + const bookmarkItems = useBookmarkStore((store) => store.bookmarks); const [result, execute] = useAsyncFn( async (inputMnemonic: string, inputdevice: string) => { @@ -27,14 +31,14 @@ export function LoginFormPart(props: LoginFormPartProps) { if (!verifyValidMnemonic(inputMnemonic)) throw new Error("Invalid or incomplete passphrase"); - await login({ + const account = await login({ mnemonic: inputMnemonic, userData: { device: inputdevice, }, }); - // TODO import (and sort out conflicts) + await importData(account, progressItems, bookmarkItems); await restore(); diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx index 2506807a..6c32612a 100644 --- a/src/pages/parts/auth/VerifyPassphrasePart.tsx +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useGoogleReCaptcha } from "react-google-recaptcha-v3"; import { useAsyncFn } from "react-use"; +import { updateSettings } from "@/backend/accounts/settings"; import { Button } from "@/components/Button"; import { Icon, Icons } from "@/components/Icon"; import { @@ -11,7 +12,13 @@ import { } from "@/components/layout/LargeCard"; import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { useAuth } from "@/hooks/auth/useAuth"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useLanguageStore } from "@/stores/language"; +import { useProgressStore } from "@/stores/progress"; +import { useSubtitleStore } from "@/stores/subtitles"; +import { useThemeStore } from "@/stores/theme"; interface VerifyPassphraseProps { mnemonic: string | null; @@ -22,7 +29,17 @@ interface VerifyPassphraseProps { export function VerifyPassphrase(props: VerifyPassphraseProps) { const [mnemonic, setMnemonic] = useState(""); - const { register, restore } = useAuth(); + const { register, restore, importData } = useAuth(); + const progressItems = useProgressStore((store) => store.items); + const bookmarkItems = useBookmarkStore((store) => store.bookmarks); + + const applicationLanguage = useLanguageStore((store) => store.language); + const defaultSubtitleLanguage = useSubtitleStore( + (store) => store.lastSelectedLanguage + ); + const applicationTheme = useThemeStore((store) => store.theme); + + const backendUrl = useBackendUrl(); const { executeRecaptcha } = useGoogleReCaptcha(); @@ -42,13 +59,19 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { if (inputMnemonic !== props.mnemonic) throw new Error("Passphrase doesn't match"); - await register({ + const account = await register({ mnemonic: inputMnemonic, userData: props.userData, recaptchaToken, }); - // TODO import (and sort out conflicts) + await importData(account, progressItems, bookmarkItems); + + await updateSettings(backendUrl, account, { + applicationLanguage, + defaultSubtitleLanguage: defaultSubtitleLanguage ?? undefined, + applicationTheme: applicationTheme ?? undefined, + }); await restore(); diff --git a/src/stores/bookmarks/BookmarkSyncer.tsx b/src/stores/bookmarks/BookmarkSyncer.tsx index 7455aa47..bd471f45 100644 --- a/src/stores/bookmarks/BookmarkSyncer.tsx +++ b/src/stores/bookmarks/BookmarkSyncer.tsx @@ -27,11 +27,13 @@ async function syncBookmarks( if (item.action === "add") { await addBookmark(url, account, { - poster: item.poster, - title: item.title ?? "", + meta: { + poster: item.poster, + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + }, tmdbId: item.tmdbId, - type: item.type ?? "", - year: item.year ?? NaN, }); continue; } diff --git a/src/stores/progress/ProgressSyncer.tsx b/src/stores/progress/ProgressSyncer.tsx index a7910e58..974b00e3 100644 --- a/src/stores/progress/ProgressSyncer.tsx +++ b/src/stores/progress/ProgressSyncer.tsx @@ -1,6 +1,10 @@ import { useEffect } from "react"; -import { removeProgress, setProgress } from "@/backend/accounts/progress"; +import { + progressUpdateItemToInput, + removeProgress, + setProgress, +} from "@/backend/accounts/progress"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { AccountWithToken, useAuthStore } from "@/stores/auth"; import { ProgressUpdateItem, useProgressStore } from "@/stores/progress"; @@ -32,21 +36,7 @@ async function syncProgress( } if (item.action === "upsert") { - await setProgress(url, account, { - duration: item.progress?.duration ?? 0, - watched: item.progress?.watched ?? 0, - tmdbId: item.tmdbId, - meta: { - title: item.title ?? "", - type: item.type ?? "", - year: item.year ?? NaN, - poster: item.poster, - }, - episodeId: item.episodeId, - seasonId: item.seasonId, - episodeNumber: item.episodeNumber, - seasonNumber: item.seasonNumber, - }); + await setProgress(url, account, progressUpdateItemToInput(item)); continue; } } catch (err) {