mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 20:01:49 +01:00
progress sycning + device name in user dropdown
This commit is contained in:
parent
ab4d72ed1a
commit
e7257e392e
55
src/backend/accounts/progress.ts
Normal file
55
src/backend/accounts/progress.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -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 }>(
|
||||||
headers: getAuthHeaders(token),
|
"/users/@me",
|
||||||
baseURL: url,
|
{
|
||||||
});
|
headers: getAuthHeaders(token),
|
||||||
|
baseURL: url,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(
|
export async function deleteUser(
|
||||||
|
@ -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}
|
||||||
|
@ -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 {
|
||||||
|
@ -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[]
|
||||||
) => {
|
) => {
|
||||||
|
@ -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 />
|
||||||
|
@ -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",
|
||||||
|
92
src/stores/progress/ProgressSyncer.tsx
Normal file
92
src/stores/progress/ProgressSyncer.tsx
Normal 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;
|
||||||
|
}
|
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user