mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
Merge branch 'main' into localization-patch
This commit is contained in:
commit
56f96e5d46
16 changed files with 410 additions and 68 deletions
|
|
@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
}
|
||||
} else {
|
||||
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 {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -91,8 +91,31 @@ export async function mergeTraktContinueWatching({
|
|||
|
||||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
const sortedPlaybackItems = [...playbackItems]
|
||||
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime())
|
||||
.slice(0, 30);
|
||||
.sort((a, b) => {
|
||||
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) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -650,12 +650,30 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
// 3. Background Async Operation
|
||||
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 {
|
||||
const result = await watchedService.unmarkEpisodeAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
showImdbId || '',
|
||||
metadata.id || '',
|
||||
episode.season_number,
|
||||
episode.episode_number
|
||||
episode.episode_number,
|
||||
episode.air_date,
|
||||
metadata?.name,
|
||||
malId,
|
||||
dayIndex,
|
||||
tmdbId
|
||||
);
|
||||
|
||||
loadEpisodesProgress(); // Sync with source of truth
|
||||
|
|
@ -768,12 +786,23 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
// 3. Background Async Operation
|
||||
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 {
|
||||
const result = await watchedService.unmarkSeasonAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
showImdbId || '',
|
||||
metadata.id || '',
|
||||
currentSeason,
|
||||
episodeNumbers
|
||||
episodeNumbers,
|
||||
lastEpisodeData?.air_date,
|
||||
metadata?.name,
|
||||
malId,
|
||||
0, // dayIndex (assuming 0 for season batch unmarking)
|
||||
tmdbId
|
||||
);
|
||||
|
||||
// Re-sync
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
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({
|
||||
imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined),
|
||||
type,
|
||||
|
|
@ -278,6 +280,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
episode,
|
||||
malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id,
|
||||
kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined,
|
||||
tmdbId: currentTmdbId,
|
||||
enabled: settings.skipIntroEnabled
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
episodeId
|
||||
});
|
||||
|
||||
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
|
||||
|
||||
const { segments: skipIntervals, outroSegment } = useSkipSegments({
|
||||
imdbId: imdbId || (id?.startsWith('tt') ? id : undefined),
|
||||
type,
|
||||
|
|
@ -221,6 +223,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
episode,
|
||||
malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id,
|
||||
kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined,
|
||||
tmdbId: currentTmdbId,
|
||||
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 currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
|
||||
|
||||
// Calculate dayIndex for same-day releases
|
||||
const currentDayIndex = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface UseSkipSegmentsProps {
|
|||
malId?: string;
|
||||
kitsuId?: string;
|
||||
releaseDate?: string;
|
||||
tmdbId?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export const useSkipSegments = ({
|
|||
malId,
|
||||
kitsuId,
|
||||
releaseDate,
|
||||
tmdbId,
|
||||
enabled
|
||||
}: UseSkipSegmentsProps) => {
|
||||
const [segments, setSegments] = useState<SkipInterval[]>([]);
|
||||
|
|
@ -29,9 +31,9 @@ export const useSkipSegments = ({
|
|||
const lastKeyRef = useRef('');
|
||||
|
||||
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([]);
|
||||
setIsLoading(false);
|
||||
fetchedRef.current = false;
|
||||
|
|
@ -55,7 +57,7 @@ export const useSkipSegments = ({
|
|||
|
||||
const fetchSegments = async () => {
|
||||
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.
|
||||
if (cancelled || lastKeyRef.current !== key) return;
|
||||
|
|
@ -78,7 +80,7 @@ export const useSkipSegments = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]);
|
||||
}, [imdbId, type, season, episode, malId, kitsuId, releaseDate, tmdbId, enabled]);
|
||||
|
||||
const getActiveSegment = (currentTime: number) => {
|
||||
return segments.find(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 lastError = null;
|
||||
|
|
@ -758,7 +795,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
for (const addon of externalMetaAddons) {
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
stremioService.getMetaDetails(normalizedType, actualId, addon.id),
|
||||
stremioService.getMetaDetails(effectiveType, actualId, addon.id),
|
||||
API_TIMEOUT
|
||||
);
|
||||
|
||||
|
|
@ -775,7 +812,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
|
||||
// If no external addon worked, fall back to catalog addon
|
||||
const result = await withTimeout(
|
||||
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||
catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId),
|
||||
API_TIMEOUT
|
||||
);
|
||||
if (actualId.startsWith('tt')) {
|
||||
|
|
@ -804,7 +841,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Load content with timeout and retry
|
||||
withRetry(async () => {
|
||||
const result = await withTimeout(
|
||||
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||
catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId),
|
||||
API_TIMEOUT
|
||||
);
|
||||
// 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([
|
||||
withRetry(async () => {
|
||||
const result = await withTimeout(
|
||||
catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId),
|
||||
catalogService.getEnhancedContentDetails(effectiveType, stremioId, addonId),
|
||||
API_TIMEOUT
|
||||
);
|
||||
if (stremioId.startsWith('tt')) {
|
||||
|
|
@ -925,6 +962,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
name: localized.title || finalMetadata.name,
|
||||
description: localized.overview || finalMetadata.description,
|
||||
movieDetails: movieDetailsObj,
|
||||
tmdbId: finalTmdbId,
|
||||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||||
};
|
||||
}
|
||||
|
|
@ -962,6 +1000,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
name: localized.name || finalMetadata.name,
|
||||
description: localized.overview || finalMetadata.description,
|
||||
tvDetails,
|
||||
tmdbId: finalTmdbId,
|
||||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -680,8 +680,9 @@ const MDBListSettingsScreen: React.FC = () => {
|
|||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, !isMdbListEnabled && styles. disabledCard]}>
|
||||
{isMdbListEnabled &&
|
||||
<>
|
||||
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
|
||||
<Text style={[styles.sectionTitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
|
||||
{t('mdblist.api_section')}
|
||||
</Text>
|
||||
|
|
@ -865,7 +866,7 @@ const MDBListSettingsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
</>}
|
||||
</ScrollView>
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,8 @@ export async function getContentDetails(
|
|||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
|
||||
const isValidId = await stremioService.isValidContentId(type, id);
|
||||
|
||||
if (!isValidId) {
|
||||
break;
|
||||
}
|
||||
// isValidContentId gate removed — getMetaDetails uses addonCanServeId()
|
||||
// for per-addon prefix matching, avoiding false negatives for custom ID types.
|
||||
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
|
||||
|
||||
if (meta) {
|
||||
|
|
@ -102,10 +99,8 @@ export async function getBasicContentDetails(
|
|||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
if (!(await stremioService.isValidContentId(type, id))) {
|
||||
break;
|
||||
}
|
||||
|
||||
// isValidContentId gate removed — getMetaDetails uses addonCanServeId()
|
||||
// for per-addon prefix matching, avoiding false negatives for custom ID types.
|
||||
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
|
||||
if (meta) {
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -321,13 +321,9 @@ async function searchAddonCatalog(
|
|||
|
||||
const items = metas.map(meta => {
|
||||
const content = convertMetaToStreamingContent(meta, library);
|
||||
const addonSupportsMeta = Array.isArray(manifest.resources) && manifest.resources.some((resource: any) =>
|
||||
resource === 'meta' || (typeof resource === 'object' && resource?.name === 'meta')
|
||||
);
|
||||
|
||||
if (addonSupportsMeta) {
|
||||
content.addonId = manifest.id;
|
||||
}
|
||||
// Do NOT set addonId from search results — let getMetaDetails resolve the correct
|
||||
// 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.
|
||||
|
||||
const normalizedCatalogType = type ? type.toLowerCase() : type;
|
||||
if (normalizedCatalogType && content.type !== normalizedCatalogType) {
|
||||
|
|
|
|||
|
|
@ -306,7 +306,8 @@ export async function getSkipTimes(
|
|||
episode: number,
|
||||
malId?: string,
|
||||
kitsuId?: string,
|
||||
releaseDate?: string
|
||||
releaseDate?: string,
|
||||
tmdbId?: number
|
||||
): Promise<SkipInterval[]> {
|
||||
// 1. Try IntroDB (TV Shows) first
|
||||
if (imdbId) {
|
||||
|
|
@ -320,7 +321,21 @@ export async function getSkipTimes(
|
|||
let finalMalId = malId;
|
||||
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) {
|
||||
try {
|
||||
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* Used when ArmSync has already resolved the exact details.
|
||||
|
|
|
|||
|
|
@ -177,30 +177,49 @@ export function getCatalogHasMore(
|
|||
return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`);
|
||||
}
|
||||
|
||||
function addonSupportsMetaResource(addon: Manifest, type: string, id: string): boolean {
|
||||
let hasMetaSupport = false;
|
||||
let supportsIdPrefix = false;
|
||||
|
||||
/**
|
||||
* Check if an addon can serve metadata for this ID by matching ID prefix.
|
||||
* 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 || []) {
|
||||
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||
const typedResource = resource as ResourceObject;
|
||||
if (typedResource.name === 'meta' && typedResource.types?.includes(type)) {
|
||||
hasMetaSupport = true;
|
||||
supportsIdPrefix =
|
||||
!typedResource.idPrefixes?.length ||
|
||||
typedResource.idPrefixes.some(prefix => id.startsWith(prefix));
|
||||
break;
|
||||
}
|
||||
} else if (resource === 'meta' && addon.types?.includes(type)) {
|
||||
hasMetaSupport = true;
|
||||
supportsIdPrefix =
|
||||
!addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix));
|
||||
break;
|
||||
const r = resource as ResourceObject;
|
||||
if (r.name !== 'meta') continue;
|
||||
if (!r.idPrefixes?.length) return true;
|
||||
if (r.idPrefixes.some(p => id.startsWith(p))) return true;
|
||||
} else if (resource === 'meta') {
|
||||
if (!addon.idPrefixes?.length) return true;
|
||||
if (addon.idPrefixes.some(p => id.startsWith(p))) return true;
|
||||
}
|
||||
}
|
||||
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(
|
||||
|
|
@ -209,11 +228,12 @@ async function fetchMetaFromAddon(
|
|||
type: string,
|
||||
id: string
|
||||
): Promise<MetaDetails | null> {
|
||||
const resolvedType = resolveTypeForAddon(addon, type, id);
|
||||
const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || '');
|
||||
const encodedId = encodeURIComponent(id);
|
||||
const url = queryParams
|
||||
? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}`
|
||||
: `${baseUrl}/meta/${type}/${encodedId}.json`;
|
||||
? `${baseUrl}/meta/${resolvedType}/${encodedId}.json?${queryParams}`
|
||||
: `${baseUrl}/meta/${resolvedType}/${encodedId}.json`;
|
||||
|
||||
const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
|
||||
return response.data?.meta?.id ? response.data.meta : null;
|
||||
|
|
@ -226,7 +246,11 @@ export async function getMetaDetails(
|
|||
preferredAddonId?: string
|
||||
): Promise<MetaDetails | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +258,7 @@ export async function getMetaDetails(
|
|||
|
||||
if (preferredAddonId) {
|
||||
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
|
||||
if (preferredAddon?.resources && addonSupportsMetaResource(preferredAddon, type, id)) {
|
||||
if (preferredAddon?.resources && addonCanServeId(preferredAddon, id)) {
|
||||
try {
|
||||
const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id);
|
||||
if (meta) {
|
||||
|
|
@ -249,7 +273,7 @@ export async function getMetaDetails(
|
|||
for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) {
|
||||
try {
|
||||
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)));
|
||||
if (response.data?.meta?.id) {
|
||||
return response.data.meta;
|
||||
|
|
@ -264,7 +288,7 @@ export async function getMetaDetails(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!addonSupportsMetaResource(addon, type, id)) {
|
||||
if (!addonCanServeId(addon, id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -582,6 +582,32 @@ export class TMDBService {
|
|||
/**
|
||||
* 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> {
|
||||
const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language });
|
||||
|
||||
|
|
|
|||
|
|
@ -1396,7 +1396,9 @@ export class TraktService {
|
|||
*/
|
||||
public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> {
|
||||
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);
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to get playback progress with images:', error);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { logger } from '../utils/logger';
|
|||
import { MalSync } from './mal/MalSync';
|
||||
import { MalAuth } from './mal/MalAuth';
|
||||
import { ArmSyncService } from './mal/ArmSyncService';
|
||||
import { MalApiService } from './mal/MalApi';
|
||||
|
||||
export interface LocalWatchedItem {
|
||||
content_id: string;
|
||||
|
|
@ -577,10 +578,16 @@ class WatchedService {
|
|||
/**
|
||||
* Unmark a movie as watched (remove from history).
|
||||
* @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)
|
||||
*/
|
||||
public async unmarkMovieAsWatched(
|
||||
imdbId: string,
|
||||
malId?: number,
|
||||
tmdbId?: number,
|
||||
title?: string,
|
||||
fallbackImdbId?: string
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
|
|
@ -594,6 +601,21 @@ class WatchedService {
|
|||
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
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
|
|
@ -627,7 +649,12 @@ class WatchedService {
|
|||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
episode: number,
|
||||
releaseDate?: string,
|
||||
showTitle?: string,
|
||||
malId?: number,
|
||||
dayIndex?: number,
|
||||
tmdbId?: number
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
|
|
@ -688,7 +730,12 @@ class WatchedService {
|
|||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episodeNumbers: number[]
|
||||
episodeNumbers: number[],
|
||||
releaseDate?: string,
|
||||
showTitle?: string,
|
||||
malId?: number,
|
||||
dayIndex?: number,
|
||||
tmdbId?: number
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
|
||||
|
|
@ -708,6 +755,79 @@ class WatchedService {
|
|||
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
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue