Merge pull request #421 from tapframe/simkl

Simkl Init (Alpha)
This commit is contained in:
Nayif 2026-01-18 14:32:29 +05:30 committed by GitHub
commit d7dc0ea8ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2539 additions and 148 deletions

3
.gitignore vendored
View file

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

1
LibTorrent Submodule

@ -0,0 +1 @@
Subproject commit eb1c71397b8716b97fcd375fd646e96c89632a5e

BIN
assets/simkl-favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
assets/simkl-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

0
assets/trakt-favicon.png Normal file
View file

1
iTorrent Submodule

@ -0,0 +1 @@
Subproject commit c27088b0ac36bf9bb30fae34dc36db1231263bfd

View file

@ -28,6 +28,7 @@ import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import * as Haptics from 'expo-haptics';
import { TraktService } from '../../services/traktService';
import { SimklService } from '../../services/simklService';
import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings';
@ -221,6 +222,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const lastTraktSyncRef = useRef<number>(0);
const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback)
// Track last Simkl sync to prevent excessive API calls
const lastSimklSyncRef = useRef<number>(0);
const SIMKL_SYNC_COOLDOWN = 0; // disabled (always fetch Simkl playback)
// Track last Trakt reconcile per item (local -> Trakt catch-up)
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item
@ -471,13 +476,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const traktService = TraktService.getInstance();
const isTraktAuthed = await traktService.isAuthenticated();
const simklService = SimklService.getInstance();
// Prefer Trakt if both are authenticated
const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false;
logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`);
// Declare groupPromises outside the if block
let groupPromises: Promise<void>[] = [];
// In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress
// when local is ahead (scrobble lag/offline playback).
let localProgressIndex: Map<string, LocalProgressEntry[]> | null = null;
if (isTraktAuthed) {
if (isTraktAuthed || isSimklAuthed) {
try {
const allProgress = await storageService.getAllWatchProgress();
const index = new Map<string, LocalProgressEntry[]>();
@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}
// Non-Trakt: use local storage
if (!isTraktAuthed) {
// Local-only mode (no Trakt, no Simkl): use local storage
if (!isTraktAuthed && !isSimklAuthed) {
const allProgress = await storageService.getAllWatchProgress();
if (Object.keys(allProgress).length === 0) {
setContinueWatchingItems([]);
@ -1300,8 +1311,219 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
})();
// Wait for all groups and trakt merge to settle, then finalize loading state
await Promise.allSettled([...groupPromises, traktMergePromise]);
// SIMKL: fetch playback progress (in-progress, paused) and merge similarly to Trakt
const simklMergePromise = (async () => {
try {
if (!isSimklAuthed || isTraktAuthed) return;
const now = Date.now();
if (SIMKL_SYNC_COOLDOWN > 0 && (now - lastSimklSyncRef.current) < SIMKL_SYNC_COOLDOWN) {
return;
}
lastSimklSyncRef.current = now;
const playbackItems = await simklService.getPlaybackStatus();
logger.log(`[CW][Simkl] playback items: ${playbackItems.length}`);
const simklBatch: ContinueWatchingItem[] = [];
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const sortedPlaybackItems = [...playbackItems]
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime())
.slice(0, 30);
for (const item of sortedPlaybackItems) {
try {
// Skip accidental clicks
if ((item.progress ?? 0) < 2) continue;
const pausedAt = new Date(item.paused_at).getTime();
if (pausedAt < thirtyDaysAgo) continue;
if (item.type === 'movie' && item.movie?.ids?.imdb) {
// Skip completed movies
if (item.progress >= 85) continue;
const imdbId = item.movie.ids.imdb.startsWith('tt')
? item.movie.ids.imdb
: `tt${item.movie.ids.imdb}`;
const movieKey = `movie:${imdbId}`;
if (recentlyRemovedRef.current.has(movieKey)) continue;
const cachedData = await getCachedMetadata('movie', imdbId);
if (!cachedData?.basicContent) continue;
simklBatch.push({
...cachedData.basicContent,
id: imdbId,
type: 'movie',
progress: item.progress,
lastUpdated: pausedAt,
addonId: undefined,
} as ContinueWatchingItem);
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
const showImdb = item.show.ids.imdb.startsWith('tt')
? item.show.ids.imdb
: `tt${item.show.ids.imdb}`;
const episodeNum = (item.episode as any).episode ?? (item.episode as any).number;
if (episodeNum === undefined || episodeNum === null) {
logger.warn('[CW][Simkl] Missing episode number in playback item, skipping', item);
continue;
}
const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue;
const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent) continue;
// If episode is completed (>= 85%), find next episode
if (item.progress >= 85) {
const metadata = cachedData.metadata;
if (metadata?.videos) {
const nextEpisode = findNextEpisode(
item.episode.season,
episodeNum,
metadata.videos,
undefined,
showImdb
);
if (nextEpisode) {
simklBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0,
lastUpdated: pausedAt,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
addonId: undefined,
} as ContinueWatchingItem);
}
}
continue;
}
simklBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: item.progress,
lastUpdated: pausedAt,
season: item.episode.season,
episode: episodeNum,
episodeTitle: item.episode.title || `Episode ${episodeNum}`,
addonId: undefined,
} as ContinueWatchingItem);
}
} catch {
// Continue with other items
}
}
if (simklBatch.length === 0) {
setContinueWatchingItems([]);
return;
}
// Dedupe (keep most recent per show/movie)
const deduped = new Map<string, ContinueWatchingItem>();
for (const item of simklBatch) {
const key = `${item.type}:${item.id}`;
const existing = deduped.get(key);
if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
deduped.set(key, item);
}
}
// Filter removed items
const filteredItems: ContinueWatchingItem[] = [];
for (const item of deduped.values()) {
const key = item.type === 'series' && item.season && item.episode
? `${item.type}:${item.id}:${item.season}:${item.episode}`
: `${item.type}:${item.id}`;
if (recentlyRemovedRef.current.has(key)) continue;
const removeId = item.type === 'series' && item.season && item.episode
? `${item.id}:${item.season}:${item.episode}`
: item.id;
const isRemoved = await storageService.isContinueWatchingRemoved(removeId, item.type);
if (!isRemoved) filteredItems.push(item);
}
// Overlay local progress when local is ahead or newer
const adjustedItems = filteredItems.map((it) => {
if (!localProgressIndex) return it;
const matches: LocalProgressEntry[] = [];
for (const idVariant of getIdVariants(it.id)) {
const list = localProgressIndex.get(`${it.type}:${idVariant}`);
if (!list) continue;
for (const entry of list) {
if (it.type === 'series' && it.season !== undefined && it.episode !== undefined) {
if (entry.season === it.season && entry.episode === it.episode) {
matches.push(entry);
}
} else {
matches.push(entry);
}
}
}
if (matches.length === 0) return it;
const mostRecentLocal = matches.reduce<LocalProgressEntry | null>((acc, cur) => {
if (!acc) return cur;
return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc;
}, null);
const highestLocal = matches.reduce<LocalProgressEntry | null>((acc, cur) => {
if (!acc) return cur;
return (cur.progressPercent ?? 0) > (acc.progressPercent ?? 0) ? cur : acc;
}, null);
if (!mostRecentLocal || !highestLocal) return it;
const localProgress = mostRecentLocal.progressPercent;
const simklProgress = it.progress ?? 0;
const localTs = mostRecentLocal.lastUpdated ?? 0;
const simklTs = it.lastUpdated ?? 0;
const isAhead = isFinite(localProgress) && localProgress > simklProgress + 0.5;
const isLocalNewer = localTs > simklTs + 5000;
if (isAhead || isLocalNewer) {
return {
...it,
progress: localProgress,
lastUpdated: localTs > 0 ? localTs : it.lastUpdated,
} as ContinueWatchingItem;
}
// Otherwise keep Simkl, but if local has a newer timestamp, use it for ordering
if (localTs > 0 && localTs > simklTs) {
return {
...it,
lastUpdated: localTs,
} as ContinueWatchingItem;
}
return it;
});
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
setContinueWatchingItems(adjustedItems);
} catch (err) {
logger.error('[SimklSync] Error in Simkl merge:', err);
}
})();
// Wait for all groups and provider merges to settle, then finalize loading state
await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]);
} catch (error) {
// Continue even if loading fails
} finally {

View file

@ -0,0 +1,23 @@
import React from 'react';
import { Image, StyleSheet } from 'react-native';
interface SimklIconProps {
size?: number;
color?: string;
style?: any;
}
const SimklIcon: React.FC<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
return (
<Image
source={require('../../../assets/simkl-favicon.png')}
style={[
{ width: size, height: size, flex: 1 },
style
]}
resizeMode="cover"
/>
);
};
export default SimklIcon;

View file

@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg';
interface TraktIconProps {
size?: number;
color?: string;
style?: any;
}
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224' }) => {
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224', style }) => {
return (
<View style={{ width: size, height: size }}>
<View style={[{ width: size, height: size, flex: 1 }, style]}>
<Svg
width={size}
height={size}
width="100%"
height="100%"
viewBox="0 0 144.8 144.8"
>
<Path

View file

@ -67,6 +67,7 @@ interface PlayerRouteParams {
year?: number;
streamProvider?: string;
streamName?: string;
videoType?: string;
id: string;
type: string;
episodeId?: string;
@ -92,6 +93,42 @@ const KSPlayerCore: React.FC = () => {
initialPosition: routeInitialPosition
} = params;
const videoType = (params as any)?.videoType as string | undefined;
useEffect(() => {
if (!__DEV__) return;
const headerKeys = Object.keys(headers || {});
logger.log('[KSPlayerCore] route params', {
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
id,
type,
episodeId,
imdbId,
title,
episodeTitle,
season,
episode,
quality,
year,
streamProvider,
streamName,
videoType,
headersKeys: headerKeys,
headersCount: headerKeys.length,
});
}, [uri, episodeId]);
useEffect(() => {
if (!__DEV__) return;
const headerKeys = Object.keys(headers || {});
logger.log('[KSPlayerCore] source update', {
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
videoType,
headersCount: headerKeys.length,
headersKeys: headerKeys,
});
}, [uri, headers, videoType]);
// --- Hooks ---
const playerState = usePlayerState();
const {
@ -399,6 +436,17 @@ const KSPlayerCore: React.FC = () => {
// Handlers
const onLoad = (data: any) => {
if (__DEV__) {
logger.log('[KSPlayerCore] onLoad', {
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
duration: data?.duration,
audioTracksCount: Array.isArray(data?.audioTracks) ? data.audioTracks.length : 0,
textTracksCount: Array.isArray(data?.textTracks) ? data.textTracks.length : 0,
videoType,
headersKeys: Object.keys(headers || {}),
});
}
setDuration(data.duration);
if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks);
if (data.textTracks) tracks.setKsTextTracks(data.textTracks);
@ -482,6 +530,18 @@ const KSPlayerCore: React.FC = () => {
} catch (e) {
msg = 'Error parsing error details';
}
if (__DEV__) {
logger.error('[KSPlayerCore] onError', {
msg,
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
videoType,
streamProvider,
streamName,
headersKeys: Object.keys(headers || {}),
rawError: error,
});
}
modals.setErrorDetails(msg);
modals.setShowErrorModal(true);
};
@ -525,6 +585,17 @@ const KSPlayerCore: React.FC = () => {
modals.setShowSourcesModal(false);
return;
}
if (__DEV__) {
logger.log('[KSPlayerCore] switching stream', {
fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url,
newStreamHeadersKeys: Object.keys(newStream?.headers || {}),
newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown',
newName: newStream?.name || newStream?.title || 'Unknown',
});
}
modals.setShowSourcesModal(false);
setPaused(true);
@ -559,6 +630,19 @@ const KSPlayerCore: React.FC = () => {
setPaused(true);
const ep = modals.selectedEpisodeForStreams;
if (__DEV__) {
logger.log('[KSPlayerCore] switching episode stream', {
toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url,
streamHeadersKeys: Object.keys(stream?.headers || {}),
ep: {
season: ep?.season_number,
episode: ep?.episode_number,
name: ep?.name,
stremioId: ep?.stremioId,
},
});
}
const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]);
const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown';
const newStreamName = stream.name || stream.title || 'Unknown Stream';

View file

@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import {
SimklService,
SimklContentData,
SimklPlaybackData,
SimklUserSettings,
SimklStats
} 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[]>([]);
const [userSettings, setUserSettings] = useState<SimklUserSettings | null>(null);
const [userStats, setUserStats] = useState<SimklStats | null>(null);
// 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]);
// Load user settings and stats
const loadUserProfile = useCallback(async () => {
if (!isAuthenticated) return;
try {
const settings = await simklService.getUserSettings();
setUserSettings(settings);
const stats = await simklService.getUserStats();
setUserStats(stats);
} catch (error) {
logger.error('[useSimklIntegration] Error loading user profile:', 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();
logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`);
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';
const epNum = (item.episode as any).episode ?? (item.episode as any).number;
episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined;
}
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();
loadUserProfile();
}
}, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]);
// 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,
userSettings,
userStats,
};
}

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

@ -522,6 +522,27 @@
"sync_success_msg": "تمت مزامنة تقدم المشاهدة مع Trakt بنجاح.",
"sync_error_msg": "فشلت المزامنة. يرجى المحاولة مرة أخرى."
},
"simkl": {
"title": "إعدادات Simkl",
"settings_title": "إعدادات Simkl",
"connect_title": "الاتصال بـ Simkl",
"connect_desc": "زامن تاريخ مشاهدتك وتتبع ما تشاهده",
"sign_in": "تسجيل الدخول بـ Simkl",
"sign_out": "قطع الاتصال",
"sign_out_confirm": "هل أنت متأكد من أنك تريد قطع الاتصال من Simkl؟",
"syncing_desc": "عناصرك المشاهدة تتم مزامنتها مع Simkl.",
"auth_success_title": "تم الاتصال بنجاح",
"auth_success_msg": "تم ربط حساب Simkl الخاص بك بنجاح.",
"auth_error_title": "خطأ في المصادقة",
"auth_error_msg": "فشل في إكمال المصادقة مع Simkl.",
"auth_error_generic": "حدث خطأ أثناء المصادقة.",
"sign_out_error": "فشل في قطع الاتصال من Simkl.",
"config_error_title": "خطأ في التكوين",
"config_error_msg": "معرف عميل Simkl مفقود في متغيرات البيئة.",
"conflict_title": "تعارض",
"conflict_msg": "لا يمكنك ربط Simkl بينما Trakt متصل. يرجى قطع اتصال Trakt أولاً.",
"disclaimer": "Nuvio غير مرتبط بشركة Simkl."
},
"tmdb_settings": {
"title": "إعدادات TMDb",
"metadata_enrichment": "إثراء البيانات التعريفية",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "متصل",
"mdblist_desc": "تفعيل لإضافة التقييمات والمراجعات",
"simkl": "Simkl",
"simkl_connected": "متصل",
"simkl_desc": "تتبع ما تشاهده",
"tmdb": "TMDB",
"tmdb_desc": "مزود البيانات التعريفية والشعارات",
"openrouter": "OpenRouter API",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Wiedergabefortschritt erfolgreich mit Trakt synchronisiert.",
"sync_error_msg": "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"simkl": {
"title": "Simkl Einstellungen",
"settings_title": "Simkl Einstellungen",
"connect_title": "Mit Simkl verbinden",
"connect_desc": "Synchronisieren Sie Ihren Verlauf und verfolgen Sie, was Sie sehen",
"sign_in": "Mit Simkl anmelden",
"sign_out": "Trennen",
"sign_out_confirm": "Sind Sie sicher, dass Sie die Verbindung zu Simkl trennen möchten?",
"syncing_desc": "Ihre gesehenen Elemente werden mit Simkl synchronisiert.",
"auth_success_title": "Erfolgreich verbunden",
"auth_success_msg": "Ihr Simkl-Konto wurde erfolgreich verbunden.",
"auth_error_title": "Authentifizierungsfehler",
"auth_error_msg": "Authentifizierung mit Simkl fehlgeschlagen.",
"auth_error_generic": "Bei der Authentifizierung ist ein Fehler aufgetreten.",
"sign_out_error": "Verbindung zu Simkl konnte nicht getrennt werden.",
"config_error_title": "Konfigurationsfehler",
"config_error_msg": "Simkl Client ID fehlt in den Umgebungsvariablen.",
"conflict_title": "Konflikt",
"conflict_msg": "Sie können Simkl nicht verbinden, während Trakt verbunden ist. Bitte trennen Sie zuerst Trakt.",
"disclaimer": "Nuvio ist nicht mit Simkl verbunden."
},
"tmdb_settings": {
"title": "TMDb Einstellungen",
"metadata_enrichment": "Metadaten-Anreicherung",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Verbunden",
"mdblist_desc": "Aktivieren, um Bewertungen & Rezensionen hinzuzufügen",
"simkl": "Simkl",
"simkl_connected": "Verbunden",
"simkl_desc": "Verfolge, was du schaust",
"tmdb": "TMDB",
"tmdb_desc": "Metadaten- & Logo-Quellanbieter",
"openrouter": "OpenRouter API",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Successfully synced your watch progress with Trakt.",
"sync_error_msg": "Sync failed. Please try again."
},
"simkl": {
"title": "Simkl Settings",
"settings_title": "Simkl Settings",
"connect_title": "Connect with Simkl",
"connect_desc": "Sync your watch history and track what you're watching",
"sign_in": "Sign In with Simkl",
"sign_out": "Disconnect",
"sign_out_confirm": "Are you sure you want to disconnect from Simkl?",
"syncing_desc": "Your watched items are syncing with Simkl.",
"auth_success_title": "Successfully Connected",
"auth_success_msg": "Your Simkl account has been connected successfully.",
"auth_error_title": "Authentication Error",
"auth_error_msg": "Failed to complete authentication with Simkl.",
"auth_error_generic": "An error occurred during authentication.",
"sign_out_error": "Failed to disconnect from Simkl.",
"config_error_title": "Configuration Error",
"config_error_msg": "Simkl Client ID is missing in environment variables.",
"conflict_title": "Conflict",
"conflict_msg": "You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.",
"disclaimer": "Nuvio is not affiliated with Simkl."
},
"tmdb_settings": {
"title": "TMDb Settings",
"metadata_enrichment": "Metadata Enrichment",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Connected",
"mdblist_desc": "Enable to add ratings & reviews",
"simkl": "Simkl",
"simkl_connected": "Connected",
"simkl_desc": "Track what you watch",
"tmdb": "TMDB",
"tmdb_desc": "Metadata & logo source provider",
"openrouter": "OpenRouter API",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Sincronización del progreso con Trakt completada con éxito.",
"sync_error_msg": "La sincronización falló. Por favor, inténtalo de nuevo."
},
"simkl": {
"title": "Configuración de Simkl",
"settings_title": "Configuración de Simkl",
"connect_title": "Conectar con Simkl",
"connect_desc": "Sincroniza tu historial de visualización y rastrea lo que ves",
"sign_in": "Iniciar sesión con Simkl",
"sign_out": "Desconectar",
"sign_out_confirm": "¿Estás seguro de que quieres desconectar de Simkl?",
"syncing_desc": "Tus elementos vistos se están sincronizando con Simkl.",
"auth_success_title": "Conectado exitosamente",
"auth_success_msg": "Tu cuenta de Simkl se ha conectado exitosamente.",
"auth_error_title": "Error de autenticación",
"auth_error_msg": "Error al completar la autenticación con Simkl.",
"auth_error_generic": "Ocurrió un error durante la autenticación.",
"sign_out_error": "Error al desconectar de Simkl.",
"config_error_title": "Error de configuración",
"config_error_msg": "El ID de cliente de Simkl falta en las variables de entorno.",
"conflict_title": "Conflicto",
"conflict_msg": "No puedes conectar Simkl mientras Trakt está conectado. Por favor, desconecta Trakt primero.",
"disclaimer": "Nuvio no está afiliado con Simkl."
},
"tmdb_settings": {
"title": "Ajustes de TMDb",
"metadata_enrichment": "Enriquecimiento de metadatos",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Conectado",
"mdblist_desc": "Activar para añadir valoraciones y reseñas",
"simkl": "Simkl",
"simkl_connected": "Conectado",
"simkl_desc": "Rastrea lo que ves",
"tmdb": "TMDB",
"tmdb_desc": "Proveedor de metadatos y logos",
"openrouter": "API de OpenRouter",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Votre progression a été synchronisée avec succès avec Trakt.",
"sync_error_msg": "La synchronisation a échoué. Veuillez réessayer."
},
"simkl": {
"title": "Paramètres Simkl",
"settings_title": "Paramètres Simkl",
"connect_title": "Se connecter avec Simkl",
"connect_desc": "Synchronisez votre historique de visionnage et suivez ce que vous regardez",
"sign_in": "Se connecter avec Simkl",
"sign_out": "Déconnecter",
"sign_out_confirm": "Êtes-vous sûr de vouloir vous déconnecter de Simkl ?",
"syncing_desc": "Vos éléments regardés sont synchronisés avec Simkl.",
"auth_success_title": "Connecté avec succès",
"auth_success_msg": "Votre compte Simkl a été connecté avec succès.",
"auth_error_title": "Erreur d'authentification",
"auth_error_msg": "Échec de l'authentification avec Simkl.",
"auth_error_generic": "Une erreur s'est produite lors de l'authentification.",
"sign_out_error": "Échec de la déconnexion de Simkl.",
"config_error_title": "Erreur de configuration",
"config_error_msg": "L'ID client Simkl est manquant dans les variables d'environnement.",
"conflict_title": "Conflit",
"conflict_msg": "Vous ne pouvez pas connecter Simkl tant que Trakt est connecté. Veuillez d'abord déconnecter Trakt.",
"disclaimer": "Nuvio n'est pas affilié à Simkl."
},
"tmdb_settings": {
"title": "Paramètres TMDb",
"metadata_enrichment": "Enrichissement des métadonnées",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Connecté",
"mdblist_desc": "Activer pour ajouter les notes et avis",
"simkl": "Simkl",
"simkl_connected": "Connecté",
"simkl_desc": "Suivez ce que vous regardez",
"tmdb": "TMDB",
"tmdb_desc": "Fournisseur de métadonnées et de logos",
"openrouter": "API OpenRouter",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Progressi di visione sincronizzati con successo con Trakt.",
"sync_error_msg": "Sincronizzazione fallita. Riprova."
},
"simkl": {
"title": "Impostazioni Simkl",
"settings_title": "Impostazioni Simkl",
"connect_title": "Connetti con Simkl",
"connect_desc": "Sincronizza la tua cronologia di visione e traccia ciò che guardi",
"sign_in": "Accedi con Simkl",
"sign_out": "Disconnetti",
"sign_out_confirm": "Sei sicuro di voler disconnettere da Simkl?",
"syncing_desc": "I tuoi elementi guardati sono in sincronizzazione con Simkl.",
"auth_success_title": "Connesso con successo",
"auth_success_msg": "Il tuo account Simkl è stato connesso con successo.",
"auth_error_title": "Errore di autenticazione",
"auth_error_msg": "Impossibile completare l'autenticazione con Simkl.",
"auth_error_generic": "Si è verificato un errore durante l'autenticazione.",
"sign_out_error": "Impossibile disconnettere da Simkl.",
"config_error_title": "Errore di configurazione",
"config_error_msg": "L'ID client Simkl manca nelle variabili d'ambiente.",
"conflict_title": "Conflitto",
"conflict_msg": "Non puoi connettere Simkl mentre Trakt è connesso. Disconnetti prima Trakt.",
"disclaimer": "Nuvio non è affiliato con Simkl."
},
"tmdb_settings": {
"title": "Impostazioni TMDb",
"metadata_enrichment": "Arricchimento metadati",
@ -675,6 +696,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Connesso",
"mdblist_desc": "Abilita per aggiungere voti e recensioni",
"simkl": "Simkl",
"simkl_connected": "Connesso",
"simkl_desc": "Traccia ciò che guardi",
"tmdb": "TMDB",
"tmdb_desc": "Sorgente metadati e loghi",
"openrouter": "API OpenRouter",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.",
"sync_error_msg": "Falha na sincronização. Tente novamente."
},
"simkl": {
"title": "Configurações do Simkl",
"settings_title": "Configurações do Simkl",
"connect_title": "Conectar com Simkl",
"connect_desc": "Sincronize seu histórico de visualização e rastreie o que você assiste",
"sign_in": "Entrar com Simkl",
"sign_out": "Desconectar",
"sign_out_confirm": "Tem certeza de que deseja desconectar do Simkl?",
"syncing_desc": "Seus itens assistidos estão sendo sincronizados com o Simkl.",
"auth_success_title": "Conectado com sucesso",
"auth_success_msg": "Sua conta Simkl foi conectada com sucesso.",
"auth_error_title": "Erro de autenticação",
"auth_error_msg": "Falha ao completar a autenticação com o Simkl.",
"auth_error_generic": "Ocorreu um erro durante a autenticação.",
"sign_out_error": "Falha ao desconectar do Simkl.",
"config_error_title": "Erro de configuração",
"config_error_msg": "O ID do cliente Simkl está faltando nas variáveis de ambiente.",
"conflict_title": "Conflito",
"conflict_msg": "Você não pode conectar o Simkl enquanto o Trakt está conectado. Desconecte o Trakt primeiro.",
"disclaimer": "Nuvio não é afiliado ao Simkl."
},
"tmdb_settings": {
"title": "Configurações do TMDb",
"metadata_enrichment": "Enriquecimento de Metadados",
@ -689,6 +710,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Conectado",
"mdblist_desc": "Habilitar para adicionar avaliações e resenhas",
"simkl": "Simkl",
"simkl_connected": "Conectado",
"simkl_desc": "Acompanhe o que você assiste",
"tmdb": "TMDB",
"tmdb_desc": "Provedor de metadados e logos",
"openrouter": "OpenRouter API",

View file

@ -522,6 +522,27 @@
"sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.",
"sync_error_msg": "Falha na sincronização. Tenta novamente."
},
"simkl": {
"title": "Configurações do Simkl",
"settings_title": "Configurações do Simkl",
"connect_title": "Ligar ao Simkl",
"connect_desc": "Sincroniza o teu histórico de visualização e rastreia o que vês",
"sign_in": "Entrar com Simkl",
"sign_out": "Desligar",
"sign_out_confirm": "Tens a certeza de que queres desligar do Simkl?",
"syncing_desc": "Os teus itens vistos estão a ser sincronizados com o Simkl.",
"auth_success_title": "Ligado com sucesso",
"auth_success_msg": "A tua conta Simkl foi ligada com sucesso.",
"auth_error_title": "Erro de autenticação",
"auth_error_msg": "Falha ao completar a autenticação com o Simkl.",
"auth_error_generic": "Ocorreu um erro durante a autenticação.",
"sign_out_error": "Falha ao desligar do Simkl.",
"config_error_title": "Erro de configuração",
"config_error_msg": "O ID do cliente Simkl está em falta nas variáveis de ambiente.",
"conflict_title": "Conflito",
"conflict_msg": "Não podes ligar o Simkl enquanto o Trakt está ligado. Desliga primeiro o Trakt.",
"disclaimer": "Nuvio não é afiliado ao Simkl."
},
"tmdb_settings": {
"title": "Configurações do TMDb",
"metadata_enrichment": "Enriquecimento de Metadados",
@ -689,6 +710,9 @@
"mdblist": "MDBList",
"mdblist_connected": "Conectado",
"mdblist_desc": "Ativar para adicionar avaliações e críticas",
"simkl": "Simkl",
"simkl_connected": "Conectado",
"simkl_desc": "Acompanhe o que vê",
"tmdb": "TMDB",
"tmdb_desc": "Provedor de metadados e logos",
"openrouter": "OpenRouter API",

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
@ -372,12 +375,23 @@ const SettingsScreen: React.FC = () => {
return (
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
{isItemVisible('trakt') && (
<SettingItem
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
<SettingItem
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={!isItemVisible('simkl')}
isTablet={isTablet}
/>
)}
{isItemVisible('simkl') && (
<SettingItem
title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
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,16 +677,26 @@ 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
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
customIcon={<TraktIcon size={20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast
isLast={!isItemVisible('simkl')}
/>
)}
{isItemVisible('simkl') && (
<SettingItem
title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={20} />}
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,462 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
ScrollView,
StatusBar,
Platform,
Image,
} 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';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
const {
isAuthenticated,
isLoading,
checkAuthStatus,
refreshAuthStatus,
userSettings,
userStats
} = 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(t('common.success'), t('simkl.auth_success_msg'));
} else {
openAlert(t('common.error'), t('simkl.auth_error_msg'));
}
})
.catch(err => {
logger.error('[SimklSettingsScreen] Token exchange error:', err);
openAlert(t('common.error'), t('simkl.auth_error_generic'));
})
.finally(() => setIsExchangingCode(false));
} else if (response.type === 'error') {
openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown')));
}
}
}, [response, refreshAuthStatus]);
const handleSignIn = () => {
if (!SIMKL_CLIENT_ID) {
openAlert(t('simkl.config_error_title'), t('simkl.config_error_msg'));
return;
}
if (isTraktAuthenticated) {
openAlert(t('simkl.conflict_title'), t('simkl.conflict_msg'));
return;
}
promptAsync();
};
const handleSignOut = async () => {
await simklService.logout();
refreshAuthStatus();
openAlert(t('common.success'), t('simkl.sign_out_confirm'));
};
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 }]}>
{t('simkl.settings_title')} (Alpha)
</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}>
<View style={styles.profileHeader}>
{userSettings?.user?.avatar ? (
<Image
source={{ uri: userSettings.user.avatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.elevation3 }]}>
<MaterialIcons name="person" size={20} color={currentTheme.colors.mediumEmphasis} />
</View>
)}
<View style={styles.profileText}>
{userSettings?.user && (
<Text style={[styles.statusTitle, { color: currentTheme.colors.highEmphasis }]}>
{userSettings.user.name}
</Text>
)}
{userSettings?.account?.type && (
<Text style={[styles.accountType, { color: currentTheme.colors.mediumEmphasis, textTransform: 'capitalize' }]}>
{userSettings.account.type} Account
</Text>
)}
</View>
</View>
<Text style={[styles.statusDesc, { color: currentTheme.colors.mediumEmphasis }]}>
{t('simkl.syncing_desc')}
</Text>
{userStats && (
<View style={[styles.statsGrid, { borderTopColor: currentTheme.colors.border, borderBottomColor: currentTheme.colors.border }]}>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.movies?.completed?.count || 0}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Movies
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{(userStats.tv?.watching?.count || 0) + (userStats.tv?.completed?.count || 0)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
TV Shows
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.anime?.completed?.count || 0}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Anime
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{Math.round(((userStats.total_mins || 0) + (userStats.movies?.total_mins || 0) + (userStats.tv?.total_mins || 0) + (userStats.anime?.total_mins || 0)) / 60)}h
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Watched
</Text>
</View>
</View>
)}
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>{t('simkl.sign_out')}</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
<Text style={[styles.signInTitle, { color: currentTheme.colors.highEmphasis }]}>
{t('simkl.connect_title')}
</Text>
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{t('simkl.connect_desc')}
</Text>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: currentTheme.colors.primary }
]}
onPress={handleSignIn}
disabled={!request || isExchangingCode}
>
{isExchangingCode ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.buttonText}>{t('simkl.sign_in')}</Text>
)}
</TouchableOpacity>
</View>
)}
</View>
<View style={styles.logoSection}>
<Image
source={require('../../assets/simkl-logo.png')}
style={styles.logo}
resizeMode="contain"
/>
</View>
<Text style={[styles.disclaimer, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{t('simkl.disclaimer')}
</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: 'stretch',
paddingVertical: 8,
},
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 12,
backgroundColor: '#00000010',
},
avatarPlaceholder: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
profileText: {
flex: 1,
},
statusTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 2,
},
accountType: {
fontSize: 13,
fontWeight: '500',
marginBottom: 8,
},
statusDesc: {
fontSize: 14,
marginBottom: 8,
},
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 16,
marginVertical: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 17,
fontWeight: '700',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
fontWeight: '500',
},
button: {
width: '100%',
height: 48,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: 'white',
},
disclaimer: {
fontSize: 12,
textAlign: 'center',
marginTop: 8,
marginBottom: 8,
},
logoSection: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
marginTop: 16,
marginBottom: 0,
},
logo: {
width: 150,
height: 30,
},
logoContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 28,
marginBottom: 24,
borderRadius: 12,
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
});
export default SimklSettingsScreen;

View file

@ -16,13 +16,14 @@ import { useNavigation } from '@react-navigation/native';
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import FastImage from '@d11/react-native-fast-image';
import { traktService, TraktUser } from '../services/traktService';
import { traktService, TraktUser, TraktUserStats } from '../services/traktService';
import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
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';
@ -54,6 +55,7 @@ const TraktSettingsScreen: React.FC = () => {
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [userStats, setUserStats] = useState<TraktUserStats | null>(null);
const { currentTheme } = useTheme();
const {
@ -67,6 +69,7 @@ const TraktSettingsScreen: React.FC = () => {
isLoading: traktLoading,
refreshAuthStatus
} = useTraktIntegration();
const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration();
const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false);
const [showThresholdModal, setShowThresholdModal] = useState(false);
@ -107,8 +110,16 @@ const TraktSettingsScreen: React.FC = () => {
if (authenticated) {
const profile = await traktService.getUserProfile();
setUserProfile(profile);
try {
const stats = await traktService.getUserStats();
setUserStats(stats);
} catch (statsError) {
logger.warn('[TraktSettingsScreen] Failed to load stats:', statsError);
setUserStats(null);
}
} else {
setUserProfile(null);
setUserStats(null);
}
} catch (error) {
logger.error('[TraktSettingsScreen] Error checking auth status:', error);
@ -184,6 +195,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
};
@ -347,6 +362,42 @@ const TraktSettingsScreen: React.FC = () => {
]}>
{t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })}
</Text>
{userStats && (
<View style={[styles.statsGrid, { borderTopColor: currentTheme.colors.border, borderBottomColor: currentTheme.colors.border }]}>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.movies?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Movies
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.shows?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Shows
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.episodes?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Episodes
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{Math.round(((userStats.minutes ?? 0) + (userStats.movies?.minutes ?? 0) + (userStats.shows?.minutes ?? 0) + (userStats.episodes?.minutes ?? 0)) / 60)}h
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Watched
</Text>
</View>
</View>
)}
</View>
<TouchableOpacity
@ -702,14 +753,35 @@ const styles = StyleSheet.create({
color: '#000',
},
statsContainer: {
marginTop: 16,
paddingTop: 16,
marginTop: 12,
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: 'rgba(150,150,150,0.2)',
},
joinedDate: {
fontSize: 14,
},
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 14,
marginTop: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statValue: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
statLabel: {
fontSize: 11,
fontWeight: '500',
},
settingsSection: {
padding: 20,
},

View file

@ -25,7 +25,6 @@ import {
getQualityNumeric,
detectMkvViaHead,
inferVideoTypeFromUrl,
filterHeadersForVidrock,
sortStreamsByQuality,
} from './utils';
import {
@ -234,25 +233,26 @@ export const useStreamsScreen = () => {
return 0;
};
const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number }> = [];
const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; originalIndex: number }> = [];
Object.entries(streamsData).forEach(([addonId, { streams }]) => {
const qualityFiltered = filterByQuality(streams);
const filteredStreams = filterByLanguage(qualityFiltered);
filteredStreams.forEach(stream => {
filteredStreams.forEach((stream, index) => {
const quality = getQualityNumeric(stream.name || stream.title);
const providerPriority = getProviderPriority(addonId);
allStreams.push({ stream, quality, providerPriority });
allStreams.push({ stream, quality, providerPriority, originalIndex: index });
});
});
if (allStreams.length === 0) return null;
// Sort primarily by provider priority, then respect the addon's internal order (originalIndex)
// This ensures if an addon lists 1080p before 4K, we pick 1080p
allStreams.sort((a, b) => {
if (a.quality !== b.quality) return b.quality - a.quality;
if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority;
return 0;
return a.originalIndex - b.originalIndex;
});
logger.log(
@ -355,11 +355,17 @@ export const useStreamsScreen = () => {
// Navigate to player
const navigateToPlayer = useCallback(
async (stream: Stream, options?: { headers?: Record<string, string> }) => {
const finalHeaders = filterHeadersForVidrock(options?.headers || (stream.headers as any));
const optionHeaders = options?.headers;
const streamHeaders = (stream.headers as any) as Record<string, string> | undefined;
const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as
| Record<string, string>
| undefined;
const streamProvider = stream.addonId || (stream as any).addonName || stream.name;
const finalHeaders = optionHeaders || streamHeaders || proxyHeaders;
const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams;
const streamName = stream.name || stream.title || 'Unnamed Stream';
const streamProvider = stream.addonId || stream.addonName || stream.name;
const resolvedStreamProvider = streamProvider;
// Save stream to cache
try {
@ -392,6 +398,22 @@ export const useStreamsScreen = () => {
}
} catch { }
if (__DEV__) {
const finalHeaderKeys = Object.keys(finalHeaders || {});
logger.log('[StreamsScreen][navigateToPlayer] stream selection', {
url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url,
addonId: stream.addonId,
addonName: (stream as any).addonName,
name: stream.name,
title: stream.title,
inferredVideoType: videoType,
optionHeadersKeys: Object.keys(optionHeaders || {}),
streamHeadersKeys: Object.keys(streamHeaders || {}),
finalHeadersKeys: finalHeaderKeys,
});
}
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
navigation.navigate(playerRoute as any, {
@ -402,7 +424,7 @@ export const useStreamsScreen = () => {
episode: (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined,
quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: metadata?.year,
streamProvider,
streamProvider: resolvedStreamProvider,
streamName,
headers: finalHeaders,
id,
@ -423,6 +445,24 @@ export const useStreamsScreen = () => {
try {
if (!stream.url) return;
if (__DEV__) {
const streamHeaders = (stream.headers as any) as Record<string, string> | undefined;
const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as
| Record<string, string>
| undefined;
logger.log('[StreamsScreen][handleStreamPress] pressed stream', {
url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url,
addonId: stream.addonId,
addonName: (stream as any).addonName,
name: stream.name,
title: stream.title,
streamHeadersKeys: Object.keys(streamHeaders || {}),
proxyHeadersKeys: Object.keys(proxyHeaders || {}),
inferredVideoType: inferVideoTypeFromUrl(stream.url),
});
}
// Block magnet links
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
openAlert('Not supported', 'Torrent streaming is not supported yet.');

View file

@ -0,0 +1,608 @@
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;
// Simkl docs show `episode` in playback responses, but some APIs return `number`
episode?: number;
number?: number;
title: string;
tvdb_season?: number;
tvdb_number?: number;
};
}
export interface SimklUserSettings {
user: {
name: string;
joined_at: string;
gender?: string;
avatar: string;
bio?: string;
loc?: string;
age?: string;
};
account: {
id: number;
timezone?: string;
type?: string;
};
connections?: Record<string, boolean>;
}
export interface SimklStats {
total_mins: number;
movies?: {
total_mins: number;
completed?: { count: number };
};
tv?: {
total_mins: number;
watching?: { count: number };
completed?: { count: number };
};
anime?: {
total_mins: number;
watching?: { count: number };
completed?: { count: 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[]> {
// Docs: GET /sync/playback/{type} with {type} values `movies` or `episodes`.
// Some docs also mention appending /movie or /episode; we try both variants for safety.
const tryEndpoints = async (endpoints: string[]): Promise<SimklPlaybackData[]> => {
for (const endpoint of endpoints) {
try {
const res = await this.apiRequest<SimklPlaybackData[]>(endpoint);
if (Array.isArray(res)) {
logger.log(`[SimklService] getPlaybackStatus: ${endpoint} -> ${res.length} items`);
return res;
}
} catch (e) {
logger.warn(`[SimklService] getPlaybackStatus: ${endpoint} failed`, e);
}
}
return [];
};
const movies = await tryEndpoints([
'/sync/playback/movies',
'/sync/playback/movie',
'/sync/playback?type=movies'
]);
const episodes = await tryEndpoints([
'/sync/playback/episodes',
'/sync/playback/episode',
'/sync/playback?type=episodes'
]);
const combined = [...episodes, ...movies]
.filter(Boolean)
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime());
logger.log(`[SimklService] getPlaybackStatus: combined ${combined.length} items (episodes=${episodes.length}, movies=${movies.length})`);
return combined;
}
/**
* 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);
}
/**
* Get user settings/profile
*/
public async getUserSettings(): Promise<SimklUserSettings | null> {
try {
const response = await this.apiRequest<SimklUserSettings>('/users/settings', 'POST');
logger.log('[SimklService] getUserSettings:', JSON.stringify(response));
return response;
} catch (error) {
logger.error('[SimklService] Failed to get user settings:', error);
return null;
}
}
/**
* Get user stats
*/
public async getUserStats(): Promise<SimklStats | null> {
try {
if (!await this.isAuthenticated()) {
return null;
}
// Need account ID from settings first
const settings = await this.getUserSettings();
if (!settings?.account?.id) {
logger.warn('[SimklService] Cannot get user stats: no account ID');
return null;
}
const response = await this.apiRequest<SimklStats>(`/users/${settings.account.id}/stats`, 'POST');
logger.log('[SimklService] getUserStats:', JSON.stringify(response));
return response;
} catch (error) {
logger.error('[SimklService] Failed to get user stats:', error);
return null;
}
}}

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

@ -27,6 +27,22 @@ export interface TraktUser {
avatar?: string;
}
export interface TraktUserStats {
movies?: {
watched?: number;
minutes?: number;
};
shows?: {
watched?: number;
minutes?: number;
};
episodes?: {
watched?: number;
minutes?: number;
};
minutes?: number; // total minutes watched
}
export interface TraktWatchedItem {
movie?: {
title: string;
@ -1117,6 +1133,13 @@ export class TraktService {
return this.apiRequest<TraktUser>('/users/me?extended=full');
}
/**
* Get the user's watch stats
*/
public async getUserStats(): Promise<TraktUserStats> {
return this.apiRequest<TraktUserStats>('/users/me/stats');
}
/**
* Get the user's watched movies
*/

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