feat: implement ARM + Jikan date-based scrobbling and 90% completion threshold

This commit is contained in:
paregi12 2026-01-17 14:21:10 +05:30
parent 7a09c46ccb
commit 9fcbe88ed4
8 changed files with 239 additions and 13 deletions

View file

@ -75,7 +75,7 @@ const AndroidVideoPlayer: React.FC = () => {
const { const {
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
streamProvider, streamName, headers, id, type, episodeId, imdbId, streamProvider, streamName, headers, id, type, episodeId, imdbId,
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, releaseDate
} = route.params; } = route.params;
// --- State & Custom Hooks --- // --- State & Custom Hooks ---
@ -261,7 +261,11 @@ const AndroidVideoPlayer: React.FC = () => {
playerState.paused, playerState.paused,
traktAutosync, traktAutosync,
controlsHook.seekToTime, controlsHook.seekToTime,
currentStreamProvider currentStreamProvider,
imdbId,
season,
episode,
releaseDate
); );
const gestureControls = usePlayerGestureControls({ const gestureControls = usePlayerGestureControls({

View file

@ -189,7 +189,12 @@ const KSPlayerCore: React.FC = () => {
duration, duration,
paused, paused,
traktAutosync, traktAutosync,
controls.seekToTime controls.seekToTime,
undefined,
imdbId,
season,
episode,
undefined // releaseDate not yet implemented for iOS
); );
// Gestures // Gestures

View file

@ -3,6 +3,7 @@ import { AppState, AppStateStatus } from 'react-native';
import { storageService } from '../../../services/storageService'; import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings'; import { useSettings } from '../../../hooks/useSettings';
import { watchedService } from '../../../services/watchedService';
export const useWatchProgress = ( export const useWatchProgress = (
id: string | undefined, id: string | undefined,
@ -13,7 +14,12 @@ export const useWatchProgress = (
paused: boolean, paused: boolean,
traktAutosync: any, traktAutosync: any,
seekToTime: (time: number) => void, seekToTime: (time: number) => void,
addonId?: string addonId?: string,
// New parameters for MAL scrobbling
imdbId?: string,
season?: number,
episode?: number,
releaseDate?: string
) => { ) => {
const [resumePosition, setResumePosition] = useState<number | null>(null); const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null); const [savedDuration, setSavedDuration] = useState<number | null>(null);
@ -23,6 +29,7 @@ export const useWatchProgress = (
const { settings: appSettings } = useSettings(); const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef<number | null>(null); const initialSeekTargetRef = useRef<number | null>(null);
const hasScrobbledRef = useRef(false);
// Values refs for unmount cleanup // Values refs for unmount cleanup
const currentTimeRef = useRef(currentTime); const currentTimeRef = useRef(currentTime);
@ -120,6 +127,26 @@ export const useWatchProgress = (
try { try {
await storageService.setWatchProgress(id, type, progress, episodeId); await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
// Requirement 1: Auto Episode Tracking (>= 90% completion)
const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
if (progressPercent >= 90 && !hasScrobbledRef.current) {
hasScrobbledRef.current = true;
logger.log(`[useWatchProgress] 90% threshold reached, scrobbling to MAL...`);
if (type === 'series' && imdbId && season !== undefined && episode !== undefined) {
watchedService.markEpisodeAsWatched(
imdbId,
id,
season,
episode,
new Date(),
releaseDate
);
} else if (type === 'movie' && imdbId) {
watchedService.markMovieAsWatched(imdbId);
}
}
} catch (error) { } catch (error) {
logger.error('[useWatchProgress] Error saving watch progress:', error); logger.error('[useWatchProgress] Error saving watch progress:', error);
} }

View file

@ -149,6 +149,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string; backdrop?: string;
videoType?: string; videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] }; groupedEpisodes?: { [seasonNumber: number]: any[] };
}; };
PlayerAndroid: { PlayerAndroid: {
@ -169,6 +170,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string; backdrop?: string;
videoType?: string; videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] }; groupedEpisodes?: { [seasonNumber: number]: any[] };
}; };
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };

View file

@ -367,6 +367,7 @@ export const useStreamsScreen = () => {
const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined;
const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
const releaseDate = type === 'movie' ? metadata?.released : currentEpisode?.air_date;
await streamCacheService.saveStreamToCache( await streamCacheService.saveStreamToCache(
id, id,
@ -412,6 +413,7 @@ export const useStreamsScreen = () => {
availableStreams: streamsToPass, availableStreams: streamsToPass,
backdrop: metadata?.banner || bannerImage, backdrop: metadata?.banner || bannerImage,
videoType, videoType,
releaseDate,
} as any); } as any);
}, },
[metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL]

View file

@ -0,0 +1,126 @@
import axios from 'axios';
import { logger } from '../utils/logger';
interface ArmEntry {
anidb?: number;
anilist?: number;
'anime-planet'?: string;
anisearch?: number;
imdb?: string;
kitsu?: number;
livechart?: number;
'notify-moe'?: string;
themoviedb?: number;
thetvdb?: number;
myanimelist?: number;
}
interface DateSyncResult {
malId: number;
episode: number;
title?: string;
}
const JIKAN_BASE = 'https://api.jikan.moe/v4';
const ARM_BASE = 'https://arm.haglund.dev/api/v2';
export const ArmSyncService = {
/**
* Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping)
* and Jikan (for Air Date matching).
*
* @param imdbId The IMDb ID of the show
* @param releaseDateStr The air date of the episode (YYYY-MM-DD)
* @returns {Promise<DateSyncResult | null>} The resolved MAL ID and Episode number
*/
resolveByDate: async (imdbId: string, releaseDateStr: string): Promise<DateSyncResult | null> => {
try {
const targetDate = new Date(releaseDateStr);
if (isNaN(targetDate.getTime())) {
logger.warn(`[ArmSync] Invalid date provided: ${releaseDateStr}`);
return null;
}
logger.log(`[ArmSync] Resolving ${imdbId} for date ${releaseDateStr}...`);
// 1. Fetch Candidates from ARM
const armRes = await axios.get<ArmEntry[]>(`${ARM_BASE}/imdb`, {
params: { id: imdbId }
});
const malIds = armRes.data
.map(entry => entry.myanimelist)
.filter((id): id is number => !!id);
if (malIds.length === 0) {
logger.warn(`[ArmSync] No MAL IDs found in ARM for ${imdbId}`);
return null;
}
logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`);
// 2. Validate Candidates via Jikan Dates
// Helper to delay (Jikan Rate Limit: 3 req/sec)
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
for (const malId of malIds) {
await delay(500); // Respect rate limits
try {
const detailsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}`);
const anime = detailsRes.data.data;
const startDate = anime.aired?.from ? new Date(anime.aired.from) : null;
const endDate = anime.aired?.to ? new Date(anime.aired.to) : null;
// Date Matching Logic
let isMatch = false;
if (startDate) {
// Buffer: Allow +/- 24h for timezone differences
const buffer = 24 * 60 * 60 * 1000;
const targetTime = targetDate.getTime();
const startTime = startDate.getTime() - buffer;
const endTime = endDate ? endDate.getTime() + buffer : null;
if (targetTime >= startTime) {
if (!endTime || targetTime <= endTime) {
isMatch = true;
}
}
}
if (isMatch) {
logger.log(`[ArmSync] Match found! ID ${malId} covers ${releaseDateStr}`);
// 3. Find Exact Episode
await delay(500);
// Fetch first page of episodes (usually enough for seasonal anime)
// Ideally we'd paginate, but for now page 1 covers 95% of cases.
const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`);
const episodes = epsRes.data.data;
const matchEp = episodes.find((ep: any) => {
if (!ep.aired) return false;
const epDate = new Date(ep.aired);
return epDate.toISOString().split('T')[0] === releaseDateStr;
});
if (matchEp) {
logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`);
return {
malId,
episode: matchEp.mal_id,
title: matchEp.title
};
}
}
} catch (e) {
logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e);
}
}
} catch (e) {
logger.error('[ArmSync] Resolution failed:', e);
}
return null;
}
};

View file

@ -199,6 +199,47 @@ export const MalSync = {
} }
}, },
/**
* Direct scrobble with known MAL ID and Episode
* Used when ArmSync has already resolved the exact details.
*/
scrobbleDirect: async (malId: number, episodeNumber: number) => {
try {
// Respect user settings
const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
if (!isEnabled || !isAutoUpdate) return;
// Check current status
const currentInfo = await MalApiService.getMyListStatus(malId);
const currentStatus = currentInfo.my_list_status?.status;
// Auto-Add check
if (!currentStatus) {
const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true;
if (!autoAdd) {
console.log(`[MalSync] Skipping direct scrobble: Not in list and auto-add disabled`);
return;
}
}
// Safety checks (Completed/Dropped/Regression)
if (currentStatus === 'completed' || currentStatus === 'dropped') return;
if (currentInfo.my_list_status?.num_episodes_watched && episodeNumber <= currentInfo.my_list_status.num_episodes_watched) return;
// Determine Status
let status: MalListStatus = 'watching';
if (currentInfo.num_episodes > 0 && episodeNumber >= currentInfo.num_episodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, episodeNumber);
console.log(`[MalSync] Direct synced MAL ID ${malId} Ep ${episodeNumber} (${status})`);
} catch (e) {
console.error('[MalSync] Direct scrobble failed:', e);
}
},
/** /**
* Import MAL list items into local library * Import MAL list items into local library
*/ */

View file

@ -4,6 +4,7 @@ import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger'; 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 { mappingService } from './MappingService'; import { mappingService } from './MappingService';
/** /**
@ -84,7 +85,8 @@ class WatchedService {
showId: string, showId: string,
season: number, season: number,
episode: number, episode: number,
watchedAt: Date = new Date() watchedAt: Date = new Date(),
releaseDate?: string // Optional release date for precise matching
): Promise<{ success: boolean; syncedToTrakt: boolean }> { ): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try { try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
@ -106,14 +108,31 @@ class WatchedService {
// Sync to MAL // Sync to MAL
const malToken = MalAuth.getToken(); const malToken = MalAuth.getToken();
if (malToken && showImdbId) { if (malToken && showImdbId) {
MalSync.scrobbleEpisode( // Strategy 1: "Perfect Match" using ARM + Release Date
'Anime', // Title fallback let synced = false;
episode, if (releaseDate) {
0, // Total episodes (MalSync will fetch) try {
'series', const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate);
season, if (armResult) {
showImdbId await MalSync.scrobbleDirect(armResult.malId, armResult.episode);
).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); synced = true;
}
} catch (e) {
logger.warn('[WatchedService] ARM Sync failed, falling back to offline map:', e);
}
}
// Strategy 2: Offline Mapping Fallback
if (!synced) {
MalSync.scrobbleEpisode(
'Anime', // Title fallback
episode,
0,
'series',
season,
showImdbId
).catch(err => logger.error('[WatchedService] MAL sync failed:', err));
}
} }
// Store locally as "completed" // Store locally as "completed"