mirror of
https://github.com/p-stream/p-stream.git
synced 2026-08-01 09:09:13 +00:00
add watch history
This commit is contained in:
parent
45ecb9d8f5
commit
e544334bea
12 changed files with 749 additions and 6 deletions
|
|
@ -378,6 +378,10 @@
|
|||
"mediaList": {
|
||||
"stopEditing": "Stop editing"
|
||||
},
|
||||
"watchHistory": {
|
||||
"sectionTitle": "Watch History",
|
||||
"recentlyWatched": "Recently Watched"
|
||||
},
|
||||
"search": {
|
||||
"allResults": "That's all we have...",
|
||||
"failed": "Failed to find media, try again!",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SessionResponse, getAuthHeaders } from "@/backend/accounts/auth";
|
|||
import { AccountWithToken } from "@/stores/auth";
|
||||
import { BookmarkMediaItem } from "@/stores/bookmarks";
|
||||
import { ProgressMediaItem } from "@/stores/progress";
|
||||
import { WatchHistoryItem } from "@/stores/watchHistory";
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
|
|
@ -60,6 +61,28 @@ export interface ProgressResponse {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchHistoryResponse {
|
||||
tmdbId: string;
|
||||
season: {
|
||||
id?: string;
|
||||
number?: number;
|
||||
};
|
||||
episode: {
|
||||
id?: string;
|
||||
number?: number;
|
||||
};
|
||||
meta: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
};
|
||||
duration: string;
|
||||
watched: string;
|
||||
watchedAt: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
||||
const entries = responses.map((bookmark) => {
|
||||
const item: BookmarkMediaItem = {
|
||||
|
|
@ -128,6 +151,35 @@ export function progressResponsesToEntries(responses: ProgressResponse[]) {
|
|||
return items;
|
||||
}
|
||||
|
||||
export function watchHistoryResponsesToEntries(
|
||||
responses: WatchHistoryResponse[],
|
||||
) {
|
||||
const items: Record<string, WatchHistoryItem> = {};
|
||||
|
||||
responses.forEach((v) => {
|
||||
const key = v.episode?.id ? `${v.tmdbId}-${v.episode.id}` : v.tmdbId;
|
||||
|
||||
items[key] = {
|
||||
type: v.meta.type,
|
||||
title: v.meta.title,
|
||||
poster: v.meta.poster,
|
||||
year: v.meta.year,
|
||||
progress: {
|
||||
duration: Number(v.duration),
|
||||
watched: Number(v.watched),
|
||||
},
|
||||
watchedAt: new Date(v.watchedAt).getTime(),
|
||||
completed: v.completed,
|
||||
episodeId: v.episode?.id,
|
||||
seasonId: v.season?.id,
|
||||
seasonNumber: v.season?.number,
|
||||
episodeNumber: v.episode?.number,
|
||||
};
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function getUser(
|
||||
url: string,
|
||||
token: string,
|
||||
|
|
@ -181,3 +233,13 @@ export async function getProgress(url: string, account: AccountWithToken) {
|
|||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWatchHistory(url: string, account: AccountWithToken) {
|
||||
return ofetch<WatchHistoryResponse[]>(
|
||||
`/users/${account.userId}/watch-history`,
|
||||
{
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
111
src/backend/accounts/watchHistory.ts
Normal file
111
src/backend/accounts/watchHistory.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
import {
|
||||
WatchHistoryItem,
|
||||
WatchHistoryUpdateItem,
|
||||
} from "@/stores/watchHistory";
|
||||
|
||||
export interface WatchHistoryInput {
|
||||
meta?: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: string;
|
||||
};
|
||||
tmdbId: string;
|
||||
watched: number;
|
||||
duration: number;
|
||||
watchedAt: string;
|
||||
completed: boolean;
|
||||
seasonId?: string;
|
||||
episodeId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export interface WatchHistoryResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export function watchHistoryUpdateItemToInput(
|
||||
item: WatchHistoryUpdateItem,
|
||||
): WatchHistoryInput {
|
||||
return {
|
||||
duration: item.progress?.duration ?? 0,
|
||||
watched: item.progress?.watched ?? 0,
|
||||
watchedAt: item.watchedAt
|
||||
? new Date(item.watchedAt).toISOString()
|
||||
: new Date().toISOString(),
|
||||
completed: item.completed ?? false,
|
||||
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 watchHistoryItemToInputs(
|
||||
id: string,
|
||||
item: WatchHistoryItem,
|
||||
): WatchHistoryInput {
|
||||
return {
|
||||
duration: item.progress.duration,
|
||||
watched: item.progress.watched,
|
||||
watchedAt: new Date(item.watchedAt).toISOString(),
|
||||
completed: item.completed,
|
||||
tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id,
|
||||
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 async function setWatchHistory(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
input: WatchHistoryInput,
|
||||
) {
|
||||
return ofetch<WatchHistoryResponse>(
|
||||
`/users/${account.userId}/watch-history/${input.tmdbId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
body: input,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeWatchHistory(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
id: string,
|
||||
episodeId?: string,
|
||||
seasonId?: string,
|
||||
) {
|
||||
await ofetch(`/users/${account.userId}/watch-history/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
body: {
|
||||
episodeId,
|
||||
seasonId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -291,6 +291,9 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
|
|||
<DropdownLink href="/settings" icon={Icons.SETTINGS}>
|
||||
{t("navigation.menu.settings")}
|
||||
</DropdownLink>
|
||||
<DropdownLink href="/watch-history" icon={Icons.CLOCK}>
|
||||
{t("home.watchHistory.sectionTitle")}
|
||||
</DropdownLink>
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<DropdownLink href="/dev" icon={Icons.COMPRESS}>
|
||||
{t("navigation.menu.development")}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
getBookmarks,
|
||||
getProgress,
|
||||
getUser,
|
||||
getWatchHistory,
|
||||
} from "@/backend/accounts/user";
|
||||
import { useAuthData } from "@/hooks/auth/useAuthData";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
|
|
@ -238,12 +239,14 @@ export function useAuth() {
|
|||
throw err;
|
||||
}
|
||||
|
||||
const [bookmarks, progress, settings, groupOrder] = await Promise.all([
|
||||
getBookmarks(backendUrl, account),
|
||||
getProgress(backendUrl, account),
|
||||
getSettings(backendUrl, account),
|
||||
getGroupOrder(backendUrl, account),
|
||||
]);
|
||||
const [bookmarks, progress, watchHistory, settings, groupOrder] =
|
||||
await Promise.all([
|
||||
getBookmarks(backendUrl, account),
|
||||
getProgress(backendUrl, account),
|
||||
getWatchHistory(backendUrl, account),
|
||||
getSettings(backendUrl, account),
|
||||
getGroupOrder(backendUrl, account),
|
||||
]);
|
||||
|
||||
// Update account store with fresh user data (including nickname)
|
||||
const { setAccount } = useAuthStore.getState();
|
||||
|
|
@ -260,6 +263,7 @@ export function useAuth() {
|
|||
user.session,
|
||||
progress,
|
||||
bookmarks,
|
||||
watchHistory,
|
||||
settings,
|
||||
groupOrder,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import {
|
|||
BookmarkResponse,
|
||||
ProgressResponse,
|
||||
UserResponse,
|
||||
WatchHistoryResponse,
|
||||
bookmarkResponsesToEntries,
|
||||
progressResponsesToEntries,
|
||||
watchHistoryResponsesToEntries,
|
||||
} from "@/backend/accounts/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
|
|
@ -17,6 +19,7 @@ import { usePreferencesStore } from "@/stores/preferences";
|
|||
import { useProgressStore } from "@/stores/progress";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import { useWatchHistoryStore } from "@/stores/watchHistory";
|
||||
|
||||
export function useAuthData() {
|
||||
const loggedIn = !!useAuthStore((s) => s.account);
|
||||
|
|
@ -25,6 +28,7 @@ export function useAuthData() {
|
|||
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||
const clearBookmarks = useBookmarkStore((s) => s.clear);
|
||||
const clearProgress = useProgressStore((s) => s.clear);
|
||||
const clearWatchHistory = useWatchHistoryStore((s) => s.clear);
|
||||
const clearGroupOrder = useGroupOrderStore((s) => s.clear);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
|
||||
|
|
@ -37,6 +41,7 @@ export function useAuthData() {
|
|||
|
||||
const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
|
||||
const replaceItems = useProgressStore((s) => s.replaceItems);
|
||||
const replaceWatchHistory = useWatchHistoryStore((s) => s.replaceItems);
|
||||
|
||||
const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails);
|
||||
const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay);
|
||||
|
|
@ -122,12 +127,14 @@ export function useAuthData() {
|
|||
removeAccount();
|
||||
clearBookmarks();
|
||||
clearProgress();
|
||||
clearWatchHistory();
|
||||
clearGroupOrder();
|
||||
setFebboxKey(null);
|
||||
}, [
|
||||
removeAccount,
|
||||
clearBookmarks,
|
||||
clearProgress,
|
||||
clearWatchHistory,
|
||||
clearGroupOrder,
|
||||
setFebboxKey,
|
||||
]);
|
||||
|
|
@ -138,11 +145,13 @@ export function useAuthData() {
|
|||
_session: SessionResponse,
|
||||
progress: ProgressResponse[],
|
||||
bookmarks: BookmarkResponse[],
|
||||
watchHistory: WatchHistoryResponse[],
|
||||
settings: SettingsResponse,
|
||||
groupOrder: { groupOrder: string[] },
|
||||
) => {
|
||||
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
|
||||
replaceItems(progressResponsesToEntries(progress));
|
||||
replaceWatchHistory(watchHistoryResponsesToEntries(watchHistory));
|
||||
|
||||
if (groupOrder?.groupOrder) {
|
||||
useGroupOrderStore.getState().setGroupOrder(groupOrder.groupOrder);
|
||||
|
|
@ -283,6 +292,7 @@ export function useAuthData() {
|
|||
[
|
||||
replaceBookmarks,
|
||||
replaceItems,
|
||||
replaceWatchHistory,
|
||||
setAppLanguage,
|
||||
importSubtitleLanguage,
|
||||
setTheme,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { changeAppLanguage, useLanguageStore } from "@/stores/language";
|
|||
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
|
||||
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
||||
import { ThemeProvider } from "@/stores/theme";
|
||||
import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer";
|
||||
import { detectRegion, useRegionStore } from "@/utils/detectRegion";
|
||||
|
||||
import {
|
||||
|
|
@ -248,6 +249,7 @@ root.render(
|
|||
<ThemeProvider applyGlobal>
|
||||
<ProgressSyncer />
|
||||
<BookmarkSyncer />
|
||||
<WatchHistorySyncer />
|
||||
<GroupSyncer />
|
||||
<SettingsSyncer />
|
||||
<TheRouter>
|
||||
|
|
|
|||
209
src/pages/watchHistory/WatchHistory.tsx
Normal file
209
src/pages/watchHistory/WatchHistory.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { WatchHistoryItem, useWatchHistoryStore } from "@/stores/watchHistory";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
interface WatchHistoryProps {
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
function formatWatchHistorySeries(historyItem: WatchHistoryItem) {
|
||||
if (
|
||||
!historyItem.episodeId ||
|
||||
!historyItem.seasonId ||
|
||||
!historyItem.episodeNumber
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
episode: historyItem.episodeNumber,
|
||||
season: historyItem.seasonNumber,
|
||||
episodeId: historyItem.episodeId,
|
||||
seasonId: historyItem.seasonId,
|
||||
};
|
||||
}
|
||||
|
||||
function getWatchHistoryPercentage(
|
||||
historyItem: WatchHistoryItem,
|
||||
): number | undefined {
|
||||
const { progress } = historyItem;
|
||||
if (!progress.duration || progress.duration <= 0) return undefined;
|
||||
if (!progress.watched || progress.watched < 0) return undefined;
|
||||
|
||||
const percentage = Math.min(
|
||||
(progress.watched / progress.duration) * 100,
|
||||
100,
|
||||
);
|
||||
return percentage;
|
||||
}
|
||||
|
||||
export function WatchHistory({ onShowDetails }: WatchHistoryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t: randomT } = useRandomTranslation();
|
||||
const emptyText = randomT(`home.search.empty`);
|
||||
const navigate = useNavigate();
|
||||
const watchHistory = useWatchHistoryStore((s) => s.items);
|
||||
const removeItem = useWatchHistoryStore((s) => s.removeItem);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { showModal } = useOverlayStack();
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
if (onShowDetails) {
|
||||
onShowDetails(media);
|
||||
} else {
|
||||
showModal("details", {
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
// Group items by show/movie
|
||||
const groupedItems: Record<string, WatchHistoryItem[]> = {};
|
||||
|
||||
Object.entries(watchHistory).forEach(([key, historyItem]) => {
|
||||
// For shows, group by the base show ID (remove episode/season suffix)
|
||||
// For movies, use the full key
|
||||
const groupKey =
|
||||
historyItem.type === "show"
|
||||
? key.split("-")[0] // Remove episode ID suffix for shows
|
||||
: key;
|
||||
|
||||
if (!groupedItems[groupKey]) {
|
||||
groupedItems[groupKey] = [];
|
||||
}
|
||||
groupedItems[groupKey].push(historyItem);
|
||||
});
|
||||
|
||||
// For each group, get the most recent item
|
||||
const output: Array<{ media: MediaItem; historyItem: WatchHistoryItem }> =
|
||||
[];
|
||||
Object.entries(groupedItems).forEach(([groupKey, groupItems]) => {
|
||||
// Sort group by most recent watchedAt
|
||||
const sortedGroup = groupItems.sort((a, b) => b.watchedAt - a.watchedAt);
|
||||
const mostRecentItem = sortedGroup[0];
|
||||
|
||||
output.push({
|
||||
media: {
|
||||
id: groupKey,
|
||||
title: mostRecentItem.title,
|
||||
year: mostRecentItem.year,
|
||||
poster: mostRecentItem.poster,
|
||||
type: mostRecentItem.type,
|
||||
},
|
||||
historyItem: mostRecentItem,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by most recently watched
|
||||
output.sort((a, b) => b.historyItem.watchedAt - a.historyItem.watchedAt);
|
||||
|
||||
return output;
|
||||
}, [watchHistory]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex flex-col items-center justify-center translate-y-1/2">
|
||||
<p className="text-[18.5px] pb-3">{emptyText}</p>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={() => navigate("/")}
|
||||
className="mt-4"
|
||||
>
|
||||
{t("notFound.goHome")}
|
||||
</Button>
|
||||
</div>
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<Heading1 className="text-2xl font-bold text-white">
|
||||
{t("home.watchHistory.sectionTitle")}
|
||||
</Heading1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} className="text-xl" />
|
||||
<span className="ml-2">{t("discover.page.back")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SectionHeading
|
||||
title={t("home.watchHistory.recentlyWatched")}
|
||||
icon={Icons.CLOCK}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-watch-history"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
|
||||
<MediaGrid ref={gridRef}>
|
||||
{items.map(({ media, historyItem }) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
<MediaCard
|
||||
media={media}
|
||||
series={formatWatchHistorySeries(historyItem)}
|
||||
linkable
|
||||
percentage={getWatchHistoryPercentage(historyItem)}
|
||||
onClose={
|
||||
editing
|
||||
? () => {
|
||||
// Remove all watch history items for this show/movie
|
||||
Object.keys(watchHistory).forEach((key) => {
|
||||
const item = watchHistory[key];
|
||||
const groupKey =
|
||||
item.type === "show" ? key.split("-")[0] : key;
|
||||
if (groupKey === media.id) {
|
||||
removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
closable={editing}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension"
|
|||
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
|
||||
import { RegisterPage } from "@/pages/Register";
|
||||
import { SupportPage } from "@/pages/Support";
|
||||
import { WatchHistory } from "@/pages/watchHistory/WatchHistory";
|
||||
import { Layout } from "@/setup/Layout";
|
||||
import { useHistoryListener } from "@/stores/history";
|
||||
import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack";
|
||||
|
|
@ -201,6 +202,8 @@ function App() {
|
|||
<Route path="/discover/all" element={<DiscoverMore />} />
|
||||
{/* Bookmarks page */}
|
||||
<Route path="/bookmarks" element={<AllBookmarks />} />
|
||||
{/* Watch History page */}
|
||||
<Route path="/watch-history" element={<WatchHistory />} />
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { persist } from "zustand/middleware";
|
|||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||
import { useWatchHistoryStore } from "@/stores/watchHistory";
|
||||
import {
|
||||
ProgressModificationOptions,
|
||||
ProgressModificationResult,
|
||||
|
|
@ -141,6 +142,12 @@ export const useProgressStore = create(
|
|||
watched: 0,
|
||||
};
|
||||
item.progress = { ...progress };
|
||||
|
||||
// Update watch history
|
||||
const completed =
|
||||
progress.duration > 0 &&
|
||||
progress.watched / progress.duration > 0.9;
|
||||
useWatchHistoryStore.getState().addItem(meta, progress, completed);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +174,11 @@ export const useProgressStore = create(
|
|||
};
|
||||
|
||||
item.episodes[meta.episode.tmdbId].progress = { ...progress };
|
||||
|
||||
// Update watch history
|
||||
const completed =
|
||||
progress.duration > 0 && progress.watched / progress.duration > 0.9;
|
||||
useWatchHistoryStore.getState().addItem(meta, progress, completed);
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
|
|
|
|||
134
src/stores/watchHistory/WatchHistorySyncer.tsx
Normal file
134
src/stores/watchHistory/WatchHistorySyncer.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
removeWatchHistory,
|
||||
setWatchHistory,
|
||||
watchHistoryUpdateItemToInput,
|
||||
} from "@/backend/accounts/watchHistory";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
WatchHistoryUpdateItem,
|
||||
useWatchHistoryStore,
|
||||
} from "@/stores/watchHistory";
|
||||
|
||||
const syncIntervalMs = 1 * 60 * 1000; // 1 minute intervals
|
||||
|
||||
async function syncWatchHistory(
|
||||
items: WatchHistoryUpdateItem[],
|
||||
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) continue; // not logged in, dont sync to server
|
||||
|
||||
try {
|
||||
if (item.action === "delete") {
|
||||
await removeWatchHistory(
|
||||
url,
|
||||
account,
|
||||
item.tmdbId,
|
||||
item.episodeId,
|
||||
item.seasonId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.action === "add" || item.action === "update") {
|
||||
await setWatchHistory(
|
||||
url,
|
||||
account,
|
||||
watchHistoryUpdateItemToInput(item),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to sync watch history: ${item.tmdbId} - ${item.action}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function WatchHistorySyncer() {
|
||||
const clearUpdateQueue = useWatchHistoryStore((s) => s.clearUpdateQueue);
|
||||
const removeUpdateItem = useWatchHistoryStore((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]);
|
||||
|
||||
// Immediate sync when items are added to queue
|
||||
useEffect(() => {
|
||||
let lastQueueLength = 0;
|
||||
|
||||
const checkAndSync = async () => {
|
||||
const currentQueueLength =
|
||||
useWatchHistoryStore.getState().updateQueue.length;
|
||||
// Only sync immediately if queue grew (items were added)
|
||||
if (currentQueueLength > lastQueueLength && currentQueueLength > 0) {
|
||||
if (!url) return;
|
||||
const state = useWatchHistoryStore.getState();
|
||||
const user = useAuthStore.getState();
|
||||
await syncWatchHistory(
|
||||
state.updateQueue,
|
||||
removeUpdateItem,
|
||||
url,
|
||||
user.account,
|
||||
);
|
||||
}
|
||||
lastQueueLength = currentQueueLength;
|
||||
};
|
||||
|
||||
// Override the addItem function to trigger immediate sync
|
||||
const originalAddItem = useWatchHistoryStore.getState().addItem;
|
||||
useWatchHistoryStore.setState({
|
||||
addItem: (...args) => {
|
||||
originalAddItem(...args);
|
||||
// Trigger sync after adding item
|
||||
setTimeout(checkAndSync, 100);
|
||||
},
|
||||
});
|
||||
|
||||
// Also override removeItem
|
||||
const originalRemoveItem = useWatchHistoryStore.getState().removeItem;
|
||||
useWatchHistoryStore.setState({
|
||||
removeItem: (...args) => {
|
||||
originalRemoveItem(...args);
|
||||
// Trigger sync after removing item
|
||||
setTimeout(checkAndSync, 100);
|
||||
},
|
||||
});
|
||||
}, [removeUpdateItem, url]);
|
||||
|
||||
// Regular interval sync
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
(async () => {
|
||||
if (!url) return;
|
||||
const state = useWatchHistoryStore.getState();
|
||||
const user = useAuthStore.getState();
|
||||
await syncWatchHistory(
|
||||
state.updateQueue,
|
||||
removeUpdateItem,
|
||||
url,
|
||||
user.account,
|
||||
);
|
||||
})();
|
||||
}, syncIntervalMs);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [removeUpdateItem, url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
189
src/stores/watchHistory/index.ts
Normal file
189
src/stores/watchHistory/index.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||
|
||||
export interface WatchHistoryItem {
|
||||
title: string;
|
||||
year?: number;
|
||||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
progress: {
|
||||
watched: number;
|
||||
duration: number;
|
||||
};
|
||||
watchedAt: number; // timestamp when last watched
|
||||
completed: boolean; // whether the item was completed
|
||||
episodeId?: string;
|
||||
seasonId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export interface WatchHistoryUpdateItem {
|
||||
title?: string;
|
||||
year?: number;
|
||||
poster?: string;
|
||||
type?: "show" | "movie";
|
||||
progress?: {
|
||||
watched: number;
|
||||
duration: number;
|
||||
};
|
||||
watchedAt?: number;
|
||||
completed?: boolean;
|
||||
tmdbId: string;
|
||||
id: string;
|
||||
episodeId?: string;
|
||||
seasonId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
action: "add" | "update" | "delete";
|
||||
}
|
||||
|
||||
export interface WatchHistoryStore {
|
||||
items: Record<string, WatchHistoryItem>;
|
||||
updateQueue: WatchHistoryUpdateItem[];
|
||||
addItem(
|
||||
meta: PlayerMeta,
|
||||
progress: { watched: number; duration: number },
|
||||
completed: boolean,
|
||||
): void;
|
||||
updateItem(
|
||||
id: string,
|
||||
progress: { watched: number; duration: number },
|
||||
completed: boolean,
|
||||
): void;
|
||||
removeItem(id: string): void;
|
||||
replaceItems(items: Record<string, WatchHistoryItem>): void;
|
||||
clear(): void;
|
||||
clearUpdateQueue(): void;
|
||||
removeUpdateItem(id: string): void;
|
||||
}
|
||||
|
||||
let updateId = 0;
|
||||
|
||||
export const useWatchHistoryStore = create(
|
||||
persist(
|
||||
immer<WatchHistoryStore>((set) => ({
|
||||
items: {},
|
||||
updateQueue: [],
|
||||
addItem(meta, progress, completed) {
|
||||
set((s) => {
|
||||
// add to updateQueue
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
tmdbId: meta.tmdbId,
|
||||
title: meta.title,
|
||||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
type: meta.type,
|
||||
progress: { ...progress },
|
||||
watchedAt: Date.now(),
|
||||
completed,
|
||||
id: updateId.toString(),
|
||||
episodeId: meta.episode?.tmdbId,
|
||||
seasonId: meta.season?.tmdbId,
|
||||
seasonNumber: meta.season?.number,
|
||||
episodeNumber: meta.episode?.number,
|
||||
action: "add",
|
||||
});
|
||||
|
||||
// add to watch history store
|
||||
const key = meta.episode
|
||||
? `${meta.tmdbId}-${meta.episode.tmdbId}`
|
||||
: meta.tmdbId;
|
||||
s.items[key] = {
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
progress: { ...progress },
|
||||
watchedAt: Date.now(),
|
||||
completed,
|
||||
episodeId: meta.episode?.tmdbId,
|
||||
seasonId: meta.season?.tmdbId,
|
||||
seasonNumber: meta.season?.number,
|
||||
episodeNumber: meta.episode?.number,
|
||||
};
|
||||
});
|
||||
},
|
||||
updateItem(id, progress, completed) {
|
||||
set((s) => {
|
||||
if (!s.items[id]) return;
|
||||
|
||||
// add to updateQueue
|
||||
updateId += 1;
|
||||
const item = s.items[id];
|
||||
s.updateQueue.push({
|
||||
tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
poster: item.poster,
|
||||
type: item.type,
|
||||
progress: { ...progress },
|
||||
watchedAt: Date.now(),
|
||||
completed,
|
||||
id: updateId.toString(),
|
||||
episodeId: item.episodeId,
|
||||
seasonId: item.seasonId,
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeNumber: item.episodeNumber,
|
||||
action: "update",
|
||||
});
|
||||
|
||||
// update item
|
||||
item.progress = { ...progress };
|
||||
item.watchedAt = Date.now();
|
||||
item.completed = completed;
|
||||
});
|
||||
},
|
||||
removeItem(id) {
|
||||
set((s) => {
|
||||
updateId += 1;
|
||||
|
||||
// Parse the key to extract TMDB ID and episode ID for episodes
|
||||
const isEpisode = id.includes("-");
|
||||
const tmdbId = isEpisode ? id.split("-")[0] : id;
|
||||
const episodeId = isEpisode ? id.split("-")[1] : undefined;
|
||||
|
||||
s.updateQueue.push({
|
||||
id: updateId.toString(),
|
||||
action: "delete",
|
||||
tmdbId,
|
||||
episodeId,
|
||||
// For movies, seasonId will be undefined, for episodes it might need to be derived from the item
|
||||
seasonId: s.items[id]?.seasonId,
|
||||
seasonNumber: s.items[id]?.seasonNumber,
|
||||
episodeNumber: s.items[id]?.episodeNumber,
|
||||
});
|
||||
|
||||
delete s.items[id];
|
||||
});
|
||||
},
|
||||
replaceItems(items: Record<string, WatchHistoryItem>) {
|
||||
set((s) => {
|
||||
s.items = items;
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
set((s) => {
|
||||
s.items = {};
|
||||
});
|
||||
},
|
||||
clearUpdateQueue() {
|
||||
set((s) => {
|
||||
s.updateQueue = [];
|
||||
});
|
||||
},
|
||||
removeUpdateItem(id: string) {
|
||||
set((s) => {
|
||||
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "__MW::watchHistory",
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
Reference in a new issue