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 { 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<UserResponse> {
return ofetch<UserResponse>("/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(

View File

@ -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 }) {
</div>
<Transition animation="slide-down" show={open}>
<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">
<UserAvatar />
{userId}
{decryptData(deviceName, bufferSeed)}
</DropdownLink>
) : (
<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}>
HELP MEEE
</DropdownLink>
{userId ? (
{deviceName ? (
<DropdownLink
className="!text-type-danger opacity-75 hover:opacity-100"
icon={Icons.LOGOUT}

View File

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

View File

@ -1,6 +1,6 @@
import { useCallback } from "react";
import { LoginResponse } from "@/backend/accounts/auth";
import { LoginResponse, SessionResponse } from "@/backend/accounts/auth";
import {
BookmarkResponse,
ProgressResponse,
@ -23,11 +23,17 @@ export function useAuthData() {
const replaceItems = useProgressStore((s) => 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[]
) => {

View File

@ -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(
<HelmetProvider>
<Suspense fallback={<LoadingScreen type="lazy" />}>
<ThemeProvider>
<ProgressSyncer />
<BookmarkSyncer />
<TheRouter>
<MigrationRunner />

View File

@ -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",

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;
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<string, ProgressMediaItem>): 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",