progress sycning + device name in user dropdown

This commit is contained in:
mrjvs 2023-11-19 20:47:20 +01:00
parent ab4d72ed1a
commit e7257e392e
9 changed files with 211 additions and 19 deletions

View File

@ -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<ProgressResponse>(
`/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,
},
});
}

View File

@ -1,6 +1,6 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
import { getAuthHeaders } from "@/backend/accounts/auth"; import { SessionResponse, getAuthHeaders } from "@/backend/accounts/auth";
import { AccountWithToken } from "@/stores/auth"; import { AccountWithToken } from "@/stores/auth";
import { BookmarkMediaItem } from "@/stores/bookmarks"; import { BookmarkMediaItem } from "@/stores/bookmarks";
import { ProgressMediaItem } from "@/stores/progress"; import { ProgressMediaItem } from "@/stores/progress";
@ -32,6 +32,8 @@ export interface BookmarkResponse {
export interface ProgressResponse { export interface ProgressResponse {
tmdbId: string; tmdbId: string;
seasonId?: string; seasonId?: string;
seasonNumber?: number;
episodeNumber?: number;
episodeId?: string; episodeId?: string;
meta: { meta: {
title: string; title: string;
@ -83,13 +85,13 @@ export function progressResponsesToEntries(responses: ProgressResponse[]) {
if (item.type === "show" && v.seasonId && v.episodeId) { if (item.type === "show" && v.seasonId && v.episodeId) {
item.seasons[v.seasonId] = { item.seasons[v.seasonId] = {
id: v.seasonId, id: v.seasonId,
number: 0, // TODO missing number: v.seasonNumber ?? 0,
title: "", // TODO missing title: "",
}; };
item.episodes[v.episodeId] = { item.episodes[v.episodeId] = {
id: v.seasonId, id: v.seasonId,
number: 0, // TODO missing number: v.episodeNumber ?? 0,
title: "", // TODO missing title: "",
progress: { progress: {
duration: v.duration, duration: v.duration,
watched: v.watched, watched: v.watched,
@ -106,11 +108,14 @@ export function progressResponsesToEntries(responses: ProgressResponse[]) {
export async function getUser( export async function getUser(
url: string, url: string,
token: string token: string
): Promise<UserResponse> { ): Promise<{ user: UserResponse; session: SessionResponse }> {
return ofetch<UserResponse>("/users/@me", { return ofetch<{ user: UserResponse; session: SessionResponse }>(
"/users/@me",
{
headers: getAuthHeaders(token), headers: getAuthHeaders(token),
baseURL: url, baseURL: url,
}); }
);
} }
export async function deleteUser( export async function deleteUser(

View File

@ -1,7 +1,8 @@
import classNames from "classnames"; import classNames from "classnames";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto";
import { UserAvatar } from "@/components/Avatar"; import { UserAvatar } from "@/components/Avatar";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
@ -80,7 +81,12 @@ function CircleDropdownLink(props: { icon: Icons; href: string }) {
export function LinksDropdown(props: { children: React.ReactNode }) { export function LinksDropdown(props: { children: React.ReactNode }) {
const [open, setOpen] = useState(false); 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(); const { logout } = useAuth();
useEffect(() => { useEffect(() => {
@ -104,10 +110,10 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
</div> </div>
<Transition animation="slide-down" show={open}> <Transition animation="slide-down" show={open}>
<div className="rounded-lg absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0"> <div className="rounded-lg absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0">
{userId ? ( {deviceName && bufferSeed ? (
<DropdownLink className="text-white" href="/settings"> <DropdownLink className="text-white" href="/settings">
<UserAvatar /> <UserAvatar />
{userId} {decryptData(deviceName, bufferSeed)}
</DropdownLink> </DropdownLink>
) : ( ) : (
<DropdownLink href="/login" icon={Icons.RISING_STAR} highlight> <DropdownLink href="/login" icon={Icons.RISING_STAR} highlight>
@ -124,7 +130,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
<DropdownLink href="/faq" icon={Icons.FILM}> <DropdownLink href="/faq" icon={Icons.FILM}>
HELP MEEE HELP MEEE
</DropdownLink> </DropdownLink>
{userId ? ( {deviceName ? (
<DropdownLink <DropdownLink
className="!text-type-danger opacity-75 hover:opacity-100" className="!text-type-danger opacity-75 hover:opacity-100"
icon={Icons.LOGOUT} icon={Icons.LOGOUT}

View File

@ -69,7 +69,7 @@ export function useAuth() {
const user = await getUser(backendUrl, loginResult.token); const user = await getUser(backendUrl, loginResult.token);
const seedBase64 = bytesToBase64(keys.seed); const seedBase64 = bytesToBase64(keys.seed);
await userDataLogin(loginResult, user, seedBase64); await userDataLogin(loginResult, user.user, user.session, seedBase64);
}, },
[userDataLogin, backendUrl] [userDataLogin, backendUrl]
); );
@ -109,6 +109,7 @@ export function useAuth() {
await userDataLogin( await userDataLogin(
registerResult, registerResult,
registerResult.user, registerResult.user,
registerResult.session,
bytesToBase64(keys.seed) bytesToBase64(keys.seed)
); );
}, },
@ -125,7 +126,7 @@ export function useAuth() {
const bookmarks = await getBookmarks(backendUrl, currentAccount); const bookmarks = await getBookmarks(backendUrl, currentAccount);
const progress = await getProgress(backendUrl, currentAccount); const progress = await getProgress(backendUrl, currentAccount);
syncData(user, progress, bookmarks); syncData(user.user, user.session, progress, bookmarks);
}, [backendUrl, currentAccount, syncData]); }, [backendUrl, currentAccount, syncData]);
return { return {

View File

@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { LoginResponse } from "@/backend/accounts/auth"; import { LoginResponse, SessionResponse } from "@/backend/accounts/auth";
import { import {
BookmarkResponse, BookmarkResponse,
ProgressResponse, ProgressResponse,
@ -23,11 +23,17 @@ export function useAuthData() {
const replaceItems = useProgressStore((s) => s.replaceItems); const replaceItems = useProgressStore((s) => s.replaceItems);
const login = useCallback( const login = useCallback(
async (account: LoginResponse, user: UserResponse, seed: string) => { async (
account: LoginResponse,
user: UserResponse,
session: SessionResponse,
seed: string
) => {
setAccount({ setAccount({
token: account.token, token: account.token,
userId: user.id, userId: user.id,
sessionId: account.session.id, sessionId: account.session.id,
deviceName: session.device,
profile: user.profile, profile: user.profile,
seed, seed,
}); });
@ -45,6 +51,7 @@ export function useAuthData() {
const syncData = useCallback( const syncData = useCallback(
async ( async (
_user: UserResponse, _user: UserResponse,
_session: SessionResponse,
progress: ProgressResponse[], progress: ProgressResponse[],
bookmarks: BookmarkResponse[] bookmarks: BookmarkResponse[]
) => { ) => {

View File

@ -19,6 +19,7 @@ import { conf } from "@/setup/config";
import i18n from "@/setup/i18n"; import i18n from "@/setup/i18n";
import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer"; import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer";
import { useLanguageStore } from "@/stores/language"; import { useLanguageStore } from "@/stores/language";
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
import { useThemeStore } from "@/stores/theme"; import { useThemeStore } from "@/stores/theme";
import { initializeChromecast } from "./setup/chromecast"; import { initializeChromecast } from "./setup/chromecast";
@ -79,6 +80,7 @@ ReactDOM.render(
<HelmetProvider> <HelmetProvider>
<Suspense fallback={<LoadingScreen type="lazy" />}> <Suspense fallback={<LoadingScreen type="lazy" />}>
<ThemeProvider> <ThemeProvider>
<ProgressSyncer />
<BookmarkSyncer /> <BookmarkSyncer />
<TheRouter> <TheRouter>
<MigrationRunner /> <MigrationRunner />

View File

@ -15,6 +15,7 @@ export type AccountWithToken = Account & {
userId: string; userId: string;
token: string; token: string;
seed: string; seed: string;
deviceName: string;
}; };
interface AuthStore { interface AuthStore {
@ -23,6 +24,7 @@ interface AuthStore {
proxySet: null | string[]; // TODO actually use these settings proxySet: null | string[]; // TODO actually use these settings
removeAccount(): void; removeAccount(): void;
setAccount(acc: AccountWithToken): void; setAccount(acc: AccountWithToken): void;
updateDeviceName(deviceName: string): void;
updateAccount(acc: Account): 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", name: "__MW::auth",

View File

@ -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;
}

View File

@ -45,6 +45,8 @@ export interface ProgressUpdateItem {
id: string; id: string;
episodeId?: string; episodeId?: string;
seasonId?: string; seasonId?: string;
episodeNumber?: number;
seasonNumber?: number;
action: "upsert" | "delete"; action: "upsert" | "delete";
} }
@ -60,6 +62,8 @@ export interface ProgressStore {
removeItem(id: string): void; removeItem(id: string): void;
replaceItems(items: Record<string, ProgressMediaItem>): void; replaceItems(items: Record<string, ProgressMediaItem>): void;
clear(): void; clear(): void;
clearUpdateQueue(): void;
removeUpdateItem(id: string): void;
} }
let updateId = 0; let updateId = 0;
@ -100,6 +104,8 @@ export const useProgressStore = create(
id: updateId.toString(), id: updateId.toString(),
episodeId: meta.episode?.tmdbId, episodeId: meta.episode?.tmdbId,
seasonId: meta.season?.tmdbId, seasonId: meta.season?.tmdbId,
seasonNumber: meta.season?.number,
episodeNumber: meta.episode?.number,
action: "upsert", action: "upsert",
}); });
@ -155,6 +161,16 @@ export const useProgressStore = create(
clear() { clear() {
this.replaceItems({}); this.replaceItems({});
}, },
clearUpdateQueue() {
set((s) => {
s.updateQueue = [];
});
},
removeUpdateItem(id: string) {
set((s) => {
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
});
},
})), })),
{ {
name: "__MW::progress", name: "__MW::progress",