mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 09:45: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")
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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(
|
||||
imdbId: string,
|
||||
): 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 { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
||||
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 { detectRegion, useRegionStore } from "@/utils/detectRegion";
|
||||
|
||||
|
|
@ -252,6 +255,9 @@ root.render(
|
|||
<WatchHistorySyncer />
|
||||
<GroupSyncer />
|
||||
<SettingsSyncer />
|
||||
<TraktBookmarkSyncer />
|
||||
<TraktHistorySyncer />
|
||||
<TraktScrobbler />
|
||||
<TheRouter>
|
||||
<MigrationRunner />
|
||||
</TheRouter>
|
||||
|
|
|
|||
|
|
@ -96,8 +96,10 @@ export function RealPlayerView() {
|
|||
});
|
||||
useEffect(() => {
|
||||
reset();
|
||||
// Reset watch party state when media changes
|
||||
openedWatchPartyRef.current = false;
|
||||
return () => {
|
||||
reset();
|
||||
};
|
||||
}, [paramsData, reset]);
|
||||
|
||||
// Auto-open watch party menu if URL contains watchparty parameter
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { conf } from "@/setup/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useTraktStore } from "@/stores/trakt/store";
|
||||
|
||||
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(
|
||||
props: BackendEditProps &
|
||||
ProxyEditProps &
|
||||
|
|
@ -812,6 +873,7 @@ export function ConnectionsPart(
|
|||
mode="settings"
|
||||
/>
|
||||
<TIDBEdit tidbKey={props.tidbKey} setTIDBKey={props.setTIDBKey} />
|
||||
<TraktEdit />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommand
|
|||
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
|
||||
import { NotificationModal } from "@/components/overlays/notificationsModal";
|
||||
import { SupportInfoModal } from "@/components/overlays/SupportInfoModal";
|
||||
import { TraktAuthHandler } from "@/components/TraktAuthHandler";
|
||||
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
|
||||
import { useOnlineListener } from "@/hooks/usePing";
|
||||
import { AboutPage } from "@/pages/About";
|
||||
|
|
@ -126,6 +127,7 @@ function App() {
|
|||
|
||||
return (
|
||||
<Layout>
|
||||
<TraktAuthHandler />
|
||||
<LanguageProvider />
|
||||
<NotificationModal id="notifications" />
|
||||
<KeyboardCommandsModal id="keyboard-commands" />
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ interface Config {
|
|||
BANNER_MESSAGE: string;
|
||||
BANNER_ID: string;
|
||||
USE_TRAKT: boolean;
|
||||
TRAKT_CLIENT_ID: string;
|
||||
TRAKT_CLIENT_SECRET: string;
|
||||
TRAKT_REDIRECT_URI: string;
|
||||
HIDE_PROXY_ONBOARDING: boolean;
|
||||
SHOW_SUPPORT_BAR: boolean;
|
||||
SUPPORT_BAR_VALUE: string;
|
||||
|
|
@ -64,6 +67,9 @@ export interface RuntimeConfig {
|
|||
BANNER_MESSAGE: string | null;
|
||||
BANNER_ID: string | null;
|
||||
USE_TRAKT: boolean;
|
||||
TRAKT_CLIENT_ID: string | null;
|
||||
TRAKT_CLIENT_SECRET: string | null;
|
||||
TRAKT_REDIRECT_URI: string | null;
|
||||
HIDE_PROXY_ONBOARDING: boolean;
|
||||
SHOW_SUPPORT_BAR: boolean;
|
||||
SUPPORT_BAR_VALUE: string;
|
||||
|
|
@ -98,6 +104,9 @@ const env: Record<keyof Config, undefined | string> = {
|
|||
BANNER_MESSAGE: import.meta.env.VITE_BANNER_MESSAGE,
|
||||
BANNER_ID: import.meta.env.VITE_BANNER_ID,
|
||||
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,
|
||||
SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR,
|
||||
SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE,
|
||||
|
|
@ -192,6 +201,9 @@ export function conf(): RuntimeConfig {
|
|||
BANNER_MESSAGE: getKey("BANNER_MESSAGE"),
|
||||
BANNER_ID: getKey("BANNER_ID"),
|
||||
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",
|
||||
SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true",
|
||||
SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface BookmarkUpdateItem {
|
|||
export interface BookmarkStore {
|
||||
bookmarks: Record<string, BookmarkMediaItem>;
|
||||
updateQueue: BookmarkUpdateItem[];
|
||||
traktUpdateQueue: BookmarkUpdateItem[];
|
||||
addBookmark(meta: PlayerMeta): void;
|
||||
addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void;
|
||||
removeBookmark(id: string): void;
|
||||
|
|
@ -57,6 +58,8 @@ export interface BookmarkStore {
|
|||
clear(): void;
|
||||
clearUpdateQueue(): void;
|
||||
removeUpdateItem(id: string): void;
|
||||
clearTraktUpdateQueue(): void;
|
||||
removeTraktUpdateItem(id: string): void;
|
||||
}
|
||||
|
||||
let updateId = 0;
|
||||
|
|
@ -66,14 +69,22 @@ export const useBookmarkStore = create(
|
|||
immer<BookmarkStore>((set) => ({
|
||||
bookmarks: {},
|
||||
updateQueue: [],
|
||||
traktUpdateQueue: [],
|
||||
removeBookmark(id) {
|
||||
set((s) => {
|
||||
const existing = s.bookmarks[id];
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "delete",
|
||||
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];
|
||||
});
|
||||
|
|
@ -81,7 +92,7 @@ export const useBookmarkStore = create(
|
|||
addBookmark(meta) {
|
||||
set((s) => {
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: meta.tmdbId,
|
||||
|
|
@ -89,7 +100,9 @@ export const useBookmarkStore = create(
|
|||
title: meta.title,
|
||||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
});
|
||||
};
|
||||
s.updateQueue.push(item);
|
||||
s.traktUpdateQueue.push(item);
|
||||
|
||||
s.bookmarks[meta.tmdbId] = {
|
||||
type: meta.type,
|
||||
|
|
@ -103,7 +116,7 @@ export const useBookmarkStore = create(
|
|||
addBookmarkWithGroups(meta, groups) {
|
||||
set((s) => {
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: meta.tmdbId,
|
||||
|
|
@ -112,7 +125,9 @@ export const useBookmarkStore = create(
|
|||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
group: groups,
|
||||
});
|
||||
};
|
||||
s.updateQueue.push(item);
|
||||
s.traktUpdateQueue.push(item);
|
||||
|
||||
s.bookmarks[meta.tmdbId] = {
|
||||
type: meta.type,
|
||||
|
|
@ -144,6 +159,18 @@ export const useBookmarkStore = create(
|
|||
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(
|
||||
showId: string,
|
||||
episodeId: string,
|
||||
|
|
@ -181,7 +208,7 @@ export const useBookmarkStore = create(
|
|||
|
||||
// Add to update queue for syncing
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: showId,
|
||||
|
|
@ -190,7 +217,9 @@ export const useBookmarkStore = create(
|
|||
year: bookmark.year,
|
||||
poster: bookmark.poster,
|
||||
type: bookmark.type,
|
||||
});
|
||||
};
|
||||
s.updateQueue.push(item);
|
||||
s.traktUpdateQueue.push(item);
|
||||
});
|
||||
},
|
||||
isEpisodeFavorited(showId: string, episodeId: string): boolean {
|
||||
|
|
@ -225,7 +254,7 @@ export const useBookmarkStore = create(
|
|||
const bookmark = s.bookmarks[bookmarkId];
|
||||
if (bookmark) {
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: bookmarkId,
|
||||
|
|
@ -235,7 +264,9 @@ export const useBookmarkStore = create(
|
|||
type: bookmark.type,
|
||||
group: bookmark.group,
|
||||
favoriteEpisodes: bookmark.favoriteEpisodes,
|
||||
});
|
||||
};
|
||||
s.updateQueue.push(item);
|
||||
s.traktUpdateQueue.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -263,7 +294,7 @@ export const useBookmarkStore = create(
|
|||
const bookmark = s.bookmarks[bookmarkId];
|
||||
if (bookmark) {
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
const item: BookmarkUpdateItem = {
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: bookmarkId,
|
||||
|
|
@ -273,7 +304,9 @@ export const useBookmarkStore = create(
|
|||
type: bookmark.type,
|
||||
group: bookmark.group,
|
||||
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