diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index f782cc8e..baf10e34 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -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!", diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index dd7de943..137d0cd1 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -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 = {}; + + 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( + `/users/${account.userId}/watch-history`, + { + headers: getAuthHeaders(account.token), + baseURL: url, + }, + ); +} diff --git a/src/backend/accounts/watchHistory.ts b/src/backend/accounts/watchHistory.ts new file mode 100644 index 00000000..fdc910e1 --- /dev/null +++ b/src/backend/accounts/watchHistory.ts @@ -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( + `/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, + }, + }); +} diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index 21573dc7..dd2dac2d 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -291,6 +291,9 @@ export function LinksDropdown(props: { children: React.ReactNode }) { {t("navigation.menu.settings")} + + {t("home.watchHistory.sectionTitle")} + {process.env.NODE_ENV === "development" ? ( {t("navigation.menu.development")} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 029b45f9..5194dacc 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -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, ); diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 8482ead1..f6ff80b5 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -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, diff --git a/src/index.tsx b/src/index.tsx index a9823b58..0e9e8a5e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( + diff --git a/src/pages/watchHistory/WatchHistory.tsx b/src/pages/watchHistory/WatchHistory.tsx new file mode 100644 index 00000000..8c2bc34d --- /dev/null +++ b/src/pages/watchHistory/WatchHistory.tsx @@ -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(); + 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 = {}; + + 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 ( + + +
+

{emptyText}

+ +
+
+
+ ); + } + + return ( + + +
+ + {t("home.watchHistory.sectionTitle")} + +
+ +
+ +
+ + +
+ +
+
+ + + {items.map(({ media, historyItem }) => ( +
) => + e.preventDefault() + } + > + { + // 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} + /> +
+ ))} +
+
+
+ ); +} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 6e43169d..b5c180ce 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -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() { } /> {/* Bookmarks page */} } /> + {/* Watch History page */} + } /> {/* Settings page */} 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() { diff --git a/src/stores/watchHistory/WatchHistorySyncer.tsx b/src/stores/watchHistory/WatchHistorySyncer.tsx new file mode 100644 index 00000000..12c4926b --- /dev/null +++ b/src/stores/watchHistory/WatchHistorySyncer.tsx @@ -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; +} diff --git a/src/stores/watchHistory/index.ts b/src/stores/watchHistory/index.ts new file mode 100644 index 00000000..d628fff1 --- /dev/null +++ b/src/stores/watchHistory/index.ts @@ -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; + 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): void; + clear(): void; + clearUpdateQueue(): void; + removeUpdateItem(id: string): void; +} + +let updateId = 0; + +export const useWatchHistoryStore = create( + persist( + immer((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) { + 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", + }, + ), +);