mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-29 05:48:45 +00:00
Merge pull request #650 from chrisk325/special-id
fix: special id trakt scrobbling and mark as watched sync behavior
This commit is contained in:
commit
e615cea5b0
7 changed files with 413 additions and 182 deletions
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue