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

View file

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

View file

@ -3,6 +3,7 @@ import { AppState, AppStateStatus } from 'react-native';
import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings';
import { watchedService } from '../../../services/watchedService';
export const useWatchProgress = (
id: string | undefined,
@ -13,7 +14,12 @@ export const useWatchProgress = (
paused: boolean,
traktAutosync: any,
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 [savedDuration, setSavedDuration] = useState<number | null>(null);
@ -23,6 +29,7 @@ export const useWatchProgress = (
const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef<number | null>(null);
const hasScrobbledRef = useRef(false);
// Values refs for unmount cleanup
const currentTimeRef = useRef(currentTime);
@ -120,6 +127,26 @@ export const useWatchProgress = (
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
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) {
logger.error('[useWatchProgress] Error saving watch progress:', error);
}

View file

@ -149,6 +149,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string;
videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] };
};
PlayerAndroid: {
@ -169,6 +170,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string;
videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] };
};
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 episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
const releaseDate = type === 'movie' ? metadata?.released : currentEpisode?.air_date;
await streamCacheService.saveStreamToCache(
id,
@ -412,6 +413,7 @@ export const useStreamsScreen = () => {
availableStreams: streamsToPass,
backdrop: metadata?.banner || bannerImage,
videoType,
releaseDate,
} as any);
},
[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
*/

View file

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