simkl init

This commit is contained in:
tapframe 2026-01-18 13:48:19 +05:30
parent bfba45e74a
commit 25e1102832
11 changed files with 1615 additions and 122 deletions

3
.gitignore vendored
View file

@ -97,4 +97,5 @@ trakt-docss
# Removed submodules (kept locally)
libmpv-android/
mpv-android/
mpvKt/
mpvKt/
simkl-docss

View 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;

View 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
};
}

View file

@ -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(() => {

View file

@ -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}

View file

@ -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={[

View 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;

View file

@ -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
};

View 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);
}
}

View file

@ -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();

View file

@ -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
*/