Merge branch 'main' into localization-patch

This commit is contained in:
albyalex96 2026-03-19 18:51:30 +01:00 committed by GitHub
commit 56f96e5d46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 410 additions and 68 deletions

View file

@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
} }
} else { } else {
if (item.type === 'movie') { if (item.type === 'movie') {
watchedService.unmarkMovieAsWatched(item.id, item.imdb_id ?? undefined); watchedService.unmarkMovieAsWatched(item.id, undefined, undefined, item.name, item.imdb_id ?? undefined);
} else { } else {
// Unmarking a series from the top level is tricky as we don't know the exact episodes. // 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. // For safety and consistency with old behavior, we just clear the legacy flag.

View file

@ -91,8 +91,31 @@ export async function mergeTraktContinueWatching({
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const sortedPlaybackItems = [...playbackItems] const sortedPlaybackItems = [...playbackItems]
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()) .sort((a, b) => {
.slice(0, 30); const getBaseTime = (item: any) =>
new Date(
item.paused_at ||
item.updated_at ||
item.last_watched_at ||
0
).getTime();
const getPriorityTime = (item: any) => {
const base = getBaseTime(item);
// NEW EPISODE PRIORITY BOOST
if (item.episode && (item.progress ?? 0) < 1) {
const aired = new Date(item.episode.first_aired || 0).getTime();
const daysSinceAired = (Date.now() - aired) / (1000 * 60 * 60 * 24);
if (daysSinceAired >= 0 && daysSinceAired < 60) {
return base + 1000000000; // boost to top on aired ep
}
}
return base;
};
return getPriorityTime(b) - getPriorityTime(a);
})
.slice(0, 30);
for (const item of sortedPlaybackItems) { for (const item of sortedPlaybackItems) {
try { try {

View file

@ -650,12 +650,30 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
// 3. Background Async Operation // 3. Background Async Operation
const showImdbId = imdbId || metadata.id; const showImdbId = imdbId || metadata.id;
const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
// Calculate dayIndex for same-day releases
let dayIndex = 0;
if (episode.air_date) {
const sameDayEpisodes = episodes
.filter(ep => ep.air_date === episode.air_date)
.sort((a, b) => a.episode_number - b.episode_number);
dayIndex = sameDayEpisodes.findIndex(ep => ep.episode_number === episode.episode_number);
if (dayIndex < 0) dayIndex = 0;
}
try { try {
const result = await watchedService.unmarkEpisodeAsWatched( const result = await watchedService.unmarkEpisodeAsWatched(
showImdbId, showImdbId || '',
metadata.id, metadata.id || '',
episode.season_number, episode.season_number,
episode.episode_number episode.episode_number,
episode.air_date,
metadata?.name,
malId,
dayIndex,
tmdbId
); );
loadEpisodesProgress(); // Sync with source of truth loadEpisodesProgress(); // Sync with source of truth
@ -768,12 +786,23 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
// 3. Background Async Operation // 3. Background Async Operation
const showImdbId = imdbId || metadata.id; const showImdbId = imdbId || metadata.id;
const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
const lastEp = Math.max(...episodeNumbers);
const lastEpisodeData = seasonEpisodes.find(e => e.episode_number === lastEp);
try { try {
const result = await watchedService.unmarkSeasonAsWatched( const result = await watchedService.unmarkSeasonAsWatched(
showImdbId, showImdbId || '',
metadata.id, metadata.id || '',
currentSeason, currentSeason,
episodeNumbers episodeNumbers,
lastEpisodeData?.air_date,
metadata?.name,
malId,
0, // dayIndex (assuming 0 for season batch unmarking)
tmdbId
); );
// Re-sync // Re-sync

View file

@ -271,6 +271,8 @@ const AndroidVideoPlayer: React.FC = () => {
const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId);
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
const { segments: skipIntervals, outroSegment } = useSkipSegments({ const { segments: skipIntervals, outroSegment } = useSkipSegments({
imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined),
type, type,
@ -278,6 +280,7 @@ const AndroidVideoPlayer: React.FC = () => {
episode, episode,
malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id,
kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined,
tmdbId: currentTmdbId,
enabled: settings.skipIntroEnabled enabled: settings.skipIntroEnabled
}); });

View file

@ -214,6 +214,8 @@ const KSPlayerCore: React.FC = () => {
episodeId episodeId
}); });
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
const { segments: skipIntervals, outroSegment } = useSkipSegments({ const { segments: skipIntervals, outroSegment } = useSkipSegments({
imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), imdbId: imdbId || (id?.startsWith('tt') ? id : undefined),
type, type,
@ -221,6 +223,7 @@ const KSPlayerCore: React.FC = () => {
episode, episode,
malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id,
kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined,
tmdbId: currentTmdbId,
enabled: settings.skipIntroEnabled enabled: settings.skipIntroEnabled
}); });
@ -243,7 +246,6 @@ const KSPlayerCore: React.FC = () => {
}); });
const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
// Calculate dayIndex for same-day releases // Calculate dayIndex for same-day releases
const currentDayIndex = useMemo(() => { const currentDayIndex = useMemo(() => {

View file

@ -10,6 +10,7 @@ interface UseSkipSegmentsProps {
malId?: string; malId?: string;
kitsuId?: string; kitsuId?: string;
releaseDate?: string; releaseDate?: string;
tmdbId?: number;
enabled: boolean; enabled: boolean;
} }
@ -21,6 +22,7 @@ export const useSkipSegments = ({
malId, malId,
kitsuId, kitsuId,
releaseDate, releaseDate,
tmdbId,
enabled enabled
}: UseSkipSegmentsProps) => { }: UseSkipSegmentsProps) => {
const [segments, setSegments] = useState<SkipInterval[]>([]); const [segments, setSegments] = useState<SkipInterval[]>([]);
@ -29,9 +31,9 @@ export const useSkipSegments = ({
const lastKeyRef = useRef(''); const lastKeyRef = useRef('');
useEffect(() => { useEffect(() => {
const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`; const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}-${tmdbId}`;
if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) {
setSegments([]); setSegments([]);
setIsLoading(false); setIsLoading(false);
fetchedRef.current = false; fetchedRef.current = false;
@ -55,7 +57,7 @@ export const useSkipSegments = ({
const fetchSegments = async () => { const fetchSegments = async () => {
try { try {
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate, tmdbId);
// Ignore stale responses from old requests. // Ignore stale responses from old requests.
if (cancelled || lastKeyRef.current !== key) return; if (cancelled || lastKeyRef.current !== key) return;
@ -78,7 +80,7 @@ export const useSkipSegments = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]); }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, tmdbId, enabled]);
const getActiveSegment = (currentTime: number) => { const getActiveSegment = (currentTime: number) => {
return segments.find( return segments.find(

View file

@ -722,8 +722,45 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} }
} }
// If normalizedType is not a known type (e.g. "other" from Gemini/AI search),
// resolve the correct type via TMDB before fetching addon metadata.
let effectiveType = normalizedType;
if (normalizedType !== 'movie' && normalizedType !== 'series') {
try {
if (actualId.startsWith('tt')) {
// Use TMDB /find endpoint which returns tv_results + movie_results simultaneously
// — gives definitive type in one call with no sequential guessing
const tmdbSvc = TMDBService.getInstance();
const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId);
if (resolved) {
effectiveType = resolved.type;
setTmdbId(resolved.tmdbId);
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB /find`);
}
} else if (actualId.startsWith('tmdb:')) {
// For tmdb: IDs try both in parallel, prefer series
const tmdbSvc = TMDBService.getInstance();
const tmdbRaw = parseInt(actualId.split(':')[1]);
if (!isNaN(tmdbRaw)) {
const [movieResult, seriesResult] = await Promise.allSettled([
tmdbSvc.getMovieDetails(String(tmdbRaw)).catch(() => null),
tmdbSvc.getTVShowDetails(tmdbRaw).catch(() => null),
]);
const hasMovie = movieResult.status === 'fulfilled' && !!movieResult.value;
const hasSeries = seriesResult.status === 'fulfilled' && !!seriesResult.value;
// Prefer series when both exist (anime/TV tagged as "other" is usually a series)
if (hasSeries) effectiveType = 'series';
else if (hasMovie) effectiveType = 'movie';
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB parallel check`);
}
}
} catch (e) {
if (__DEV__) console.log('🔍 [useMetadata] Failed to resolve type via TMDB, using fallback:', e);
}
}
// Load all data in parallel // Load all data in parallel
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId }); if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type: effectiveType, actualId, addonId });
let contentResult: any = null; let contentResult: any = null;
let lastError = null; let lastError = null;
@ -758,7 +795,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
for (const addon of externalMetaAddons) { for (const addon of externalMetaAddons) {
try { try {
const result = await withTimeout( const result = await withTimeout(
stremioService.getMetaDetails(normalizedType, actualId, addon.id), stremioService.getMetaDetails(effectiveType, actualId, addon.id),
API_TIMEOUT API_TIMEOUT
); );
@ -775,7 +812,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// If no external addon worked, fall back to catalog addon // If no external addon worked, fall back to catalog addon
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId),
API_TIMEOUT API_TIMEOUT
); );
if (actualId.startsWith('tt')) { if (actualId.startsWith('tt')) {
@ -804,7 +841,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Load content with timeout and retry // Load content with timeout and retry
withRetry(async () => { withRetry(async () => {
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId),
API_TIMEOUT API_TIMEOUT
); );
// Store the actual ID used (could be IMDB) // Store the actual ID used (could be IMDB)
@ -841,7 +878,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [content, castData] = await Promise.allSettled([ const [content, castData] = await Promise.allSettled([
withRetry(async () => { withRetry(async () => {
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId), catalogService.getEnhancedContentDetails(effectiveType, stremioId, addonId),
API_TIMEOUT API_TIMEOUT
); );
if (stremioId.startsWith('tt')) { if (stremioId.startsWith('tt')) {
@ -925,6 +962,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
name: localized.title || finalMetadata.name, name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description, description: localized.overview || finalMetadata.description,
movieDetails: movieDetailsObj, movieDetails: movieDetailsObj,
tmdbId: finalTmdbId,
...(productionInfo.length > 0 && { networks: productionInfo }), ...(productionInfo.length > 0 && { networks: productionInfo }),
}; };
} }
@ -962,6 +1000,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
name: localized.name || finalMetadata.name, name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description, description: localized.overview || finalMetadata.description,
tvDetails, tvDetails,
tmdbId: finalTmdbId,
...(productionInfo.length > 0 && { networks: productionInfo }), ...(productionInfo.length > 0 && { networks: productionInfo }),
}; };
} }

View file

@ -680,8 +680,9 @@ const MDBListSettingsScreen: React.FC = () => {
/> />
</View> </View>
</View> </View>
{isMdbListEnabled &&
<View style={[styles.card, !isMdbListEnabled && styles. disabledCard]}> <>
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
<Text style={[styles.sectionTitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}> <Text style={[styles.sectionTitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{t('mdblist.api_section')} {t('mdblist.api_section')}
</Text> </Text>
@ -865,7 +866,7 @@ const MDBListSettingsScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</>}
</ScrollView> </ScrollView>
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}

View file

@ -38,11 +38,8 @@ export async function getContentDetails(
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
try { try {
const isValidId = await stremioService.isValidContentId(type, id); // isValidContentId gate removed — getMetaDetails uses addonCanServeId()
// for per-addon prefix matching, avoiding false negatives for custom ID types.
if (!isValidId) {
break;
}
meta = await stremioService.getMetaDetails(type, id, preferredAddonId); meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) { if (meta) {
@ -102,10 +99,8 @@ export async function getBasicContentDetails(
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
try { try {
if (!(await stremioService.isValidContentId(type, id))) { // isValidContentId gate removed — getMetaDetails uses addonCanServeId()
break; // for per-addon prefix matching, avoiding false negatives for custom ID types.
}
meta = await stremioService.getMetaDetails(type, id, preferredAddonId); meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) { if (meta) {
break; break;

View file

@ -321,13 +321,9 @@ async function searchAddonCatalog(
const items = metas.map(meta => { const items = metas.map(meta => {
const content = convertMetaToStreamingContent(meta, library); const content = convertMetaToStreamingContent(meta, library);
const addonSupportsMeta = Array.isArray(manifest.resources) && manifest.resources.some((resource: any) => // Do NOT set addonId from search results — let getMetaDetails resolve the correct
resource === 'meta' || (typeof resource === 'object' && resource?.name === 'meta') // meta addon by ID prefix matching. Setting it here causes 404s when two addons
); // are installed and one returns IDs the other can't serve metadata for.
if (addonSupportsMeta) {
content.addonId = manifest.id;
}
const normalizedCatalogType = type ? type.toLowerCase() : type; const normalizedCatalogType = type ? type.toLowerCase() : type;
if (normalizedCatalogType && content.type !== normalizedCatalogType) { if (normalizedCatalogType && content.type !== normalizedCatalogType) {

View file

@ -306,7 +306,8 @@ export async function getSkipTimes(
episode: number, episode: number,
malId?: string, malId?: string,
kitsuId?: string, kitsuId?: string,
releaseDate?: string releaseDate?: string,
tmdbId?: number
): Promise<SkipInterval[]> { ): Promise<SkipInterval[]> {
// 1. Try IntroDB (TV Shows) first // 1. Try IntroDB (TV Shows) first
if (imdbId) { if (imdbId) {
@ -320,7 +321,21 @@ export async function getSkipTimes(
let finalMalId = malId; let finalMalId = malId;
let finalEpisode = episode; let finalEpisode = episode;
// If we have IMDb ID and Release Date, try ArmSyncService to resolve exact MAL ID and Episode // Priority 1: TMDB-based Resolution (Highest Accuracy)
if (!finalMalId && tmdbId && releaseDate) {
try {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate);
if (tmdbResult) {
finalMalId = tmdbResult.malId.toString();
finalEpisode = tmdbResult.episode;
logger.log(`[IntroService] TMDB resolved: MAL ${finalMalId} Ep ${finalEpisode}`);
}
} catch (e) {
logger.warn('[IntroService] TMDB resolve failed', e);
}
}
// Priority 2: IMDb-based ARM Sync (Fallback)
if (!finalMalId && imdbId && releaseDate) { if (!finalMalId && imdbId && releaseDate) {
try { try {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate);

View file

@ -282,6 +282,71 @@ export const MalSync = {
} }
}, },
unscrobbleEpisode: async (
animeTitle: string,
episodeNumber: number,
type: 'movie' | 'series' = 'series',
season?: number,
imdbId?: string,
releaseDate?: string,
providedMalId?: number,
dayIndex?: number,
tmdbId?: number
) => {
try {
if (!MalAuth.isAuthenticated()) return;
let malId: number | null = providedMalId || null;
let finalEpisodeNumber = episodeNumber;
// Resolve ID using same strategies as scrobbling
if (!malId && tmdbId && releaseDate) {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
malId = tmdbResult.malId;
finalEpisodeNumber = tmdbResult.episode;
}
}
if (!malId && imdbId && type === 'series' && releaseDate) {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex);
if (armResult) {
malId = armResult.malId;
finalEpisodeNumber = armResult.episode;
}
}
if (!malId) {
malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId);
}
if (!malId) return;
// Get current count
const currentInfo = await MalApiService.getMyListStatus(malId);
if (!currentInfo.my_list_status) return;
// Decrement logic: Only if the episode we are unmarking is the LAST one watched or current
const currentlyWatched = currentInfo.my_list_status.num_episodes_watched;
if (finalEpisodeNumber === currentlyWatched) {
const newCount = Math.max(0, finalEpisodeNumber - 1);
let newStatus = currentInfo.my_list_status.status;
// If we unmark everything, maybe move back to 'plan_to_watch' or keep 'watching'
if (newCount === 0 && newStatus === 'watching') {
// Optional: Move back to plan to watch if desired
} else if (newStatus === 'completed') {
newStatus = 'watching';
}
await MalApiService.updateStatus(malId, newStatus, newCount);
console.log(`[MalSync] Unscrobbled MAL ID ${malId} to Ep ${newCount}`);
}
} catch (e) {
console.error('[MalSync] Unscrobble failed:', e);
}
},
/** /**
* Direct scrobble with known MAL ID and Episode * Direct scrobble with known MAL ID and Episode
* Used when ArmSync has already resolved the exact details. * Used when ArmSync has already resolved the exact details.

View file

@ -177,30 +177,49 @@ export function getCatalogHasMore(
return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`); return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`);
} }
function addonSupportsMetaResource(addon: Manifest, type: string, id: string): boolean { /**
let hasMetaSupport = false; * Check if an addon can serve metadata for this ID by matching ID prefix.
let supportsIdPrefix = false; * Does NOT require a type match type is resolved separately via resolveTypeForAddon.
*/
function addonCanServeId(addon: Manifest, id: string): boolean {
for (const resource of addon.resources || []) { for (const resource of addon.resources || []) {
if (typeof resource === 'object' && resource !== null && 'name' in resource) { if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject; const r = resource as ResourceObject;
if (typedResource.name === 'meta' && typedResource.types?.includes(type)) { if (r.name !== 'meta') continue;
hasMetaSupport = true; if (!r.idPrefixes?.length) return true;
supportsIdPrefix = if (r.idPrefixes.some(p => id.startsWith(p))) return true;
!typedResource.idPrefixes?.length || } else if (resource === 'meta') {
typedResource.idPrefixes.some(prefix => id.startsWith(prefix)); if (!addon.idPrefixes?.length) return true;
break; if (addon.idPrefixes.some(p => id.startsWith(p))) return true;
}
} else if (resource === 'meta' && addon.types?.includes(type)) {
hasMetaSupport = true;
supportsIdPrefix =
!addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix));
break;
} }
} }
return false;
}
const requiresIdPrefix = !!addon.idPrefixes?.length; /**
return hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); * Resolve the correct type to use in the metadata URL for a given addon.
* Looks at what types the addon declares for its meta resource matching this ID prefix,
* rather than blindly trusting the passed-in type (which may be "other", "Movie", etc.).
* Falls back to lowercased passed-in type if no better match found.
*/
function resolveTypeForAddon(addon: Manifest, type: string, id: string): string {
const lowerFallback = type ? type.toLowerCase() : type;
for (const resource of addon.resources || []) {
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const r = resource as ResourceObject;
if (r.name !== 'meta' || !r.types?.length) continue;
const prefixMatch = !r.idPrefixes?.length || r.idPrefixes.some(p => id.startsWith(p));
if (prefixMatch) {
const exact = r.types.find(t => t.toLowerCase() === lowerFallback);
return exact ?? r.types[0];
}
}
}
if (addon.types?.length) {
const exact = addon.types.find(t => t.toLowerCase() === lowerFallback);
return exact ?? addon.types[0];
}
return lowerFallback;
} }
async function fetchMetaFromAddon( async function fetchMetaFromAddon(
@ -209,11 +228,12 @@ async function fetchMetaFromAddon(
type: string, type: string,
id: string id: string
): Promise<MetaDetails | null> { ): Promise<MetaDetails | null> {
const resolvedType = resolveTypeForAddon(addon, type, id);
const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || ''); const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || '');
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const url = queryParams const url = queryParams
? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` ? `${baseUrl}/meta/${resolvedType}/${encodedId}.json?${queryParams}`
: `${baseUrl}/meta/${type}/${encodedId}.json`; : `${baseUrl}/meta/${resolvedType}/${encodedId}.json`;
const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
return response.data?.meta?.id ? response.data.meta : null; return response.data?.meta?.id ? response.data.meta : null;
@ -226,7 +246,11 @@ export async function getMetaDetails(
preferredAddonId?: string preferredAddonId?: string
): Promise<MetaDetails | null> { ): Promise<MetaDetails | null> {
try { try {
if (!(await ctx.isValidContentId(type, id))) { // isValidContentId gate removed — addonCanServeId() handles per-addon ID prefix
// filtering correctly. The gate caused false negatives when type was non-standard
// or prefixes weren't indexed yet, silently returning null before any addon was tried.
const lowerId = (id || '').toLowerCase();
if (!id || lowerId === 'null' || lowerId === 'undefined' || lowerId === 'moviebox' || lowerId === 'torbox') {
return null; return null;
} }
@ -234,7 +258,7 @@ export async function getMetaDetails(
if (preferredAddonId) { if (preferredAddonId) {
const preferredAddon = addons.find(addon => addon.id === preferredAddonId); const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
if (preferredAddon?.resources && addonSupportsMetaResource(preferredAddon, type, id)) { if (preferredAddon?.resources && addonCanServeId(preferredAddon, id)) {
try { try {
const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id); const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id);
if (meta) { if (meta) {
@ -249,7 +273,7 @@ export async function getMetaDetails(
for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) { for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) {
try { try {
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const url = `${baseUrl}/meta/${type}/${encodedId}.json`; const url = `${baseUrl}/meta/${type ? type.toLowerCase() : type}/${encodedId}.json`;
const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
if (response.data?.meta?.id) { if (response.data?.meta?.id) {
return response.data.meta; return response.data.meta;
@ -264,7 +288,7 @@ export async function getMetaDetails(
continue; continue;
} }
if (!addonSupportsMetaResource(addon, type, id)) { if (!addonCanServeId(addon, id)) {
continue; continue;
} }

View file

@ -582,6 +582,32 @@ export class TMDBService {
/** /**
* Find TMDB ID by IMDB ID * Find TMDB ID by IMDB ID
*/ */
/**
* Resolve both the TMDB ID and the correct content type ('movie' | 'series') for an IMDb ID.
* Uses TMDB's /find endpoint which returns tv_results and movie_results simultaneously,
* giving a definitive type without sequential guessing.
* TV results take priority since "other"-typed search results are usually series/anime.
*/
async findTypeAndIdByIMDB(imdbId: string): Promise<{ tmdbId: number; type: 'movie' | 'series' } | null> {
try {
const baseImdbId = imdbId.split(':')[0];
const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, {
headers: await this.getHeaders(),
params: await this.getParams({ external_source: 'imdb_id' }),
});
if (response.data.tv_results?.length > 0) {
return { tmdbId: response.data.tv_results[0].id, type: 'series' };
}
if (response.data.movie_results?.length > 0) {
return { tmdbId: response.data.movie_results[0].id, type: 'movie' };
}
return null;
} catch {
return null;
}
}
async findTMDBIdByIMDB(imdbId: string, language: string = 'en-US'): Promise<number | null> { async findTMDBIdByIMDB(imdbId: string, language: string = 'en-US'): Promise<number | null> {
const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language }); const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language });

View file

@ -1396,7 +1396,9 @@ export class TraktService {
*/ */
public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> { public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> {
try { try {
const endpoint = type ? `/sync/playback/${type}?extended=images` : '/sync/playback?extended=images'; // extended=full,images so we receive episode.first_aired (needed for the new-episode priority boost
// in mergeTraktContinueWatching.ts — brand-new/recently-aired episodes now jump to the top of Continue Watching).
const endpoint = type ? `/sync/playback/${type}?extended=full,images` : '/sync/playback?extended=full,images';
return this.apiRequest<TraktPlaybackItem[]>(endpoint); return this.apiRequest<TraktPlaybackItem[]>(endpoint);
} catch (error) { } catch (error) {
logger.error('[TraktService] Failed to get playback progress with images:', error); logger.error('[TraktService] Failed to get playback progress with images:', error);

View file

@ -6,6 +6,7 @@ import { logger } from '../utils/logger';
import { MalSync } from './mal/MalSync'; import { MalSync } from './mal/MalSync';
import { MalAuth } from './mal/MalAuth'; import { MalAuth } from './mal/MalAuth';
import { ArmSyncService } from './mal/ArmSyncService'; import { ArmSyncService } from './mal/ArmSyncService';
import { MalApiService } from './mal/MalApi';
export interface LocalWatchedItem { export interface LocalWatchedItem {
content_id: string; content_id: string;
@ -577,10 +578,16 @@ 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 imdbId - The primary content ID (may be a provider ID like "kitsu:123")
* @param malId - Optional MAL ID
* @param tmdbId - Optional TMDB ID
* @param title - Optional title
* @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format) * @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format)
*/ */
public async unmarkMovieAsWatched( public async unmarkMovieAsWatched(
imdbId: string, imdbId: string,
malId?: number,
tmdbId?: number,
title?: string,
fallbackImdbId?: string fallbackImdbId?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> { ): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try { try {
@ -594,6 +601,21 @@ class WatchedService {
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
} }
// Sync to MAL
if (MalAuth.isAuthenticated()) {
MalSync.unscrobbleEpisode(
title || 'Movie',
1,
'movie',
undefined,
imdbId,
undefined,
malId,
undefined,
tmdbId
).catch(err => logger.error('[WatchedService] MAL movie unsync failed:', err));
}
// Simkl Unmark — try both IDs // Simkl Unmark — try both IDs
const isSimklAuth = await this.simklService.isAuthenticated(); const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) { if (isSimklAuth) {
@ -627,7 +649,12 @@ class WatchedService {
showImdbId: string, showImdbId: string,
showId: string, showId: string,
season: number, season: number,
episode: number episode: number,
releaseDate?: string,
showTitle?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number
): Promise<{ success: boolean; syncedToTrakt: boolean }> { ): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try { try {
logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`); logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`);
@ -647,6 +674,21 @@ class WatchedService {
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
} }
// Sync to MAL
if (MalAuth.isAuthenticated()) {
MalSync.unscrobbleEpisode(
showTitle || 'Anime',
episode,
'series',
season,
showImdbId,
releaseDate,
malId,
dayIndex,
tmdbId
).catch(err => logger.error('[WatchedService] MAL unsync failed:', err));
}
// Simkl Unmark — use best available ID // Simkl Unmark — use best available ID
const isSimklAuth = await this.simklService.isAuthenticated(); const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) { if (isSimklAuth) {
@ -688,7 +730,12 @@ class WatchedService {
showImdbId: string, showImdbId: string,
showId: string, showId: string,
season: number, season: number,
episodeNumbers: number[] episodeNumbers: number[],
releaseDate?: string,
showTitle?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try { try {
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`); logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
@ -708,6 +755,79 @@ class WatchedService {
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
} }
// Sync to MAL (Unscrobble the latest episode in this season ONLY if it's the one we're currently on)
if (MalAuth.isAuthenticated() && episodeNumbers.length > 0) {
const maxEpisodeInSeason = Math.max(...episodeNumbers);
const resolveAndUnscrobble = async () => {
try {
// Use the robust resolution logic from MalSync.unscrobbleEpisode
// to find the ACTUAL malId and absolute episode number
let finalMalId = malId;
let resolvedEpisode = maxEpisodeInSeason;
// 1. Try TMDB Resolution
if (!finalMalId && tmdbId && releaseDate) {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
finalMalId = tmdbResult.malId;
resolvedEpisode = tmdbResult.episode;
}
}
// 2. Try IMDb/ARM Fallback
if (!finalMalId && showImdbId && releaseDate) {
const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex);
if (armResult) {
finalMalId = armResult.malId;
resolvedEpisode = armResult.episode;
}
}
// 3. Last resort: Standard lookup
if (!finalMalId) {
finalMalId = (await MalSync.getMalId(
showTitle || 'Anime',
'series',
undefined,
season,
showImdbId,
maxEpisodeInSeason,
releaseDate,
dayIndex,
tmdbId
)) || undefined;
}
if (finalMalId) {
const currentInfo = await MalApiService.getMyListStatus(finalMalId);
const currentlyWatched = currentInfo.my_list_status?.num_episodes_watched || 0;
// Only unscrobble if the season's end matches our current progress
if (currentlyWatched === resolvedEpisode) {
// Calculate the episode count BEFORE this season started
const minEpisodeInSeason = Math.min(...episodeNumbers);
const newCount = Math.max(0, minEpisodeInSeason - 1);
let newStatus: any = currentInfo.my_list_status?.status || 'watching';
if (newCount === 0 && newStatus === 'watching') {
// Optional: could move to plan_to_watch
} else if (newStatus === 'completed') {
newStatus = 'watching';
}
await MalApiService.updateStatus(finalMalId, newStatus, newCount);
logger.log(`[WatchedService] Unmarked season: MAL ID ${finalMalId} reverted to Ep ${newCount}`);
}
}
} catch (e) {
logger.error('[WatchedService] MAL season unsync resolution failed:', e);
}
};
resolveAndUnscrobble();
}
// Sync to Simkl — use best available ID // Sync to Simkl — use best available ID
const isSimklAuth = await this.simklService.isAuthenticated(); const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) { if (isSimklAuth) {