mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +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 {
|
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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
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
|
* Import MAL list items into local library
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue