mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
commit
d7dc0ea8ae
29 changed files with 2539 additions and 148 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -97,4 +97,5 @@ trakt-docss
|
|||
# Removed submodules (kept locally)
|
||||
libmpv-android/
|
||||
mpv-android/
|
||||
mpvKt/
|
||||
mpvKt/
|
||||
simkl-docss
|
||||
1
LibTorrent
Submodule
1
LibTorrent
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit eb1c71397b8716b97fcd375fd646e96c89632a5e
|
||||
BIN
assets/simkl-favicon.png
Normal file
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
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
0
assets/trakt-favicon.png
Normal file
1
iTorrent
Submodule
1
iTorrent
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit c27088b0ac36bf9bb30fae34dc36db1231263bfd
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
23
src/components/icons/SimklIcon.tsx
Normal file
23
src/components/icons/SimklIcon.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
250
src/hooks/useSimklIntegration.ts
Normal file
250
src/hooks/useSimklIntegration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
|
|||
import HomeScreenSettings from '../screens/HomeScreenSettings';
|
||||
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
||||
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
|
||||
import SimklSettingsScreen from '../screens/SimklSettingsScreen';
|
||||
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
||||
import ThemeScreen from '../screens/ThemeScreen';
|
||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||
|
|
@ -185,6 +186,7 @@ export type RootStackParamList = {
|
|||
HomeScreenSettings: undefined;
|
||||
HeroCatalogs: undefined;
|
||||
TraktSettings: undefined;
|
||||
SimklSettings: undefined;
|
||||
PlayerSettings: undefined;
|
||||
ThemeSettings: undefined;
|
||||
ScraperSettings: undefined;
|
||||
|
|
@ -1565,6 +1567,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SimklSettings"
|
||||
component={SimklSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import { AboutSettingsContent, AboutFooter } from './settings/AboutSettingsScree
|
|||
import { SettingsCard, SettingItem, ChevronRight, CustomSwitch } from './settings/SettingsComponents';
|
||||
import { useBottomSheetBackHandler } from '../hooks/useBottomSheetBackHandler';
|
||||
import { LOCALES } from '../constants/locales';
|
||||
import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||
import SimklIcon from '../components/icons/SimklIcon';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -201,6 +203,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { lastUpdate } = useCatalogContext();
|
||||
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
|
||||
const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Tablet-specific state
|
||||
|
|
@ -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={[
|
||||
|
|
|
|||
462
src/screens/SimklSettingsScreen.tsx
Normal file
462
src/screens/SimklSettingsScreen.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
608
src/services/simklService.ts
Normal file
608
src/services/simklService.ts
Normal 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;
|
||||
}
|
||||
}}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { TraktService } from './traktService';
|
||||
import { SimklService } from './simklService';
|
||||
import { storageService } from './storageService';
|
||||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -13,9 +14,11 @@ import { logger } from '../utils/logger';
|
|||
class WatchedService {
|
||||
private static instance: WatchedService;
|
||||
private traktService: TraktService;
|
||||
private simklService: SimklService;
|
||||
|
||||
private constructor() {
|
||||
this.traktService = TraktService.getInstance();
|
||||
this.simklService = SimklService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): WatchedService {
|
||||
|
|
@ -47,6 +50,13 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.addToHistory({ movies: [{ ids: { imdb: imdbId }, watched_at: watchedAt.toISOString() }] });
|
||||
logger.log(`[WatchedService] Simkl sync request sent for movie`);
|
||||
}
|
||||
|
||||
// Also store locally as "completed" (100% progress)
|
||||
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
|
||||
|
||||
|
|
@ -90,6 +100,22 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Simkl structure: shows -> seasons -> episodes
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: [{ number: episode, watched_at: watchedAt.toISOString() }]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl sync request sent for episode`);
|
||||
}
|
||||
|
||||
// Store locally as "completed"
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||
|
|
@ -135,6 +161,27 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Group by season for Simkl payload efficiency
|
||||
const seasonMap = new Map<number, any[]>();
|
||||
episodes.forEach(ep => {
|
||||
if (!seasonMap.has(ep.season)) seasonMap.set(ep.season, []);
|
||||
seasonMap.get(ep.season)?.push({ number: ep.episode, watched_at: watchedAt.toISOString() });
|
||||
});
|
||||
|
||||
const seasons = Array.from(seasonMap.entries()).map(([num, eps]) => ({ number: num, episodes: eps }));
|
||||
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: seasons
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl batch sync request sent`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode
|
||||
for (const ep of episodes) {
|
||||
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
|
||||
|
|
@ -180,6 +227,24 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
// Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it
|
||||
// We know the episodeNumbers from the arguments!
|
||||
const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() }));
|
||||
await this.simklService.addToHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: episodes
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl season sync request sent`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
|
|
@ -210,6 +275,13 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] });
|
||||
logger.log(`[WatchedService] Simkl remove request sent for movie`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
await storageService.removeWatchProgress(imdbId, 'movie');
|
||||
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
||||
|
|
@ -245,6 +317,21 @@ class WatchedService {
|
|||
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: [{ number: episode }]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl remove request sent for episode`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||
|
|
@ -281,9 +368,29 @@ class WatchedService {
|
|||
showImdbId,
|
||||
season
|
||||
);
|
||||
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
||||
showImdbId,
|
||||
season
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
const episodes = episodeNumbers.map(num => ({ number: num }));
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: episodes
|
||||
}]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl season removal request sent`);
|
||||
}
|
||||
|
||||
// Remove local progress for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
|
|
@ -301,60 +408,60 @@ class WatchedService {
|
|||
* Check if a movie is marked as watched (locally)
|
||||
*/
|
||||
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId);
|
||||
if (traktWatched) return true;
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
||||
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
return local === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
return local === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if an episode is marked as watched (locally)
|
||||
*/
|
||||
public async isEpisodeWatched(
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isEpisodeWatchedAccurate(
|
||||
showId,
|
||||
season,
|
||||
episode
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isEpisodeWatchedAccurate(
|
||||
showId,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
const progress = await storageService.getWatchProgress(
|
||||
showId,
|
||||
'series',
|
||||
episodeId
|
||||
);
|
||||
if (traktWatched) return true;
|
||||
|
||||
if (!progress) return false;
|
||||
|
||||
const pct = (progress.currentTime / progress.duration) * 100;
|
||||
return pct >= 99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
const progress = await storageService.getWatchProgress(
|
||||
showId,
|
||||
'series',
|
||||
episodeId
|
||||
);
|
||||
|
||||
if (!progress) return false;
|
||||
|
||||
const pct = (progress.currentTime / progress.duration) * 100;
|
||||
return pct >= 99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set local watched status by creating a "completed" progress entry
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue