add watch history

This commit is contained in:
Pas 2026-01-16 19:59:40 -07:00
parent 45ecb9d8f5
commit e544334bea
12 changed files with 749 additions and 6 deletions

View file

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

View file

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

View 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,
},
});
}

View file

@ -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")}

View file

@ -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,
);

View file

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

View file

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

View 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>
);
}

View file

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

View file

@ -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() {

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

View 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",
},
),
);