mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
simkl init
This commit is contained in:
parent
bfba45e74a
commit
25e1102832
11 changed files with 1615 additions and 122 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -97,4 +97,5 @@ trakt-docss
|
|||
# Removed submodules (kept locally)
|
||||
libmpv-android/
|
||||
mpv-android/
|
||||
mpvKt/
|
||||
mpvKt/
|
||||
simkl-docss
|
||||
21
src/components/icons/SimklIcon.tsx
Normal file
21
src/components/icons/SimklIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface SimklIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const SimklIcon: React.FC<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
|
||||
<Path
|
||||
d="M21.996 11.233c-.02-.85-.12-1.68-.28-2.5-.27-.08-.54-.15-.81-.22-.32 1.35-.55 2.72-.65 4.1h.005v.025c-.012.333-.012.67-.008 1.008l.008.026h-.005c.015 1.48.16 2.94.41 4.39.28-.06.56-.13.84-.2.17-.89.28-1.8.31-2.71.01-.15.01-.3.02-.45.39-1.07.66-2.22.79-3.41-.21.01-.42.01-.63.01v-.058l-.001-.01zm-3.15-5.96c-.32 1.94-.37 3.92-.12 5.88.24-.03.49-.06.74-.08l-.009-.045c.006-.33.02-.66.04-.98l.009-.045c.01-.19.03-.37.04-.55.03-.38.07-.76.11-1.13.27-.03.54-.05.81-.07-.06-1.57-.34-3.1-.78-4.57-.45.24-.9.46-1.34.69.17.29.33.6.49.9zM7.55 6.013c.09-.16.16-.33.22-.5l.024.009c.81.3 1.63.57 2.47.8l.044.01c-.13.21-.24.42-.36.63.15.54.34 1.07.56 1.58.26-.06.51-.12.77-.18l-.02-.075c-.21-1.01-.35-2.03-.42-3.05l-.04-.03c-.26-.14-.52-.28-.79-.41-.01.21-.01.42-.01.63-.8-.34-1.61-.64-2.43-.91-.12.49-.21.98-.27 1.48l.019.01zm-3.07 9.85c.66 1.05 1.46 1.99 2.37 2.79.16-.14.33-.27.49-.41-.1-.7-.16-1.41-.2-2.12-.53-.13-1.06-.23-1.59-.3-.3.63-.66 1.25-1.07 1.84v-.01-.19zm1.09-3.8c-.81.65-1.53 1.39-2.15 2.22l.02.04c.83.21 1.67.39 2.52.54.08-1.57.43-3.11 1.01-4.56-.27-.08-.54-.15-.81-.22-.22.65-.42 1.31-.59 1.98zm8.68-7.983c-.85-.14-1.72-.21-2.6-.19-.49.94-.85 1.93-1.08 2.96.26.06.52.12.77.19.16-.9.4-1.79.71-2.65.23.01.47.03.7.05.34-.14.67-.3.99-.48l.01-.01c.17.04.33.09.5.13zm5.75 1.92l-.01.07c-1.12.21-2.22.52-3.3.9.5 1.14 1.15 2.21 1.91 3.16.2-.08.41-.15.61-.22-.64-.81-1.2-1.69-1.66-2.61l.01-.06c.4-.2.81-.39 1.23-.57l.01-.07c.39-.21.79-.41 1.2-.6z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimklIcon;
|
||||
227
src/hooks/useSimklIntegration.ts
Normal file
227
src/hooks/useSimklIntegration.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import {
|
||||
SimklService,
|
||||
SimklContentData,
|
||||
SimklPlaybackData
|
||||
} from '../services/simklService';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const simklService = SimklService.getInstance();
|
||||
|
||||
export function useSimklIntegration() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Basic lists
|
||||
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
|
||||
|
||||
// Check authentication status
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const authenticated = await simklService.isAuthenticated();
|
||||
setIsAuthenticated(authenticated);
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error checking auth status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Force refresh
|
||||
const refreshAuthStatus = useCallback(async () => {
|
||||
await checkAuthStatus();
|
||||
}, [checkAuthStatus]);
|
||||
|
||||
// Load playback/continue watching
|
||||
const loadPlaybackStatus = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const playback = await simklService.getPlaybackStatus();
|
||||
setContinueWatching(playback);
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error loading playback status:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Start watching (scrobble start)
|
||||
const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobbleStart(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error starting watch:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Update progress (scrobble pause)
|
||||
const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobblePause(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error updating progress:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Stop watching (scrobble stop)
|
||||
const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobbleStop(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error stopping watch:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sync All Local Progress -> Simkl
|
||||
const syncAllProgress = useCallback(async (): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const unsynced = await storageService.getUnsyncedProgress();
|
||||
// Filter for items that specifically need SIMKL sync (unsynced.filter(i => !i.progress.simklSynced...))
|
||||
// storageService.getUnsyncedProgress currently returns items that need Trakt OR Simkl sync.
|
||||
// We should check simklSynced specifically here.
|
||||
|
||||
const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced));
|
||||
|
||||
if (itemsToSync.length === 0) return true;
|
||||
|
||||
logger.log(`[useSimklIntegration] Found ${itemsToSync.length} items to sync to Simkl`);
|
||||
|
||||
for (const item of itemsToSync) {
|
||||
try {
|
||||
const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined;
|
||||
const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined;
|
||||
|
||||
// Construct content data
|
||||
const content: SimklContentData = {
|
||||
type: item.type === 'series' ? 'episode' : 'movie',
|
||||
title: 'Unknown', // Ideally storage has title, but it might not. Simkl needs IDs mainly.
|
||||
ids: { imdb: item.id },
|
||||
season,
|
||||
episode
|
||||
};
|
||||
|
||||
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
|
||||
|
||||
// If completed (>=80% or 95% depending on logic, let's say 85% safe), add to history
|
||||
// Simkl: Stop with >= 80% marks as watched.
|
||||
// Or explicitly add to history.
|
||||
|
||||
let success = false;
|
||||
if (progressPercent >= 85) {
|
||||
// Add to history
|
||||
if (content.type === 'movie') {
|
||||
await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] });
|
||||
} else {
|
||||
await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] });
|
||||
}
|
||||
success = true; // Assume success if no throw
|
||||
} else {
|
||||
// Pause (scrobble)
|
||||
const res = await simklService.scrobblePause(content, progressPercent);
|
||||
success = !!res;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
await storageService.updateSimklSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[useSimklIntegration] Failed to sync item ${item.id}`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('[useSimklIntegration] Error syncing all progress', e);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Fetch Simkl -> Merge Local
|
||||
const fetchAndMergeSimklProgress = useCallback(async (): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const playback = await simklService.getPlaybackStatus();
|
||||
|
||||
for (const item of playback) {
|
||||
let id: string | undefined;
|
||||
let type: string;
|
||||
let episodeId: string | undefined;
|
||||
|
||||
if (item.movie) {
|
||||
id = item.movie.ids.imdb;
|
||||
type = 'movie';
|
||||
} else if (item.show && item.episode) {
|
||||
id = item.show.ids.imdb;
|
||||
type = 'series';
|
||||
episodeId = `${id}:${item.episode.season}:${item.episode.episode}`;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await storageService.mergeWithSimklProgress(
|
||||
id,
|
||||
type!,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
episodeId
|
||||
);
|
||||
|
||||
// Mark as synced locally so we don't push it back
|
||||
await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, [checkAuthStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadPlaybackStatus();
|
||||
fetchAndMergeSimklProgress();
|
||||
}
|
||||
}, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress]);
|
||||
|
||||
// App state listener for sync
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
fetchAndMergeSimklProgress();
|
||||
}
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [isAuthenticated, fetchAndMergeSimklProgress]);
|
||||
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
checkAuthStatus,
|
||||
refreshAuthStatus,
|
||||
startWatching,
|
||||
updateProgress,
|
||||
stopWatching,
|
||||
syncAllProgress,
|
||||
fetchAndMergeSimklProgress,
|
||||
continueWatching
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useTraktIntegration } from './useTraktIntegration';
|
||||
import { useSimklIntegration } from './useSimklIntegration';
|
||||
import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
|
||||
import { TraktContentData } from '../services/traktService';
|
||||
import { SimklContentData } from '../services/simklService';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
|
|
@ -30,6 +32,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
stopWatchingImmediate
|
||||
} = useTraktIntegration();
|
||||
|
||||
const {
|
||||
isAuthenticated: isSimklAuthenticated,
|
||||
startWatching: startSimkl,
|
||||
updateProgress: updateSimkl,
|
||||
stopWatching: stopSimkl
|
||||
} = useSimklIntegration();
|
||||
|
||||
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
||||
|
||||
const hasStartedWatching = useRef(false);
|
||||
|
|
@ -145,14 +154,29 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
}
|
||||
}, [options]);
|
||||
|
||||
const buildSimklContentData = useCallback((): SimklContentData => {
|
||||
return {
|
||||
type: options.type === 'series' ? 'episode' : 'movie',
|
||||
title: options.title,
|
||||
ids: {
|
||||
imdb: options.imdbId
|
||||
},
|
||||
season: options.season,
|
||||
episode: options.episode
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
// Start watching (scrobble start)
|
||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
|
||||
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -190,16 +214,28 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return;
|
||||
}
|
||||
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
if (shouldSyncTrakt) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false; // Reset stop flag when starting
|
||||
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
||||
}
|
||||
} else {
|
||||
// If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false; // Reset stop flag when starting
|
||||
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
||||
hasStopped.current = false;
|
||||
}
|
||||
|
||||
// Simkl Start
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error starting watch:', error);
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
|
||||
|
||||
// Sync progress during playback
|
||||
const handleProgressUpdate = useCallback(async (
|
||||
|
|
@ -209,7 +245,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -225,70 +264,95 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
// IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true)
|
||||
// Use regular queued method for background periodic syncs
|
||||
let success: boolean;
|
||||
let traktSuccess: boolean = false;
|
||||
|
||||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgressImmediate(contentData, progressPercent);
|
||||
if (shouldSyncTrakt) {
|
||||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgressImmediate(contentData, progressPercent);
|
||||
|
||||
if (success) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// BACKGROUND: Periodic sync - use queued method
|
||||
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
||||
logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// BACKGROUND: Periodic sync - use queued method
|
||||
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
||||
|
||||
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
||||
if (progressDiff < 0.5) {
|
||||
return;
|
||||
}
|
||||
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
||||
if (progressDiff < 0.5) {
|
||||
logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`);
|
||||
// If only Trakt is active and we skip, we should return here.
|
||||
// If Simkl is also active, we continue to let Simkl update.
|
||||
if (!shouldSyncSimkl) return;
|
||||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgress(contentData, progressPercent, force);
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgress(contentData, progressPercent, force);
|
||||
|
||||
if (success) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
// Progress sync logging removed
|
||||
// Progress sync logging removed
|
||||
logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update)
|
||||
if (shouldSyncSimkl) {
|
||||
// Debounce simkl updates slightly if needed, but hook handles calls.
|
||||
// We do basic difference check here
|
||||
const simklData = buildSimklContentData();
|
||||
await updateSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error syncing progress:', error);
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// Handle playback end/pause
|
||||
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
|
||||
|
|
@ -298,8 +362,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
// Removed excessive logging for handlePlaybackEnd calls
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled) {
|
||||
// logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +390,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
isSignificantUpdate = true;
|
||||
} else {
|
||||
// Already stopped this session, skipping duplicate call
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -390,8 +458,20 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||
const contentData = buildContentData();
|
||||
if (contentData) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
let started = false;
|
||||
// Try starting Trakt if enabled
|
||||
if (shouldSyncTrakt) {
|
||||
const s = await startWatching(contentData, progressPercent);
|
||||
if (s) started = true;
|
||||
}
|
||||
// Try starting Simkl if enabled (always 'true' effectively if authenticated)
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
started = true;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
hasStartedWatching.current = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -401,6 +481,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
// Lower threshold for unmount calls to catch more edge cases
|
||||
if (reason === 'unmount' && progressPercent < 0.5) {
|
||||
// Early unmount stop logging removed
|
||||
logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -419,13 +500,24 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return;
|
||||
}
|
||||
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
const success = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
: await stopWatching(contentData, progressPercent);
|
||||
let overallSuccess = false;
|
||||
|
||||
if (success) {
|
||||
// Update local storage sync status
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
let traktStopSuccess = false;
|
||||
if (shouldSyncTrakt) {
|
||||
traktStopSuccess = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
: await stopWatching(contentData, progressPercent);
|
||||
if (traktStopSuccess) {
|
||||
logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true;
|
||||
} else {
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (traktStopSuccess) {
|
||||
// Update local storage sync status for Trakt
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
|
|
@ -434,7 +526,30 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
} else if (shouldSyncTrakt) {
|
||||
// If Trakt stop failed, reset the stop flag so we can try again later
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
||||
}
|
||||
|
||||
// Simkl Stop
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await stopSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage sync status for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet)
|
||||
}
|
||||
|
||||
if (overallSuccess) {
|
||||
// Mark session as complete if >= user completion threshold
|
||||
if (progressPercent >= autosyncSettings.completionThreshold) {
|
||||
isSessionComplete.current = true;
|
||||
|
|
@ -450,8 +565,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
currentTime: duration,
|
||||
duration,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: Math.max(progressPercent, 100),
|
||||
traktSynced: shouldSyncTrakt ? true : undefined,
|
||||
traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined,
|
||||
simklSynced: shouldSyncSimkl ? true : undefined,
|
||||
simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined,
|
||||
} as any,
|
||||
options.episodeId,
|
||||
{ forceNotify: true }
|
||||
|
|
@ -460,11 +577,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
} catch { }
|
||||
}
|
||||
|
||||
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
// General success log if at least one service succeeded
|
||||
if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active
|
||||
logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
}
|
||||
} else {
|
||||
// If stop failed, reset the stop flag so we can try again later
|
||||
// If neither service succeeded, reset the stop flag
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`);
|
||||
logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`);
|
||||
}
|
||||
|
||||
// Reset state only for natural end or very high progress unmounts
|
||||
|
|
@ -480,7 +600,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
// Reset stop flag on error so we can try again
|
||||
hasStopped.current = false;
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// Reset state (useful when switching content)
|
||||
const resetState = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
|
|||
import HomeScreenSettings from '../screens/HomeScreenSettings';
|
||||
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
||||
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
|
||||
import SimklSettingsScreen from '../screens/SimklSettingsScreen';
|
||||
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
||||
import ThemeScreen from '../screens/ThemeScreen';
|
||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||
|
|
@ -185,6 +186,7 @@ export type RootStackParamList = {
|
|||
HomeScreenSettings: undefined;
|
||||
HeroCatalogs: undefined;
|
||||
TraktSettings: undefined;
|
||||
SimklSettings: undefined;
|
||||
PlayerSettings: undefined;
|
||||
ThemeSettings: undefined;
|
||||
ScraperSettings: undefined;
|
||||
|
|
@ -1565,6 +1567,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SimklSettings"
|
||||
component={SimklSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import { AboutSettingsContent, AboutFooter } from './settings/AboutSettingsScree
|
|||
import { SettingsCard, SettingItem, ChevronRight, CustomSwitch } from './settings/SettingsComponents';
|
||||
import { useBottomSheetBackHandler } from '../hooks/useBottomSheetBackHandler';
|
||||
import { LOCALES } from '../constants/locales';
|
||||
import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||
import SimklIcon from '../components/icons/SimklIcon';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -201,6 +203,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { lastUpdate } = useCatalogContext();
|
||||
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
|
||||
const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Tablet-specific state
|
||||
|
|
@ -378,6 +381,17 @@ const SettingsScreen: React.FC = () => {
|
|||
customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
isLast={!isItemVisible('simkl')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
)}
|
||||
{isItemVisible('simkl') && (
|
||||
<SettingItem
|
||||
title={'Simkl'}
|
||||
description={isSimklAuthenticated ? 'Connected' : 'Track what you watch'}
|
||||
customIcon={<SimklIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('SimklSettings')}
|
||||
isLast={true}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
|
|
@ -618,7 +632,7 @@ const SettingsScreen: React.FC = () => {
|
|||
contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]}
|
||||
>
|
||||
{
|
||||
LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l =>
|
||||
LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l =>
|
||||
<TouchableOpacity
|
||||
key={l.key}
|
||||
style={[
|
||||
|
|
@ -663,7 +677,7 @@ const SettingsScreen: React.FC = () => {
|
|||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* Account */}
|
||||
{(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && (
|
||||
{(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && (
|
||||
<SettingsCard title={t('settings.account').toUpperCase()}>
|
||||
{isItemVisible('trakt') && (
|
||||
<SettingItem
|
||||
|
|
@ -672,7 +686,17 @@ const SettingsScreen: React.FC = () => {
|
|||
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
isLast
|
||||
isLast={!isItemVisible('simkl')}
|
||||
/>
|
||||
)}
|
||||
{isItemVisible('simkl') && (
|
||||
<SettingItem
|
||||
title={'Simkl'}
|
||||
description={isSimklAuthenticated ? 'Connected' : 'Track what you watch'}
|
||||
customIcon={<SimklIcon size={20} color={currentTheme.colors.primary} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('SimklSettings')}
|
||||
isLast={true}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
|
@ -940,7 +964,7 @@ const SettingsScreen: React.FC = () => {
|
|||
contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]}
|
||||
>
|
||||
{
|
||||
LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l =>
|
||||
LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l =>
|
||||
<TouchableOpacity
|
||||
key={l.key}
|
||||
style={[
|
||||
|
|
|
|||
318
src/screens/SimklSettingsScreen.tsx
Normal file
318
src/screens/SimklSettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { makeRedirectUri, useAuthRequest, ResponseType, CodeChallengeMethod } from 'expo-auth-session';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
import { SimklService } from '../services/simklService';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
// Simkl configuration
|
||||
const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string;
|
||||
const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl';
|
||||
|
||||
const discovery = {
|
||||
authorizationEndpoint: 'https://simkl.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://api.simkl.com/oauth/token',
|
||||
};
|
||||
|
||||
// For use with deep linking
|
||||
const redirectUri = makeRedirectUri({
|
||||
scheme: 'nuvio',
|
||||
path: 'auth/simkl',
|
||||
});
|
||||
|
||||
const simklService = SimklService.getInstance();
|
||||
|
||||
const SimklSettingsScreen: React.FC = () => {
|
||||
const { settings } = useSettings();
|
||||
const isDarkMode = settings.enableDarkMode;
|
||||
const navigation = useNavigation();
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
checkAuthStatus,
|
||||
refreshAuthStatus
|
||||
} = useSimklIntegration();
|
||||
const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration();
|
||||
|
||||
const [isExchangingCode, setIsExchangingCode] = useState(false);
|
||||
|
||||
const openAlert = (title: string, message: string) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
// Setup expo-auth-session hook
|
||||
const [request, response, promptAsync] = useAuthRequest(
|
||||
{
|
||||
clientId: SIMKL_CLIENT_ID,
|
||||
scopes: [], // Simkl doesn't strictly use scopes for basic access
|
||||
redirectUri: SIMKL_REDIRECT_URI, // Must match what is set in Simkl Dashboard
|
||||
responseType: ResponseType.Code,
|
||||
// codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant
|
||||
},
|
||||
discovery
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, [checkAuthStatus]);
|
||||
|
||||
// Handle the response from the auth request
|
||||
useEffect(() => {
|
||||
if (response) {
|
||||
if (response.type === 'success') {
|
||||
const { code } = response.params;
|
||||
setIsExchangingCode(true);
|
||||
logger.log('[SimklSettingsScreen] Auth code received, exchanging...');
|
||||
|
||||
simklService.exchangeCodeForToken(code)
|
||||
.then(success => {
|
||||
if (success) {
|
||||
refreshAuthStatus();
|
||||
openAlert('Success', 'Connected to Simkl successfully!');
|
||||
} else {
|
||||
openAlert('Error', 'Failed to connect to Simkl.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('[SimklSettingsScreen] Token exchange error:', err);
|
||||
openAlert('Error', 'An error occurred during connection.');
|
||||
})
|
||||
.finally(() => setIsExchangingCode(false));
|
||||
} else if (response.type === 'error') {
|
||||
openAlert('Error', 'Authentication error: ' + (response.error?.message || 'Unknown'));
|
||||
}
|
||||
}
|
||||
}, [response, refreshAuthStatus]);
|
||||
|
||||
const handleSignIn = () => {
|
||||
if (!SIMKL_CLIENT_ID) {
|
||||
openAlert('Configuration Error', 'Simkl Client ID is missing in environment variables.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTraktAuthenticated) {
|
||||
openAlert('Conflict', 'You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.');
|
||||
return;
|
||||
}
|
||||
|
||||
promptAsync();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await simklService.logout();
|
||||
refreshAuthStatus();
|
||||
openAlert('Signed Out', 'You have disconnected from Simkl.');
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[
|
||||
styles.container,
|
||||
{ backgroundColor: isDarkMode ? currentTheme.colors.darkBackground : '#F2F2F7' }
|
||||
]}>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
|
||||
/>
|
||||
<Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
|
||||
Settings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.headerTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
|
||||
Simkl Integration
|
||||
</Text>
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={[
|
||||
styles.card,
|
||||
{ backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white }
|
||||
]}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
) : isAuthenticated ? (
|
||||
<View style={styles.profileContainer}>
|
||||
<Text style={[styles.statusTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Connected
|
||||
</Text>
|
||||
<Text style={[styles.statusDesc, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Your watched items are syncing with Simkl.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={styles.buttonText}>Disconnect</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.signInContainer}>
|
||||
<Text style={[styles.signInTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Connect Simkl
|
||||
</Text>
|
||||
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Sync your watch history and track what you're watching.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: currentTheme.colors.primary }
|
||||
]}
|
||||
onPress={handleSignIn}
|
||||
disabled={!request || isExchangingCode}
|
||||
>
|
||||
{isExchangingCode ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In with Simkl</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.disclaimer, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
|
||||
Nuvio is not affiliated with Simkl.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={[{ label: 'OK', onPress: () => setAlertVisible(false) }]}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 17,
|
||||
marginLeft: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 34,
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 40,
|
||||
alignItems: 'center',
|
||||
},
|
||||
signInContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
signInTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
signInDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
fontSize: 15,
|
||||
},
|
||||
profileContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusDesc: {
|
||||
fontSize: 15,
|
||||
marginBottom: 10,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
disclaimer: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default SimklSettingsScreen;
|
||||
|
|
@ -23,6 +23,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
|||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
||||
import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings';
|
||||
import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||
import { colors } from '../styles';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -67,6 +68,7 @@ const TraktSettingsScreen: React.FC = () => {
|
|||
isLoading: traktLoading,
|
||||
refreshAuthStatus
|
||||
} = useTraktIntegration();
|
||||
const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration();
|
||||
|
||||
const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false);
|
||||
const [showThresholdModal, setShowThresholdModal] = useState(false);
|
||||
|
|
@ -184,6 +186,10 @@ const TraktSettingsScreen: React.FC = () => {
|
|||
}, [response, checkAuthStatus, request?.codeVerifier, navigation]);
|
||||
|
||||
const handleSignIn = () => {
|
||||
if (isSimklAuthenticated) {
|
||||
openAlert('Conflict', 'You cannot connect to Trakt while Simkl is connected. Please disconnect Simkl first.');
|
||||
return;
|
||||
}
|
||||
promptAsync(); // Trigger the authentication flow
|
||||
};
|
||||
|
||||
|
|
|
|||
509
src/services/simklService.ts
Normal file
509
src/services/simklService.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Storage keys
|
||||
export const SIMKL_ACCESS_TOKEN_KEY = 'simkl_access_token';
|
||||
|
||||
// Simkl API configuration
|
||||
const SIMKL_API_URL = 'https://api.simkl.com';
|
||||
const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string;
|
||||
const SIMKL_CLIENT_SECRET = process.env.EXPO_PUBLIC_SIMKL_CLIENT_SECRET as string;
|
||||
const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl';
|
||||
|
||||
if (!SIMKL_CLIENT_ID || !SIMKL_CLIENT_SECRET) {
|
||||
logger.warn('[SimklService] Missing Simkl env vars. Simkl integration will be disabled.');
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface SimklUser {
|
||||
user: {
|
||||
name: string;
|
||||
joined_at: string;
|
||||
avatar: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SimklIds {
|
||||
simkl?: number;
|
||||
slug?: string;
|
||||
imdb?: string;
|
||||
tmdb?: number;
|
||||
mal?: string;
|
||||
tvdb?: string;
|
||||
anidb?: string;
|
||||
}
|
||||
|
||||
export interface SimklContentData {
|
||||
type: 'movie' | 'episode' | 'anime';
|
||||
title: string;
|
||||
year?: number;
|
||||
ids: SimklIds;
|
||||
// For episodes
|
||||
season?: number;
|
||||
episode?: number;
|
||||
showTitle?: string;
|
||||
// For anime
|
||||
animeType?: string;
|
||||
}
|
||||
|
||||
export interface SimklScrobbleResponse {
|
||||
id: number;
|
||||
action: 'start' | 'pause' | 'scrobble';
|
||||
progress: number;
|
||||
movie?: any;
|
||||
show?: any;
|
||||
episode?: any;
|
||||
anime?: any;
|
||||
}
|
||||
|
||||
export interface SimklPlaybackData {
|
||||
id: number;
|
||||
progress: number;
|
||||
paused_at: string;
|
||||
type: 'movie' | 'episode';
|
||||
movie?: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: SimklIds;
|
||||
};
|
||||
show?: {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: SimklIds;
|
||||
};
|
||||
episode?: {
|
||||
season: number;
|
||||
episode: number;
|
||||
title: string;
|
||||
tvdb_season?: number;
|
||||
tvdb_number?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class SimklService {
|
||||
private static instance: SimklService;
|
||||
private accessToken: string | null = null;
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
// Rate limiting & Debouncing
|
||||
private lastApiCall: number = 0;
|
||||
private readonly MIN_API_INTERVAL = 500;
|
||||
private requestQueue: Array<() => Promise<any>> = [];
|
||||
private isProcessingQueue: boolean = false;
|
||||
|
||||
// Track scrobbled items to prevent duplicates/spam
|
||||
private lastSyncTimes: Map<string, number> = new Map();
|
||||
private readonly SYNC_DEBOUNCE_MS = 15000; // 15 seconds
|
||||
|
||||
// Default completion threshold (can't be configured on Simkl side essentially, but we use it for logic)
|
||||
private readonly COMPLETION_THRESHOLD = 80;
|
||||
|
||||
private constructor() {
|
||||
// Determine cleanup logic if needed
|
||||
AppState.addEventListener('change', this.handleAppStateChange);
|
||||
}
|
||||
|
||||
public static getInstance(): SimklService {
|
||||
if (!SimklService.instance) {
|
||||
SimklService.instance = new SimklService();
|
||||
}
|
||||
return SimklService.instance;
|
||||
}
|
||||
|
||||
private handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
// Potential cleanup or flush queue logic here
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the Simkl service by loading stored token
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
const accessToken = await mmkvStorage.getItem(SIMKL_ACCESS_TOKEN_KEY);
|
||||
this.accessToken = accessToken;
|
||||
this.isInitialized = true;
|
||||
logger.log('[SimklService] Initialized, authenticated:', !!this.accessToken);
|
||||
} catch (error) {
|
||||
logger.error('[SimklService] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
*/
|
||||
public async isAuthenticated(): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
return !!this.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth URL for OAuth
|
||||
*/
|
||||
public getAuthUrl(): string {
|
||||
return `https://simkl.com/oauth/authorize?response_type=code&client_id=${SIMKL_CLIENT_ID}&redirect_uri=${encodeURIComponent(SIMKL_REDIRECT_URI)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange code for access token
|
||||
* Simkl tokens do not expire
|
||||
*/
|
||||
public async exchangeCodeForToken(code: string): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIMKL_API_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
client_id: SIMKL_CLIENT_ID,
|
||||
client_secret: SIMKL_CLIENT_SECRET,
|
||||
redirect_uri: SIMKL_REDIRECT_URI,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
logger.error('[SimklService] Token exchange error:', errorBody);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.access_token) {
|
||||
await this.saveToken(data.access_token);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[SimklService] Failed to exchange code:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveToken(accessToken: string): Promise<void> {
|
||||
this.accessToken = accessToken;
|
||||
try {
|
||||
await mmkvStorage.setItem(SIMKL_ACCESS_TOKEN_KEY, accessToken);
|
||||
logger.log('[SimklService] Token saved successfully');
|
||||
} catch (error) {
|
||||
logger.error('[SimklService] Failed to save token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
this.accessToken = null;
|
||||
await mmkvStorage.removeItem(SIMKL_ACCESS_TOKEN_KEY);
|
||||
logger.log('[SimklService] Logged out');
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API Request handler
|
||||
*/
|
||||
private async apiRequest<T>(
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
body?: any
|
||||
): Promise<T | null> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// Rate limiting
|
||||
const now = Date.now();
|
||||
const timeSinceLastCall = now - this.lastApiCall;
|
||||
if (timeSinceLastCall < this.MIN_API_INTERVAL) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL - timeSinceLastCall));
|
||||
}
|
||||
this.lastApiCall = Date.now();
|
||||
|
||||
if (!this.accessToken) {
|
||||
logger.warn('[SimklService] Cannot make request: Not authenticated');
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'simkl-api-key': SIMKL_CLIENT_ID
|
||||
};
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
if (endpoint.includes('scrobble')) {
|
||||
logger.log(`[SimklService] Requesting: ${method} ${endpoint}`, body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIMKL_API_URL}${endpoint}`, options);
|
||||
|
||||
if (response.status === 409) {
|
||||
// Conflict means already watched/scrobbled within last hour, which is strictly a success for our purposes
|
||||
logger.log(`[SimklService] 409 Conflict (Already watched/active) for ${endpoint}`);
|
||||
// We can return a mock success or null depending on what caller expects.
|
||||
// For scrobble actions (which usually return an ID or object), we might return null or handle it.
|
||||
// Simkl returns body with "watched_at" etc.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(`[SimklService] API Error ${response.status} for ${endpoint}:`, errorText);
|
||||
return null; // Return null on error
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error(`[SimklService] Network request failed for ${endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload for Scrobbling
|
||||
*/
|
||||
private buildScrobblePayload(content: SimklContentData, progress: number): any {
|
||||
// Simkl uses flexible progress but let's standardize
|
||||
const cleanProgress = Math.max(0, Math.min(100, progress));
|
||||
|
||||
const payload: any = {
|
||||
progress: cleanProgress
|
||||
};
|
||||
|
||||
// IDs object setup (remove undefined/nulls)
|
||||
const ids: any = {};
|
||||
if (content.ids.imdb) ids.imdb = content.ids.imdb;
|
||||
if (content.ids.tmdb) ids.tmdb = content.ids.tmdb;
|
||||
if (content.ids.simkl) ids.simkl = content.ids.simkl;
|
||||
if (content.ids.mal) ids.mal = content.ids.mal; // for anime
|
||||
|
||||
// Construct object based on type
|
||||
if (content.type === 'movie') {
|
||||
payload.movie = {
|
||||
title: content.title,
|
||||
year: content.year,
|
||||
ids: ids
|
||||
};
|
||||
} else if (content.type === 'episode') {
|
||||
payload.show = {
|
||||
title: content.showTitle || content.title,
|
||||
year: content.year,
|
||||
ids: {
|
||||
// If we have show IMDB/TMDB use those, otherwise fallback (might be same if passed in ids)
|
||||
// Ideally caller passes show-specific IDs in ids, but often we just have ids for the general item
|
||||
imdb: content.ids.imdb,
|
||||
tmdb: content.ids.tmdb,
|
||||
simkl: content.ids.simkl
|
||||
}
|
||||
};
|
||||
payload.episode = {
|
||||
season: content.season,
|
||||
number: content.episode
|
||||
};
|
||||
} else if (content.type === 'anime') {
|
||||
payload.anime = {
|
||||
title: content.title,
|
||||
ids: ids
|
||||
};
|
||||
// Anime also needs episode info if it's an episode
|
||||
if (content.episode) {
|
||||
payload.episode = {
|
||||
season: content.season || 1,
|
||||
number: content.episode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* SCROBBLE: START
|
||||
*/
|
||||
public async scrobbleStart(content: SimklContentData, progress: number): Promise<SimklScrobbleResponse | null> {
|
||||
try {
|
||||
const payload = this.buildScrobblePayload(content, progress);
|
||||
logger.log('[SimklService] scrobbleStart payload:', JSON.stringify(payload));
|
||||
const response = await this.apiRequest<SimklScrobbleResponse>('/scrobble/start', 'POST', payload);
|
||||
logger.log('[SimklService] scrobbleStart response:', JSON.stringify(response));
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error('[SimklService] Scrobble Start failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SCROBBLE: PAUSE
|
||||
*/
|
||||
public async scrobblePause(content: SimklContentData, progress: number): Promise<SimklScrobbleResponse | null> {
|
||||
try {
|
||||
// Debounce check
|
||||
const key = this.getContentKey(content);
|
||||
const now = Date.now();
|
||||
const lastSync = this.lastSyncTimes.get(key) || 0;
|
||||
|
||||
if (now - lastSync < this.SYNC_DEBOUNCE_MS) {
|
||||
return null; // Skip if too soon
|
||||
}
|
||||
this.lastSyncTimes.set(key, now);
|
||||
|
||||
this.lastSyncTimes.set(key, now);
|
||||
|
||||
const payload = this.buildScrobblePayload(content, progress);
|
||||
logger.log('[SimklService] scrobblePause payload:', JSON.stringify(payload));
|
||||
const response = await this.apiRequest<SimklScrobbleResponse>('/scrobble/pause', 'POST', payload);
|
||||
logger.log('[SimklService] scrobblePause response:', JSON.stringify(response));
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error('[SimklService] Scrobble Pause failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SCROBBLE: STOP
|
||||
*/
|
||||
public async scrobbleStop(content: SimklContentData, progress: number): Promise<SimklScrobbleResponse | null> {
|
||||
try {
|
||||
const payload = this.buildScrobblePayload(content, progress);
|
||||
logger.log('[SimklService] scrobbleStop payload:', JSON.stringify(payload));
|
||||
// Simkl automatically marks as watched if progress >= 80% (or server logic)
|
||||
// We just hit /scrobble/stop
|
||||
const response = await this.apiRequest<SimklScrobbleResponse>('/scrobble/stop', 'POST', payload);
|
||||
logger.log('[SimklService] scrobbleStop response:', JSON.stringify(response));
|
||||
|
||||
// If response is null (often 409 Conflict) OR we failed, but progress is high,
|
||||
// we should force "mark as watched" via history sync to be safe.
|
||||
// 409 means "Action already active" or "Checkin active", often if 'pause' was just called.
|
||||
// If the user finished (progress >= 80), we MUST ensure it's marked watched.
|
||||
if (!response && progress >= this.COMPLETION_THRESHOLD) {
|
||||
logger.log(`[SimklService] scrobbleStop failed/conflict at ${progress}%. Falling back to /sync/history to ensure watched status.`);
|
||||
|
||||
try {
|
||||
const historyPayload: any = {};
|
||||
|
||||
if (content.type === 'movie') {
|
||||
historyPayload.movies = [{
|
||||
ids: content.ids
|
||||
}];
|
||||
} else if (content.type === 'episode') {
|
||||
historyPayload.shows = [{
|
||||
ids: content.ids,
|
||||
seasons: [{
|
||||
number: content.season,
|
||||
episodes: [{ number: content.episode }]
|
||||
}]
|
||||
}];
|
||||
} else if (content.type === 'anime') {
|
||||
// Anime structure similar to shows usually, or 'anime' key?
|
||||
// Simkl API often uses 'shows' for anime too if listed as show, or 'anime' key.
|
||||
// Safest is to try 'shows' if we have standard IDs, or 'anime' if specifically anime.
|
||||
// Let's use 'anime' key if type is anime, assuming similar structure.
|
||||
historyPayload.anime = [{
|
||||
ids: content.ids,
|
||||
episodes: [{
|
||||
season: content.season || 1,
|
||||
number: content.episode
|
||||
}]
|
||||
}];
|
||||
}
|
||||
|
||||
if (Object.keys(historyPayload).length > 0) {
|
||||
const historyResponse = await this.addToHistory(historyPayload);
|
||||
logger.log('[SimklService] Fallback history sync response:', JSON.stringify(historyResponse));
|
||||
if (historyResponse) {
|
||||
// Construct a fake scrobble response to satisfy caller
|
||||
return {
|
||||
id: 0,
|
||||
action: 'scrobble',
|
||||
progress: progress,
|
||||
...payload
|
||||
} as SimklScrobbleResponse;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[SimklService] Fallback history sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error('[SimklService] Scrobble Stop failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getContentKey(content: SimklContentData): string {
|
||||
return `${content.type}:${content.ids.imdb || content.ids.tmdb || content.title}:${content.season}:${content.episode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNC: Get Playback Sessions (Continue Watching)
|
||||
*/
|
||||
/**
|
||||
* SYNC: Add items to History (Global "Mark as Watched")
|
||||
*/
|
||||
public async addToHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise<any> {
|
||||
return await this.apiRequest('/sync/history', 'POST', items);
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNC: Remove items from History
|
||||
*/
|
||||
public async removeFromHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise<any> {
|
||||
return await this.apiRequest('/sync/history/remove', 'POST', items);
|
||||
}
|
||||
|
||||
public async getPlaybackStatus(): Promise<SimklPlaybackData[]> {
|
||||
// Get both movies and episodes
|
||||
// Simkl endpoint: /sync/playback (returns all if no type specified, or we specify type)
|
||||
// Docs say /sync/playback/{type}
|
||||
// Let's trying getting all if possible, or fetch both. Docs say type is optional param?
|
||||
// Docs: /sync/playback/{type} -> actually path param seems required or at least standard.
|
||||
// But query params: type (optional).
|
||||
// Let's try fetching without path param or empty?
|
||||
// Docs: "Retrieves all paused... optionally filter by type by appending /movie"
|
||||
// Let's assume /sync/playback works for all.
|
||||
|
||||
const response = await this.apiRequest<SimklPlaybackData[]>('/sync/playback');
|
||||
return response || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* SYNC: Get Full Watch History (summary)
|
||||
* Optimization: Check /sync/activities first in real usage.
|
||||
* For now, we implement simple fetch.
|
||||
*/
|
||||
public async getAllItems(dateFrom?: string): Promise<any> {
|
||||
let url = '/sync/all-items/';
|
||||
if (dateFrom) {
|
||||
url += `?date_from=${dateFrom}`;
|
||||
}
|
||||
return await this.apiRequest(url);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ interface WatchProgress {
|
|||
traktSynced?: boolean;
|
||||
traktLastSynced?: number;
|
||||
traktProgress?: number;
|
||||
simklSynced?: boolean;
|
||||
simklLastSynced?: number;
|
||||
simklProgress?: number;
|
||||
}
|
||||
|
||||
class StorageService {
|
||||
|
|
@ -463,6 +466,46 @@ class StorageService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Simkl sync status for a watch progress entry
|
||||
*/
|
||||
public async updateSimklSyncStatus(
|
||||
id: string,
|
||||
type: string,
|
||||
simklSynced: boolean,
|
||||
simklProgress?: number,
|
||||
episodeId?: string,
|
||||
exactTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
if (existingProgress) {
|
||||
// Preserve the highest Simkl progress and currentTime values
|
||||
const highestSimklProgress = (() => {
|
||||
if (simklProgress === undefined) return existingProgress.simklProgress;
|
||||
if (existingProgress.simklProgress === undefined) return simklProgress;
|
||||
return Math.max(simklProgress, existingProgress.simklProgress);
|
||||
})();
|
||||
|
||||
const highestCurrentTime = (() => {
|
||||
if (!exactTime || exactTime <= 0) return existingProgress.currentTime;
|
||||
return Math.max(exactTime, existingProgress.currentTime);
|
||||
})();
|
||||
|
||||
const updatedProgress: WatchProgress = {
|
||||
...existingProgress,
|
||||
simklSynced,
|
||||
simklLastSynced: simklSynced ? Date.now() : existingProgress.simklLastSynced,
|
||||
simklProgress: highestSimklProgress,
|
||||
currentTime: highestCurrentTime,
|
||||
};
|
||||
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating Simkl sync status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all watch progress entries that need Trakt sync
|
||||
*/
|
||||
|
|
@ -495,8 +538,8 @@ class StorageService {
|
|||
continue;
|
||||
}
|
||||
// Check if needs sync (either never synced or local progress is newer)
|
||||
const needsSync = !progress.traktSynced ||
|
||||
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
|
||||
const needsSync = (!progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced)) ||
|
||||
(!progress.simklSynced || (progress.simklLastSynced && progress.lastUpdated > progress.simklLastSynced));
|
||||
|
||||
if (needsSync) {
|
||||
const parts = key.split(':');
|
||||
|
|
@ -611,6 +654,7 @@ class StorageService {
|
|||
duration,
|
||||
lastUpdated: traktTimestamp,
|
||||
traktSynced: true,
|
||||
simklSynced: false,
|
||||
traktLastSynced: Date.now(),
|
||||
traktProgress
|
||||
};
|
||||
|
|
@ -687,6 +731,105 @@ class StorageService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge Simkl progress with local progress using exact time when available
|
||||
*/
|
||||
public async mergeWithSimklProgress(
|
||||
id: string,
|
||||
type: string,
|
||||
simklProgress: number,
|
||||
simklPausedAt: string,
|
||||
episodeId?: string,
|
||||
exactTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const localProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
const simklTimestamp = new Date(simklPausedAt).getTime();
|
||||
|
||||
if (!localProgress) {
|
||||
let duration = await this.getContentDuration(id, type, episodeId);
|
||||
let currentTime: number;
|
||||
|
||||
if (exactTime && exactTime > 0) {
|
||||
currentTime = exactTime;
|
||||
if (!duration) {
|
||||
duration = (exactTime / simklProgress) * 100;
|
||||
}
|
||||
} else {
|
||||
if (!duration) {
|
||||
if (type === 'movie') {
|
||||
duration = 6600;
|
||||
} else if (episodeId) {
|
||||
duration = 2700;
|
||||
} else {
|
||||
duration = 3600;
|
||||
}
|
||||
}
|
||||
currentTime = (simklProgress / 100) * duration;
|
||||
}
|
||||
|
||||
const newProgress: WatchProgress = {
|
||||
currentTime,
|
||||
duration,
|
||||
lastUpdated: simklTimestamp,
|
||||
simklSynced: true,
|
||||
simklLastSynced: Date.now(),
|
||||
simklProgress
|
||||
};
|
||||
await this.setWatchProgress(id, type, newProgress, episodeId);
|
||||
} else {
|
||||
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
|
||||
const progressDiff = Math.abs(simklProgress - localProgressPercent);
|
||||
|
||||
if (progressDiff < 5 && simklProgress < 100 && localProgressPercent < 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentTime: number;
|
||||
let duration = localProgress.duration;
|
||||
|
||||
if (exactTime && exactTime > 0 && localProgress.duration > 0) {
|
||||
currentTime = exactTime;
|
||||
const calculatedDuration = (exactTime / simklProgress) * 100;
|
||||
if (Math.abs(calculatedDuration - localProgress.duration) > 300) {
|
||||
duration = calculatedDuration;
|
||||
}
|
||||
} else if (localProgress.duration > 0) {
|
||||
currentTime = (simklProgress / 100) * localProgress.duration;
|
||||
} else {
|
||||
const storedDuration = await this.getContentDuration(id, type, episodeId);
|
||||
duration = storedDuration || 0;
|
||||
if (!duration || duration <= 0) {
|
||||
if (exactTime && exactTime > 0) {
|
||||
duration = (exactTime / simklProgress) * 100;
|
||||
currentTime = exactTime;
|
||||
} else {
|
||||
if (type === 'movie') duration = 6600;
|
||||
else if (episodeId) duration = 2700;
|
||||
else duration = 3600;
|
||||
currentTime = (simklProgress / 100) * duration;
|
||||
}
|
||||
} else {
|
||||
currentTime = exactTime && exactTime > 0 ? exactTime : (simklProgress / 100) * duration;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProgress: WatchProgress = {
|
||||
...localProgress,
|
||||
currentTime,
|
||||
duration,
|
||||
lastUpdated: simklTimestamp,
|
||||
simklSynced: true,
|
||||
simklLastSynced: Date.now(),
|
||||
simklProgress
|
||||
};
|
||||
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error merging with Simkl progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async saveSubtitleSettings(settings: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
const key = await this.getSubtitleSettingsKeyScoped();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { TraktService } from './traktService';
|
||||
import { SimklService } from './simklService';
|
||||
import { storageService } from './storageService';
|
||||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -13,9 +14,11 @@ import { logger } from '../utils/logger';
|
|||
class WatchedService {
|
||||
private static instance: WatchedService;
|
||||
private traktService: TraktService;
|
||||
private simklService: SimklService;
|
||||
|
||||
private constructor() {
|
||||
this.traktService = TraktService.getInstance();
|
||||
this.simklService = SimklService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): WatchedService {
|
||||
|
|
@ -47,6 +50,13 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.addToHistory({ movies: [{ ids: { imdb: imdbId }, watched_at: watchedAt.toISOString() }] });
|
||||
logger.log(`[WatchedService] Simkl sync request sent for movie`);
|
||||
}
|
||||
|
||||
// Also store locally as "completed" (100% progress)
|
||||
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
|
||||
|
||||
|
|
@ -90,6 +100,22 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Simkl structure: shows -> seasons -> episodes
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: [{ number: episode, watched_at: watchedAt.toISOString() }]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl sync request sent for episode`);
|
||||
}
|
||||
|
||||
// Store locally as "completed"
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||
|
|
@ -135,6 +161,27 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Group by season for Simkl payload efficiency
|
||||
const seasonMap = new Map<number, any[]>();
|
||||
episodes.forEach(ep => {
|
||||
if (!seasonMap.has(ep.season)) seasonMap.set(ep.season, []);
|
||||
seasonMap.get(ep.season)?.push({ number: ep.episode, watched_at: watchedAt.toISOString() });
|
||||
});
|
||||
|
||||
const seasons = Array.from(seasonMap.entries()).map(([num, eps]) => ({ number: num, episodes: eps }));
|
||||
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: seasons
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl batch sync request sent`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode
|
||||
for (const ep of episodes) {
|
||||
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
|
||||
|
|
@ -180,6 +227,24 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it
|
||||
// We know the episodeNumbers from the arguments!
|
||||
const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() }));
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: episodes
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl season sync request sent`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
|
|
@ -210,6 +275,13 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] });
|
||||
logger.log(`[WatchedService] Simkl remove request sent for movie`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
await storageService.removeWatchProgress(imdbId, 'movie');
|
||||
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
||||
|
|
@ -245,6 +317,21 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: [{ number: episode }]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl remove request sent for episode`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||
|
|
@ -281,9 +368,29 @@ class WatchedService {
|
|||
showImdbId,
|
||||
season
|
||||
);
|
||||
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
||||
showImdbId,
|
||||
season
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
const episodes = episodeNumbers.map(num => ({ number: num }));
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: episodes
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl season removal request sent`);
|
||||
}
|
||||
|
||||
// Remove local progress for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
|
|
@ -301,60 +408,60 @@ class WatchedService {
|
|||
* Check if a movie is marked as watched (locally)
|
||||
*/
|
||||
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId);
|
||||
if (traktWatched) return true;
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
||||
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
return local === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
return local === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if an episode is marked as watched (locally)
|
||||
*/
|
||||
public async isEpisodeWatched(
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isEpisodeWatchedAccurate(
|
||||
showId,
|
||||
season,
|
||||
episode
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isEpisodeWatchedAccurate(
|
||||
showId,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
const progress = await storageService.getWatchProgress(
|
||||
showId,
|
||||
'series',
|
||||
episodeId
|
||||
);
|
||||
if (traktWatched) return true;
|
||||
|
||||
if (!progress) return false;
|
||||
|
||||
const pct = (progress.currentTime / progress.duration) * 100;
|
||||
return pct >= 99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
const progress = await storageService.getWatchProgress(
|
||||
showId,
|
||||
'series',
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (!progress) return false;
|
||||
|
||||
const pct = (progress.currentTime / progress.duration) * 100;
|
||||
return pct >= 99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set local watched status by creating a "completed" progress entry
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue