Merge pull request #650 from chrisk325/special-id

fix: special id trakt scrobbling and mark as watched sync behavior
This commit is contained in:
Nayif 2026-03-16 00:05:27 +05:30 committed by GitHub
commit e615cea5b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 413 additions and 182 deletions

View file

@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
}
} else {
if (item.type === 'movie') {
watchedService.unmarkMovieAsWatched(item.id);
watchedService.unmarkMovieAsWatched(item.id, item.imdb_id ?? undefined);
} else {
// Unmarking a series from the top level is tricky as we don't know the exact episodes.
// For safety and consistency with old behavior, we just clear the legacy flag.
@ -472,4 +472,4 @@ export default React.memo(ContentItem, (prev, next) => {
if (prev.item.id !== next.item.id) return false;
if (prev.item.poster !== next.item.poster) return false;
return true;
});
});

View file

@ -939,6 +939,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (finalTmdbId) setTmdbId(finalTmdbId);
}
// If the addon returned an imdb_id in its metadata (e.g. Kitsu addon), set it now.
// This ensures imdbId state is populated for Trakt scrobbling even without TMDB enrichment.
if (!imdbId && (finalMetadata as any).imdb_id) {
const resolvedImdb = (finalMetadata as any).imdb_id as string;
setImdbId(resolvedImdb);
// Also resolve tmdbId from the imdb_id if we still don't have it
if (!finalTmdbId) {
const foundTmdbId = await tmdbSvc.findTMDBIdByIMDB(resolvedImdb);
if (foundTmdbId) {
finalTmdbId = foundTmdbId;
setTmdbId(foundTmdbId);
setMetadata(prev => prev ? { ...prev, tmdbId: foundTmdbId } : null);
}
}
}
if (finalTmdbId) {
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
if (normalizedType === 'movie') {
@ -2230,20 +2246,48 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Fetch TMDB ID if needed and then recommendations
useEffect(() => {
const fetchTmdbIdAndRecommendations = async () => {
if (!settings.enrichMetadataWithTMDB) {
if (!metadata) return;
const isAnimeId = id.startsWith('kitsu:') || id.startsWith('mal:') || id.startsWith('anilist:');
// For anime IDs we always try to resolve tmdbId and imdbId regardless of enrichment setting,
// because they're needed for Trakt scrobbling even when TMDB enrichment is disabled.
if (!settings.enrichMetadataWithTMDB && !isAnimeId) {
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)');
return;
}
if (metadata && !tmdbId) {
if (!tmdbId) {
try {
const tmdbService = TMDBService.getInstance();
const fetchedTmdbId = await tmdbService.extractTMDBIdFromStremioId(id);
const tmdbSvc = TMDBService.getInstance();
const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id);
if (fetchedTmdbId) {
if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId });
setTmdbId(fetchedTmdbId);
// For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble
if (isAnimeId && !imdbId) {
try {
const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId);
if (externalIds?.imdb_id) {
if (__DEV__) console.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id });
setImdbId(externalIds.imdb_id);
}
} catch (e) {
if (__DEV__) console.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id });
}
}
if (!settings.enrichMetadataWithTMDB) {
// Enrichment is disabled but we still resolved tmdbId for Trakt scrobbling.
// Set it on the metadata object so the player can read it via metadata.tmdbId.
setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null);
return;
}
// Fetch certification only if granular setting is enabled
if (settings.tmdbEnrichCertification) {
const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId);
const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId);
if (certification) {
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
setMetadata(prev => prev ? {

View file

@ -53,9 +53,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Generate a unique session key for this content instance
useEffect(() => {
const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || '');
const contentKey = options.type === 'movie'
? `movie:${options.imdbId}`
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
? `movie:${resolvedId}`
: `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`;
// Reset all session state for new content
@ -109,11 +110,22 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return null;
}
if (!options.imdbId || options.imdbId.trim() === '') {
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId');
// Resolve the best available ID: prefer a proper IMDb ID, fall back to the Stremio content ID.
// This allows scrobbling for content with special IDs (e.g. "kitsu:123", "tmdb:456") where
// the IMDb ID hasn't been resolved yet — Trakt will match by title + season/episode instead.
const imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : '';
const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
const resolvedImdbId = imdbIdRaw || stremioIdRaw;
if (!resolvedImdbId) {
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId and id');
return null;
}
if (!imdbIdRaw && stremioIdRaw) {
logger.warn(`[TraktAutosync] imdbId is empty, falling back to stremio id "${stremioIdRaw}" — Trakt will match by title`);
}
const numericYear = parseYear(options.year);
const numericShowYear = parseYear(options.showYear);
@ -125,7 +137,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
if (options.type === 'movie') {
return {
type: 'movie',
imdbId: options.imdbId.trim(),
imdbId: resolvedImdbId,
title: options.title.trim(),
year: numericYear // Can be undefined now
};
@ -140,26 +152,34 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return null;
}
const resolvedShowImdbId = (options.showImdbId && options.showImdbId.trim())
? options.showImdbId.trim()
: resolvedImdbId;
return {
type: 'episode',
imdbId: options.imdbId.trim(),
imdbId: resolvedImdbId,
title: options.title.trim(),
year: numericYear,
season: options.season,
episode: options.episode,
showTitle: (options.showTitle || options.title).trim(),
showYear: numericShowYear || numericYear,
showImdbId: (options.showImdbId || options.imdbId).trim()
showImdbId: resolvedShowImdbId
};
}
}, [options]);
const buildSimklContentData = useCallback((): SimklContentData => {
// Use the same fallback logic: prefer imdbId, fall back to stremio id
const resolvedId = (options.imdbId && options.imdbId.trim())
? options.imdbId.trim()
: (options.id && options.id.trim()) ? options.id.trim() : '';
return {
type: options.type === 'series' ? 'episode' : 'movie',
title: options.title,
ids: {
imdb: options.imdbId
imdb: resolvedId
},
season: options.season,
episode: options.episode
@ -623,4 +643,4 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
handlePlaybackEnd,
resetState
};
}
}

View file

@ -1536,8 +1536,29 @@ class CatalogService {
const addonOrderRef: Record<string, number> = {};
searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
// Global dedupe across emitted results
const globalSeen = new Set<string>();
// Human-readable labels for known content types
const CATALOG_TYPE_LABELS: Record<string, string> = {
'movie': 'Movies',
'series': 'TV Shows',
'anime.series': 'Anime Series',
'anime.movie': 'Anime Movies',
'other': 'Other',
'tv': 'TV',
'channel': 'Channels',
};
const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']);
// Collect all sections from all addons first, then sort and dedup before emitting.
// This avoids race conditions where concurrent addon workers steal each other's IDs
// from a shared globalSeen set before they get a chance to emit.
type PendingSection = {
addonId: string;
addonName: string;
sectionName: string;
catalogIndex: number;
results: StreamingContent[];
};
const allPendingSections: PendingSection[] = [];
await Promise.all(
searchableAddons.map(async (addon) => {
@ -1552,47 +1573,24 @@ class CatalogService {
const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
// Fetch all catalogs for this addon in parallel
const settled = await Promise.allSettled(
searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery))
);
if (controller.cancelled) return;
// If addon has multiple search catalogs, emit each as its own section.
// If only one, emit as a single addon section (original behaviour).
const hasMultipleCatalogs = searchableCatalogs.length > 1;
const catalogResultsList: { catalog: any; results: StreamingContent[] }[] = [];
for (let i = 0; i < searchableCatalogs.length; i++) {
const s = settled[i];
if (s.status === 'fulfilled' && Array.isArray(s.value) && s.value.length > 0) {
catalogResultsList.push({ catalog: searchableCatalogs[i], results: s.value });
} else if (s.status === 'rejected') {
logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason);
}
}
if (catalogResultsList.length === 0) {
logger.log(`No results from ${addon.name}`);
return;
}
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
if (hasMultipleCatalogs) {
// Human-readable labels for known content types used as fallback section names
const CATALOG_TYPE_LABELS: Record<string, string> = {
'movie': 'Movies',
'series': 'TV Shows',
'anime.series': 'Anime Series',
'anime.movie': 'Anime Movies',
'other': 'Other',
'tv': 'TV',
'channel': 'Channels',
};
for (let ci = 0; ci < searchableCatalogs.length; ci++) {
const s = settled[ci];
const catalog = searchableCatalogs[ci];
if (s.status === 'rejected' || !(s as PromiseFulfilledResult<StreamingContent[]>).value?.length) {
if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason);
continue;
}
// Emit each catalog as its own section, in manifest order
for (let ci = 0; ci < catalogResultsList.length; ci++) {
const { catalog, results } = catalogResultsList[ci];
if (controller.cancelled) return;
const results = (s as PromiseFulfilledResult<StreamingContent[]>).value;
// Within-catalog dedup: prefer dot-type over generic for same ID
const bestById = new Map<string, StreamingContent>();
@ -1604,74 +1602,63 @@ class CatalogService {
}
// Stamp catalog type onto results
const stamped = Array.from(bestById.values()).map(item => {
if (catalog.type && item.type !== catalog.type) {
return { ...item, type: catalog.type };
}
return item;
});
const stamped = Array.from(bestById.values()).map(item =>
catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
);
// Dedupe against global seen
const unique = stamped.filter(item => {
const key = `${item.type}:${item.id}`;
if (globalSeen.has(key)) return false;
globalSeen.add(key);
return true;
});
// Build section name — use type label if catalog name is generic
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name)
? typeLabel
: catalog.name;
const sectionName = `${addon.name} - ${catalogLabel}`;
const catalogIndex = addonRank * 1000 + ci;
if (unique.length > 0 && !controller.cancelled) {
// Build section name:
// - If catalog.name is generic ("Search") or same as addon name, use type label instead
// - Otherwise use catalog.name as-is
const GENERIC_NAMES = new Set(['search', 'Search']);
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
const catalogLabel = (!catalog.name || GENERIC_NAMES.has(catalog.name) || catalog.name === addon.name)
? typeLabel
: catalog.name;
const sectionName = `${addon.name} - ${catalogLabel}`;
// catalogIndex encodes addon rank + position within addon for deterministic ordering
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
const catalogIndex = addonRank * 1000 + ci;
logger.log(`Emitting ${unique.length} results from ${sectionName}`);
onAddonResults({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: unique });
}
allPendingSections.push({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped });
}
} else {
// Single catalog — one section per addon
const allResults = catalogResultsList.flatMap(c => c.results);
const s = settled[0];
const catalog = searchableCatalogs[0];
if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult<StreamingContent[]>).value?.length) {
if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason);
return;
}
const bestByIdWithinAddon = new Map<string, StreamingContent>();
for (const item of allResults) {
const existing = bestByIdWithinAddon.get(item.id);
const results = (s as PromiseFulfilledResult<StreamingContent[]>).value;
const bestById = new Map<string, StreamingContent>();
for (const item of results) {
const existing = bestById.get(item.id);
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
bestByIdWithinAddon.set(item.id, item);
bestById.set(item.id, item);
}
}
const deduped = Array.from(bestByIdWithinAddon.values());
const stamped = Array.from(bestById.values()).map(item =>
catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
);
const localSeen = new Set<string>();
const unique = deduped.filter(item => {
const key = `${item.type}:${item.id}`;
if (localSeen.has(key) || globalSeen.has(key)) return false;
localSeen.add(key);
globalSeen.add(key);
return true;
});
if (unique.length > 0 && !controller.cancelled) {
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
onAddonResults({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: unique });
}
allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped });
}
} catch (e) {
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
}
})
);
if (controller.cancelled) return;
// Sort by catalogIndex (addon manifest order + position within addon) then emit.
// No cross-section dedup — each section is shown separately so duplicates across
// sections are intentional (e.g. same movie in Cinemeta and People Search).
allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex);
for (const section of allPendingSections) {
if (controller.cancelled) return;
if (section.results.length > 0) {
logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`);
onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results });
}
}
})();
return {

View file

@ -523,24 +523,58 @@ export class TMDBService {
}
/**
* Extract TMDB ID from Stremio ID
* Stremio IDs for series are typically in the format: tt1234567:1:1 (imdbId:season:episode)
* or just tt1234567 for the series itself
* Extract TMDB ID from Stremio ID.
* Handles standard IMDb IDs (tt1234567) as well as anime provider IDs:
* - kitsu:12345 looks up via ARM (arm.haglund.dev)
* - mal:12345 looks up via ARM
* - anilist:12345 looks up via ARM
*/
async extractTMDBIdFromStremioId(stremioId: string): Promise<number | null> {
try {
// Extract the base ID (remove season/episode info if present)
const baseId = stremioId.split(':')[0];
// Strip season/episode suffix — e.g. "kitsu:7936:5" → "kitsu:7936"
const parts = stremioId.split(':');
const prefix = parts[0];
const numericId = parts[1];
// Only try to convert if it's an IMDb ID (starts with 'tt')
if (!baseId.startsWith('tt')) {
// Standard IMDb ID
if (prefix.startsWith('tt') || /^\d{7,}$/.test(prefix)) {
const baseId = prefix.startsWith('tt') ? prefix : `tt${prefix}`;
return await this.findTMDBIdByIMDB(baseId);
}
// Anime provider IDs — resolve via ARM (https://arm.haglund.dev/api/v2)
const ARM_SOURCES: Record<string, string> = {
kitsu: 'kitsu',
mal: 'myanimelist',
anilist: 'anilist',
};
const armSource = ARM_SOURCES[prefix];
if (armSource && numericId && /^\d+$/.test(numericId)) {
const cacheKey = this.generateCacheKey('arm_tmdb', { source: armSource, id: numericId });
const cached = this.getCachedData<number>(cacheKey);
if (cached !== null) return cached;
logger.log(`[TMDB] Resolving TMDB ID for ${prefix}:${numericId} via ARM`);
const response = await axios.get('https://arm.haglund.dev/api/v2/ids', {
params: { source: armSource, id: numericId },
timeout: 8000,
});
const tmdbId: number | undefined = response.data?.themoviedb;
if (tmdbId) {
this.setCachedData(cacheKey, tmdbId);
logger.log(`[TMDB] ARM resolved ${prefix}:${numericId} → TMDB ${tmdbId}`);
return tmdbId;
}
logger.warn(`[TMDB] ARM did not return a TMDB ID for ${prefix}:${numericId}`);
return null;
}
// Use the existing findTMDBIdByIMDB function to get the TMDB ID
const tmdbId = await this.findTMDBIdByIMDB(baseId);
return tmdbId;
return null;
} catch (error) {
logger.warn('[TMDB] extractTMDBIdFromStremioId failed:', error);
return null;
}
}

View file

@ -1155,17 +1155,19 @@ export class TraktService {
}
public async isMovieWatchedAccurate(imdbId: string): Promise<boolean> {
public async isMovieWatchedAccurate(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
try {
const imdb = imdbId.startsWith('tt')
? imdbId
: `tt${imdbId}`;
const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`;
const imdb = normalise(imdbId);
const fallback = fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId.trim()
? normalise(fallbackImdbId)
: null;
const movies = await this.apiRequest<any[]>('/sync/watched/movies');
const moviesArray = Array.isArray(movies) ? movies : [];
return moviesArray.some(
(m: any) => m.movie?.ids?.imdb === imdb
(m: any) => m.movie?.ids?.imdb === imdb || (fallback && m.movie?.ids?.imdb === fallback)
);
} catch (err) {
logger.warn('[TraktService] Movie watched check failed', err);
@ -1176,14 +1178,20 @@ export class TraktService {
public async isEpisodeWatchedAccurate(
showImdbId: string,
season: number,
episode: number
episode: number,
fallbackImdbId?: string
): Promise<boolean> {
try {
if (season === 0) return false;
const imdb = showImdbId.startsWith('tt')
? showImdbId
: `tt${showImdbId}`;
const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`;
const isRealImdbId = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
// Use fallback if primary isn't a real IMDb ID
const resolvedId = isRealImdbId(showImdbId) ? showImdbId
: (fallbackImdbId && isRealImdbId(fallbackImdbId) ? fallbackImdbId : showImdbId);
const imdb = normalise(resolvedId);
const watchedShows = await this.apiRequest<any[]>(
'/sync/watched/shows'
@ -1521,16 +1529,25 @@ export class TraktService {
imdbId: string,
season: number,
episode: number,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
fallbackImdbId?: string
): Promise<boolean> {
try {
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
const resolvedId = isImdbFormat(imdbId) ? imdbId
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
if (resolvedId !== imdbId) {
logger.log(`[TraktService] addToWatchedEpisodes: falling back from "${imdbId}" to "${resolvedId}"`);
}
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
return false;
}
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`);
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${resolvedId} (trakt: ${traktId})`);
// Use shows array with seasons/episodes structure per Trakt API docs
await this.apiRequest('/sync/history', 'POST', {
@ -1570,16 +1587,22 @@ export class TraktService {
public async markSeasonAsWatched(
imdbId: string,
season: number,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
fallbackImdbId?: string
): Promise<boolean> {
try {
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
const resolvedId = isImdbFormat(imdbId) ? imdbId
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
if (resolvedId !== imdbId) logger.log(`[TraktService] markSeasonAsWatched: falling back from "${imdbId}" to "${resolvedId}"`);
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
return false;
}
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`);
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${resolvedId} (trakt: ${traktId})`);
// Mark entire season - Trakt will mark all episodes in the season
await this.apiRequest('/sync/history', 'POST', {
@ -1614,7 +1637,8 @@ export class TraktService {
public async markEpisodesAsWatched(
imdbId: string,
episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
fallbackImdbId?: string
): Promise<boolean> {
try {
if (episodes.length === 0) {
@ -1622,13 +1646,18 @@ export class TraktService {
return false;
}
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
const resolvedId = isImdbFormat(imdbId) ? imdbId
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
if (resolvedId !== imdbId) logger.log(`[TraktService] markEpisodesAsWatched: falling back from "${imdbId}" to "${resolvedId}"`);
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
return false;
}
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`);
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${resolvedId}`);
// Group episodes by season for the API call
const seasonMap = new Map<number, Array<{ number: number; watched_at: string }>>();
@ -1709,12 +1738,18 @@ export class TraktService {
*/
public async removeSeasonFromHistory(
imdbId: string,
season: number
season: number,
fallbackImdbId?: string
): Promise<boolean> {
try {
logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`);
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
const resolvedId = (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId))
? fallbackImdbId
: imdbId;
const fullImdbId = resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`;
const payload: TraktHistoryRemovePayload = {
shows: [
@ -1735,6 +1770,19 @@ export class TraktService {
const result = await this.removeFromHistory(payload);
if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
logger.log(`[TraktService] removeSeasonFromHistory: retrying with fallback ID "${fallbackImdbId}"`);
const fb = fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}`;
const fallbackResult = await this.removeFromHistory({
shows: [{ ids: { imdb: fb }, seasons: [{ number: season }] }]
});
if (fallbackResult) {
logger.log(`[TraktService] Season removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`);
return fallbackResult.deleted.episodes > 0;
}
return false;
}
if (result) {
const success = result.deleted.episodes > 0;
logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
@ -1902,7 +1950,11 @@ export class TraktService {
}
/**
* Validate content data before making API calls
* Validate content data before making API calls.
*
* IMDb ID validation is intentionally lenient: a non-IMDb provider ID (e.g. "kitsu:123")
* is allowed through with a warning. Trakt can still scrobble via title + season/episode.
* A truly empty ID is still blocked.
*/
private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
@ -1915,8 +1967,11 @@ export class TraktService {
errors.push('Missing or empty title');
}
// Block only truly empty IDs — non-IMDb provider IDs are allowed (warn, don't fail)
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
errors.push('Missing or empty IMDb ID');
} else if (!/^tt\d+$/.test(contentData.imdbId) && !/^\d{7,}$/.test(contentData.imdbId)) {
logger.warn(`[TraktService] imdbId "${contentData.imdbId}" is not a standard IMDb ID — Trakt will match by title/season/episode`);
}
if (contentData.type === 'episode') {
@ -1929,8 +1984,11 @@ export class TraktService {
if (!contentData.showTitle || contentData.showTitle.trim() === '') {
errors.push('Missing or empty show title');
}
if (!contentData.showYear || contentData.showYear < 1900) {
errors.push('Invalid show year');
// showYear is intentionally not required — Trakt can match episodes by
// show title + season + episode number alone. Anime and many non-Western
// shows often have year=0 or missing; blocking scrobble for them is wrong.
if (contentData.showYear !== undefined && contentData.showYear > 0 && contentData.showYear < 1900) {
logger.warn(`[TraktService] showYear ${contentData.showYear} looks invalid, omitting from payload`);
}
}
@ -1991,19 +2049,25 @@ export class TraktService {
return null;
}
// Ensure IMDb ID includes the 'tt' prefix for Trakt scrobble payloads
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
? contentData.imdbId
: `tt${contentData.imdbId}`;
const isRealImdbId = /^tt\d+$/.test(contentData.imdbId) || /^\d{7,}$/.test(contentData.imdbId);
// Build movie payload - only include year if valid
const movieData: { title: string; year?: number; ids: { imdb: string } } = {
const movieData: { title: string; year?: number; ids: { imdb?: string } } = {
title: contentData.title.trim(),
ids: {
imdb: imdbIdWithPrefix
}
ids: {}
};
// Only add IMDb ID to payload when it's a real IMDb format — sending a provider ID
// (e.g. "kitsu:123") causes Trakt to fail the lookup. Without it, Trakt matches by title.
if (isRealImdbId) {
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
? contentData.imdbId
: `tt${contentData.imdbId}`;
(movieData.ids as any).imdb = imdbIdWithPrefix;
} else {
logger.warn(`[TraktService] Movie imdbId "${contentData.imdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`);
}
// Only add year if it's valid (prevents year: 0 or invalid years)
if (isValidYear(contentData.year)) {
movieData.year = contentData.year;
@ -2067,12 +2131,17 @@ export class TraktService {
progress: clampedProgress
};
// Add show IMDB ID if available
// Add show IMDB ID if available and valid IMDb format
if (contentData.showImdbId && contentData.showImdbId.trim() !== '') {
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
? contentData.showImdbId
: `tt${contentData.showImdbId}`;
payload.show.ids.imdb = showImdbWithPrefix;
const isRealShowImdbId = /^tt\d+$/.test(contentData.showImdbId) || /^\d{7,}$/.test(contentData.showImdbId);
if (isRealShowImdbId) {
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
? contentData.showImdbId
: `tt${contentData.showImdbId}`;
payload.show.ids.imdb = showImdbWithPrefix;
} else {
logger.warn(`[TraktService] showImdbId "${contentData.showImdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`);
}
}
// Add episode IMDB ID if available (for specific episode IDs)
@ -2679,21 +2748,43 @@ export class TraktService {
}
/**
* Remove a movie from watched history by IMDB ID
* Remove a movie from watched history by IMDB ID.
* If the primary imdbId is not a valid IMDb ID (e.g. a provider ID like "kitsu:123"),
* falls back to fallbackImdbId (typically the resolved IMDb ID from metadata).
*/
public async removeMovieFromHistory(imdbId: string): Promise<boolean> {
public async removeMovieFromHistory(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
try {
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
// Resolve which ID to use: prefer a proper IMDb-format ID
let resolvedId = imdbId;
if (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId) && fallbackImdbId !== imdbId) {
logger.log(`[TraktService] removeMovieFromHistory: "${imdbId}" is not IMDb format, falling back to "${fallbackImdbId}"`);
resolvedId = fallbackImdbId;
}
const payload: TraktHistoryRemovePayload = {
movies: [
{
ids: {
imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`
imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`
}
}
]
};
const result = await this.removeFromHistory(payload);
// If primary attempt deleted nothing and we haven't tried the fallback yet, try it
if ((result === null || result.deleted.movies === 0) && fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
logger.log(`[TraktService] removeMovieFromHistory: primary attempt found nothing, retrying with fallback ID "${fallbackImdbId}"`);
const fallbackPayload: TraktHistoryRemovePayload = {
movies: [{ ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` } }]
};
const fallbackResult = await this.removeFromHistory(fallbackPayload);
return fallbackResult !== null && fallbackResult.deleted.movies > 0;
}
return result !== null && result.deleted.movies > 0;
} catch (error) {
logger.error('[TraktService] Failed to remove movie from history:', error);
@ -2704,14 +2795,24 @@ export class TraktService {
/**
* Remove an episode from watched history by IMDB IDs
*/
public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number): Promise<boolean> {
public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number, fallbackImdbId?: string): Promise<boolean> {
try {
logger.log(`🔍 [TraktService] removeEpisodeFromHistory called for ${showImdbId} S${season}E${episode}`);
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
const resolvedId = (!isImdbFormat(showImdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId))
? fallbackImdbId
: showImdbId;
if (resolvedId !== showImdbId) {
logger.log(`[TraktService] removeEpisodeFromHistory: "${showImdbId}" is not IMDb format, falling back to "${resolvedId}"`);
}
const payload: TraktHistoryRemovePayload = {
shows: [
{
ids: {
imdb: showImdbId.startsWith('tt') ? showImdbId : `tt${showImdbId}`
imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`
},
seasons: [
{
@ -2731,6 +2832,23 @@ export class TraktService {
const result = await this.removeFromHistory(payload);
// If nothing was deleted and we haven't tried the fallback yet, retry with it
if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
logger.log(`[TraktService] removeEpisodeFromHistory: retrying with fallback ID "${fallbackImdbId}"`);
const fallbackPayload: TraktHistoryRemovePayload = {
shows: [{
ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` },
seasons: [{ number: season, episodes: [{ number: episode }] }]
}]
};
const fallbackResult = await this.removeFromHistory(fallbackPayload);
if (fallbackResult) {
logger.log(`✅ [TraktService] Episode removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`);
return fallbackResult.deleted.episodes > 0;
}
return false;
}
if (result) {
const success = result.deleted.episodes > 0;
logger.log(`✅ [TraktService] Episode removal success: ${success} (${result.deleted.episodes} episodes deleted)`);

View file

@ -316,12 +316,15 @@ class WatchedService {
let syncedToTrakt = false;
// Sync to Trakt
// showId is the Stremio content ID — pass it as fallback so Trakt can resolve
// anime/provider IDs (e.g. kitsu:123) that aren't valid IMDb IDs
if (isTraktAuth) {
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
showImdbId,
season,
episode,
watchedAt
watchedAt,
showId !== showImdbId ? showId : undefined
);
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
}
@ -445,7 +448,8 @@ class WatchedService {
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
showImdbId,
episodes,
watchedAt
watchedAt,
showId !== showImdbId ? showId : undefined
);
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
}
@ -523,7 +527,8 @@ class WatchedService {
syncedToTrakt = await this.traktService.markSeasonAsWatched(
showImdbId,
season,
watchedAt
watchedAt,
showId !== showImdbId ? showId : undefined
);
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
}
@ -570,32 +575,40 @@ class WatchedService {
}
/**
* Unmark a movie as watched (remove from history)
* Unmark a movie as watched (remove from history).
* @param imdbId - The primary content ID (may be a provider ID like "kitsu:123")
* @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format)
*/
public async unmarkMovieAsWatched(
imdbId: string
imdbId: string,
fallbackImdbId?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}${fallbackImdbId && fallbackImdbId !== imdbId ? ` (fallback: ${fallbackImdbId})` : ''}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId, fallbackImdbId);
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
}
// Simkl Unmark
// Simkl Unmark — try both IDs
const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) {
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] });
const simklId = (fallbackImdbId && fallbackImdbId !== imdbId) ? fallbackImdbId : imdbId;
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: simklId } }] });
logger.log(`[WatchedService] Simkl remove request sent for movie`);
}
// Remove local progress
// Remove local progress — clear both IDs to be safe
await storageService.removeWatchProgress(imdbId, 'movie');
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
if (fallbackImdbId && fallbackImdbId !== imdbId) {
await storageService.removeWatchProgress(fallbackImdbId, 'movie');
await mmkvStorage.removeItem(`watched:movie:${fallbackImdbId}`);
}
await this.removeLocalWatchedItems([
{ content_id: imdbId, season: null, episode: null },
]);
@ -622,21 +635,25 @@ class WatchedService {
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
const fallback = showId !== showImdbId ? showId : undefined;
if (isTraktAuth) {
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
showImdbId,
season,
episode
episode,
fallback
);
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
}
// Simkl Unmark
// Simkl Unmark — use best available ID
const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) {
const simklId = showImdbId || showId;
await this.simklService.removeFromHistory({
shows: [{
ids: { imdb: showImdbId },
ids: { imdb: simklId },
seasons: [{
number: season,
episodes: [{ number: episode }]
@ -679,26 +696,27 @@ class WatchedService {
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
const fallback = showId !== showImdbId ? showId : undefined;
if (isTraktAuth) {
// Remove entire season from Trakt
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
showImdbId,
season
season,
fallback
);
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
}
// Sync to Simkl
// Sync to Simkl — use best available ID
const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) {
const simklId = showImdbId || showId;
const episodes = episodeNumbers.map(num => ({ number: num }));
await this.simklService.removeFromHistory({
shows: [{
ids: { imdb: showImdbId },
seasons: [{
number: season,
episodes: episodes
}]
ids: { imdb: simklId },
seasons: [{ number: season, episodes: episodes }]
}]
});
logger.log(`[WatchedService] Simkl season removal request sent`);
@ -728,18 +746,26 @@ class WatchedService {
/**
* Check if a movie is marked as watched (locally)
*/
public async isMovieWatched(imdbId: string): Promise<boolean> {
public async isMovieWatched(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
try {
const isAuthed = await this.traktService.isAuthenticated();
if (isAuthed) {
const traktWatched =
await this.traktService.isMovieWatchedAccurate(imdbId);
await this.traktService.isMovieWatchedAccurate(imdbId, fallbackImdbId);
if (traktWatched) return true;
}
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
return local === 'true';
if (local === 'true') return true;
// Also check under fallback ID locally
if (fallbackImdbId && fallbackImdbId !== imdbId) {
const localFallback = await mmkvStorage.getItem(`watched:movie:${fallbackImdbId}`);
if (localFallback === 'true') return true;
}
return false;
} catch {
return false;
}
@ -752,7 +778,8 @@ class WatchedService {
public async isEpisodeWatched(
showId: string,
season: number,
episode: number
episode: number,
fallbackImdbId?: string
): Promise<boolean> {
try {
const isAuthed = await this.traktService.isAuthenticated();
@ -762,7 +789,8 @@ class WatchedService {
await this.traktService.isEpisodeWatchedAccurate(
showId,
season,
episode
episode,
fallbackImdbId
);
if (traktWatched) return true;
}