pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

add watch history

Pas e544334b 45ecb9d8

+749 -6
+4
src/assets/locales/en.json
··· 378 378 "mediaList": { 379 379 "stopEditing": "Stop editing" 380 380 }, 381 + "watchHistory": { 382 + "sectionTitle": "Watch History", 383 + "recentlyWatched": "Recently Watched" 384 + }, 381 385 "search": { 382 386 "allResults": "That's all we have...", 383 387 "failed": "Failed to find media, try again!",
+62
src/backend/accounts/user.ts
··· 4 4 import { AccountWithToken } from "@/stores/auth"; 5 5 import { BookmarkMediaItem } from "@/stores/bookmarks"; 6 6 import { ProgressMediaItem } from "@/stores/progress"; 7 + import { WatchHistoryItem } from "@/stores/watchHistory"; 7 8 8 9 export interface UserResponse { 9 10 id: string; ··· 60 61 updatedAt: string; 61 62 } 62 63 64 + export interface WatchHistoryResponse { 65 + tmdbId: string; 66 + season: { 67 + id?: string; 68 + number?: number; 69 + }; 70 + episode: { 71 + id?: string; 72 + number?: number; 73 + }; 74 + meta: { 75 + title: string; 76 + year: number; 77 + poster?: string; 78 + type: "show" | "movie"; 79 + }; 80 + duration: string; 81 + watched: string; 82 + watchedAt: string; 83 + completed: boolean; 84 + } 85 + 63 86 export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) { 64 87 const entries = responses.map((bookmark) => { 65 88 const item: BookmarkMediaItem = { ··· 128 151 return items; 129 152 } 130 153 154 + export function watchHistoryResponsesToEntries( 155 + responses: WatchHistoryResponse[], 156 + ) { 157 + const items: Record<string, WatchHistoryItem> = {}; 158 + 159 + responses.forEach((v) => { 160 + const key = v.episode?.id ? `${v.tmdbId}-${v.episode.id}` : v.tmdbId; 161 + 162 + items[key] = { 163 + type: v.meta.type, 164 + title: v.meta.title, 165 + poster: v.meta.poster, 166 + year: v.meta.year, 167 + progress: { 168 + duration: Number(v.duration), 169 + watched: Number(v.watched), 170 + }, 171 + watchedAt: new Date(v.watchedAt).getTime(), 172 + completed: v.completed, 173 + episodeId: v.episode?.id, 174 + seasonId: v.season?.id, 175 + seasonNumber: v.season?.number, 176 + episodeNumber: v.episode?.number, 177 + }; 178 + }); 179 + 180 + return items; 181 + } 182 + 131 183 export async function getUser( 132 184 url: string, 133 185 token: string, ··· 181 233 baseURL: url, 182 234 }); 183 235 } 236 + 237 + export async function getWatchHistory(url: string, account: AccountWithToken) { 238 + return ofetch<WatchHistoryResponse[]>( 239 + `/users/${account.userId}/watch-history`, 240 + { 241 + headers: getAuthHeaders(account.token), 242 + baseURL: url, 243 + }, 244 + ); 245 + }
+111
src/backend/accounts/watchHistory.ts
··· 1 + import { ofetch } from "ofetch"; 2 + 3 + import { getAuthHeaders } from "@/backend/accounts/auth"; 4 + import { AccountWithToken } from "@/stores/auth"; 5 + import { 6 + WatchHistoryItem, 7 + WatchHistoryUpdateItem, 8 + } from "@/stores/watchHistory"; 9 + 10 + export interface WatchHistoryInput { 11 + meta?: { 12 + title: string; 13 + year: number; 14 + poster?: string; 15 + type: string; 16 + }; 17 + tmdbId: string; 18 + watched: number; 19 + duration: number; 20 + watchedAt: string; 21 + completed: boolean; 22 + seasonId?: string; 23 + episodeId?: string; 24 + seasonNumber?: number; 25 + episodeNumber?: number; 26 + } 27 + 28 + export interface WatchHistoryResponse { 29 + success: boolean; 30 + } 31 + 32 + export function watchHistoryUpdateItemToInput( 33 + item: WatchHistoryUpdateItem, 34 + ): WatchHistoryInput { 35 + return { 36 + duration: item.progress?.duration ?? 0, 37 + watched: item.progress?.watched ?? 0, 38 + watchedAt: item.watchedAt 39 + ? new Date(item.watchedAt).toISOString() 40 + : new Date().toISOString(), 41 + completed: item.completed ?? false, 42 + tmdbId: item.tmdbId, 43 + meta: { 44 + title: item.title ?? "", 45 + type: item.type ?? "", 46 + year: item.year ?? NaN, 47 + poster: item.poster, 48 + }, 49 + episodeId: item.episodeId, 50 + seasonId: item.seasonId, 51 + episodeNumber: item.episodeNumber, 52 + seasonNumber: item.seasonNumber, 53 + }; 54 + } 55 + 56 + export function watchHistoryItemToInputs( 57 + id: string, 58 + item: WatchHistoryItem, 59 + ): WatchHistoryInput { 60 + return { 61 + duration: item.progress.duration, 62 + watched: item.progress.watched, 63 + watchedAt: new Date(item.watchedAt).toISOString(), 64 + completed: item.completed, 65 + tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id, 66 + meta: { 67 + title: item.title, 68 + type: item.type, 69 + year: item.year ?? NaN, 70 + poster: item.poster, 71 + }, 72 + episodeId: item.episodeId, 73 + seasonId: item.seasonId, 74 + episodeNumber: item.episodeNumber, 75 + seasonNumber: item.seasonNumber, 76 + }; 77 + } 78 + 79 + export async function setWatchHistory( 80 + url: string, 81 + account: AccountWithToken, 82 + input: WatchHistoryInput, 83 + ) { 84 + return ofetch<WatchHistoryResponse>( 85 + `/users/${account.userId}/watch-history/${input.tmdbId}`, 86 + { 87 + method: "PUT", 88 + headers: getAuthHeaders(account.token), 89 + baseURL: url, 90 + body: input, 91 + }, 92 + ); 93 + } 94 + 95 + export async function removeWatchHistory( 96 + url: string, 97 + account: AccountWithToken, 98 + id: string, 99 + episodeId?: string, 100 + seasonId?: string, 101 + ) { 102 + await ofetch(`/users/${account.userId}/watch-history/${id}`, { 103 + method: "DELETE", 104 + headers: getAuthHeaders(account.token), 105 + baseURL: url, 106 + body: { 107 + episodeId, 108 + seasonId, 109 + }, 110 + }); 111 + }
+3
src/components/LinksDropdown.tsx
··· 291 291 <DropdownLink href="/settings" icon={Icons.SETTINGS}> 292 292 {t("navigation.menu.settings")} 293 293 </DropdownLink> 294 + <DropdownLink href="/watch-history" icon={Icons.CLOCK}> 295 + {t("home.watchHistory.sectionTitle")} 296 + </DropdownLink> 294 297 {process.env.NODE_ENV === "development" ? ( 295 298 <DropdownLink href="/dev" icon={Icons.COMPRESS}> 296 299 {t("navigation.menu.development")}
+10 -6
src/hooks/auth/useAuth.ts
··· 27 27 getBookmarks, 28 28 getProgress, 29 29 getUser, 30 + getWatchHistory, 30 31 } from "@/backend/accounts/user"; 31 32 import { useAuthData } from "@/hooks/auth/useAuthData"; 32 33 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; ··· 238 239 throw err; 239 240 } 240 241 241 - const [bookmarks, progress, settings, groupOrder] = await Promise.all([ 242 - getBookmarks(backendUrl, account), 243 - getProgress(backendUrl, account), 244 - getSettings(backendUrl, account), 245 - getGroupOrder(backendUrl, account), 246 - ]); 242 + const [bookmarks, progress, watchHistory, settings, groupOrder] = 243 + await Promise.all([ 244 + getBookmarks(backendUrl, account), 245 + getProgress(backendUrl, account), 246 + getWatchHistory(backendUrl, account), 247 + getSettings(backendUrl, account), 248 + getGroupOrder(backendUrl, account), 249 + ]); 247 250 248 251 // Update account store with fresh user data (including nickname) 249 252 const { setAccount } = useAuthStore.getState(); ··· 260 263 user.session, 261 264 progress, 262 265 bookmarks, 266 + watchHistory, 263 267 settings, 264 268 groupOrder, 265 269 );
+10
src/hooks/auth/useAuthData.ts
··· 6 6 BookmarkResponse, 7 7 ProgressResponse, 8 8 UserResponse, 9 + WatchHistoryResponse, 9 10 bookmarkResponsesToEntries, 10 11 progressResponsesToEntries, 12 + watchHistoryResponsesToEntries, 11 13 } from "@/backend/accounts/user"; 12 14 import { useAuthStore } from "@/stores/auth"; 13 15 import { useBookmarkStore } from "@/stores/bookmarks"; ··· 17 19 import { useProgressStore } from "@/stores/progress"; 18 20 import { useSubtitleStore } from "@/stores/subtitles"; 19 21 import { useThemeStore } from "@/stores/theme"; 22 + import { useWatchHistoryStore } from "@/stores/watchHistory"; 20 23 21 24 export function useAuthData() { 22 25 const loggedIn = !!useAuthStore((s) => s.account); ··· 25 28 const setProxySet = useAuthStore((s) => s.setProxySet); 26 29 const clearBookmarks = useBookmarkStore((s) => s.clear); 27 30 const clearProgress = useProgressStore((s) => s.clear); 31 + const clearWatchHistory = useWatchHistoryStore((s) => s.clear); 28 32 const clearGroupOrder = useGroupOrderStore((s) => s.clear); 29 33 const setTheme = useThemeStore((s) => s.setTheme); 30 34 const setAppLanguage = useLanguageStore((s) => s.setLanguage); ··· 37 41 38 42 const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks); 39 43 const replaceItems = useProgressStore((s) => s.replaceItems); 44 + const replaceWatchHistory = useWatchHistoryStore((s) => s.replaceItems); 40 45 41 46 const setEnableThumbnails = usePreferencesStore((s) => s.setEnableThumbnails); 42 47 const setEnableAutoplay = usePreferencesStore((s) => s.setEnableAutoplay); ··· 122 127 removeAccount(); 123 128 clearBookmarks(); 124 129 clearProgress(); 130 + clearWatchHistory(); 125 131 clearGroupOrder(); 126 132 setFebboxKey(null); 127 133 }, [ 128 134 removeAccount, 129 135 clearBookmarks, 130 136 clearProgress, 137 + clearWatchHistory, 131 138 clearGroupOrder, 132 139 setFebboxKey, 133 140 ]); ··· 138 145 _session: SessionResponse, 139 146 progress: ProgressResponse[], 140 147 bookmarks: BookmarkResponse[], 148 + watchHistory: WatchHistoryResponse[], 141 149 settings: SettingsResponse, 142 150 groupOrder: { groupOrder: string[] }, 143 151 ) => { 144 152 replaceBookmarks(bookmarkResponsesToEntries(bookmarks)); 145 153 replaceItems(progressResponsesToEntries(progress)); 154 + replaceWatchHistory(watchHistoryResponsesToEntries(watchHistory)); 146 155 147 156 if (groupOrder?.groupOrder) { 148 157 useGroupOrderStore.getState().setGroupOrder(groupOrder.groupOrder); ··· 283 292 [ 284 293 replaceBookmarks, 285 294 replaceItems, 295 + replaceWatchHistory, 286 296 setAppLanguage, 287 297 importSubtitleLanguage, 288 298 setTheme,
+2
src/index.tsx
··· 30 30 import { ProgressSyncer } from "@/stores/progress/ProgressSyncer"; 31 31 import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer"; 32 32 import { ThemeProvider } from "@/stores/theme"; 33 + import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer"; 33 34 import { detectRegion, useRegionStore } from "@/utils/detectRegion"; 34 35 35 36 import { ··· 248 249 <ThemeProvider applyGlobal> 249 250 <ProgressSyncer /> 250 251 <BookmarkSyncer /> 252 + <WatchHistorySyncer /> 251 253 <GroupSyncer /> 252 254 <SettingsSyncer /> 253 255 <TheRouter>
+209
src/pages/watchHistory/WatchHistory.tsx
··· 1 + import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 + import { useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useNavigate } from "react-router-dom"; 5 + 6 + import { Button } from "@/components/buttons/Button"; 7 + import { EditButton } from "@/components/buttons/EditButton"; 8 + import { Icon, Icons } from "@/components/Icon"; 9 + import { SectionHeading } from "@/components/layout/SectionHeading"; 10 + import { WideContainer } from "@/components/layout/WideContainer"; 11 + import { MediaCard } from "@/components/media/MediaCard"; 12 + import { MediaGrid } from "@/components/media/MediaGrid"; 13 + import { Heading1 } from "@/components/utils/Text"; 14 + import { useRandomTranslation } from "@/hooks/useRandomTranslation"; 15 + import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 16 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 17 + import { WatchHistoryItem, useWatchHistoryStore } from "@/stores/watchHistory"; 18 + import { MediaItem } from "@/utils/mediaTypes"; 19 + 20 + interface WatchHistoryProps { 21 + onShowDetails?: (media: MediaItem) => void; 22 + } 23 + 24 + function formatWatchHistorySeries(historyItem: WatchHistoryItem) { 25 + if ( 26 + !historyItem.episodeId || 27 + !historyItem.seasonId || 28 + !historyItem.episodeNumber 29 + ) 30 + return undefined; 31 + return { 32 + episode: historyItem.episodeNumber, 33 + season: historyItem.seasonNumber, 34 + episodeId: historyItem.episodeId, 35 + seasonId: historyItem.seasonId, 36 + }; 37 + } 38 + 39 + function getWatchHistoryPercentage( 40 + historyItem: WatchHistoryItem, 41 + ): number | undefined { 42 + const { progress } = historyItem; 43 + if (!progress.duration || progress.duration <= 0) return undefined; 44 + if (!progress.watched || progress.watched < 0) return undefined; 45 + 46 + const percentage = Math.min( 47 + (progress.watched / progress.duration) * 100, 48 + 100, 49 + ); 50 + return percentage; 51 + } 52 + 53 + export function WatchHistory({ onShowDetails }: WatchHistoryProps) { 54 + const { t } = useTranslation(); 55 + const { t: randomT } = useRandomTranslation(); 56 + const emptyText = randomT(`home.search.empty`); 57 + const navigate = useNavigate(); 58 + const watchHistory = useWatchHistoryStore((s) => s.items); 59 + const removeItem = useWatchHistoryStore((s) => s.removeItem); 60 + const [editing, setEditing] = useState(false); 61 + const [gridRef] = useAutoAnimate<HTMLDivElement>(); 62 + const { showModal } = useOverlayStack(); 63 + 64 + const handleShowDetails = async (media: MediaItem) => { 65 + if (onShowDetails) { 66 + onShowDetails(media); 67 + } else { 68 + showModal("details", { 69 + id: Number(media.id), 70 + type: media.type === "movie" ? "movie" : "show", 71 + }); 72 + } 73 + }; 74 + 75 + const items = useMemo(() => { 76 + // Group items by show/movie 77 + const groupedItems: Record<string, WatchHistoryItem[]> = {}; 78 + 79 + Object.entries(watchHistory).forEach(([key, historyItem]) => { 80 + // For shows, group by the base show ID (remove episode/season suffix) 81 + // For movies, use the full key 82 + const groupKey = 83 + historyItem.type === "show" 84 + ? key.split("-")[0] // Remove episode ID suffix for shows 85 + : key; 86 + 87 + if (!groupedItems[groupKey]) { 88 + groupedItems[groupKey] = []; 89 + } 90 + groupedItems[groupKey].push(historyItem); 91 + }); 92 + 93 + // For each group, get the most recent item 94 + const output: Array<{ media: MediaItem; historyItem: WatchHistoryItem }> = 95 + []; 96 + Object.entries(groupedItems).forEach(([groupKey, groupItems]) => { 97 + // Sort group by most recent watchedAt 98 + const sortedGroup = groupItems.sort((a, b) => b.watchedAt - a.watchedAt); 99 + const mostRecentItem = sortedGroup[0]; 100 + 101 + output.push({ 102 + media: { 103 + id: groupKey, 104 + title: mostRecentItem.title, 105 + year: mostRecentItem.year, 106 + poster: mostRecentItem.poster, 107 + type: mostRecentItem.type, 108 + }, 109 + historyItem: mostRecentItem, 110 + }); 111 + }); 112 + 113 + // Sort by most recently watched 114 + output.sort((a, b) => b.historyItem.watchedAt - a.historyItem.watchedAt); 115 + 116 + return output; 117 + }, [watchHistory]); 118 + 119 + if (items.length === 0) { 120 + return ( 121 + <SubPageLayout> 122 + <WideContainer> 123 + <div className="flex flex-col items-center justify-center translate-y-1/2"> 124 + <p className="text-[18.5px] pb-3">{emptyText}</p> 125 + <Button 126 + theme="purple" 127 + onClick={() => navigate("/")} 128 + className="mt-4" 129 + > 130 + {t("notFound.goHome")} 131 + </Button> 132 + </div> 133 + </WideContainer> 134 + </SubPageLayout> 135 + ); 136 + } 137 + 138 + return ( 139 + <SubPageLayout> 140 + <WideContainer> 141 + <div className="flex items-center justify-between gap-8"> 142 + <Heading1 className="text-2xl font-bold text-white"> 143 + {t("home.watchHistory.sectionTitle")} 144 + </Heading1> 145 + </div> 146 + 147 + <div className="flex items-center gap-4 pb-8"> 148 + <button 149 + type="button" 150 + onClick={() => navigate("/")} 151 + className="flex items-center text-white hover:text-gray-300 transition-colors" 152 + > 153 + <Icon icon={Icons.ARROW_LEFT} className="text-xl" /> 154 + <span className="ml-2">{t("discover.page.back")}</span> 155 + </button> 156 + </div> 157 + 158 + <SectionHeading 159 + title={t("home.watchHistory.recentlyWatched")} 160 + icon={Icons.CLOCK} 161 + > 162 + <div className="flex items-center gap-2"> 163 + <EditButton 164 + editing={editing} 165 + onEdit={setEditing} 166 + id="edit-button-watch-history" 167 + /> 168 + </div> 169 + </SectionHeading> 170 + 171 + <MediaGrid ref={gridRef}> 172 + {items.map(({ media, historyItem }) => ( 173 + <div 174 + key={media.id} 175 + style={{ userSelect: "none" }} 176 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 177 + e.preventDefault() 178 + } 179 + > 180 + <MediaCard 181 + media={media} 182 + series={formatWatchHistorySeries(historyItem)} 183 + linkable 184 + percentage={getWatchHistoryPercentage(historyItem)} 185 + onClose={ 186 + editing 187 + ? () => { 188 + // Remove all watch history items for this show/movie 189 + Object.keys(watchHistory).forEach((key) => { 190 + const item = watchHistory[key]; 191 + const groupKey = 192 + item.type === "show" ? key.split("-")[0] : key; 193 + if (groupKey === media.id) { 194 + removeItem(key); 195 + } 196 + }); 197 + } 198 + : undefined 199 + } 200 + closable={editing} 201 + onShowDetails={handleShowDetails} 202 + /> 203 + </div> 204 + ))} 205 + </MediaGrid> 206 + </WideContainer> 207 + </SubPageLayout> 208 + ); 209 + }
+3
src/setup/App.tsx
··· 40 40 import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy"; 41 41 import { RegisterPage } from "@/pages/Register"; 42 42 import { SupportPage } from "@/pages/Support"; 43 + import { WatchHistory } from "@/pages/watchHistory/WatchHistory"; 43 44 import { Layout } from "@/setup/Layout"; 44 45 import { useHistoryListener } from "@/stores/history"; 45 46 import { useClearModalsOnNavigation } from "@/stores/interface/overlayStack"; ··· 201 202 <Route path="/discover/all" element={<DiscoverMore />} /> 202 203 {/* Bookmarks page */} 203 204 <Route path="/bookmarks" element={<AllBookmarks />} /> 205 + {/* Watch History page */} 206 + <Route path="/watch-history" element={<WatchHistory />} /> 204 207 {/* Settings page */} 205 208 <Route 206 209 path="/settings"
+12
src/stores/progress/index.ts
··· 3 3 import { immer } from "zustand/middleware/immer"; 4 4 5 5 import { PlayerMeta } from "@/stores/player/slices/source"; 6 + import { useWatchHistoryStore } from "@/stores/watchHistory"; 6 7 import { 7 8 ProgressModificationOptions, 8 9 ProgressModificationResult, ··· 141 142 watched: 0, 142 143 }; 143 144 item.progress = { ...progress }; 145 + 146 + // Update watch history 147 + const completed = 148 + progress.duration > 0 && 149 + progress.watched / progress.duration > 0.9; 150 + useWatchHistoryStore.getState().addItem(meta, progress, completed); 144 151 return; 145 152 } 146 153 ··· 167 174 }; 168 175 169 176 item.episodes[meta.episode.tmdbId].progress = { ...progress }; 177 + 178 + // Update watch history 179 + const completed = 180 + progress.duration > 0 && progress.watched / progress.duration > 0.9; 181 + useWatchHistoryStore.getState().addItem(meta, progress, completed); 170 182 }); 171 183 }, 172 184 clear() {
+134
src/stores/watchHistory/WatchHistorySyncer.tsx
··· 1 + import { useEffect } from "react"; 2 + 3 + import { 4 + removeWatchHistory, 5 + setWatchHistory, 6 + watchHistoryUpdateItemToInput, 7 + } from "@/backend/accounts/watchHistory"; 8 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 9 + import { AccountWithToken, useAuthStore } from "@/stores/auth"; 10 + import { 11 + WatchHistoryUpdateItem, 12 + useWatchHistoryStore, 13 + } from "@/stores/watchHistory"; 14 + 15 + const syncIntervalMs = 1 * 60 * 1000; // 1 minute intervals 16 + 17 + async function syncWatchHistory( 18 + items: WatchHistoryUpdateItem[], 19 + finish: (id: string) => void, 20 + url: string, 21 + account: AccountWithToken | null, 22 + ) { 23 + for (const item of items) { 24 + // complete it beforehand so it doesn't get handled while in progress 25 + finish(item.id); 26 + 27 + if (!account) continue; // not logged in, dont sync to server 28 + 29 + try { 30 + if (item.action === "delete") { 31 + await removeWatchHistory( 32 + url, 33 + account, 34 + item.tmdbId, 35 + item.episodeId, 36 + item.seasonId, 37 + ); 38 + continue; 39 + } 40 + 41 + if (item.action === "add" || item.action === "update") { 42 + await setWatchHistory( 43 + url, 44 + account, 45 + watchHistoryUpdateItemToInput(item), 46 + ); 47 + continue; 48 + } 49 + } catch (err) { 50 + console.error( 51 + `Failed to sync watch history: ${item.tmdbId} - ${item.action}`, 52 + err, 53 + ); 54 + } 55 + } 56 + } 57 + 58 + export function WatchHistorySyncer() { 59 + const clearUpdateQueue = useWatchHistoryStore((s) => s.clearUpdateQueue); 60 + const removeUpdateItem = useWatchHistoryStore((s) => s.removeUpdateItem); 61 + const url = useBackendUrl(); 62 + 63 + // when booting for the first time, clear update queue. 64 + // we dont want to process persisted update items 65 + useEffect(() => { 66 + clearUpdateQueue(); 67 + }, [clearUpdateQueue]); 68 + 69 + // Immediate sync when items are added to queue 70 + useEffect(() => { 71 + let lastQueueLength = 0; 72 + 73 + const checkAndSync = async () => { 74 + const currentQueueLength = 75 + useWatchHistoryStore.getState().updateQueue.length; 76 + // Only sync immediately if queue grew (items were added) 77 + if (currentQueueLength > lastQueueLength && currentQueueLength > 0) { 78 + if (!url) return; 79 + const state = useWatchHistoryStore.getState(); 80 + const user = useAuthStore.getState(); 81 + await syncWatchHistory( 82 + state.updateQueue, 83 + removeUpdateItem, 84 + url, 85 + user.account, 86 + ); 87 + } 88 + lastQueueLength = currentQueueLength; 89 + }; 90 + 91 + // Override the addItem function to trigger immediate sync 92 + const originalAddItem = useWatchHistoryStore.getState().addItem; 93 + useWatchHistoryStore.setState({ 94 + addItem: (...args) => { 95 + originalAddItem(...args); 96 + // Trigger sync after adding item 97 + setTimeout(checkAndSync, 100); 98 + }, 99 + }); 100 + 101 + // Also override removeItem 102 + const originalRemoveItem = useWatchHistoryStore.getState().removeItem; 103 + useWatchHistoryStore.setState({ 104 + removeItem: (...args) => { 105 + originalRemoveItem(...args); 106 + // Trigger sync after removing item 107 + setTimeout(checkAndSync, 100); 108 + }, 109 + }); 110 + }, [removeUpdateItem, url]); 111 + 112 + // Regular interval sync 113 + useEffect(() => { 114 + const interval = setInterval(() => { 115 + (async () => { 116 + if (!url) return; 117 + const state = useWatchHistoryStore.getState(); 118 + const user = useAuthStore.getState(); 119 + await syncWatchHistory( 120 + state.updateQueue, 121 + removeUpdateItem, 122 + url, 123 + user.account, 124 + ); 125 + })(); 126 + }, syncIntervalMs); 127 + 128 + return () => { 129 + clearInterval(interval); 130 + }; 131 + }, [removeUpdateItem, url]); 132 + 133 + return null; 134 + }
+189
src/stores/watchHistory/index.ts
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { immer } from "zustand/middleware/immer"; 4 + 5 + import { PlayerMeta } from "@/stores/player/slices/source"; 6 + 7 + export interface WatchHistoryItem { 8 + title: string; 9 + year?: number; 10 + poster?: string; 11 + type: "show" | "movie"; 12 + progress: { 13 + watched: number; 14 + duration: number; 15 + }; 16 + watchedAt: number; // timestamp when last watched 17 + completed: boolean; // whether the item was completed 18 + episodeId?: string; 19 + seasonId?: string; 20 + seasonNumber?: number; 21 + episodeNumber?: number; 22 + } 23 + 24 + export interface WatchHistoryUpdateItem { 25 + title?: string; 26 + year?: number; 27 + poster?: string; 28 + type?: "show" | "movie"; 29 + progress?: { 30 + watched: number; 31 + duration: number; 32 + }; 33 + watchedAt?: number; 34 + completed?: boolean; 35 + tmdbId: string; 36 + id: string; 37 + episodeId?: string; 38 + seasonId?: string; 39 + seasonNumber?: number; 40 + episodeNumber?: number; 41 + action: "add" | "update" | "delete"; 42 + } 43 + 44 + export interface WatchHistoryStore { 45 + items: Record<string, WatchHistoryItem>; 46 + updateQueue: WatchHistoryUpdateItem[]; 47 + addItem( 48 + meta: PlayerMeta, 49 + progress: { watched: number; duration: number }, 50 + completed: boolean, 51 + ): void; 52 + updateItem( 53 + id: string, 54 + progress: { watched: number; duration: number }, 55 + completed: boolean, 56 + ): void; 57 + removeItem(id: string): void; 58 + replaceItems(items: Record<string, WatchHistoryItem>): void; 59 + clear(): void; 60 + clearUpdateQueue(): void; 61 + removeUpdateItem(id: string): void; 62 + } 63 + 64 + let updateId = 0; 65 + 66 + export const useWatchHistoryStore = create( 67 + persist( 68 + immer<WatchHistoryStore>((set) => ({ 69 + items: {}, 70 + updateQueue: [], 71 + addItem(meta, progress, completed) { 72 + set((s) => { 73 + // add to updateQueue 74 + updateId += 1; 75 + s.updateQueue.push({ 76 + tmdbId: meta.tmdbId, 77 + title: meta.title, 78 + year: meta.releaseYear, 79 + poster: meta.poster, 80 + type: meta.type, 81 + progress: { ...progress }, 82 + watchedAt: Date.now(), 83 + completed, 84 + id: updateId.toString(), 85 + episodeId: meta.episode?.tmdbId, 86 + seasonId: meta.season?.tmdbId, 87 + seasonNumber: meta.season?.number, 88 + episodeNumber: meta.episode?.number, 89 + action: "add", 90 + }); 91 + 92 + // add to watch history store 93 + const key = meta.episode 94 + ? `${meta.tmdbId}-${meta.episode.tmdbId}` 95 + : meta.tmdbId; 96 + s.items[key] = { 97 + type: meta.type, 98 + title: meta.title, 99 + year: meta.releaseYear, 100 + poster: meta.poster, 101 + progress: { ...progress }, 102 + watchedAt: Date.now(), 103 + completed, 104 + episodeId: meta.episode?.tmdbId, 105 + seasonId: meta.season?.tmdbId, 106 + seasonNumber: meta.season?.number, 107 + episodeNumber: meta.episode?.number, 108 + }; 109 + }); 110 + }, 111 + updateItem(id, progress, completed) { 112 + set((s) => { 113 + if (!s.items[id]) return; 114 + 115 + // add to updateQueue 116 + updateId += 1; 117 + const item = s.items[id]; 118 + s.updateQueue.push({ 119 + tmdbId: item.episodeId ? item.seasonId || id.split("-")[0] : id, 120 + title: item.title, 121 + year: item.year, 122 + poster: item.poster, 123 + type: item.type, 124 + progress: { ...progress }, 125 + watchedAt: Date.now(), 126 + completed, 127 + id: updateId.toString(), 128 + episodeId: item.episodeId, 129 + seasonId: item.seasonId, 130 + seasonNumber: item.seasonNumber, 131 + episodeNumber: item.episodeNumber, 132 + action: "update", 133 + }); 134 + 135 + // update item 136 + item.progress = { ...progress }; 137 + item.watchedAt = Date.now(); 138 + item.completed = completed; 139 + }); 140 + }, 141 + removeItem(id) { 142 + set((s) => { 143 + updateId += 1; 144 + 145 + // Parse the key to extract TMDB ID and episode ID for episodes 146 + const isEpisode = id.includes("-"); 147 + const tmdbId = isEpisode ? id.split("-")[0] : id; 148 + const episodeId = isEpisode ? id.split("-")[1] : undefined; 149 + 150 + s.updateQueue.push({ 151 + id: updateId.toString(), 152 + action: "delete", 153 + tmdbId, 154 + episodeId, 155 + // For movies, seasonId will be undefined, for episodes it might need to be derived from the item 156 + seasonId: s.items[id]?.seasonId, 157 + seasonNumber: s.items[id]?.seasonNumber, 158 + episodeNumber: s.items[id]?.episodeNumber, 159 + }); 160 + 161 + delete s.items[id]; 162 + }); 163 + }, 164 + replaceItems(items: Record<string, WatchHistoryItem>) { 165 + set((s) => { 166 + s.items = items; 167 + }); 168 + }, 169 + clear() { 170 + set((s) => { 171 + s.items = {}; 172 + }); 173 + }, 174 + clearUpdateQueue() { 175 + set((s) => { 176 + s.updateQueue = []; 177 + }); 178 + }, 179 + removeUpdateItem(id: string) { 180 + set((s) => { 181 + s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)]; 182 + }); 183 + }, 184 + })), 185 + { 186 + name: "__MW::watchHistory", 187 + }, 188 + ), 189 + );