mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-15 15:36:01 +00:00
feat: implement ARM + Jikan date-based scrobbling and 90% completion threshold
This commit is contained in:
parent
7a09c46ccb
commit
9fcbe88ed4
8 changed files with 239 additions and 13 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
126
src/services/mal/ArmSyncService.ts
Normal file
126
src/services/mal/ArmSyncService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue