This commit is contained in:
Pas 2026-02-24 23:56:15 -07:00
parent 04618295fe
commit fad27b2572
15 changed files with 1671 additions and 13 deletions

View file

@ -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=

View file

@ -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> {

View 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;
}

View file

@ -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>

View file

@ -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

View file

@ -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>
);

View file

@ -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" />

View file

@ -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") ?? "",

View file

@ -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);
}
});
}

View 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;
}

View 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;
}

View 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
View 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
View 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
View 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[];
}