mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +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 {
|
} 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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue