diff --git a/src/backend/accounts/progress.ts b/src/backend/accounts/progress.ts new file mode 100644 index 00000000..a05ed517 --- /dev/null +++ b/src/backend/accounts/progress.ts @@ -0,0 +1,55 @@ +import { ofetch } from "ofetch"; + +import { getAuthHeaders } from "@/backend/accounts/auth"; +import { ProgressResponse } from "@/backend/accounts/user"; +import { AccountWithToken } from "@/stores/auth"; + +export interface ProgressInput { + meta?: { + title: string; + year: number; + poster?: string; + type: string; + }; + tmdbId: string; + watched?: number; + duration?: number; + seasonId?: string; + episodeId?: string; + seasonNumber?: number; + episodeNumber?: number; +} + +export async function setProgress( + url: string, + account: AccountWithToken, + input: ProgressInput +) { + return ofetch( + `/users/${account.userId}/progress/${input.tmdbId}`, + { + method: "PUT", + headers: getAuthHeaders(account.token), + baseURL: url, + body: input, + } + ); +} + +export async function removeProgress( + url: string, + account: AccountWithToken, + id: string, + episodeId?: string, + seasonId?: string +) { + await ofetch(`/users/${account.userId}/progress/${id}`, { + method: "DELETE", + headers: getAuthHeaders(account.token), + baseURL: url, + body: { + episodeId, + seasonId, + }, + }); +} diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index 65ad63e7..4665e5b3 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -1,6 +1,6 @@ import { ofetch } from "ofetch"; -import { getAuthHeaders } from "@/backend/accounts/auth"; +import { SessionResponse, getAuthHeaders } from "@/backend/accounts/auth"; import { AccountWithToken } from "@/stores/auth"; import { BookmarkMediaItem } from "@/stores/bookmarks"; import { ProgressMediaItem } from "@/stores/progress"; @@ -32,6 +32,8 @@ export interface BookmarkResponse { export interface ProgressResponse { tmdbId: string; seasonId?: string; + seasonNumber?: number; + episodeNumber?: number; episodeId?: string; meta: { title: string; @@ -83,13 +85,13 @@ export function progressResponsesToEntries(responses: ProgressResponse[]) { if (item.type === "show" && v.seasonId && v.episodeId) { item.seasons[v.seasonId] = { id: v.seasonId, - number: 0, // TODO missing - title: "", // TODO missing + number: v.seasonNumber ?? 0, + title: "", }; item.episodes[v.episodeId] = { id: v.seasonId, - number: 0, // TODO missing - title: "", // TODO missing + number: v.episodeNumber ?? 0, + title: "", progress: { duration: v.duration, watched: v.watched, @@ -106,11 +108,14 @@ export function progressResponsesToEntries(responses: ProgressResponse[]) { export async function getUser( url: string, token: string -): Promise { - return ofetch("/users/@me", { - headers: getAuthHeaders(token), - baseURL: url, - }); +): Promise<{ user: UserResponse; session: SessionResponse }> { + return ofetch<{ user: UserResponse; session: SessionResponse }>( + "/users/@me", + { + headers: getAuthHeaders(token), + baseURL: url, + } + ); } export async function deleteUser( diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index 9f74517c..b4e94337 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -1,7 +1,8 @@ import classNames from "classnames"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useHistory } from "react-router-dom"; +import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto"; import { UserAvatar } from "@/components/Avatar"; import { Icon, Icons } from "@/components/Icon"; import { Transition } from "@/components/Transition"; @@ -80,7 +81,12 @@ function CircleDropdownLink(props: { icon: Icons; href: string }) { export function LinksDropdown(props: { children: React.ReactNode }) { const [open, setOpen] = useState(false); - const userId = useAuthStore((s) => s.account?.userId); + const deviceName = useAuthStore((s) => s.account?.deviceName); + const seed = useAuthStore((s) => s.account?.seed); + const bufferSeed = useMemo( + () => (seed ? base64ToBuffer(seed) : null), + [seed] + ); const { logout } = useAuth(); useEffect(() => { @@ -104,10 +110,10 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
- {userId ? ( + {deviceName && bufferSeed ? ( - {userId} + {decryptData(deviceName, bufferSeed)} ) : ( @@ -124,7 +130,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) { HELP MEEE - {userId ? ( + {deviceName ? ( s.replaceItems); const login = useCallback( - async (account: LoginResponse, user: UserResponse, seed: string) => { + async ( + account: LoginResponse, + user: UserResponse, + session: SessionResponse, + seed: string + ) => { setAccount({ token: account.token, userId: user.id, sessionId: account.session.id, + deviceName: session.device, profile: user.profile, seed, }); @@ -45,6 +51,7 @@ export function useAuthData() { const syncData = useCallback( async ( _user: UserResponse, + _session: SessionResponse, progress: ProgressResponse[], bookmarks: BookmarkResponse[] ) => { diff --git a/src/index.tsx b/src/index.tsx index 08567b27..a5886a41 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ import { conf } from "@/setup/config"; import i18n from "@/setup/i18n"; import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer"; import { useLanguageStore } from "@/stores/language"; +import { ProgressSyncer } from "@/stores/progress/ProgressSyncer"; import { useThemeStore } from "@/stores/theme"; import { initializeChromecast } from "./setup/chromecast"; @@ -79,6 +80,7 @@ ReactDOM.render( }> + diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index 37d70287..164a8a0f 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -15,6 +15,7 @@ export type AccountWithToken = Account & { userId: string; token: string; seed: string; + deviceName: string; }; interface AuthStore { @@ -23,6 +24,7 @@ interface AuthStore { proxySet: null | string[]; // TODO actually use these settings removeAccount(): void; setAccount(acc: AccountWithToken): void; + updateDeviceName(deviceName: string): void; updateAccount(acc: Account): void; } @@ -51,6 +53,12 @@ export const useAuthStore = create( }; }); }, + updateDeviceName(deviceName) { + set((s) => { + if (!s.account) return; + s.account.deviceName = deviceName; + }); + }, })), { name: "__MW::auth", diff --git a/src/stores/progress/ProgressSyncer.tsx b/src/stores/progress/ProgressSyncer.tsx new file mode 100644 index 00000000..a7910e58 --- /dev/null +++ b/src/stores/progress/ProgressSyncer.tsx @@ -0,0 +1,92 @@ +import { useEffect } from "react"; + +import { removeProgress, setProgress } from "@/backend/accounts/progress"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { AccountWithToken, useAuthStore } from "@/stores/auth"; +import { ProgressUpdateItem, useProgressStore } from "@/stores/progress"; + +const syncIntervalMs = 5 * 1000; + +async function syncProgress( + items: ProgressUpdateItem[], + finish: (id: string) => void, + url: string, + account: AccountWithToken | null +) { + for (const item of items) { + // complete it beforehand so it doesn't get handled while in progress + finish(item.id); + + if (!account) return; // not logged in, dont sync to server + + try { + if (item.action === "delete") { + await removeProgress( + url, + account, + item.tmdbId, + item.seasonId, + item.episodeId + ); + continue; + } + + 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, + }); + continue; + } + } catch (err) { + console.error( + `Failed to sync progress: ${item.tmdbId} - ${item.action}`, + err + ); + } + } +} + +export function ProgressSyncer() { + const clearUpdateQueue = useProgressStore((s) => s.clearUpdateQueue); + const removeUpdateItem = useProgressStore((s) => s.removeUpdateItem); + const url = useBackendUrl(); + + // when booting for the first time, clear update queue. + // we dont want to process persisted update items + useEffect(() => { + clearUpdateQueue(); + }, [clearUpdateQueue]); + + useEffect(() => { + const interval = setInterval(() => { + (async () => { + const state = useProgressStore.getState(); + const user = useAuthStore.getState(); + await syncProgress( + state.updateQueue, + removeUpdateItem, + url, + user.account + ); + })(); + }, syncIntervalMs); + + return () => { + clearInterval(interval); + }; + }, [removeUpdateItem, url]); + + return null; +} diff --git a/src/stores/progress/index.ts b/src/stores/progress/index.ts index 905e2201..359d1d9f 100644 --- a/src/stores/progress/index.ts +++ b/src/stores/progress/index.ts @@ -45,6 +45,8 @@ export interface ProgressUpdateItem { id: string; episodeId?: string; seasonId?: string; + episodeNumber?: number; + seasonNumber?: number; action: "upsert" | "delete"; } @@ -60,6 +62,8 @@ export interface ProgressStore { removeItem(id: string): void; replaceItems(items: Record): void; clear(): void; + clearUpdateQueue(): void; + removeUpdateItem(id: string): void; } let updateId = 0; @@ -100,6 +104,8 @@ export const useProgressStore = create( id: updateId.toString(), episodeId: meta.episode?.tmdbId, seasonId: meta.season?.tmdbId, + seasonNumber: meta.season?.number, + episodeNumber: meta.episode?.number, action: "upsert", }); @@ -155,6 +161,16 @@ export const useProgressStore = create( clear() { this.replaceItems({}); }, + clearUpdateQueue() { + set((s) => { + s.updateQueue = []; + }); + }, + removeUpdateItem(id: string) { + set((s) => { + s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)]; + }); + }, })), { name: "__MW::progress",