mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
trakt
This commit is contained in:
parent
04618295fe
commit
fad27b2572
15 changed files with 1671 additions and 13 deletions
|
|
@ -12,3 +12,7 @@ VITE_APP_DOMAIN=http://localhost:5173
|
||||||
|
|
||||||
# Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com")
|
# Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com")
|
||||||
VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
|
VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
|
||||||
|
|
||||||
|
# Trakt integration setup. Get these at https://trakt.tv/oauth/applications
|
||||||
|
VITE_TRAKT_CLIENT_ID=
|
||||||
|
VITE_TRAKT_CLIENT_SECRET=
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,26 @@ export function getMediaPoster(posterPath: string | null): string | undefined {
|
||||||
if (posterPath) return imgUrl;
|
if (posterPath) return imgUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the poster URL for a movie or show from TMDB by ID.
|
||||||
|
* Use this when importing from external sources (e.g. Trakt) that may not have poster URLs.
|
||||||
|
*/
|
||||||
|
export async function getPosterForMedia(
|
||||||
|
tmdbId: string,
|
||||||
|
type: "movie" | "show",
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const tmdbType =
|
||||||
|
type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
|
||||||
|
const details = await getMediaDetails(tmdbId, tmdbType, false);
|
||||||
|
const posterPath =
|
||||||
|
(details as TMDBMovieData | TMDBShowData).poster_path ?? null;
|
||||||
|
return getMediaPoster(posterPath);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCollectionDetails(collectionId: number): Promise<any> {
|
export async function getCollectionDetails(collectionId: number): Promise<any> {
|
||||||
return get<any>(`/collection/${collectionId}`);
|
return get<any>(`/collection/${collectionId}`);
|
||||||
}
|
}
|
||||||
|
|
@ -493,6 +513,32 @@ export async function getEpisodes(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve TMDB season and episode IDs for a show. Use when external sources
|
||||||
|
* (e.g. Trakt) only provide season/episode numbers.
|
||||||
|
*/
|
||||||
|
export async function getEpisodeIds(
|
||||||
|
showTmdbId: string,
|
||||||
|
seasonNumber: number,
|
||||||
|
episodeNumber: number,
|
||||||
|
): Promise<{ seasonId: string; episodeId: string } | null> {
|
||||||
|
try {
|
||||||
|
const data = await get<TMDBSeason>(
|
||||||
|
`/tv/${showTmdbId}/season/${seasonNumber}`,
|
||||||
|
);
|
||||||
|
const episode = data.episodes.find(
|
||||||
|
(e) => e.episode_number === episodeNumber,
|
||||||
|
);
|
||||||
|
if (!episode) return null;
|
||||||
|
return {
|
||||||
|
seasonId: data.id.toString(),
|
||||||
|
episodeId: episode.id.toString(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMovieFromExternalId(
|
export async function getMovieFromExternalId(
|
||||||
imdbId: string,
|
imdbId: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
|
|
|
||||||
46
src/components/TraktAuthHandler.tsx
Normal file
46
src/components/TraktAuthHandler.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useTraktAuthStore } from "@/stores/trakt/store";
|
||||||
|
import { traktService } from "@/utils/trakt";
|
||||||
|
|
||||||
|
export function TraktAuthHandler() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const processedRef = useRef(false);
|
||||||
|
const setStatus = useTraktAuthStore((s) => s.setStatus);
|
||||||
|
const setError = useTraktAuthStore((s) => s.setError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code || processedRef.current) return;
|
||||||
|
processedRef.current = true;
|
||||||
|
|
||||||
|
const exchange = async () => {
|
||||||
|
setStatus("syncing");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const redirectUri = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
const success = await traktService.exchangeCodeForToken(
|
||||||
|
code,
|
||||||
|
redirectUri,
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.delete("code");
|
||||||
|
setSearchParams(newParams, { replace: true });
|
||||||
|
} else {
|
||||||
|
setError("Failed to connect to Trakt");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Trakt auth failed", err);
|
||||||
|
setError(err?.message ?? "Failed to connect to Trakt");
|
||||||
|
} finally {
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exchange();
|
||||||
|
}, [code, searchParams, setSearchParams, setStatus, setError]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,9 @@ import { changeAppLanguage, useLanguageStore } from "@/stores/language";
|
||||||
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
|
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
|
||||||
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
||||||
import { ThemeProvider } from "@/stores/theme";
|
import { ThemeProvider } from "@/stores/theme";
|
||||||
|
import { TraktBookmarkSyncer } from "@/stores/trakt/TraktBookmarkSyncer";
|
||||||
|
import { TraktHistorySyncer } from "@/stores/trakt/TraktHistorySyncer";
|
||||||
|
import { TraktScrobbler } from "@/stores/trakt/TraktScrobbler";
|
||||||
import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer";
|
import { WatchHistorySyncer } from "@/stores/watchHistory/WatchHistorySyncer";
|
||||||
import { detectRegion, useRegionStore } from "@/utils/detectRegion";
|
import { detectRegion, useRegionStore } from "@/utils/detectRegion";
|
||||||
|
|
||||||
|
|
@ -252,6 +255,9 @@ root.render(
|
||||||
<WatchHistorySyncer />
|
<WatchHistorySyncer />
|
||||||
<GroupSyncer />
|
<GroupSyncer />
|
||||||
<SettingsSyncer />
|
<SettingsSyncer />
|
||||||
|
<TraktBookmarkSyncer />
|
||||||
|
<TraktHistorySyncer />
|
||||||
|
<TraktScrobbler />
|
||||||
<TheRouter>
|
<TheRouter>
|
||||||
<MigrationRunner />
|
<MigrationRunner />
|
||||||
</TheRouter>
|
</TheRouter>
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,10 @@ export function RealPlayerView() {
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset();
|
reset();
|
||||||
// Reset watch party state when media changes
|
|
||||||
openedWatchPartyRef.current = false;
|
openedWatchPartyRef.current = false;
|
||||||
|
return () => {
|
||||||
|
reset();
|
||||||
|
};
|
||||||
}, [paramsData, reset]);
|
}, [paramsData, reset]);
|
||||||
|
|
||||||
// Auto-open watch party menu if URL contains watchparty parameter
|
// Auto-open watch party menu if URL contains watchparty parameter
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
|
import { useTraktStore } from "@/stores/trakt/store";
|
||||||
|
|
||||||
import { RegionSelectorPart } from "./RegionSelectorPart";
|
import { RegionSelectorPart } from "./RegionSelectorPart";
|
||||||
|
|
||||||
|
|
@ -773,6 +774,66 @@ export function TIDBEdit({ tidbKey, setTIDBKey }: TIDBKeyProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TraktEdit() {
|
||||||
|
const { user, status, logout, error } = useTraktStore();
|
||||||
|
const config = conf();
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const redirectUri =
|
||||||
|
config.TRAKT_REDIRECT_URI ??
|
||||||
|
`${window.location.origin}${window.location.pathname}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: config.TRAKT_CLIENT_ID ?? "",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
window.location.href = `https://trakt.tv/oauth/authorize?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.TRAKT_CLIENT_ID || !config.TRAKT_CLIENT_SECRET) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard>
|
||||||
|
<div className="flex justify-between items-center gap-4">
|
||||||
|
<div className="my-3">
|
||||||
|
<p className="text-white font-bold mb-3">Trakt</p>
|
||||||
|
<p className="max-w-[30rem] font-medium">
|
||||||
|
Sync your watchlist and history with Trakt.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-type-danger mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.images?.avatar?.full && (
|
||||||
|
<img
|
||||||
|
src={user.images.avatar.full}
|
||||||
|
alt={user.username}
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="font-bold">{user.name || user.username}</span>
|
||||||
|
</div>
|
||||||
|
<Button theme="danger" onClick={logout}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
theme="purple"
|
||||||
|
onClick={connect}
|
||||||
|
disabled={status === "syncing"}
|
||||||
|
>
|
||||||
|
{status === "syncing" ? "Syncing..." : "Connect Trakt"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectionsPart(
|
export function ConnectionsPart(
|
||||||
props: BackendEditProps &
|
props: BackendEditProps &
|
||||||
ProxyEditProps &
|
ProxyEditProps &
|
||||||
|
|
@ -812,6 +873,7 @@ export function ConnectionsPart(
|
||||||
mode="settings"
|
mode="settings"
|
||||||
/>
|
/>
|
||||||
<TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} />
|
<TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} />
|
||||||
|
<TraktEdit />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommand
|
||||||
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
|
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
|
||||||
import { NotificationModal } from "@/components/overlays/notificationsModal";
|
import { NotificationModal } from "@/components/overlays/notificationsModal";
|
||||||
import { SupportInfoModal } from "@/components/overlays/SupportInfoModal";
|
import { SupportInfoModal } from "@/components/overlays/SupportInfoModal";
|
||||||
|
import { TraktAuthHandler } from "@/components/TraktAuthHandler";
|
||||||
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
|
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
|
||||||
import { useOnlineListener } from "@/hooks/usePing";
|
import { useOnlineListener } from "@/hooks/usePing";
|
||||||
import { AboutPage } from "@/pages/About";
|
import { AboutPage } from "@/pages/About";
|
||||||
|
|
@ -126,6 +127,7 @@ function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<TraktAuthHandler />
|
||||||
<LanguageProvider />
|
<LanguageProvider />
|
||||||
<NotificationModal id="notifications" />
|
<NotificationModal id="notifications" />
|
||||||
<KeyboardCommandsModal id="keyboard-commands" />
|
<KeyboardCommandsModal id="keyboard-commands" />
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ interface Config {
|
||||||
BANNER_MESSAGE: string;
|
BANNER_MESSAGE: string;
|
||||||
BANNER_ID: string;
|
BANNER_ID: string;
|
||||||
USE_TRAKT: boolean;
|
USE_TRAKT: boolean;
|
||||||
|
TRAKT_CLIENT_ID: string;
|
||||||
|
TRAKT_CLIENT_SECRET: string;
|
||||||
|
TRAKT_REDIRECT_URI: string;
|
||||||
HIDE_PROXY_ONBOARDING: boolean;
|
HIDE_PROXY_ONBOARDING: boolean;
|
||||||
SHOW_SUPPORT_BAR: boolean;
|
SHOW_SUPPORT_BAR: boolean;
|
||||||
SUPPORT_BAR_VALUE: string;
|
SUPPORT_BAR_VALUE: string;
|
||||||
|
|
@ -64,6 +67,9 @@ export interface RuntimeConfig {
|
||||||
BANNER_MESSAGE: string | null;
|
BANNER_MESSAGE: string | null;
|
||||||
BANNER_ID: string | null;
|
BANNER_ID: string | null;
|
||||||
USE_TRAKT: boolean;
|
USE_TRAKT: boolean;
|
||||||
|
TRAKT_CLIENT_ID: string | null;
|
||||||
|
TRAKT_CLIENT_SECRET: string | null;
|
||||||
|
TRAKT_REDIRECT_URI: string | null;
|
||||||
HIDE_PROXY_ONBOARDING: boolean;
|
HIDE_PROXY_ONBOARDING: boolean;
|
||||||
SHOW_SUPPORT_BAR: boolean;
|
SHOW_SUPPORT_BAR: boolean;
|
||||||
SUPPORT_BAR_VALUE: string;
|
SUPPORT_BAR_VALUE: string;
|
||||||
|
|
@ -98,6 +104,9 @@ const env: Record<keyof Config, undefined | string> = {
|
||||||
BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE,
|
BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE,
|
||||||
BANNER_ID: import.meta.env.VITE_BANNER_ID,
|
BANNER_ID: import.meta.env.VITE_BANNER_ID,
|
||||||
USE_TRAKT: import.meta.env.VITE_USE_TRAKT,
|
USE_TRAKT: import.meta.env.VITE_USE_TRAKT,
|
||||||
|
TRAKT_CLIENT_ID: import.meta.env.VITE_TRAKT_CLIENT_ID,
|
||||||
|
TRAKT_CLIENT_SECRET: import.meta.env.VITE_TRAKT_CLIENT_SECRET,
|
||||||
|
TRAKT_REDIRECT_URI: import.meta.env.VITE_TRAKT_REDIRECT_URI,
|
||||||
HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING,
|
HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING,
|
||||||
SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR,
|
SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR,
|
||||||
SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE,
|
SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE,
|
||||||
|
|
@ -192,6 +201,9 @@ export function conf(): RuntimeConfig {
|
||||||
BANNER_MESSAGE: getKey("BANNER_MESSAGE"),
|
BANNER_MESSAGE: getKey("BANNER_MESSAGE"),
|
||||||
BANNER_ID: getKey("BANNER_ID"),
|
BANNER_ID: getKey("BANNER_ID"),
|
||||||
USE_TRAKT: getKey("USE_TRAKT", "false") === "true",
|
USE_TRAKT: getKey("USE_TRAKT", "false") === "true",
|
||||||
|
TRAKT_CLIENT_ID: getKey("TRAKT_CLIENT_ID"),
|
||||||
|
TRAKT_CLIENT_SECRET: getKey("TRAKT_CLIENT_SECRET"),
|
||||||
|
TRAKT_REDIRECT_URI: getKey("TRAKT_REDIRECT_URI"),
|
||||||
HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true",
|
HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true",
|
||||||
SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true",
|
SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true",
|
||||||
SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
|
SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface BookmarkUpdateItem {
|
||||||
export interface BookmarkStore {
|
export interface BookmarkStore {
|
||||||
bookmarks: Record<string, BookmarkMediaItem>;
|
bookmarks: Record<string, BookmarkMediaItem>;
|
||||||
updateQueue: BookmarkUpdateItem[];
|
updateQueue: BookmarkUpdateItem[];
|
||||||
|
traktUpdateQueue: BookmarkUpdateItem[];
|
||||||
addBookmark(meta: PlayerMeta): void;
|
addBookmark(meta: PlayerMeta): void;
|
||||||
addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void;
|
addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void;
|
||||||
removeBookmark(id: string): void;
|
removeBookmark(id: string): void;
|
||||||
|
|
@ -57,6 +58,8 @@ export interface BookmarkStore {
|
||||||
clear(): void;
|
clear(): void;
|
||||||
clearUpdateQueue(): void;
|
clearUpdateQueue(): void;
|
||||||
removeUpdateItem(id: string): void;
|
removeUpdateItem(id: string): void;
|
||||||
|
clearTraktUpdateQueue(): void;
|
||||||
|
removeTraktUpdateItem(id: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let updateId = 0;
|
let updateId = 0;
|
||||||
|
|
@ -66,14 +69,22 @@ export const useBookmarkStore = create(
|
||||||
immer<BookmarkStore>((set) => ({
|
immer<BookmarkStore>((set) => ({
|
||||||
bookmarks: {},
|
bookmarks: {},
|
||||||
updateQueue: [],
|
updateQueue: [],
|
||||||
|
traktUpdateQueue: [],
|
||||||
removeBookmark(id) {
|
removeBookmark(id) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
const existing = s.bookmarks[id];
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "delete",
|
action: "delete",
|
||||||
tmdbId: id,
|
tmdbId: id,
|
||||||
});
|
type: existing?.type,
|
||||||
|
title: existing?.title,
|
||||||
|
year: existing?.year,
|
||||||
|
group: existing?.group,
|
||||||
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
|
|
||||||
delete s.bookmarks[id];
|
delete s.bookmarks[id];
|
||||||
});
|
});
|
||||||
|
|
@ -81,7 +92,7 @@ export const useBookmarkStore = create(
|
||||||
addBookmark(meta) {
|
addBookmark(meta) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "add",
|
action: "add",
|
||||||
tmdbId: meta.tmdbId,
|
tmdbId: meta.tmdbId,
|
||||||
|
|
@ -89,7 +100,9 @@ export const useBookmarkStore = create(
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
year: meta.releaseYear,
|
year: meta.releaseYear,
|
||||||
poster: meta.poster,
|
poster: meta.poster,
|
||||||
});
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
|
|
||||||
s.bookmarks[meta.tmdbId] = {
|
s.bookmarks[meta.tmdbId] = {
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|
@ -103,7 +116,7 @@ export const useBookmarkStore = create(
|
||||||
addBookmarkWithGroups(meta, groups) {
|
addBookmarkWithGroups(meta, groups) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "add",
|
action: "add",
|
||||||
tmdbId: meta.tmdbId,
|
tmdbId: meta.tmdbId,
|
||||||
|
|
@ -112,7 +125,9 @@ export const useBookmarkStore = create(
|
||||||
year: meta.releaseYear,
|
year: meta.releaseYear,
|
||||||
poster: meta.poster,
|
poster: meta.poster,
|
||||||
group: groups,
|
group: groups,
|
||||||
});
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
|
|
||||||
s.bookmarks[meta.tmdbId] = {
|
s.bookmarks[meta.tmdbId] = {
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|
@ -144,6 +159,18 @@ export const useBookmarkStore = create(
|
||||||
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
|
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
clearTraktUpdateQueue() {
|
||||||
|
set((s) => {
|
||||||
|
s.traktUpdateQueue = [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeTraktUpdateItem(id: string) {
|
||||||
|
set((s) => {
|
||||||
|
s.traktUpdateQueue = [
|
||||||
|
...s.traktUpdateQueue.filter((v) => v.id !== id),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
toggleFavoriteEpisode(
|
toggleFavoriteEpisode(
|
||||||
showId: string,
|
showId: string,
|
||||||
episodeId: string,
|
episodeId: string,
|
||||||
|
|
@ -181,7 +208,7 @@ export const useBookmarkStore = create(
|
||||||
|
|
||||||
// Add to update queue for syncing
|
// Add to update queue for syncing
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "add",
|
action: "add",
|
||||||
tmdbId: showId,
|
tmdbId: showId,
|
||||||
|
|
@ -190,7 +217,9 @@ export const useBookmarkStore = create(
|
||||||
year: bookmark.year,
|
year: bookmark.year,
|
||||||
poster: bookmark.poster,
|
poster: bookmark.poster,
|
||||||
type: bookmark.type,
|
type: bookmark.type,
|
||||||
});
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isEpisodeFavorited(showId: string, episodeId: string): boolean {
|
isEpisodeFavorited(showId: string, episodeId: string): boolean {
|
||||||
|
|
@ -225,7 +254,7 @@ export const useBookmarkStore = create(
|
||||||
const bookmark = s.bookmarks[bookmarkId];
|
const bookmark = s.bookmarks[bookmarkId];
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "add",
|
action: "add",
|
||||||
tmdbId: bookmarkId,
|
tmdbId: bookmarkId,
|
||||||
|
|
@ -235,7 +264,9 @@ export const useBookmarkStore = create(
|
||||||
type: bookmark.type,
|
type: bookmark.type,
|
||||||
group: bookmark.group,
|
group: bookmark.group,
|
||||||
favoriteEpisodes: bookmark.favoriteEpisodes,
|
favoriteEpisodes: bookmark.favoriteEpisodes,
|
||||||
});
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +294,7 @@ export const useBookmarkStore = create(
|
||||||
const bookmark = s.bookmarks[bookmarkId];
|
const bookmark = s.bookmarks[bookmarkId];
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
updateId += 1;
|
updateId += 1;
|
||||||
s.updateQueue.push({
|
const item: BookmarkUpdateItem = {
|
||||||
id: updateId.toString(),
|
id: updateId.toString(),
|
||||||
action: "add",
|
action: "add",
|
||||||
tmdbId: bookmarkId,
|
tmdbId: bookmarkId,
|
||||||
|
|
@ -273,7 +304,9 @@ export const useBookmarkStore = create(
|
||||||
type: bookmark.type,
|
type: bookmark.type,
|
||||||
group: bookmark.group,
|
group: bookmark.group,
|
||||||
favoriteEpisodes: bookmark.favoriteEpisodes,
|
favoriteEpisodes: bookmark.favoriteEpisodes,
|
||||||
});
|
};
|
||||||
|
s.updateQueue.push(item);
|
||||||
|
s.traktUpdateQueue.push(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
276
src/stores/trakt/TraktBookmarkSyncer.tsx
Normal file
276
src/stores/trakt/TraktBookmarkSyncer.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useInterval } from "react-use";
|
||||||
|
|
||||||
|
import { getPosterForMedia } from "@/backend/metadata/tmdb";
|
||||||
|
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||||
|
import { useTraktAuthStore } from "@/stores/trakt/store";
|
||||||
|
import { modifyBookmarks } from "@/utils/bookmarkModifications";
|
||||||
|
import { traktService } from "@/utils/trakt";
|
||||||
|
import { TraktContentData, TraktList } from "@/utils/traktTypes";
|
||||||
|
|
||||||
|
const TRAKT_SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 min
|
||||||
|
const INITIAL_SYNC_DELAY_MS = 2000; // Re-sync after backend restore
|
||||||
|
|
||||||
|
function listId(list: TraktList): string {
|
||||||
|
return list.ids.slug ?? String(list.ids.trakt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findListByName(
|
||||||
|
username: string,
|
||||||
|
groupName: string,
|
||||||
|
): Promise<TraktList | null> {
|
||||||
|
const lists = await traktService.getLists(username);
|
||||||
|
return lists.find((l) => l.name === groupName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureListExists(
|
||||||
|
username: string,
|
||||||
|
groupName: string,
|
||||||
|
): Promise<TraktList | null> {
|
||||||
|
const existing = await findListByName(username, groupName);
|
||||||
|
if (existing) return existing;
|
||||||
|
try {
|
||||||
|
return await traktService.createList(username, groupName);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraktBookmarkSyncer() {
|
||||||
|
const { traktUpdateQueue, removeTraktUpdateItem, replaceBookmarks } =
|
||||||
|
useBookmarkStore();
|
||||||
|
const { accessToken, user } = useTraktAuthStore();
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
// Sync from Local to Trakt
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const processQueue = async () => {
|
||||||
|
const queue = [...traktUpdateQueue];
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
const slug = user?.ids?.slug;
|
||||||
|
const hasLists = Boolean(slug);
|
||||||
|
|
||||||
|
for (const item of queue) {
|
||||||
|
removeTraktUpdateItem(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentData: TraktContentData = {
|
||||||
|
title: item.title ?? "",
|
||||||
|
year: item.year,
|
||||||
|
tmdbId: item.tmdbId,
|
||||||
|
type: (item.type === "movie" ? "movie" : "show") as
|
||||||
|
| "movie"
|
||||||
|
| "show"
|
||||||
|
| "episode",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.action === "add") {
|
||||||
|
await traktService.addToWatchlist(contentData);
|
||||||
|
if (hasLists && item.group?.length) {
|
||||||
|
for (const groupName of item.group) {
|
||||||
|
const list = await ensureListExists(slug!, groupName);
|
||||||
|
if (list) {
|
||||||
|
await traktService.addToList(slug!, listId(list), [
|
||||||
|
contentData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item.action === "delete") {
|
||||||
|
await traktService.removeFromWatchlist(contentData);
|
||||||
|
if (hasLists && item.group?.length) {
|
||||||
|
for (const groupName of item.group) {
|
||||||
|
const list = await findListByName(slug!, groupName);
|
||||||
|
if (list) {
|
||||||
|
await traktService.removeFromList(slug!, listId(list), [
|
||||||
|
contentData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync bookmark to Trakt", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processQueue();
|
||||||
|
}, [accessToken, user?.ids?.slug, traktUpdateQueue, removeTraktUpdateItem]);
|
||||||
|
|
||||||
|
// Push local bookmarks to Trakt (watchlist + groups)
|
||||||
|
const syncBookmarksToTrakt = useCallback(async () => {
|
||||||
|
if (!accessToken || isSyncingRef.current) return;
|
||||||
|
const slug = useTraktAuthStore.getState().user?.ids?.slug;
|
||||||
|
if (!slug) return;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (!useTraktAuthStore.getState().user) {
|
||||||
|
await traktService.getUserProfile();
|
||||||
|
}
|
||||||
|
const bookmarks = useBookmarkStore.getState().bookmarks;
|
||||||
|
for (const [tmdbId, b] of Object.entries(bookmarks)) {
|
||||||
|
try {
|
||||||
|
const contentData: TraktContentData = {
|
||||||
|
tmdbId,
|
||||||
|
title: b.title,
|
||||||
|
year: b.year,
|
||||||
|
type: b.type === "movie" ? "movie" : "show",
|
||||||
|
};
|
||||||
|
await traktService.addToWatchlist(contentData);
|
||||||
|
if (b.group?.length) {
|
||||||
|
for (const groupName of b.group) {
|
||||||
|
const list = await ensureListExists(slug, groupName);
|
||||||
|
if (list) {
|
||||||
|
await traktService.addToList(slug, listId(list), [contentData]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to push bookmark to Trakt:", tmdbId, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const syncWatchlistFromTrakt = useCallback(async () => {
|
||||||
|
if (!accessToken || isSyncingRef.current) return;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (!useTraktAuthStore.getState().user) {
|
||||||
|
await traktService.getUserProfile();
|
||||||
|
}
|
||||||
|
const watchlist = await traktService.getWatchlist();
|
||||||
|
const store = useBookmarkStore.getState();
|
||||||
|
const merged = { ...store.bookmarks };
|
||||||
|
|
||||||
|
for (const item of watchlist) {
|
||||||
|
const type = item.movie ? "movie" : "show";
|
||||||
|
const media = item.movie || item.show;
|
||||||
|
if (!media) continue;
|
||||||
|
|
||||||
|
const tmdbId = media.ids.tmdb?.toString();
|
||||||
|
if (!tmdbId) continue;
|
||||||
|
|
||||||
|
if (!merged[tmdbId]) {
|
||||||
|
const poster = await getPosterForMedia(tmdbId, type);
|
||||||
|
merged[tmdbId] = {
|
||||||
|
type: type as "movie" | "show",
|
||||||
|
title: media.title,
|
||||||
|
year: media.year,
|
||||||
|
poster,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBookmarks(merged);
|
||||||
|
|
||||||
|
const slug = useTraktAuthStore.getState().user?.ids?.slug;
|
||||||
|
if (slug) {
|
||||||
|
try {
|
||||||
|
const lists = await traktService.getLists(slug);
|
||||||
|
const currentBookmarks = useBookmarkStore.getState().bookmarks;
|
||||||
|
let modifiedBookmarks = { ...currentBookmarks };
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
const listTitle = list.name;
|
||||||
|
const items = await traktService.getListItems(slug, listId(list));
|
||||||
|
for (const li of items) {
|
||||||
|
const media = li.movie || li.show;
|
||||||
|
if (!media?.ids?.tmdb) continue;
|
||||||
|
|
||||||
|
const tmdbId = media.ids.tmdb.toString();
|
||||||
|
const type = li.movie ? "movie" : "show";
|
||||||
|
const bookmark = modifiedBookmarks[tmdbId];
|
||||||
|
|
||||||
|
if (!bookmark) {
|
||||||
|
const poster = await getPosterForMedia(tmdbId, type);
|
||||||
|
modifiedBookmarks[tmdbId] = {
|
||||||
|
type: type as "movie" | "show",
|
||||||
|
title: media.title,
|
||||||
|
year: media.year,
|
||||||
|
poster,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
group: [listTitle],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const groups = bookmark.group ?? [];
|
||||||
|
if (!groups.includes(listTitle)) {
|
||||||
|
const { modifiedBookmarks: next } = modifyBookmarks(
|
||||||
|
modifiedBookmarks,
|
||||||
|
[tmdbId],
|
||||||
|
{ addGroups: [listTitle] },
|
||||||
|
);
|
||||||
|
modifiedBookmarks = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNewBookmarks =
|
||||||
|
Object.keys(modifiedBookmarks).length !==
|
||||||
|
Object.keys(currentBookmarks).length;
|
||||||
|
const hasGroupChanges = Object.keys(modifiedBookmarks).some(
|
||||||
|
(id) =>
|
||||||
|
JSON.stringify(modifiedBookmarks[id]?.group ?? []) !==
|
||||||
|
JSON.stringify(currentBookmarks[id]?.group ?? []),
|
||||||
|
);
|
||||||
|
if (hasNewBookmarks || hasGroupChanges) {
|
||||||
|
replaceBookmarks(modifiedBookmarks);
|
||||||
|
}
|
||||||
|
} catch (listError) {
|
||||||
|
console.warn("Failed to sync Trakt lists (groups)", listError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync Trakt watchlist to local", error);
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [accessToken, replaceBookmarks]);
|
||||||
|
|
||||||
|
const fullSync = useCallback(async () => {
|
||||||
|
await syncWatchlistFromTrakt(); // Pull Trakt → local, merge
|
||||||
|
await syncBookmarksToTrakt(); // Push local → Trakt
|
||||||
|
}, [syncWatchlistFromTrakt, syncBookmarksToTrakt]);
|
||||||
|
|
||||||
|
// Wait for Trakt auth store to rehydrate from persist (accessToken may be null on first render)
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (useTraktAuthStore.persist?.hasHydrated?.()) {
|
||||||
|
setHydrated(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (check()) return;
|
||||||
|
const unsub = useTraktAuthStore.persist?.onFinishHydration?.(() => {
|
||||||
|
setHydrated(true);
|
||||||
|
});
|
||||||
|
const t = setTimeout(() => setHydrated(true), 500);
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On mount (after hydration): pull immediately (Trakt → local)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated || !accessToken) return;
|
||||||
|
syncWatchlistFromTrakt();
|
||||||
|
const t = setTimeout(fullSync, INITIAL_SYNC_DELAY_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [hydrated, accessToken, syncWatchlistFromTrakt, fullSync]);
|
||||||
|
|
||||||
|
// Periodic full sync (pull + push)
|
||||||
|
useInterval(fullSync, TRAKT_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
205
src/stores/trakt/TraktHistorySyncer.tsx
Normal file
205
src/stores/trakt/TraktHistorySyncer.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useInterval } from "react-use";
|
||||||
|
|
||||||
|
import { importWatchHistory } from "@/backend/accounts/import";
|
||||||
|
import {
|
||||||
|
watchHistoryItemToInputs,
|
||||||
|
watchHistoryItemsToInputs,
|
||||||
|
} from "@/backend/accounts/watchHistory";
|
||||||
|
import { getPosterForMedia } from "@/backend/metadata/tmdb";
|
||||||
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useTraktAuthStore } from "@/stores/trakt/store";
|
||||||
|
import { WatchHistoryItem, useWatchHistoryStore } from "@/stores/watchHistory";
|
||||||
|
import { traktService } from "@/utils/trakt";
|
||||||
|
import { TraktContentData } from "@/utils/traktTypes";
|
||||||
|
|
||||||
|
const PROGRESS_THRESHOLD = 0.25; // Sync to Trakt if watched >= 25%
|
||||||
|
const TRAKT_HISTORY_SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 min
|
||||||
|
const INITIAL_SYNC_DELAY_MS = 2000; // Re-sync after backend restore
|
||||||
|
|
||||||
|
function toTraktContentData(
|
||||||
|
id: string,
|
||||||
|
item: WatchHistoryItem,
|
||||||
|
): TraktContentData | null {
|
||||||
|
const { watched, duration } = item.progress;
|
||||||
|
const progress = duration > 0 ? watched / duration : 0;
|
||||||
|
if (progress < PROGRESS_THRESHOLD) return null;
|
||||||
|
|
||||||
|
const input = watchHistoryItemToInputs(id, item);
|
||||||
|
|
||||||
|
if (item.type === "movie") {
|
||||||
|
return {
|
||||||
|
type: "movie",
|
||||||
|
tmdbId: input.tmdbId,
|
||||||
|
title: item.title,
|
||||||
|
year: item.year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
item.type === "show" &&
|
||||||
|
input.seasonNumber != null &&
|
||||||
|
input.episodeNumber != null
|
||||||
|
) {
|
||||||
|
const showTmdbId = id.includes("-") ? id.split("-")[0] : input.tmdbId;
|
||||||
|
const episodeTmdbId =
|
||||||
|
item.episodeId ?? (id.includes("-") ? id.split("-")[1] : undefined);
|
||||||
|
if (!episodeTmdbId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "episode",
|
||||||
|
tmdbId: episodeTmdbId,
|
||||||
|
title: item.title,
|
||||||
|
year: item.year,
|
||||||
|
season: input.seasonNumber,
|
||||||
|
episode: input.episodeNumber,
|
||||||
|
showTitle: item.title,
|
||||||
|
showYear: item.year,
|
||||||
|
showTmdbId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraktHistorySyncer() {
|
||||||
|
const { accessToken } = useTraktAuthStore();
|
||||||
|
const backendUrl = useBackendUrl();
|
||||||
|
const account = useAuthStore((s) => s.account);
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
const syncHistoryFromTrakt = useCallback(async () => {
|
||||||
|
if (!accessToken || isSyncingRef.current) return;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
try {
|
||||||
|
const items = await traktService.getHistoryItems();
|
||||||
|
const store = useWatchHistoryStore.getState();
|
||||||
|
const merged = { ...store.items };
|
||||||
|
|
||||||
|
for (const hi of items) {
|
||||||
|
if (hi.type === "movie" && hi.movie) {
|
||||||
|
const tmdbId = hi.movie.ids.tmdb?.toString();
|
||||||
|
if (!tmdbId) continue;
|
||||||
|
if (!merged[tmdbId]) {
|
||||||
|
const poster = await getPosterForMedia(tmdbId, "movie");
|
||||||
|
merged[tmdbId] = {
|
||||||
|
type: "movie",
|
||||||
|
title: hi.movie.title,
|
||||||
|
year: hi.movie.year,
|
||||||
|
poster,
|
||||||
|
progress: { watched: 0, duration: 1 },
|
||||||
|
watchedAt: new Date(hi.watched_at).getTime(),
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (hi.type === "episode" && hi.episode && hi.show) {
|
||||||
|
const showTmdbId = hi.show.ids.tmdb?.toString();
|
||||||
|
const episodeTmdbId = hi.episode.ids?.tmdb?.toString();
|
||||||
|
if (!showTmdbId || !episodeTmdbId) continue;
|
||||||
|
const key = `${showTmdbId}-${episodeTmdbId}`;
|
||||||
|
if (!merged[key]) {
|
||||||
|
const poster = await getPosterForMedia(showTmdbId, "show");
|
||||||
|
merged[key] = {
|
||||||
|
type: "show",
|
||||||
|
title: hi.show.title,
|
||||||
|
year: hi.show.year,
|
||||||
|
poster,
|
||||||
|
progress: { watched: 0, duration: 1 },
|
||||||
|
watchedAt: new Date(hi.watched_at).getTime(),
|
||||||
|
completed: false,
|
||||||
|
episodeId: episodeTmdbId,
|
||||||
|
seasonNumber: hi.episode.season,
|
||||||
|
episodeNumber: hi.episode.number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(merged).length > Object.keys(store.items).length) {
|
||||||
|
const newKeys = Object.keys(merged).filter((k) => !(k in store.items));
|
||||||
|
useWatchHistoryStore.getState().replaceItems(merged);
|
||||||
|
if (backendUrl && account && newKeys.length > 0) {
|
||||||
|
const newItems = Object.fromEntries(
|
||||||
|
newKeys.map((k) => [k, merged[k]!]),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await importWatchHistory(
|
||||||
|
backendUrl,
|
||||||
|
account,
|
||||||
|
watchHistoryItemsToInputs(newItems),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to import Trakt history to backend", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to sync history from Trakt", err);
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [accessToken, backendUrl, account]);
|
||||||
|
|
||||||
|
const syncHistoryToTrakt = useCallback(async () => {
|
||||||
|
if (!accessToken || isSyncingRef.current) return;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = useWatchHistoryStore.getState().items;
|
||||||
|
|
||||||
|
for (const [id, item] of Object.entries(items)) {
|
||||||
|
const contentData = toTraktContentData(id, item);
|
||||||
|
if (!contentData) continue;
|
||||||
|
|
||||||
|
const input = watchHistoryItemToInputs(id, item);
|
||||||
|
const watchedAt = input.watchedAt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await traktService.addToHistory(contentData, watchedAt);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to sync watch history to Trakt: ${id}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync watch history to Trakt", error);
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const fullSync = useCallback(async () => {
|
||||||
|
await syncHistoryFromTrakt();
|
||||||
|
await syncHistoryToTrakt();
|
||||||
|
}, [syncHistoryFromTrakt, syncHistoryToTrakt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => useTraktAuthStore.persist?.hasHydrated?.() ?? false;
|
||||||
|
if (check()) {
|
||||||
|
setHydrated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unsub = useTraktAuthStore.persist?.onFinishHydration?.(() =>
|
||||||
|
setHydrated(true),
|
||||||
|
);
|
||||||
|
const t = setTimeout(() => setHydrated(true), 500);
|
||||||
|
return () => {
|
||||||
|
unsub?.();
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On mount (after hydration): pull from Trakt immediately, then full sync after delay
|
||||||
|
// (delay ensures we run after auth restore overwrites the store)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated || !accessToken) return;
|
||||||
|
syncHistoryFromTrakt();
|
||||||
|
const t = setTimeout(fullSync, INITIAL_SYNC_DELAY_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [hydrated, accessToken, syncHistoryFromTrakt, fullSync]);
|
||||||
|
|
||||||
|
useInterval(fullSync, TRAKT_HISTORY_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
133
src/stores/trakt/TraktScrobbler.tsx
Normal file
133
src/stores/trakt/TraktScrobbler.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useInterval } from "react-use";
|
||||||
|
|
||||||
|
import { playerStatus } from "@/stores/player/slices/source";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { useTraktAuthStore } from "@/stores/trakt/store";
|
||||||
|
import { traktService } from "@/utils/trakt";
|
||||||
|
import { TraktContentData } from "@/utils/traktTypes";
|
||||||
|
|
||||||
|
export function TraktScrobbler() {
|
||||||
|
const { accessToken } = useTraktAuthStore();
|
||||||
|
const { status, meta } = usePlayerStore((s) => ({
|
||||||
|
status: s.status,
|
||||||
|
meta: s.meta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastStatusRef = useRef(status);
|
||||||
|
const lastScrobbleRef = useRef<{
|
||||||
|
contentData: TraktContentData;
|
||||||
|
progressPercent: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Helper to construct content data
|
||||||
|
const getContentData = useCallback((): TraktContentData | null => {
|
||||||
|
if (!meta) return null;
|
||||||
|
const data: TraktContentData = {
|
||||||
|
title: meta.title,
|
||||||
|
year: meta.releaseYear,
|
||||||
|
tmdbId: meta.tmdbId,
|
||||||
|
type: meta.type === "movie" ? "movie" : "episode",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (meta.type === "show") {
|
||||||
|
if (!meta.season || !meta.episode) return null;
|
||||||
|
data.season = meta.season.number;
|
||||||
|
data.episode = meta.episode.number;
|
||||||
|
data.showTitle = meta.title;
|
||||||
|
data.showYear = meta.releaseYear;
|
||||||
|
data.showTmdbId = meta.tmdbId;
|
||||||
|
data.tmdbId = meta.episode.tmdbId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, [meta]);
|
||||||
|
|
||||||
|
// Handle Status Changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
|
||||||
|
const contentData = getContentData();
|
||||||
|
const { progress } = usePlayerStore.getState();
|
||||||
|
const progressPercent =
|
||||||
|
progress.duration > 0 ? (progress.time / progress.duration) * 100 : 0;
|
||||||
|
|
||||||
|
const handleStatusChange = async () => {
|
||||||
|
// When we have content, cache it for use when we transition to IDLE
|
||||||
|
// (reset() clears meta before we can read it, so we need the cached values)
|
||||||
|
if (contentData) {
|
||||||
|
lastScrobbleRef.current = { contentData, progressPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAYING
|
||||||
|
if (
|
||||||
|
status === playerStatus.PLAYING &&
|
||||||
|
lastStatusRef.current !== playerStatus.PLAYING
|
||||||
|
) {
|
||||||
|
if (contentData) {
|
||||||
|
await traktService.startWatching(contentData, progressPercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// STOPPED / IDLE - use cached data since meta is already null
|
||||||
|
else if (
|
||||||
|
status === playerStatus.IDLE &&
|
||||||
|
lastStatusRef.current !== playerStatus.IDLE
|
||||||
|
) {
|
||||||
|
const cached = lastScrobbleRef.current;
|
||||||
|
if (cached) {
|
||||||
|
await traktService.stopWatching(
|
||||||
|
cached.contentData,
|
||||||
|
cached.progressPercent,
|
||||||
|
);
|
||||||
|
lastScrobbleRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PAUSED (Any other state coming from PLAYING)
|
||||||
|
else if (
|
||||||
|
status !== playerStatus.PLAYING &&
|
||||||
|
lastStatusRef.current === playerStatus.PLAYING
|
||||||
|
) {
|
||||||
|
if (contentData) {
|
||||||
|
await traktService.pauseWatching(contentData, progressPercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastStatusRef.current = status;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleStatusChange();
|
||||||
|
}, [accessToken, status, getContentData, meta]);
|
||||||
|
|
||||||
|
// Periodic Update (Keep Alive / Progress Update)
|
||||||
|
useInterval(() => {
|
||||||
|
if (!accessToken || !meta || status !== playerStatus.PLAYING) return;
|
||||||
|
|
||||||
|
const contentData = getContentData();
|
||||||
|
if (!contentData) return;
|
||||||
|
|
||||||
|
const { progress } = usePlayerStore.getState();
|
||||||
|
const progressPercent =
|
||||||
|
progress.duration > 0 ? (progress.time / progress.duration) * 100 : 0;
|
||||||
|
|
||||||
|
traktService
|
||||||
|
.startWatching(contentData, progressPercent)
|
||||||
|
.catch(console.error);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// Send stop when user closes tab or navigates away
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
const cached = lastScrobbleRef.current;
|
||||||
|
if (cached) {
|
||||||
|
traktService.stopWatchingOnUnload(
|
||||||
|
cached.contentData,
|
||||||
|
cached.progressPercent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("pagehide", handleBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
79
src/stores/trakt/store.ts
Normal file
79
src/stores/trakt/store.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
import { TraktUser } from "@/utils/traktTypes";
|
||||||
|
|
||||||
|
export type TraktStatus = "idle" | "syncing";
|
||||||
|
|
||||||
|
export interface TraktAuthStore {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
expiresAt: number | null;
|
||||||
|
user: TraktUser | null;
|
||||||
|
status: TraktStatus;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
setTokens(accessToken: string, refreshToken: string, expiresAt: number): void;
|
||||||
|
setUser(user: TraktUser): void;
|
||||||
|
setStatus(status: TraktStatus): void;
|
||||||
|
setError(error: string | null): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTraktAuthStore = create(
|
||||||
|
persist(
|
||||||
|
immer<TraktAuthStore>((set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
status: "idle",
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
setTokens(accessToken, refreshToken, expiresAt) {
|
||||||
|
set((s) => {
|
||||||
|
s.accessToken = accessToken;
|
||||||
|
s.refreshToken = refreshToken;
|
||||||
|
s.expiresAt = expiresAt;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setUser(user) {
|
||||||
|
set((s) => {
|
||||||
|
s.user = user;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setStatus(status) {
|
||||||
|
set((s) => {
|
||||||
|
s.status = status;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setError(error) {
|
||||||
|
set((s) => {
|
||||||
|
s.error = error;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
set((s) => {
|
||||||
|
s.accessToken = null;
|
||||||
|
s.refreshToken = null;
|
||||||
|
s.expiresAt = null;
|
||||||
|
s.user = null;
|
||||||
|
s.status = "idle";
|
||||||
|
s.error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "__MW::trakt_auth",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useTraktStore() {
|
||||||
|
const user = useTraktAuthStore((s) => s.user);
|
||||||
|
const status = useTraktAuthStore((s) => s.status);
|
||||||
|
const error = useTraktAuthStore((s) => s.error);
|
||||||
|
const logout = useTraktAuthStore((s) => s.clear);
|
||||||
|
return { user, status, logout, error };
|
||||||
|
}
|
||||||
550
src/utils/trakt.ts
Normal file
550
src/utils/trakt.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
import { ofetch } from "ofetch";
|
||||||
|
import slugify from "slugify";
|
||||||
|
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
import { useTraktAuthStore } from "@/stores/trakt/store";
|
||||||
|
import {
|
||||||
|
TraktContentData,
|
||||||
|
TraktHistoryItem,
|
||||||
|
TraktList,
|
||||||
|
TraktListItem,
|
||||||
|
TraktScrobbleResponse,
|
||||||
|
TraktUser,
|
||||||
|
TraktWatchedItem,
|
||||||
|
TraktWatchlistItem,
|
||||||
|
} from "@/utils/traktTypes";
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
export const TRAKT_API_URL = "https://api.trakt.tv";
|
||||||
|
|
||||||
|
export class TraktService {
|
||||||
|
// eslint-disable-next-line no-use-before-define -- self-reference for singleton
|
||||||
|
private static instance: TraktService;
|
||||||
|
|
||||||
|
private readonly MIN_API_INTERVAL = 500;
|
||||||
|
|
||||||
|
private lastApiCall: number = 0;
|
||||||
|
|
||||||
|
private requestQueue: Array<() => Promise<any>> = [];
|
||||||
|
|
||||||
|
private isProcessingQueue: boolean = false;
|
||||||
|
|
||||||
|
public static getInstance(): TraktService {
|
||||||
|
if (!TraktService.instance) {
|
||||||
|
TraktService.instance = new TraktService();
|
||||||
|
}
|
||||||
|
return TraktService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- part of instance API
|
||||||
|
public getAuthUrl(): string {
|
||||||
|
const config = conf();
|
||||||
|
if (!config.TRAKT_CLIENT_ID || !config.TRAKT_REDIRECT_URI) return "";
|
||||||
|
return `${TRAKT_API_URL}/oauth/authorize?response_type=code&client_id=${
|
||||||
|
config.TRAKT_CLIENT_ID
|
||||||
|
}&redirect_uri=${encodeURIComponent(config.TRAKT_REDIRECT_URI)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exchangeCodeForToken(
|
||||||
|
code: string,
|
||||||
|
redirectUri?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const config = conf();
|
||||||
|
if (!config.TRAKT_CLIENT_ID || !config.TRAKT_CLIENT_SECRET)
|
||||||
|
throw new Error("Missing Trakt config");
|
||||||
|
|
||||||
|
const resolvedRedirectUri =
|
||||||
|
redirectUri ?? config.TRAKT_REDIRECT_URI ?? undefined;
|
||||||
|
if (!resolvedRedirectUri)
|
||||||
|
throw new Error("Missing redirect_uri for token exchange");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await ofetch(`${TRAKT_API_URL}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: {
|
||||||
|
code,
|
||||||
|
client_id: config.TRAKT_CLIENT_ID,
|
||||||
|
client_secret: config.TRAKT_CLIENT_SECRET,
|
||||||
|
redirect_uri: resolvedRedirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||||
|
useTraktAuthStore
|
||||||
|
.getState()
|
||||||
|
.setTokens(data.access_token, data.refresh_token, expiresAt);
|
||||||
|
|
||||||
|
// Fetch user profile immediately
|
||||||
|
await this.getUserProfile();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg =
|
||||||
|
error?.data?.message ?? error?.message ?? "Failed to exchange code";
|
||||||
|
console.error("[TraktService] Failed to exchange code:", error);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- called as this.refreshToken from apiRequest
|
||||||
|
public async refreshToken(): Promise<void> {
|
||||||
|
const config = conf();
|
||||||
|
const { refreshToken } = useTraktAuthStore.getState();
|
||||||
|
if (
|
||||||
|
!refreshToken ||
|
||||||
|
!config.TRAKT_CLIENT_ID ||
|
||||||
|
!config.TRAKT_CLIENT_SECRET ||
|
||||||
|
!config.TRAKT_REDIRECT_URI
|
||||||
|
)
|
||||||
|
throw new Error("Missing refresh token or config");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await ofetch(`${TRAKT_API_URL}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: config.TRAKT_CLIENT_ID,
|
||||||
|
client_secret: config.TRAKT_CLIENT_SECRET,
|
||||||
|
redirect_uri: config.TRAKT_REDIRECT_URI,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||||
|
useTraktAuthStore
|
||||||
|
.getState()
|
||||||
|
.setTokens(data.access_token, data.refresh_token, expiresAt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TraktService] Failed to refresh token:", error);
|
||||||
|
useTraktAuthStore.getState().clear();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async apiRequest<T>(
|
||||||
|
endpoint: string,
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||||
|
body: any = undefined,
|
||||||
|
retryCount = 0,
|
||||||
|
): Promise<T> {
|
||||||
|
const config = conf();
|
||||||
|
const { expiresAt } = useTraktAuthStore.getState();
|
||||||
|
|
||||||
|
if (!config.TRAKT_CLIENT_ID) throw new Error("Missing Trakt Client ID");
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastCall = now - this.lastApiCall;
|
||||||
|
if (timeSinceLastCall < this.MIN_API_INTERVAL) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, this.MIN_API_INTERVAL - timeSinceLastCall);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.lastApiCall = Date.now();
|
||||||
|
|
||||||
|
// Refresh token if needed
|
||||||
|
if (expiresAt && expiresAt < Date.now() + 60000) {
|
||||||
|
await this.refreshToken();
|
||||||
|
}
|
||||||
|
// Get fresh token after potential refresh
|
||||||
|
const freshAccessToken = useTraktAuthStore.getState().accessToken;
|
||||||
|
|
||||||
|
if (!freshAccessToken) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ofetch(`${TRAKT_API_URL}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"trakt-api-version": "2",
|
||||||
|
"trakt-api-key": config.TRAKT_CLIENT_ID,
|
||||||
|
Authorization: `Bearer ${freshAccessToken}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
retry: 0, // We handle retries manually for 429
|
||||||
|
});
|
||||||
|
return response as T;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 429 && retryCount < 3) {
|
||||||
|
const retryAfter = error.response.headers.get("Retry-After");
|
||||||
|
const delay = retryAfter
|
||||||
|
? parseInt(retryAfter, 10) * 1000
|
||||||
|
: 1000 * 2 ** retryCount;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, delay);
|
||||||
|
});
|
||||||
|
return this.apiRequest<T>(endpoint, method, body, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 404 (Not Found) gracefully
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
console.warn(`[TraktService] 404 Not Found: ${endpoint}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 409 (Conflict) - usually means already scrobbled/watched
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
console.warn(`[TraktService] 409 Conflict: ${endpoint}`);
|
||||||
|
return error.response._data; // Return the data anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queueRequest<T>(request: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.requestQueue.push(async () => {
|
||||||
|
try {
|
||||||
|
const result = await request();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.processQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue() {
|
||||||
|
if (this.isProcessingQueue) return;
|
||||||
|
this.isProcessingQueue = true;
|
||||||
|
|
||||||
|
while (this.requestQueue.length > 0) {
|
||||||
|
const request = this.requestQueue.shift();
|
||||||
|
if (request) {
|
||||||
|
await request();
|
||||||
|
// Minimum interval between requests in queue
|
||||||
|
if (this.requestQueue.length > 0) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, this.MIN_API_INTERVAL);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Profile
|
||||||
|
public async getUserProfile(): Promise<TraktUser> {
|
||||||
|
const profile = await this.apiRequest<TraktUser>(
|
||||||
|
"/users/me?extended=full,images",
|
||||||
|
);
|
||||||
|
useTraktAuthStore.getState().setUser(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchlist - fetch movies and shows separately for reliability
|
||||||
|
public async getWatchlist(): Promise<TraktWatchlistItem[]> {
|
||||||
|
const limit = 100;
|
||||||
|
const q = `extended=full,images`;
|
||||||
|
const allItems: TraktWatchlistItem[] = [];
|
||||||
|
|
||||||
|
for (const type of ["movies", "shows"] as const) {
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const results = await this.apiRequest<TraktWatchlistItem[]>(
|
||||||
|
`/sync/watchlist/${type}/rank/asc?${q}&page=${page}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
allItems.push(...results);
|
||||||
|
if (results.length < limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addToWatchlist(item: TraktContentData): Promise<void> {
|
||||||
|
const payload = this.buildSyncPayload(item);
|
||||||
|
await this.apiRequest("/sync/watchlist", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeFromWatchlist(item: TraktContentData): Promise<void> {
|
||||||
|
const payload = this.buildSyncPayload(item);
|
||||||
|
await this.apiRequest("/sync/watchlist/remove", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personal Lists (for groups/collections sync)
|
||||||
|
public async getLists(username: string): Promise<TraktList[]> {
|
||||||
|
const results = await this.apiRequest<TraktList[]>(
|
||||||
|
`/users/${username}/lists`,
|
||||||
|
);
|
||||||
|
return Array.isArray(results) ? results : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createList(username: string, name: string): Promise<TraktList> {
|
||||||
|
return this.apiRequest<TraktList>(`/users/${username}/lists`, "POST", {
|
||||||
|
name,
|
||||||
|
privacy: "private",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getListItems(
|
||||||
|
username: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<TraktListItem[]> {
|
||||||
|
const limit = 100;
|
||||||
|
const allItems: TraktListItem[] = [];
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const results = await this.apiRequest<TraktListItem[]>(
|
||||||
|
`/users/${username}/lists/${listId}/items?page=${page}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
const arr = Array.isArray(results) ? results : [];
|
||||||
|
allItems.push(...arr);
|
||||||
|
if (arr.length < limit) break;
|
||||||
|
}
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addToList(
|
||||||
|
username: string,
|
||||||
|
listId: string,
|
||||||
|
items: TraktContentData[],
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = this.buildListPayload(items);
|
||||||
|
if (Object.keys(payload).length === 0) return;
|
||||||
|
await this.apiRequest(
|
||||||
|
`/users/${username}/lists/${listId}/items`,
|
||||||
|
"POST",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeFromList(
|
||||||
|
username: string,
|
||||||
|
listId: string,
|
||||||
|
items: TraktContentData[],
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = this.buildListPayload(items);
|
||||||
|
if (Object.keys(payload).length === 0) return;
|
||||||
|
await this.apiRequest(
|
||||||
|
`/users/${username}/lists/${listId}/items/remove`,
|
||||||
|
"POST",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildListPayload(items: TraktContentData[]): any {
|
||||||
|
const movies: any[] = [];
|
||||||
|
const shows: any[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === "movie") {
|
||||||
|
const ids = this.buildIds(item);
|
||||||
|
movies.push({ ids, title: item.title, year: item.year });
|
||||||
|
} else if (item.type === "show" || item.type === "episode") {
|
||||||
|
const ids = {
|
||||||
|
tmdb: item.showTmdbId
|
||||||
|
? parseInt(item.showTmdbId, 10)
|
||||||
|
: item.tmdbId
|
||||||
|
? parseInt(item.tmdbId, 10)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
shows.push({
|
||||||
|
ids,
|
||||||
|
title: item.showTitle ?? item.title,
|
||||||
|
year: item.showYear ?? item.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload: any = {};
|
||||||
|
if (movies.length) payload.movies = movies;
|
||||||
|
if (shows.length) payload.shows = shows;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static groupToSlug(groupName: string): string {
|
||||||
|
return slugify(groupName, { lower: true, strict: true }) || "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrobble (report what we're watching to Trakt - shows in Trakt app)
|
||||||
|
public async startWatching(
|
||||||
|
item: TraktContentData,
|
||||||
|
progress: number,
|
||||||
|
): Promise<TraktScrobbleResponse> {
|
||||||
|
const payload = this.buildScrobblePayload(item, progress);
|
||||||
|
return this.queueRequest(() =>
|
||||||
|
this.apiRequest<TraktScrobbleResponse>(
|
||||||
|
"/scrobble/start",
|
||||||
|
"POST",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pauseWatching(
|
||||||
|
item: TraktContentData,
|
||||||
|
progress: number,
|
||||||
|
): Promise<TraktScrobbleResponse> {
|
||||||
|
const payload = this.buildScrobblePayload(item, progress);
|
||||||
|
return this.queueRequest(() =>
|
||||||
|
this.apiRequest<TraktScrobbleResponse>(
|
||||||
|
"/scrobble/pause",
|
||||||
|
"POST",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stopWatching(
|
||||||
|
item: TraktContentData,
|
||||||
|
progress: number,
|
||||||
|
): Promise<TraktScrobbleResponse> {
|
||||||
|
const payload = this.buildScrobblePayload(item, progress);
|
||||||
|
return this.queueRequest(() =>
|
||||||
|
this.apiRequest<TraktScrobbleResponse>("/scrobble/stop", "POST", payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget stop for page unload. Uses fetch keepalive so the request
|
||||||
|
* can complete even after the tab closes.
|
||||||
|
*/
|
||||||
|
public stopWatchingOnUnload(item: TraktContentData, progress: number): void {
|
||||||
|
const config = conf();
|
||||||
|
const { accessToken } = useTraktAuthStore.getState();
|
||||||
|
if (!accessToken || !config.TRAKT_CLIENT_ID) return;
|
||||||
|
|
||||||
|
const payload = this.buildScrobblePayload(item, progress);
|
||||||
|
const url = `${TRAKT_API_URL}/scrobble/stop`;
|
||||||
|
fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"trakt-api-version": "2",
|
||||||
|
"trakt-api-key": config.TRAKT_CLIENT_ID,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// History (Watched) - aggregated view
|
||||||
|
public async getHistory(): Promise<TraktWatchedItem[]> {
|
||||||
|
const limit = 100;
|
||||||
|
const fetchAll = async (endpoint: string) => {
|
||||||
|
let page = 1;
|
||||||
|
const items: TraktWatchedItem[] = [];
|
||||||
|
// eslint-disable-next-line no-constant-condition -- pagination loop
|
||||||
|
while (true) {
|
||||||
|
const results = await this.apiRequest<TraktWatchedItem[]>(
|
||||||
|
`${endpoint}?extended=full,images&page=${page}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
items.push(...results);
|
||||||
|
if (results.length < limit) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
const [movies, shows] = await Promise.all([
|
||||||
|
fetchAll("/sync/watched/movies"),
|
||||||
|
fetchAll("/sync/watched/shows"),
|
||||||
|
]);
|
||||||
|
return [...movies, ...shows];
|
||||||
|
}
|
||||||
|
|
||||||
|
// History (full list with episodes) - for importing into app
|
||||||
|
public async getHistoryItems(): Promise<TraktHistoryItem[]> {
|
||||||
|
const limit = 100;
|
||||||
|
const all: TraktHistoryItem[] = [];
|
||||||
|
for (const type of ["movies", "episodes"] as const) {
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const results = await this.apiRequest<TraktHistoryItem[]>(
|
||||||
|
`/sync/history/${type}?extended=full&page=${page}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
const arr = Array.isArray(results) ? results : [];
|
||||||
|
all.push(...arr);
|
||||||
|
if (arr.length < limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addToHistory(
|
||||||
|
item: TraktContentData,
|
||||||
|
watchedAt?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = this.buildSyncPayload(item);
|
||||||
|
if (watchedAt) {
|
||||||
|
if (payload.movies)
|
||||||
|
payload.movies.forEach((m: any) => {
|
||||||
|
m.watched_at = watchedAt;
|
||||||
|
});
|
||||||
|
if (payload.episodes)
|
||||||
|
payload.episodes.forEach((e: any) => {
|
||||||
|
e.watched_at = watchedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.apiRequest("/sync/history", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeFromHistory(item: TraktContentData): Promise<void> {
|
||||||
|
const payload = this.buildSyncPayload(item);
|
||||||
|
await this.apiRequest("/sync/history/remove", "POST", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
private buildSyncPayload(item: TraktContentData): any {
|
||||||
|
const ids = this.buildIds(item);
|
||||||
|
if (item.type === "movie") {
|
||||||
|
return { movies: [{ ...item, ids }] };
|
||||||
|
}
|
||||||
|
if (item.type === "show") {
|
||||||
|
return { shows: [{ ...item, ids }] };
|
||||||
|
}
|
||||||
|
if (item.type === "episode") {
|
||||||
|
return { episodes: [{ ids }] };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildScrobblePayload(item: TraktContentData, progress: number): any {
|
||||||
|
const ids = this.buildIds(item);
|
||||||
|
const progressFixed = Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, parseFloat(progress.toFixed(2))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.type === "movie") {
|
||||||
|
return {
|
||||||
|
movie: {
|
||||||
|
title: item.title,
|
||||||
|
year: item.year,
|
||||||
|
ids,
|
||||||
|
},
|
||||||
|
progress: progressFixed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "episode") {
|
||||||
|
return {
|
||||||
|
show: {
|
||||||
|
title: item.showTitle,
|
||||||
|
year: item.showYear,
|
||||||
|
ids: {
|
||||||
|
imdb: item.showImdbId,
|
||||||
|
tmdb: item.showTmdbId ? parseInt(item.showTmdbId, 10) : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
season: item.season,
|
||||||
|
number: item.episode,
|
||||||
|
},
|
||||||
|
progress: progressFixed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this -- part of payload builder chain
|
||||||
|
private buildIds(item: TraktContentData): any {
|
||||||
|
const ids: any = {};
|
||||||
|
if (item.imdbId) ids.imdb = item.imdbId;
|
||||||
|
if (item.tmdbId) ids.tmdb = parseInt(item.tmdbId, 10);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const traktService = TraktService.getInstance();
|
||||||
202
src/utils/traktTypes.ts
Normal file
202
src/utils/traktTypes.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
export interface TraktUser {
|
||||||
|
username: string;
|
||||||
|
name?: string;
|
||||||
|
private: boolean;
|
||||||
|
vip: boolean;
|
||||||
|
joined_at: string;
|
||||||
|
ids: { slug: string };
|
||||||
|
images?: {
|
||||||
|
avatar: {
|
||||||
|
full: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktImages {
|
||||||
|
poster?: {
|
||||||
|
full: string;
|
||||||
|
medium: string;
|
||||||
|
thumb: string;
|
||||||
|
};
|
||||||
|
fanart?: {
|
||||||
|
full: string;
|
||||||
|
medium: string;
|
||||||
|
thumb: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktHistoryItem {
|
||||||
|
id: number;
|
||||||
|
watched_at: string;
|
||||||
|
action: string;
|
||||||
|
type: "movie" | "episode";
|
||||||
|
movie?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; tmdb: number };
|
||||||
|
};
|
||||||
|
episode?: {
|
||||||
|
season: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
ids: { trakt: number; tmdb?: number };
|
||||||
|
};
|
||||||
|
show?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; tmdb: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktWatchedItem {
|
||||||
|
movie?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
runtime?: number;
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
show?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
runtime?: number;
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
plays: number;
|
||||||
|
last_watched_at: string;
|
||||||
|
last_updated_at?: string;
|
||||||
|
seasons?: {
|
||||||
|
number: number;
|
||||||
|
episodes: {
|
||||||
|
number: number;
|
||||||
|
plays: number;
|
||||||
|
last_watched_at: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktWatchlistItem {
|
||||||
|
movie?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
show?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
listed_at: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktPlaybackItem {
|
||||||
|
progress: number;
|
||||||
|
paused_at: string;
|
||||||
|
id: number;
|
||||||
|
type: "movie" | "episode";
|
||||||
|
movie?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
runtime?: number;
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
episode?: {
|
||||||
|
season: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
ids: { trakt: number; tvdb?: number; imdb?: string; tmdb?: number };
|
||||||
|
runtime?: number;
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
show?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
images?: TraktImages;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktScrobbleResponse {
|
||||||
|
id: number;
|
||||||
|
action: "start" | "pause" | "scrobble" | "conflict";
|
||||||
|
progress: number;
|
||||||
|
movie?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: { trakt: number; slug: string; imdb: string; tmdb: number };
|
||||||
|
};
|
||||||
|
episode?: {
|
||||||
|
season: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
ids: { trakt: number; tvdb?: number; imdb?: string; tmdb?: number };
|
||||||
|
};
|
||||||
|
show?: {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
ids: {
|
||||||
|
trakt: number;
|
||||||
|
slug: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb: string;
|
||||||
|
tmdb: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktContentData {
|
||||||
|
type: "movie" | "episode" | "show";
|
||||||
|
imdbId?: string;
|
||||||
|
tmdbId?: string;
|
||||||
|
title: string;
|
||||||
|
year?: number;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
showTitle?: string;
|
||||||
|
showYear?: number;
|
||||||
|
showImdbId?: string;
|
||||||
|
showTmdbId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktList {
|
||||||
|
name: string;
|
||||||
|
ids: { trakt: number; slug: string | null };
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktListItem {
|
||||||
|
type: "movie" | "show" | "season" | "episode";
|
||||||
|
movie?: { title: string; year: number; ids: { tmdb: number } };
|
||||||
|
show?: { title: string; year: number; ids: { tmdb: number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraktHistoryRemovePayload {
|
||||||
|
movies?: Array<{
|
||||||
|
ids: { trakt?: number; slug?: string; imdb?: string; tmdb?: number };
|
||||||
|
}>;
|
||||||
|
shows?: Array<{
|
||||||
|
ids: {
|
||||||
|
trakt?: number;
|
||||||
|
slug?: string;
|
||||||
|
tvdb?: number;
|
||||||
|
imdb?: string;
|
||||||
|
tmdb?: number;
|
||||||
|
};
|
||||||
|
seasons?: Array<{ number: number; episodes?: Array<{ number: number }> }>;
|
||||||
|
}>;
|
||||||
|
episodes?: Array<{
|
||||||
|
ids: { trakt?: number; tvdb?: number; imdb?: string; tmdb?: number };
|
||||||
|
}>;
|
||||||
|
ids?: number[];
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue