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

Configure Feed

Select the types of activity you want to include in your feed.

trakt

Pas fad27b25 04618295

+1671 -13
+4
example.env
··· 12 12 13 13 # Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com") 14 14 VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com 15 + 16 + # Trakt integration setup. Get these at https://trakt.tv/oauth/applications 17 + VITE_TRAKT_CLIENT_ID= 18 + VITE_TRAKT_CLIENT_SECRET=
+46
src/backend/metadata/tmdb.ts
··· 474 474 if (posterPath) return imgUrl; 475 475 } 476 476 477 + /** 478 + * Fetches the poster URL for a movie or show from TMDB by ID. 479 + * Use this when importing from external sources (e.g. Trakt) that may not have poster URLs. 480 + */ 481 + export async function getPosterForMedia( 482 + tmdbId: string, 483 + type: "movie" | "show", 484 + ): Promise<string | undefined> { 485 + try { 486 + const tmdbType = 487 + type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV; 488 + const details = await getMediaDetails(tmdbId, tmdbType, false); 489 + const posterPath = 490 + (details as TMDBMovieData | TMDBShowData).poster_path ?? null; 491 + return getMediaPoster(posterPath); 492 + } catch { 493 + return undefined; 494 + } 495 + } 496 + 477 497 export async function getCollectionDetails(collectionId: number): Promise<any> { 478 498 return get<any>(`/collection/${collectionId}`); 479 499 } ··· 491 511 still_path: e.still_path, 492 512 overview: e.overview, 493 513 })); 514 + } 515 + 516 + /** 517 + * Resolve TMDB season and episode IDs for a show. Use when external sources 518 + * (e.g. Trakt) only provide season/episode numbers. 519 + */ 520 + export async function getEpisodeIds( 521 + showTmdbId: string, 522 + seasonNumber: number, 523 + episodeNumber: number, 524 + ): Promise<{ seasonId: string; episodeId: string } | null> { 525 + try { 526 + const data = await get<TMDBSeason>( 527 + `/tv/${showTmdbId}/season/${seasonNumber}`, 528 + ); 529 + const episode = data.episodes.find( 530 + (e) => e.episode_number === episodeNumber, 531 + ); 532 + if (!episode) return null; 533 + return { 534 + seasonId: data.id.toString(), 535 + episodeId: episode.id.toString(), 536 + }; 537 + } catch { 538 + return null; 539 + } 494 540 } 495 541 496 542 export async function getMovieFromExternalId(
+46
src/components/TraktAuthHandler.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { useSearchParams } from "react-router-dom"; 3 + 4 + import { useTraktAuthStore } from "@/stores/trakt/store"; 5 + import { traktService } from "@/utils/trakt"; 6 + 7 + export function TraktAuthHandler() { 8 + const [searchParams, setSearchParams] = useSearchParams(); 9 + const code = searchParams.get("code"); 10 + const processedRef = useRef(false); 11 + const setStatus = useTraktAuthStore((s) => s.setStatus); 12 + const setError = useTraktAuthStore((s) => s.setError); 13 + 14 + useEffect(() => { 15 + if (!code || processedRef.current) return; 16 + processedRef.current = true; 17 + 18 + const exchange = async () => { 19 + setStatus("syncing"); 20 + setError(null); 21 + try { 22 + const redirectUri = `${window.location.origin}${window.location.pathname}`; 23 + const success = await traktService.exchangeCodeForToken( 24 + code, 25 + redirectUri, 26 + ); 27 + if (success) { 28 + const newParams = new URLSearchParams(searchParams); 29 + newParams.delete("code"); 30 + setSearchParams(newParams, { replace: true }); 31 + } else { 32 + setError("Failed to connect to Trakt"); 33 + } 34 + } catch (err: any) { 35 + console.error("Trakt auth failed", err); 36 + setError(err?.message ?? "Failed to connect to Trakt"); 37 + } finally { 38 + setStatus("idle"); 39 + } 40 + }; 41 + 42 + exchange(); 43 + }, [code, searchParams, setSearchParams, setStatus, setError]); 44 + 45 + return null; 46 + }
+6
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 { TraktBookmarkSyncer } from "@/stores/trakt/TraktBookmarkSyncer"; 34 + import { TraktHistorySyncer } from "@/stores/trakt/TraktHistorySyncer"; 35 + import { TraktScrobbler } from "@/stores/trakt/TraktScrobbler"; 33 36 import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer"; 34 37 import { detectRegion, useRegionStore } from "@/utils/detectRegion"; 35 38 ··· 252 255 <WatchHistorySyncer /> 253 256 <GroupSyncer /> 254 257 <SettingsSyncer /> 258 + <TraktBookmarkSyncer /> 259 + <TraktHistorySyncer /> 260 + <TraktScrobbler /> 255 261 <TheRouter> 256 262 <MigrationRunner /> 257 263 </TheRouter>
+3 -1
src/pages/PlayerView.tsx
··· 96 96 }); 97 97 useEffect(() => { 98 98 reset(); 99 - // Reset watch party state when media changes 100 99 openedWatchPartyRef.current = false; 100 + return () => { 101 + reset(); 102 + }; 101 103 }, [paramsData, reset]); 102 104 103 105 // Auto-open watch party menu if URL contains watchparty parameter
+62
src/pages/parts/settings/ConnectionsPart.tsx
··· 35 35 import { conf } from "@/setup/config"; 36 36 import { useAuthStore } from "@/stores/auth"; 37 37 import { usePreferencesStore } from "@/stores/preferences"; 38 + import { useTraktStore } from "@/stores/trakt/store"; 38 39 39 40 import { RegionSelectorPart } from "./RegionSelectorPart"; 40 41 ··· 773 774 ); 774 775 } 775 776 777 + export function TraktEdit() { 778 + const { user, status, logout, error } = useTraktStore(); 779 + const config = conf(); 780 + 781 + const connect = () => { 782 + const redirectUri = 783 + config.TRAKT_REDIRECT_URI ?? 784 + `${window.location.origin}${window.location.pathname}`; 785 + const params = new URLSearchParams({ 786 + response_type: "code", 787 + client_id: config.TRAKT_CLIENT_ID ?? "", 788 + redirect_uri: redirectUri, 789 + }); 790 + window.location.href = `https://trakt.tv/oauth/authorize?${params.toString()}`; 791 + }; 792 + 793 + if (!config.TRAKT_CLIENT_ID || !config.TRAKT_CLIENT_SECRET) return null; 794 + 795 + return ( 796 + <SettingsCard> 797 + <div className="flex justify-between items-center gap-4"> 798 + <div className="my-3"> 799 + <p className="text-white font-bold mb-3">Trakt</p> 800 + <p className="max-w-[30rem] font-medium"> 801 + Sync your watchlist and history with Trakt. 802 + </p> 803 + {error && <p className="text-type-danger mt-2">{error}</p>} 804 + </div> 805 + <div> 806 + {user ? ( 807 + <div className="flex items-center gap-4"> 808 + <div className="flex items-center gap-2"> 809 + {user.images?.avatar?.full && ( 810 + <img 811 + src={user.images.avatar.full} 812 + alt={user.username} 813 + className="w-8 h-8 rounded-full" 814 + /> 815 + )} 816 + <span className="font-bold">{user.name || user.username}</span> 817 + </div> 818 + <Button theme="danger" onClick={logout}> 819 + Disconnect 820 + </Button> 821 + </div> 822 + ) : ( 823 + <Button 824 + theme="purple" 825 + onClick={connect} 826 + disabled={status === "syncing"} 827 + > 828 + {status === "syncing" ? "Syncing..." : "Connect Trakt"} 829 + </Button> 830 + )} 831 + </div> 832 + </div> 833 + </SettingsCard> 834 + ); 835 + } 836 + 776 837 export function ConnectionsPart( 777 838 props: BackendEditProps & 778 839 ProxyEditProps & ··· 812 873 mode="settings" 813 874 /> 814 875 <TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} /> 876 + <TraktEdit /> 815 877 </div> 816 878 </div> 817 879 );
+2
src/setup/App.tsx
··· 16 16 import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; 17 17 import { NotificationModal } from "@/components/overlays/notificationsModal"; 18 18 import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; 19 + import { TraktAuthHandler } from "@/components/TraktAuthHandler"; 19 20 import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; 20 21 import { useOnlineListener } from "@/hooks/usePing"; 21 22 import { AboutPage } from "@/pages/About"; ··· 126 127 127 128 return ( 128 129 <Layout> 130 + <TraktAuthHandler /> 129 131 <LanguageProvider /> 130 132 <NotificationModal id="notifications" /> 131 133 <KeyboardCommandsModal id="keyboard-commands" />
+12
src/setup/config.ts
··· 32 32 BANNER_MESSAGE: string; 33 33 BANNER_ID: string; 34 34 USE_TRAKT: boolean; 35 + TRAKT_CLIENT_ID: string; 36 + TRAKT_CLIENT_SECRET: string; 37 + TRAKT_REDIRECT_URI: string; 35 38 HIDE_PROXY_ONBOARDING: boolean; 36 39 SHOW_SUPPORT_BAR: boolean; 37 40 SUPPORT_BAR_VALUE: string; ··· 64 67 BANNER_MESSAGE: string | null; 65 68 BANNER_ID: string | null; 66 69 USE_TRAKT: boolean; 70 + TRAKT_CLIENT_ID: string | null; 71 + TRAKT_CLIENT_SECRET: string | null; 72 + TRAKT_REDIRECT_URI: string | null; 67 73 HIDE_PROXY_ONBOARDING: boolean; 68 74 SHOW_SUPPORT_BAR: boolean; 69 75 SUPPORT_BAR_VALUE: string; ··· 98 104 BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE, 99 105 BANNER_ID: import.meta.env.VITE_BANNER_ID, 100 106 USE_TRAKT: import.meta.env.VITE_USE_TRAKT, 107 + TRAKT_CLIENT_ID: import.meta.env.VITE_TRAKT_CLIENT_ID, 108 + TRAKT_CLIENT_SECRET: import.meta.env.VITE_TRAKT_CLIENT_SECRET, 109 + TRAKT_REDIRECT_URI: import.meta.env.VITE_TRAKT_REDIRECT_URI, 101 110 HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING, 102 111 SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR, 103 112 SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE, ··· 192 201 BANNER_MESSAGE: getKey("BANNER_MESSAGE"), 193 202 BANNER_ID: getKey("BANNER_ID"), 194 203 USE_TRAKT: getKey("USE_TRAKT", "false") === "true", 204 + TRAKT_CLIENT_ID: getKey("TRAKT_CLIENT_ID"), 205 + TRAKT_CLIENT_SECRET: getKey("TRAKT_CLIENT_SECRET"), 206 + TRAKT_REDIRECT_URI: getKey("TRAKT_REDIRECT_URI"), 195 207 HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true", 196 208 SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true", 197 209 SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
+45 -12
src/stores/bookmarks/index.ts
··· 36 36 export interface BookmarkStore { 37 37 bookmarks: Record<string, BookmarkMediaItem>; 38 38 updateQueue: BookmarkUpdateItem[]; 39 + traktUpdateQueue: BookmarkUpdateItem[]; 39 40 addBookmark(meta: PlayerMeta): void; 40 41 addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void; 41 42 removeBookmark(id: string): void; ··· 57 58 clear(): void; 58 59 clearUpdateQueue(): void; 59 60 removeUpdateItem(id: string): void; 61 + clearTraktUpdateQueue(): void; 62 + removeTraktUpdateItem(id: string): void; 60 63 } 61 64 62 65 let updateId = 0; ··· 66 69 immer<BookmarkStore>((set) => ({ 67 70 bookmarks: {}, 68 71 updateQueue: [], 72 + traktUpdateQueue: [], 69 73 removeBookmark(id) { 70 74 set((s) => { 75 + const existing = s.bookmarks[id]; 71 76 updateId += 1; 72 - s.updateQueue.push({ 77 + const item: BookmarkUpdateItem = { 73 78 id: updateId.toString(), 74 79 action: "delete", 75 80 tmdbId: id, 76 - }); 81 + type: existing?.type, 82 + title: existing?.title, 83 + year: existing?.year, 84 + group: existing?.group, 85 + }; 86 + s.updateQueue.push(item); 87 + s.traktUpdateQueue.push(item); 77 88 78 89 delete s.bookmarks[id]; 79 90 }); ··· 81 92 addBookmark(meta) { 82 93 set((s) => { 83 94 updateId += 1; 84 - s.updateQueue.push({ 95 + const item: BookmarkUpdateItem = { 85 96 id: updateId.toString(), 86 97 action: "add", 87 98 tmdbId: meta.tmdbId, ··· 89 100 title: meta.title, 90 101 year: meta.releaseYear, 91 102 poster: meta.poster, 92 - }); 103 + }; 104 + s.updateQueue.push(item); 105 + s.traktUpdateQueue.push(item); 93 106 94 107 s.bookmarks[meta.tmdbId] = { 95 108 type: meta.type, ··· 103 116 addBookmarkWithGroups(meta, groups) { 104 117 set((s) => { 105 118 updateId += 1; 106 - s.updateQueue.push({ 119 + const item: BookmarkUpdateItem = { 107 120 id: updateId.toString(), 108 121 action: "add", 109 122 tmdbId: meta.tmdbId, ··· 112 125 year: meta.releaseYear, 113 126 poster: meta.poster, 114 127 group: groups, 115 - }); 128 + }; 129 + s.updateQueue.push(item); 130 + s.traktUpdateQueue.push(item); 116 131 117 132 s.bookmarks[meta.tmdbId] = { 118 133 type: meta.type, ··· 144 159 s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)]; 145 160 }); 146 161 }, 162 + clearTraktUpdateQueue() { 163 + set((s) => { 164 + s.traktUpdateQueue = []; 165 + }); 166 + }, 167 + removeTraktUpdateItem(id: string) { 168 + set((s) => { 169 + s.traktUpdateQueue = [ 170 + ...s.traktUpdateQueue.filter((v) => v.id !== id), 171 + ]; 172 + }); 173 + }, 147 174 toggleFavoriteEpisode( 148 175 showId: string, 149 176 episodeId: string, ··· 181 208 182 209 // Add to update queue for syncing 183 210 updateId += 1; 184 - s.updateQueue.push({ 211 + const item: BookmarkUpdateItem = { 185 212 id: updateId.toString(), 186 213 action: "add", 187 214 tmdbId: showId, ··· 190 217 year: bookmark.year, 191 218 poster: bookmark.poster, 192 219 type: bookmark.type, 193 - }); 220 + }; 221 + s.updateQueue.push(item); 222 + s.traktUpdateQueue.push(item); 194 223 }); 195 224 }, 196 225 isEpisodeFavorited(showId: string, episodeId: string): boolean { ··· 225 254 const bookmark = s.bookmarks[bookmarkId]; 226 255 if (bookmark) { 227 256 updateId += 1; 228 - s.updateQueue.push({ 257 + const item: BookmarkUpdateItem = { 229 258 id: updateId.toString(), 230 259 action: "add", 231 260 tmdbId: bookmarkId, ··· 235 264 type: bookmark.type, 236 265 group: bookmark.group, 237 266 favoriteEpisodes: bookmark.favoriteEpisodes, 238 - }); 267 + }; 268 + s.updateQueue.push(item); 269 + s.traktUpdateQueue.push(item); 239 270 } 240 271 }); 241 272 } ··· 263 294 const bookmark = s.bookmarks[bookmarkId]; 264 295 if (bookmark) { 265 296 updateId += 1; 266 - s.updateQueue.push({ 297 + const item: BookmarkUpdateItem = { 267 298 id: updateId.toString(), 268 299 action: "add", 269 300 tmdbId: bookmarkId, ··· 273 304 type: bookmark.type, 274 305 group: bookmark.group, 275 306 favoriteEpisodes: bookmark.favoriteEpisodes, 276 - }); 307 + }; 308 + s.updateQueue.push(item); 309 + s.traktUpdateQueue.push(item); 277 310 } 278 311 }); 279 312 }
+276
src/stores/trakt/TraktBookmarkSyncer.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { useInterval } from "react-use"; 3 + 4 + import { getPosterForMedia } from "@/backend/metadata/tmdb"; 5 + import { useBookmarkStore } from "@/stores/bookmarks"; 6 + import { useTraktAuthStore } from "@/stores/trakt/store"; 7 + import { modifyBookmarks } from "@/utils/bookmarkModifications"; 8 + import { traktService } from "@/utils/trakt"; 9 + import { TraktContentData, TraktList } from "@/utils/traktTypes"; 10 + 11 + const TRAKT_SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 min 12 + const INITIAL_SYNC_DELAY_MS = 2000; // Re-sync after backend restore 13 + 14 + function listId(list: TraktList): string { 15 + return list.ids.slug ?? String(list.ids.trakt); 16 + } 17 + 18 + async function findListByName( 19 + username: string, 20 + groupName: string, 21 + ): Promise<TraktList | null> { 22 + const lists = await traktService.getLists(username); 23 + return lists.find((l) => l.name === groupName) ?? null; 24 + } 25 + 26 + async function ensureListExists( 27 + username: string, 28 + groupName: string, 29 + ): Promise<TraktList | null> { 30 + const existing = await findListByName(username, groupName); 31 + if (existing) return existing; 32 + try { 33 + return await traktService.createList(username, groupName); 34 + } catch { 35 + return null; 36 + } 37 + } 38 + 39 + export function TraktBookmarkSyncer() { 40 + const { traktUpdateQueue, removeTraktUpdateItem, replaceBookmarks } = 41 + useBookmarkStore(); 42 + const { accessToken, user } = useTraktAuthStore(); 43 + const isSyncingRef = useRef(false); 44 + const [hydrated, setHydrated] = useState(false); 45 + 46 + // Sync from Local to Trakt 47 + useEffect(() => { 48 + if (!accessToken) return; 49 + 50 + const processQueue = async () => { 51 + const queue = [...traktUpdateQueue]; 52 + if (queue.length === 0) return; 53 + 54 + const slug = user?.ids?.slug; 55 + const hasLists = Boolean(slug); 56 + 57 + for (const item of queue) { 58 + removeTraktUpdateItem(item.id); 59 + 60 + try { 61 + const contentData: TraktContentData = { 62 + title: item.title ?? "", 63 + year: item.year, 64 + tmdbId: item.tmdbId, 65 + type: (item.type === "movie" ? "movie" : "show") as 66 + | "movie" 67 + | "show" 68 + | "episode", 69 + }; 70 + 71 + if (item.action === "add") { 72 + await traktService.addToWatchlist(contentData); 73 + if (hasLists && item.group?.length) { 74 + for (const groupName of item.group) { 75 + const list = await ensureListExists(slug!, groupName); 76 + if (list) { 77 + await traktService.addToList(slug!, listId(list), [ 78 + contentData, 79 + ]); 80 + } 81 + } 82 + } 83 + } else if (item.action === "delete") { 84 + await traktService.removeFromWatchlist(contentData); 85 + if (hasLists && item.group?.length) { 86 + for (const groupName of item.group) { 87 + const list = await findListByName(slug!, groupName); 88 + if (list) { 89 + await traktService.removeFromList(slug!, listId(list), [ 90 + contentData, 91 + ]); 92 + } 93 + } 94 + } 95 + } 96 + } catch (error) { 97 + console.error("Failed to sync bookmark to Trakt", error); 98 + } 99 + } 100 + }; 101 + 102 + processQueue(); 103 + }, [accessToken, user?.ids?.slug, traktUpdateQueue, removeTraktUpdateItem]); 104 + 105 + // Push local bookmarks to Trakt (watchlist + groups) 106 + const syncBookmarksToTrakt = useCallback(async () => { 107 + if (!accessToken || isSyncingRef.current) return; 108 + const slug = useTraktAuthStore.getState().user?.ids?.slug; 109 + if (!slug) return; 110 + isSyncingRef.current = true; 111 + try { 112 + if (!useTraktAuthStore.getState().user) { 113 + await traktService.getUserProfile(); 114 + } 115 + const bookmarks = useBookmarkStore.getState().bookmarks; 116 + for (const [tmdbId, b] of Object.entries(bookmarks)) { 117 + try { 118 + const contentData: TraktContentData = { 119 + tmdbId, 120 + title: b.title, 121 + year: b.year, 122 + type: b.type === "movie" ? "movie" : "show", 123 + }; 124 + await traktService.addToWatchlist(contentData); 125 + if (b.group?.length) { 126 + for (const groupName of b.group) { 127 + const list = await ensureListExists(slug, groupName); 128 + if (list) { 129 + await traktService.addToList(slug, listId(list), [contentData]); 130 + } 131 + } 132 + } 133 + } catch (err) { 134 + console.warn("Failed to push bookmark to Trakt:", tmdbId, err); 135 + } 136 + } 137 + } finally { 138 + isSyncingRef.current = false; 139 + } 140 + }, [accessToken]); 141 + 142 + const syncWatchlistFromTrakt = useCallback(async () => { 143 + if (!accessToken || isSyncingRef.current) return; 144 + isSyncingRef.current = true; 145 + try { 146 + if (!useTraktAuthStore.getState().user) { 147 + await traktService.getUserProfile(); 148 + } 149 + const watchlist = await traktService.getWatchlist(); 150 + const store = useBookmarkStore.getState(); 151 + const merged = { ...store.bookmarks }; 152 + 153 + for (const item of watchlist) { 154 + const type = item.movie ? "movie" : "show"; 155 + const media = item.movie || item.show; 156 + if (!media) continue; 157 + 158 + const tmdbId = media.ids.tmdb?.toString(); 159 + if (!tmdbId) continue; 160 + 161 + if (!merged[tmdbId]) { 162 + const poster = await getPosterForMedia(tmdbId, type); 163 + merged[tmdbId] = { 164 + type: type as "movie" | "show", 165 + title: media.title, 166 + year: media.year, 167 + poster, 168 + updatedAt: Date.now(), 169 + }; 170 + } 171 + } 172 + 173 + replaceBookmarks(merged); 174 + 175 + const slug = useTraktAuthStore.getState().user?.ids?.slug; 176 + if (slug) { 177 + try { 178 + const lists = await traktService.getLists(slug); 179 + const currentBookmarks = useBookmarkStore.getState().bookmarks; 180 + let modifiedBookmarks = { ...currentBookmarks }; 181 + 182 + for (const list of lists) { 183 + const listTitle = list.name; 184 + const items = await traktService.getListItems(slug, listId(list)); 185 + for (const li of items) { 186 + const media = li.movie || li.show; 187 + if (!media?.ids?.tmdb) continue; 188 + 189 + const tmdbId = media.ids.tmdb.toString(); 190 + const type = li.movie ? "movie" : "show"; 191 + const bookmark = modifiedBookmarks[tmdbId]; 192 + 193 + if (!bookmark) { 194 + const poster = await getPosterForMedia(tmdbId, type); 195 + modifiedBookmarks[tmdbId] = { 196 + type: type as "movie" | "show", 197 + title: media.title, 198 + year: media.year, 199 + poster, 200 + updatedAt: Date.now(), 201 + group: [listTitle], 202 + }; 203 + } else { 204 + const groups = bookmark.group ?? []; 205 + if (!groups.includes(listTitle)) { 206 + const { modifiedBookmarks: next } = modifyBookmarks( 207 + modifiedBookmarks, 208 + [tmdbId], 209 + { addGroups: [listTitle] }, 210 + ); 211 + modifiedBookmarks = next; 212 + } 213 + } 214 + } 215 + } 216 + 217 + const hasNewBookmarks = 218 + Object.keys(modifiedBookmarks).length !== 219 + Object.keys(currentBookmarks).length; 220 + const hasGroupChanges = Object.keys(modifiedBookmarks).some( 221 + (id) => 222 + JSON.stringify(modifiedBookmarks[id]?.group ?? []) !== 223 + JSON.stringify(currentBookmarks[id]?.group ?? []), 224 + ); 225 + if (hasNewBookmarks || hasGroupChanges) { 226 + replaceBookmarks(modifiedBookmarks); 227 + } 228 + } catch (listError) { 229 + console.warn("Failed to sync Trakt lists (groups)", listError); 230 + } 231 + } 232 + } catch (error) { 233 + console.error("Failed to sync Trakt watchlist to local", error); 234 + } finally { 235 + isSyncingRef.current = false; 236 + } 237 + }, [accessToken, replaceBookmarks]); 238 + 239 + const fullSync = useCallback(async () => { 240 + await syncWatchlistFromTrakt(); // Pull Trakt → local, merge 241 + await syncBookmarksToTrakt(); // Push local → Trakt 242 + }, [syncWatchlistFromTrakt, syncBookmarksToTrakt]); 243 + 244 + // Wait for Trakt auth store to rehydrate from persist (accessToken may be null on first render) 245 + useEffect(() => { 246 + const check = () => { 247 + if (useTraktAuthStore.persist?.hasHydrated?.()) { 248 + setHydrated(true); 249 + return true; 250 + } 251 + return false; 252 + }; 253 + if (check()) return; 254 + const unsub = useTraktAuthStore.persist?.onFinishHydration?.(() => { 255 + setHydrated(true); 256 + }); 257 + const t = setTimeout(() => setHydrated(true), 500); 258 + return () => { 259 + unsub?.(); 260 + clearTimeout(t); 261 + }; 262 + }, []); 263 + 264 + // On mount (after hydration): pull immediately (Trakt → local) 265 + useEffect(() => { 266 + if (!hydrated || !accessToken) return; 267 + syncWatchlistFromTrakt(); 268 + const t = setTimeout(fullSync, INITIAL_SYNC_DELAY_MS); 269 + return () => clearTimeout(t); 270 + }, [hydrated, accessToken, syncWatchlistFromTrakt, fullSync]); 271 + 272 + // Periodic full sync (pull + push) 273 + useInterval(fullSync, TRAKT_SYNC_INTERVAL_MS); 274 + 275 + return null; 276 + }
+205
src/stores/trakt/TraktHistorySyncer.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { useInterval } from "react-use"; 3 + 4 + import { importWatchHistory } from "@/backend/accounts/import"; 5 + import { 6 + watchHistoryItemToInputs, 7 + watchHistoryItemsToInputs, 8 + } from "@/backend/accounts/watchHistory"; 9 + import { getPosterForMedia } from "@/backend/metadata/tmdb"; 10 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 11 + import { useAuthStore } from "@/stores/auth"; 12 + import { useTraktAuthStore } from "@/stores/trakt/store"; 13 + import { WatchHistoryItem, useWatchHistoryStore } from "@/stores/watchHistory"; 14 + import { traktService } from "@/utils/trakt"; 15 + import { TraktContentData } from "@/utils/traktTypes"; 16 + 17 + const PROGRESS_THRESHOLD = 0.25; // Sync to Trakt if watched >= 25% 18 + const TRAKT_HISTORY_SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 min 19 + const INITIAL_SYNC_DELAY_MS = 2000; // Re-sync after backend restore 20 + 21 + function toTraktContentData( 22 + id: string, 23 + item: WatchHistoryItem, 24 + ): TraktContentData | null { 25 + const { watched, duration } = item.progress; 26 + const progress = duration > 0 ? watched / duration : 0; 27 + if (progress < PROGRESS_THRESHOLD) return null; 28 + 29 + const input = watchHistoryItemToInputs(id, item); 30 + 31 + if (item.type === "movie") { 32 + return { 33 + type: "movie", 34 + tmdbId: input.tmdbId, 35 + title: item.title, 36 + year: item.year, 37 + }; 38 + } 39 + 40 + if ( 41 + item.type === "show" && 42 + input.seasonNumber != null && 43 + input.episodeNumber != null 44 + ) { 45 + const showTmdbId = id.includes("-") ? id.split("-")[0] : input.tmdbId; 46 + const episodeTmdbId = 47 + item.episodeId ?? (id.includes("-") ? id.split("-")[1] : undefined); 48 + if (!episodeTmdbId) return null; 49 + 50 + return { 51 + type: "episode", 52 + tmdbId: episodeTmdbId, 53 + title: item.title, 54 + year: item.year, 55 + season: input.seasonNumber, 56 + episode: input.episodeNumber, 57 + showTitle: item.title, 58 + showYear: item.year, 59 + showTmdbId, 60 + }; 61 + } 62 + 63 + return null; 64 + } 65 + 66 + export function TraktHistorySyncer() { 67 + const { accessToken } = useTraktAuthStore(); 68 + const backendUrl = useBackendUrl(); 69 + const account = useAuthStore((s) => s.account); 70 + const isSyncingRef = useRef(false); 71 + const [hydrated, setHydrated] = useState(false); 72 + 73 + const syncHistoryFromTrakt = useCallback(async () => { 74 + if (!accessToken || isSyncingRef.current) return; 75 + isSyncingRef.current = true; 76 + try { 77 + const items = await traktService.getHistoryItems(); 78 + const store = useWatchHistoryStore.getState(); 79 + const merged = { ...store.items }; 80 + 81 + for (const hi of items) { 82 + if (hi.type === "movie" && hi.movie) { 83 + const tmdbId = hi.movie.ids.tmdb?.toString(); 84 + if (!tmdbId) continue; 85 + if (!merged[tmdbId]) { 86 + const poster = await getPosterForMedia(tmdbId, "movie"); 87 + merged[tmdbId] = { 88 + type: "movie", 89 + title: hi.movie.title, 90 + year: hi.movie.year, 91 + poster, 92 + progress: { watched: 0, duration: 1 }, 93 + watchedAt: new Date(hi.watched_at).getTime(), 94 + completed: false, 95 + }; 96 + } 97 + } else if (hi.type === "episode" && hi.episode && hi.show) { 98 + const showTmdbId = hi.show.ids.tmdb?.toString(); 99 + const episodeTmdbId = hi.episode.ids?.tmdb?.toString(); 100 + if (!showTmdbId || !episodeTmdbId) continue; 101 + const key = `${showTmdbId}-${episodeTmdbId}`; 102 + if (!merged[key]) { 103 + const poster = await getPosterForMedia(showTmdbId, "show"); 104 + merged[key] = { 105 + type: "show", 106 + title: hi.show.title, 107 + year: hi.show.year, 108 + poster, 109 + progress: { watched: 0, duration: 1 }, 110 + watchedAt: new Date(hi.watched_at).getTime(), 111 + completed: false, 112 + episodeId: episodeTmdbId, 113 + seasonNumber: hi.episode.season, 114 + episodeNumber: hi.episode.number, 115 + }; 116 + } 117 + } 118 + } 119 + 120 + if (Object.keys(merged).length > Object.keys(store.items).length) { 121 + const newKeys = Object.keys(merged).filter((k) => !(k in store.items)); 122 + useWatchHistoryStore.getState().replaceItems(merged); 123 + if (backendUrl && account && newKeys.length > 0) { 124 + const newItems = Object.fromEntries( 125 + newKeys.map((k) => [k, merged[k]!]), 126 + ); 127 + try { 128 + await importWatchHistory( 129 + backendUrl, 130 + account, 131 + watchHistoryItemsToInputs(newItems), 132 + ); 133 + } catch (err) { 134 + console.error("Failed to import Trakt history to backend", err); 135 + } 136 + } 137 + } 138 + } catch (err) { 139 + console.error("Failed to sync history from Trakt", err); 140 + } finally { 141 + isSyncingRef.current = false; 142 + } 143 + }, [accessToken, backendUrl, account]); 144 + 145 + const syncHistoryToTrakt = useCallback(async () => { 146 + if (!accessToken || isSyncingRef.current) return; 147 + isSyncingRef.current = true; 148 + 149 + try { 150 + const items = useWatchHistoryStore.getState().items; 151 + 152 + for (const [id, item] of Object.entries(items)) { 153 + const contentData = toTraktContentData(id, item); 154 + if (!contentData) continue; 155 + 156 + const input = watchHistoryItemToInputs(id, item); 157 + const watchedAt = input.watchedAt; 158 + 159 + try { 160 + await traktService.addToHistory(contentData, watchedAt); 161 + } catch (err) { 162 + console.error(`Failed to sync watch history to Trakt: ${id}`, err); 163 + } 164 + } 165 + } catch (error) { 166 + console.error("Failed to sync watch history to Trakt", error); 167 + } finally { 168 + isSyncingRef.current = false; 169 + } 170 + }, [accessToken]); 171 + 172 + const fullSync = useCallback(async () => { 173 + await syncHistoryFromTrakt(); 174 + await syncHistoryToTrakt(); 175 + }, [syncHistoryFromTrakt, syncHistoryToTrakt]); 176 + 177 + useEffect(() => { 178 + const check = () => useTraktAuthStore.persist?.hasHydrated?.() ?? false; 179 + if (check()) { 180 + setHydrated(true); 181 + return; 182 + } 183 + const unsub = useTraktAuthStore.persist?.onFinishHydration?.(() => 184 + setHydrated(true), 185 + ); 186 + const t = setTimeout(() => setHydrated(true), 500); 187 + return () => { 188 + unsub?.(); 189 + clearTimeout(t); 190 + }; 191 + }, []); 192 + 193 + // On mount (after hydration): pull from Trakt immediately, then full sync after delay 194 + // (delay ensures we run after auth restore overwrites the store) 195 + useEffect(() => { 196 + if (!hydrated || !accessToken) return; 197 + syncHistoryFromTrakt(); 198 + const t = setTimeout(fullSync, INITIAL_SYNC_DELAY_MS); 199 + return () => clearTimeout(t); 200 + }, [hydrated, accessToken, syncHistoryFromTrakt, fullSync]); 201 + 202 + useInterval(fullSync, TRAKT_HISTORY_SYNC_INTERVAL_MS); 203 + 204 + return null; 205 + }
+133
src/stores/trakt/TraktScrobbler.tsx
··· 1 + import { useCallback, useEffect, useRef } from "react"; 2 + import { useInterval } from "react-use"; 3 + 4 + import { playerStatus } from "@/stores/player/slices/source"; 5 + import { usePlayerStore } from "@/stores/player/store"; 6 + import { useTraktAuthStore } from "@/stores/trakt/store"; 7 + import { traktService } from "@/utils/trakt"; 8 + import { TraktContentData } from "@/utils/traktTypes"; 9 + 10 + export function TraktScrobbler() { 11 + const { accessToken } = useTraktAuthStore(); 12 + const { status, meta } = usePlayerStore((s) => ({ 13 + status: s.status, 14 + meta: s.meta, 15 + })); 16 + 17 + const lastStatusRef = useRef(status); 18 + const lastScrobbleRef = useRef<{ 19 + contentData: TraktContentData; 20 + progressPercent: number; 21 + } | null>(null); 22 + 23 + // Helper to construct content data 24 + const getContentData = useCallback((): TraktContentData | null => { 25 + if (!meta) return null; 26 + const data: TraktContentData = { 27 + title: meta.title, 28 + year: meta.releaseYear, 29 + tmdbId: meta.tmdbId, 30 + type: meta.type === "movie" ? "movie" : "episode", 31 + }; 32 + 33 + if (meta.type === "show") { 34 + if (!meta.season || !meta.episode) return null; 35 + data.season = meta.season.number; 36 + data.episode = meta.episode.number; 37 + data.showTitle = meta.title; 38 + data.showYear = meta.releaseYear; 39 + data.showTmdbId = meta.tmdbId; 40 + data.tmdbId = meta.episode.tmdbId; 41 + } 42 + 43 + return data; 44 + }, [meta]); 45 + 46 + // Handle Status Changes 47 + useEffect(() => { 48 + if (!accessToken) return; 49 + 50 + const contentData = getContentData(); 51 + const { progress } = usePlayerStore.getState(); 52 + const progressPercent = 53 + progress.duration > 0 ? (progress.time / progress.duration) * 100 : 0; 54 + 55 + const handleStatusChange = async () => { 56 + // When we have content, cache it for use when we transition to IDLE 57 + // (reset() clears meta before we can read it, so we need the cached values) 58 + if (contentData) { 59 + lastScrobbleRef.current = { contentData, progressPercent }; 60 + } 61 + 62 + // PLAYING 63 + if ( 64 + status === playerStatus.PLAYING && 65 + lastStatusRef.current !== playerStatus.PLAYING 66 + ) { 67 + if (contentData) { 68 + await traktService.startWatching(contentData, progressPercent); 69 + } 70 + } 71 + // STOPPED / IDLE - use cached data since meta is already null 72 + else if ( 73 + status === playerStatus.IDLE && 74 + lastStatusRef.current !== playerStatus.IDLE 75 + ) { 76 + const cached = lastScrobbleRef.current; 77 + if (cached) { 78 + await traktService.stopWatching( 79 + cached.contentData, 80 + cached.progressPercent, 81 + ); 82 + lastScrobbleRef.current = null; 83 + } 84 + } 85 + // PAUSED (Any other state coming from PLAYING) 86 + else if ( 87 + status !== playerStatus.PLAYING && 88 + lastStatusRef.current === playerStatus.PLAYING 89 + ) { 90 + if (contentData) { 91 + await traktService.pauseWatching(contentData, progressPercent); 92 + } 93 + } 94 + 95 + lastStatusRef.current = status; 96 + }; 97 + 98 + handleStatusChange(); 99 + }, [accessToken, status, getContentData, meta]); 100 + 101 + // Periodic Update (Keep Alive / Progress Update) 102 + useInterval(() => { 103 + if (!accessToken || !meta || status !== playerStatus.PLAYING) return; 104 + 105 + const contentData = getContentData(); 106 + if (!contentData) return; 107 + 108 + const { progress } = usePlayerStore.getState(); 109 + const progressPercent = 110 + progress.duration > 0 ? (progress.time / progress.duration) * 100 : 0; 111 + 112 + traktService 113 + .startWatching(contentData, progressPercent) 114 + .catch(console.error); 115 + }, 10000); 116 + 117 + // Send stop when user closes tab or navigates away 118 + useEffect(() => { 119 + const handleBeforeUnload = () => { 120 + const cached = lastScrobbleRef.current; 121 + if (cached) { 122 + traktService.stopWatchingOnUnload( 123 + cached.contentData, 124 + cached.progressPercent, 125 + ); 126 + } 127 + }; 128 + window.addEventListener("pagehide", handleBeforeUnload); 129 + return () => window.removeEventListener("pagehide", handleBeforeUnload); 130 + }, []); 131 + 132 + return null; 133 + }
+79
src/stores/trakt/store.ts
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { immer } from "zustand/middleware/immer"; 4 + 5 + import { TraktUser } from "@/utils/traktTypes"; 6 + 7 + export type TraktStatus = "idle" | "syncing"; 8 + 9 + export interface TraktAuthStore { 10 + accessToken: string | null; 11 + refreshToken: string | null; 12 + expiresAt: number | null; 13 + user: TraktUser | null; 14 + status: TraktStatus; 15 + error: string | null; 16 + 17 + setTokens(accessToken: string, refreshToken: string, expiresAt: number): void; 18 + setUser(user: TraktUser): void; 19 + setStatus(status: TraktStatus): void; 20 + setError(error: string | null): void; 21 + clear(): void; 22 + } 23 + 24 + export const useTraktAuthStore = create( 25 + persist( 26 + immer<TraktAuthStore>((set) => ({ 27 + accessToken: null, 28 + refreshToken: null, 29 + expiresAt: null, 30 + user: null, 31 + status: "idle", 32 + error: null, 33 + 34 + setTokens(accessToken, refreshToken, expiresAt) { 35 + set((s) => { 36 + s.accessToken = accessToken; 37 + s.refreshToken = refreshToken; 38 + s.expiresAt = expiresAt; 39 + }); 40 + }, 41 + setUser(user) { 42 + set((s) => { 43 + s.user = user; 44 + }); 45 + }, 46 + setStatus(status) { 47 + set((s) => { 48 + s.status = status; 49 + }); 50 + }, 51 + setError(error) { 52 + set((s) => { 53 + s.error = error; 54 + }); 55 + }, 56 + clear() { 57 + set((s) => { 58 + s.accessToken = null; 59 + s.refreshToken = null; 60 + s.expiresAt = null; 61 + s.user = null; 62 + s.status = "idle"; 63 + s.error = null; 64 + }); 65 + }, 66 + })), 67 + { 68 + name: "__MW::trakt_auth", 69 + }, 70 + ), 71 + ); 72 + 73 + export function useTraktStore() { 74 + const user = useTraktAuthStore((s) => s.user); 75 + const status = useTraktAuthStore((s) => s.status); 76 + const error = useTraktAuthStore((s) => s.error); 77 + const logout = useTraktAuthStore((s) => s.clear); 78 + return { user, status, logout, error }; 79 + }
+550
src/utils/trakt.ts
··· 1 + import { ofetch } from "ofetch"; 2 + import slugify from "slugify"; 3 + 4 + import { conf } from "@/setup/config"; 5 + import { useTraktAuthStore } from "@/stores/trakt/store"; 6 + import { 7 + TraktContentData, 8 + TraktHistoryItem, 9 + TraktList, 10 + TraktListItem, 11 + TraktScrobbleResponse, 12 + TraktUser, 13 + TraktWatchedItem, 14 + TraktWatchlistItem, 15 + } from "@/utils/traktTypes"; 16 + 17 + // Storage keys 18 + export const TRAKT_API_URL = "https://api.trakt.tv"; 19 + 20 + export class TraktService { 21 + // eslint-disable-next-line no-use-before-define -- self-reference for singleton 22 + private static instance: TraktService; 23 + 24 + private readonly MIN_API_INTERVAL = 500; 25 + 26 + private lastApiCall: number = 0; 27 + 28 + private requestQueue: Array<() => Promise<any>> = []; 29 + 30 + private isProcessingQueue: boolean = false; 31 + 32 + public static getInstance(): TraktService { 33 + if (!TraktService.instance) { 34 + TraktService.instance = new TraktService(); 35 + } 36 + return TraktService.instance; 37 + } 38 + 39 + // eslint-disable-next-line class-methods-use-this -- part of instance API 40 + public getAuthUrl(): string { 41 + const config = conf(); 42 + if (!config.TRAKT_CLIENT_ID || !config.TRAKT_REDIRECT_URI) return ""; 43 + return `${TRAKT_API_URL}/oauth/authorize?response_type=code&client_id=${ 44 + config.TRAKT_CLIENT_ID 45 + }&redirect_uri=${encodeURIComponent(config.TRAKT_REDIRECT_URI)}`; 46 + } 47 + 48 + public async exchangeCodeForToken( 49 + code: string, 50 + redirectUri?: string, 51 + ): Promise<boolean> { 52 + const config = conf(); 53 + if (!config.TRAKT_CLIENT_ID || !config.TRAKT_CLIENT_SECRET) 54 + throw new Error("Missing Trakt config"); 55 + 56 + const resolvedRedirectUri = 57 + redirectUri ?? config.TRAKT_REDIRECT_URI ?? undefined; 58 + if (!resolvedRedirectUri) 59 + throw new Error("Missing redirect_uri for token exchange"); 60 + 61 + try { 62 + const data = await ofetch(`${TRAKT_API_URL}/oauth/token`, { 63 + method: "POST", 64 + headers: { "Content-Type": "application/json" }, 65 + body: { 66 + code, 67 + client_id: config.TRAKT_CLIENT_ID, 68 + client_secret: config.TRAKT_CLIENT_SECRET, 69 + redirect_uri: resolvedRedirectUri, 70 + grant_type: "authorization_code", 71 + }, 72 + }); 73 + 74 + const expiresAt = Date.now() + data.expires_in * 1000; 75 + useTraktAuthStore 76 + .getState() 77 + .setTokens(data.access_token, data.refresh_token, expiresAt); 78 + 79 + // Fetch user profile immediately 80 + await this.getUserProfile(); 81 + 82 + return true; 83 + } catch (error: any) { 84 + const msg = 85 + error?.data?.message ?? error?.message ?? "Failed to exchange code"; 86 + console.error("[TraktService] Failed to exchange code:", error); 87 + throw new Error(msg); 88 + } 89 + } 90 + 91 + // eslint-disable-next-line class-methods-use-this -- called as this.refreshToken from apiRequest 92 + public async refreshToken(): Promise<void> { 93 + const config = conf(); 94 + const { refreshToken } = useTraktAuthStore.getState(); 95 + if ( 96 + !refreshToken || 97 + !config.TRAKT_CLIENT_ID || 98 + !config.TRAKT_CLIENT_SECRET || 99 + !config.TRAKT_REDIRECT_URI 100 + ) 101 + throw new Error("Missing refresh token or config"); 102 + 103 + try { 104 + const data = await ofetch(`${TRAKT_API_URL}/oauth/token`, { 105 + method: "POST", 106 + body: { 107 + refresh_token: refreshToken, 108 + client_id: config.TRAKT_CLIENT_ID, 109 + client_secret: config.TRAKT_CLIENT_SECRET, 110 + redirect_uri: config.TRAKT_REDIRECT_URI, 111 + grant_type: "refresh_token", 112 + }, 113 + }); 114 + 115 + const expiresAt = Date.now() + data.expires_in * 1000; 116 + useTraktAuthStore 117 + .getState() 118 + .setTokens(data.access_token, data.refresh_token, expiresAt); 119 + } catch (error) { 120 + console.error("[TraktService] Failed to refresh token:", error); 121 + useTraktAuthStore.getState().clear(); 122 + throw error; 123 + } 124 + } 125 + 126 + private async apiRequest<T>( 127 + endpoint: string, 128 + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", 129 + body: any = undefined, 130 + retryCount = 0, 131 + ): Promise<T> { 132 + const config = conf(); 133 + const { expiresAt } = useTraktAuthStore.getState(); 134 + 135 + if (!config.TRAKT_CLIENT_ID) throw new Error("Missing Trakt Client ID"); 136 + 137 + // Rate limiting 138 + const now = Date.now(); 139 + const timeSinceLastCall = now - this.lastApiCall; 140 + if (timeSinceLastCall < this.MIN_API_INTERVAL) { 141 + await new Promise((resolve) => { 142 + setTimeout(resolve, this.MIN_API_INTERVAL - timeSinceLastCall); 143 + }); 144 + } 145 + this.lastApiCall = Date.now(); 146 + 147 + // Refresh token if needed 148 + if (expiresAt && expiresAt < Date.now() + 60000) { 149 + await this.refreshToken(); 150 + } 151 + // Get fresh token after potential refresh 152 + const freshAccessToken = useTraktAuthStore.getState().accessToken; 153 + 154 + if (!freshAccessToken) throw new Error("Not authenticated"); 155 + 156 + try { 157 + const response = await ofetch(`${TRAKT_API_URL}${endpoint}`, { 158 + method, 159 + headers: { 160 + "Content-Type": "application/json", 161 + "trakt-api-version": "2", 162 + "trakt-api-key": config.TRAKT_CLIENT_ID, 163 + Authorization: `Bearer ${freshAccessToken}`, 164 + }, 165 + body, 166 + retry: 0, // We handle retries manually for 429 167 + }); 168 + return response as T; 169 + } catch (error: any) { 170 + if (error.response?.status === 429 && retryCount < 3) { 171 + const retryAfter = error.response.headers.get("Retry-After"); 172 + const delay = retryAfter 173 + ? parseInt(retryAfter, 10) * 1000 174 + : 1000 * 2 ** retryCount; 175 + await new Promise((resolve) => { 176 + setTimeout(resolve, delay); 177 + }); 178 + return this.apiRequest<T>(endpoint, method, body, retryCount + 1); 179 + } 180 + 181 + // Handle 404 (Not Found) gracefully 182 + if (error.response?.status === 404) { 183 + console.warn(`[TraktService] 404 Not Found: ${endpoint}`); 184 + throw error; 185 + } 186 + 187 + // Handle 409 (Conflict) - usually means already scrobbled/watched 188 + if (error.response?.status === 409) { 189 + console.warn(`[TraktService] 409 Conflict: ${endpoint}`); 190 + return error.response._data; // Return the data anyway 191 + } 192 + 193 + throw error; 194 + } 195 + } 196 + 197 + private async queueRequest<T>(request: () => Promise<T>): Promise<T> { 198 + return new Promise((resolve, reject) => { 199 + this.requestQueue.push(async () => { 200 + try { 201 + const result = await request(); 202 + resolve(result); 203 + } catch (error) { 204 + reject(error); 205 + } 206 + }); 207 + this.processQueue(); 208 + }); 209 + } 210 + 211 + private async processQueue() { 212 + if (this.isProcessingQueue) return; 213 + this.isProcessingQueue = true; 214 + 215 + while (this.requestQueue.length > 0) { 216 + const request = this.requestQueue.shift(); 217 + if (request) { 218 + await request(); 219 + // Minimum interval between requests in queue 220 + if (this.requestQueue.length > 0) { 221 + await new Promise((resolve) => { 222 + setTimeout(resolve, this.MIN_API_INTERVAL); 223 + }); 224 + } 225 + } 226 + } 227 + 228 + this.isProcessingQueue = false; 229 + } 230 + 231 + // User Profile 232 + public async getUserProfile(): Promise<TraktUser> { 233 + const profile = await this.apiRequest<TraktUser>( 234 + "/users/me?extended=full,images", 235 + ); 236 + useTraktAuthStore.getState().setUser(profile); 237 + return profile; 238 + } 239 + 240 + // Watchlist - fetch movies and shows separately for reliability 241 + public async getWatchlist(): Promise<TraktWatchlistItem[]> { 242 + const limit = 100; 243 + const q = `extended=full,images`; 244 + const allItems: TraktWatchlistItem[] = []; 245 + 246 + for (const type of ["movies", "shows"] as const) { 247 + for (let page = 1; ; page += 1) { 248 + const results = await this.apiRequest<TraktWatchlistItem[]>( 249 + `/sync/watchlist/${type}/rank/asc?${q}&page=${page}&limit=${limit}`, 250 + ); 251 + allItems.push(...results); 252 + if (results.length < limit) break; 253 + } 254 + } 255 + 256 + return allItems; 257 + } 258 + 259 + public async addToWatchlist(item: TraktContentData): Promise<void> { 260 + const payload = this.buildSyncPayload(item); 261 + await this.apiRequest("/sync/watchlist", "POST", payload); 262 + } 263 + 264 + public async removeFromWatchlist(item: TraktContentData): Promise<void> { 265 + const payload = this.buildSyncPayload(item); 266 + await this.apiRequest("/sync/watchlist/remove", "POST", payload); 267 + } 268 + 269 + // Personal Lists (for groups/collections sync) 270 + public async getLists(username: string): Promise<TraktList[]> { 271 + const results = await this.apiRequest<TraktList[]>( 272 + `/users/${username}/lists`, 273 + ); 274 + return Array.isArray(results) ? results : []; 275 + } 276 + 277 + public async createList(username: string, name: string): Promise<TraktList> { 278 + return this.apiRequest<TraktList>(`/users/${username}/lists`, "POST", { 279 + name, 280 + privacy: "private", 281 + }); 282 + } 283 + 284 + public async getListItems( 285 + username: string, 286 + listId: string, 287 + ): Promise<TraktListItem[]> { 288 + const limit = 100; 289 + const allItems: TraktListItem[] = []; 290 + for (let page = 1; ; page += 1) { 291 + const results = await this.apiRequest<TraktListItem[]>( 292 + `/users/${username}/lists/${listId}/items?page=${page}&limit=${limit}`, 293 + ); 294 + const arr = Array.isArray(results) ? results : []; 295 + allItems.push(...arr); 296 + if (arr.length < limit) break; 297 + } 298 + return allItems; 299 + } 300 + 301 + public async addToList( 302 + username: string, 303 + listId: string, 304 + items: TraktContentData[], 305 + ): Promise<void> { 306 + const payload = this.buildListPayload(items); 307 + if (Object.keys(payload).length === 0) return; 308 + await this.apiRequest( 309 + `/users/${username}/lists/${listId}/items`, 310 + "POST", 311 + payload, 312 + ); 313 + } 314 + 315 + public async removeFromList( 316 + username: string, 317 + listId: string, 318 + items: TraktContentData[], 319 + ): Promise<void> { 320 + const payload = this.buildListPayload(items); 321 + if (Object.keys(payload).length === 0) return; 322 + await this.apiRequest( 323 + `/users/${username}/lists/${listId}/items/remove`, 324 + "POST", 325 + payload, 326 + ); 327 + } 328 + 329 + private buildListPayload(items: TraktContentData[]): any { 330 + const movies: any[] = []; 331 + const shows: any[] = []; 332 + for (const item of items) { 333 + if (item.type === "movie") { 334 + const ids = this.buildIds(item); 335 + movies.push({ ids, title: item.title, year: item.year }); 336 + } else if (item.type === "show" || item.type === "episode") { 337 + const ids = { 338 + tmdb: item.showTmdbId 339 + ? parseInt(item.showTmdbId, 10) 340 + : item.tmdbId 341 + ? parseInt(item.tmdbId, 10) 342 + : undefined, 343 + }; 344 + shows.push({ 345 + ids, 346 + title: item.showTitle ?? item.title, 347 + year: item.showYear ?? item.year, 348 + }); 349 + } 350 + } 351 + const payload: any = {}; 352 + if (movies.length) payload.movies = movies; 353 + if (shows.length) payload.shows = shows; 354 + return payload; 355 + } 356 + 357 + public static groupToSlug(groupName: string): string { 358 + return slugify(groupName, { lower: true, strict: true }) || "list"; 359 + } 360 + 361 + // Scrobble (report what we're watching to Trakt - shows in Trakt app) 362 + public async startWatching( 363 + item: TraktContentData, 364 + progress: number, 365 + ): Promise<TraktScrobbleResponse> { 366 + const payload = this.buildScrobblePayload(item, progress); 367 + return this.queueRequest(() => 368 + this.apiRequest<TraktScrobbleResponse>( 369 + "/scrobble/start", 370 + "POST", 371 + payload, 372 + ), 373 + ); 374 + } 375 + 376 + public async pauseWatching( 377 + item: TraktContentData, 378 + progress: number, 379 + ): Promise<TraktScrobbleResponse> { 380 + const payload = this.buildScrobblePayload(item, progress); 381 + return this.queueRequest(() => 382 + this.apiRequest<TraktScrobbleResponse>( 383 + "/scrobble/pause", 384 + "POST", 385 + payload, 386 + ), 387 + ); 388 + } 389 + 390 + public async stopWatching( 391 + item: TraktContentData, 392 + progress: number, 393 + ): Promise<TraktScrobbleResponse> { 394 + const payload = this.buildScrobblePayload(item, progress); 395 + return this.queueRequest(() => 396 + this.apiRequest<TraktScrobbleResponse>("/scrobble/stop", "POST", payload), 397 + ); 398 + } 399 + 400 + /** 401 + * Fire-and-forget stop for page unload. Uses fetch keepalive so the request 402 + * can complete even after the tab closes. 403 + */ 404 + public stopWatchingOnUnload(item: TraktContentData, progress: number): void { 405 + const config = conf(); 406 + const { accessToken } = useTraktAuthStore.getState(); 407 + if (!accessToken || !config.TRAKT_CLIENT_ID) return; 408 + 409 + const payload = this.buildScrobblePayload(item, progress); 410 + const url = `${TRAKT_API_URL}/scrobble/stop`; 411 + fetch(url, { 412 + method: "POST", 413 + headers: { 414 + "Content-Type": "application/json", 415 + "trakt-api-version": "2", 416 + "trakt-api-key": config.TRAKT_CLIENT_ID, 417 + Authorization: `Bearer ${accessToken}`, 418 + }, 419 + body: JSON.stringify(payload), 420 + keepalive: true, 421 + }).catch(() => {}); 422 + } 423 + 424 + // History (Watched) - aggregated view 425 + public async getHistory(): Promise<TraktWatchedItem[]> { 426 + const limit = 100; 427 + const fetchAll = async (endpoint: string) => { 428 + let page = 1; 429 + const items: TraktWatchedItem[] = []; 430 + // eslint-disable-next-line no-constant-condition -- pagination loop 431 + while (true) { 432 + const results = await this.apiRequest<TraktWatchedItem[]>( 433 + `${endpoint}?extended=full,images&page=${page}&limit=${limit}`, 434 + ); 435 + items.push(...results); 436 + if (results.length < limit) break; 437 + page += 1; 438 + } 439 + return items; 440 + }; 441 + const [movies, shows] = await Promise.all([ 442 + fetchAll("/sync/watched/movies"), 443 + fetchAll("/sync/watched/shows"), 444 + ]); 445 + return [...movies, ...shows]; 446 + } 447 + 448 + // History (full list with episodes) - for importing into app 449 + public async getHistoryItems(): Promise<TraktHistoryItem[]> { 450 + const limit = 100; 451 + const all: TraktHistoryItem[] = []; 452 + for (const type of ["movies", "episodes"] as const) { 453 + for (let page = 1; ; page += 1) { 454 + const results = await this.apiRequest<TraktHistoryItem[]>( 455 + `/sync/history/${type}?extended=full&page=${page}&limit=${limit}`, 456 + ); 457 + const arr = Array.isArray(results) ? results : []; 458 + all.push(...arr); 459 + if (arr.length < limit) break; 460 + } 461 + } 462 + return all; 463 + } 464 + 465 + public async addToHistory( 466 + item: TraktContentData, 467 + watchedAt?: string, 468 + ): Promise<void> { 469 + const payload = this.buildSyncPayload(item); 470 + if (watchedAt) { 471 + if (payload.movies) 472 + payload.movies.forEach((m: any) => { 473 + m.watched_at = watchedAt; 474 + }); 475 + if (payload.episodes) 476 + payload.episodes.forEach((e: any) => { 477 + e.watched_at = watchedAt; 478 + }); 479 + } 480 + await this.apiRequest("/sync/history", "POST", payload); 481 + } 482 + 483 + public async removeFromHistory(item: TraktContentData): Promise<void> { 484 + const payload = this.buildSyncPayload(item); 485 + await this.apiRequest("/sync/history/remove", "POST", payload); 486 + } 487 + 488 + // Helpers 489 + private buildSyncPayload(item: TraktContentData): any { 490 + const ids = this.buildIds(item); 491 + if (item.type === "movie") { 492 + return { movies: [{ ...item, ids }] }; 493 + } 494 + if (item.type === "show") { 495 + return { shows: [{ ...item, ids }] }; 496 + } 497 + if (item.type === "episode") { 498 + return { episodes: [{ ids }] }; 499 + } 500 + return {}; 501 + } 502 + 503 + private buildScrobblePayload(item: TraktContentData, progress: number): any { 504 + const ids = this.buildIds(item); 505 + const progressFixed = Math.min( 506 + 100, 507 + Math.max(0, parseFloat(progress.toFixed(2))), 508 + ); 509 + 510 + if (item.type === "movie") { 511 + return { 512 + movie: { 513 + title: item.title, 514 + year: item.year, 515 + ids, 516 + }, 517 + progress: progressFixed, 518 + }; 519 + } 520 + 521 + if (item.type === "episode") { 522 + return { 523 + show: { 524 + title: item.showTitle, 525 + year: item.showYear, 526 + ids: { 527 + imdb: item.showImdbId, 528 + tmdb: item.showTmdbId ? parseInt(item.showTmdbId, 10) : undefined, 529 + }, 530 + }, 531 + episode: { 532 + season: item.season, 533 + number: item.episode, 534 + }, 535 + progress: progressFixed, 536 + }; 537 + } 538 + return {}; 539 + } 540 + 541 + // eslint-disable-next-line class-methods-use-this -- part of payload builder chain 542 + private buildIds(item: TraktContentData): any { 543 + const ids: any = {}; 544 + if (item.imdbId) ids.imdb = item.imdbId; 545 + if (item.tmdbId) ids.tmdb = parseInt(item.tmdbId, 10); 546 + return ids; 547 + } 548 + } 549 + 550 + export const traktService = TraktService.getInstance();
+202
src/utils/traktTypes.ts
··· 1 + export interface TraktUser { 2 + username: string; 3 + name?: string; 4 + private: boolean; 5 + vip: boolean; 6 + joined_at: string; 7 + ids: { slug: string }; 8 + images?: { 9 + avatar: { 10 + full: string; 11 + }; 12 + }; 13 + } 14 + 15 + export interface TraktImages { 16 + poster?: { 17 + full: string; 18 + medium: string; 19 + thumb: string; 20 + }; 21 + fanart?: { 22 + full: string; 23 + medium: string; 24 + thumb: string; 25 + }; 26 + } 27 + 28 + export interface TraktHistoryItem { 29 + id: number; 30 + watched_at: string; 31 + action: string; 32 + type: "movie" | "episode"; 33 + movie?: { 34 + title: string; 35 + year: number; 36 + ids: { trakt: number; tmdb: number }; 37 + }; 38 + episode?: { 39 + season: number; 40 + number: number; 41 + title: string; 42 + ids: { trakt: number; tmdb?: number }; 43 + }; 44 + show?: { 45 + title: string; 46 + year: number; 47 + ids: { trakt: number; tmdb: number }; 48 + }; 49 + } 50 + 51 + export interface TraktWatchedItem { 52 + movie?: { 53 + title: string; 54 + year: number; 55 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 56 + runtime?: number; 57 + images?: TraktImages; 58 + }; 59 + show?: { 60 + title: string; 61 + year: number; 62 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 63 + runtime?: number; 64 + images?: TraktImages; 65 + }; 66 + plays: number; 67 + last_watched_at: string; 68 + last_updated_at?: string; 69 + seasons?: { 70 + number: number; 71 + episodes: { 72 + number: number; 73 + plays: number; 74 + last_watched_at: string; 75 + }[]; 76 + }[]; 77 + } 78 + 79 + export interface TraktWatchlistItem { 80 + movie?: { 81 + title: string; 82 + year: number; 83 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 84 + images?: TraktImages; 85 + }; 86 + show?: { 87 + title: string; 88 + year: number; 89 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 90 + images?: TraktImages; 91 + }; 92 + listed_at: string; 93 + id: number; 94 + } 95 + 96 + export interface TraktPlaybackItem { 97 + progress: number; 98 + paused_at: string; 99 + id: number; 100 + type: "movie" | "episode"; 101 + movie?: { 102 + title: string; 103 + year: number; 104 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 105 + runtime?: number; 106 + images?: TraktImages; 107 + }; 108 + episode?: { 109 + season: number; 110 + number: number; 111 + title: string; 112 + ids: { trakt: number; tvdb?: number; imdb?: string; tmdb?: number }; 113 + runtime?: number; 114 + images?: TraktImages; 115 + }; 116 + show?: { 117 + title: string; 118 + year: number; 119 + ids: { 120 + trakt: number; 121 + slug: string; 122 + tvdb?: number; 123 + imdb: string; 124 + tmdb: number; 125 + }; 126 + images?: TraktImages; 127 + }; 128 + } 129 + 130 + export interface TraktScrobbleResponse { 131 + id: number; 132 + action: "start" | "pause" | "scrobble" | "conflict"; 133 + progress: number; 134 + movie?: { 135 + title: string; 136 + year: number; 137 + ids: { trakt: number; slug: string; imdb: string; tmdb: number }; 138 + }; 139 + episode?: { 140 + season: number; 141 + number: number; 142 + title: string; 143 + ids: { trakt: number; tvdb?: number; imdb?: string; tmdb?: number }; 144 + }; 145 + show?: { 146 + title: string; 147 + year: number; 148 + ids: { 149 + trakt: number; 150 + slug: string; 151 + tvdb?: number; 152 + imdb: string; 153 + tmdb: number; 154 + }; 155 + }; 156 + } 157 + 158 + export interface TraktContentData { 159 + type: "movie" | "episode" | "show"; 160 + imdbId?: string; 161 + tmdbId?: string; 162 + title: string; 163 + year?: number; 164 + season?: number; 165 + episode?: number; 166 + showTitle?: string; 167 + showYear?: number; 168 + showImdbId?: string; 169 + showTmdbId?: string; 170 + } 171 + 172 + export interface TraktList { 173 + name: string; 174 + ids: { trakt: number; slug: string | null }; 175 + item_count: number; 176 + } 177 + 178 + export interface TraktListItem { 179 + type: "movie" | "show" | "season" | "episode"; 180 + movie?: { title: string; year: number; ids: { tmdb: number } }; 181 + show?: { title: string; year: number; ids: { tmdb: number } }; 182 + } 183 + 184 + export interface TraktHistoryRemovePayload { 185 + movies?: Array<{ 186 + ids: { trakt?: number; slug?: string; imdb?: string; tmdb?: number }; 187 + }>; 188 + shows?: Array<{ 189 + ids: { 190 + trakt?: number; 191 + slug?: string; 192 + tvdb?: number; 193 + imdb?: string; 194 + tmdb?: number; 195 + }; 196 + seasons?: Array<{ number: number; episodes?: Array<{ number: number }> }>; 197 + }>; 198 + episodes?: Array<{ 199 + ids: { trakt?: number; tvdb?: number; imdb?: string; tmdb?: number }; 200 + }>; 201 + ids?: number[]; 202 + }