mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 03:22:53 +00:00
ui changes
This commit is contained in:
parent
25e1102832
commit
ea2debb9dd
11 changed files with 623 additions and 45 deletions
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
|
|
@ -28,6 +28,7 @@ import { storageService } from '../../services/storageService';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { TraktService } from '../../services/traktService';
|
import { TraktService } from '../../services/traktService';
|
||||||
|
import { SimklService } from '../../services/simklService';
|
||||||
import { stremioService } from '../../services/stremioService';
|
import { stremioService } from '../../services/stremioService';
|
||||||
import { streamCacheService } from '../../services/streamCacheService';
|
import { streamCacheService } from '../../services/streamCacheService';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
|
@ -221,6 +222,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
const lastTraktSyncRef = useRef<number>(0);
|
const lastTraktSyncRef = useRef<number>(0);
|
||||||
const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback)
|
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)
|
// Track last Trakt reconcile per item (local -> Trakt catch-up)
|
||||||
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
|
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
|
||||||
const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item
|
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 traktService = TraktService.getInstance();
|
||||||
const isTraktAuthed = await traktService.isAuthenticated();
|
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
|
// Declare groupPromises outside the if block
|
||||||
let groupPromises: Promise<void>[] = [];
|
let groupPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
// In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress
|
// 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).
|
// when local is ahead (scrobble lag/offline playback).
|
||||||
let localProgressIndex: Map<string, LocalProgressEntry[]> | null = null;
|
let localProgressIndex: Map<string, LocalProgressEntry[]> | null = null;
|
||||||
if (isTraktAuthed) {
|
if (isTraktAuthed || isSimklAuthed) {
|
||||||
try {
|
try {
|
||||||
const allProgress = await storageService.getAllWatchProgress();
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
const index = new Map<string, LocalProgressEntry[]>();
|
const index = new Map<string, LocalProgressEntry[]>();
|
||||||
|
|
@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-Trakt: use local storage
|
// Local-only mode (no Trakt, no Simkl): use local storage
|
||||||
if (!isTraktAuthed) {
|
if (!isTraktAuthed && !isSimklAuthed) {
|
||||||
const allProgress = await storageService.getAllWatchProgress();
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
if (Object.keys(allProgress).length === 0) {
|
if (Object.keys(allProgress).length === 0) {
|
||||||
setContinueWatchingItems([]);
|
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
|
// SIMKL: fetch playback progress (in-progress, paused) and merge similarly to Trakt
|
||||||
await Promise.allSettled([...groupPromises, traktMergePromise]);
|
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) {
|
} catch (error) {
|
||||||
// Continue even if loading fails
|
// Continue even if loading fails
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import { Image, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
interface SimklIconProps {
|
interface SimklIconProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
|
|
@ -9,12 +9,14 @@ interface SimklIconProps {
|
||||||
|
|
||||||
const SimklIcon: React.FC<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
|
const SimklIcon: React.FC<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
|
||||||
return (
|
return (
|
||||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
|
<Image
|
||||||
<Path
|
source={require('../../../assets/simkl-favicon.png')}
|
||||||
d="M21.996 11.233c-.02-.85-.12-1.68-.28-2.5-.27-.08-.54-.15-.81-.22-.32 1.35-.55 2.72-.65 4.1h.005v.025c-.012.333-.012.67-.008 1.008l.008.026h-.005c.015 1.48.16 2.94.41 4.39.28-.06.56-.13.84-.2.17-.89.28-1.8.31-2.71.01-.15.01-.3.02-.45.39-1.07.66-2.22.79-3.41-.21.01-.42.01-.63.01v-.058l-.001-.01zm-3.15-5.96c-.32 1.94-.37 3.92-.12 5.88.24-.03.49-.06.74-.08l-.009-.045c.006-.33.02-.66.04-.98l.009-.045c.01-.19.03-.37.04-.55.03-.38.07-.76.11-1.13.27-.03.54-.05.81-.07-.06-1.57-.34-3.1-.78-4.57-.45.24-.9.46-1.34.69.17.29.33.6.49.9zM7.55 6.013c.09-.16.16-.33.22-.5l.024.009c.81.3 1.63.57 2.47.8l.044.01c-.13.21-.24.42-.36.63.15.54.34 1.07.56 1.58.26-.06.51-.12.77-.18l-.02-.075c-.21-1.01-.35-2.03-.42-3.05l-.04-.03c-.26-.14-.52-.28-.79-.41-.01.21-.01.42-.01.63-.8-.34-1.61-.64-2.43-.91-.12.49-.21.98-.27 1.48l.019.01zm-3.07 9.85c.66 1.05 1.46 1.99 2.37 2.79.16-.14.33-.27.49-.41-.1-.7-.16-1.41-.2-2.12-.53-.13-1.06-.23-1.59-.3-.3.63-.66 1.25-1.07 1.84v-.01-.19zm1.09-3.8c-.81.65-1.53 1.39-2.15 2.22l.02.04c.83.21 1.67.39 2.52.54.08-1.57.43-3.11 1.01-4.56-.27-.08-.54-.15-.81-.22-.22.65-.42 1.31-.59 1.98zm8.68-7.983c-.85-.14-1.72-.21-2.6-.19-.49.94-.85 1.93-1.08 2.96.26.06.52.12.77.19.16-.9.4-1.79.71-2.65.23.01.47.03.7.05.34-.14.67-.3.99-.48l.01-.01c.17.04.33.09.5.13zm5.75 1.92l-.01.07c-1.12.21-2.22.52-3.3.9.5 1.14 1.15 2.21 1.91 3.16.2-.08.41-.15.61-.22-.64-.81-1.2-1.69-1.66-2.61l.01-.06c.4-.2.81-.39 1.23-.57l.01-.07c.39-.21.79-.41 1.2-.6z"
|
style={[
|
||||||
fill={color}
|
{ width: size, height: size, flex: 1 },
|
||||||
/>
|
style
|
||||||
</Svg>
|
]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg';
|
||||||
interface TraktIconProps {
|
interface TraktIconProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
style?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224' }) => {
|
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224', style }) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size }}>
|
<View style={[{ width: size, height: size, flex: 1 }, style]}>
|
||||||
<Svg
|
<Svg
|
||||||
width={size}
|
width="100%"
|
||||||
height={size}
|
height="100%"
|
||||||
viewBox="0 0 144.8 144.8"
|
viewBox="0 0 144.8 144.8"
|
||||||
>
|
>
|
||||||
<Path
|
<Path
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { AppState, AppStateStatus } from 'react-native';
|
||||||
import {
|
import {
|
||||||
SimklService,
|
SimklService,
|
||||||
SimklContentData,
|
SimklContentData,
|
||||||
SimklPlaybackData
|
SimklPlaybackData,
|
||||||
|
SimklUserSettings,
|
||||||
|
SimklStats
|
||||||
} from '../services/simklService';
|
} from '../services/simklService';
|
||||||
import { storageService } from '../services/storageService';
|
import { storageService } from '../services/storageService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
@ -16,6 +18,8 @@ export function useSimklIntegration() {
|
||||||
|
|
||||||
// Basic lists
|
// Basic lists
|
||||||
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
|
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
|
||||||
|
const [userSettings, setUserSettings] = useState<SimklUserSettings | null>(null);
|
||||||
|
const [userStats, setUserStats] = useState<SimklStats | null>(null);
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const checkAuthStatus = useCallback(async () => {
|
const checkAuthStatus = useCallback(async () => {
|
||||||
|
|
@ -46,6 +50,20 @@ export function useSimklIntegration() {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [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)
|
// Start watching (scrobble start)
|
||||||
const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||||
if (!isAuthenticated) return false;
|
if (!isAuthenticated) return false;
|
||||||
|
|
@ -153,6 +171,7 @@ export function useSimklIntegration() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playback = await simklService.getPlaybackStatus();
|
const playback = await simklService.getPlaybackStatus();
|
||||||
|
logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`);
|
||||||
|
|
||||||
for (const item of playback) {
|
for (const item of playback) {
|
||||||
let id: string | undefined;
|
let id: string | undefined;
|
||||||
|
|
@ -165,7 +184,8 @@ export function useSimklIntegration() {
|
||||||
} else if (item.show && item.episode) {
|
} else if (item.show && item.episode) {
|
||||||
id = item.show.ids.imdb;
|
id = item.show.ids.imdb;
|
||||||
type = 'series';
|
type = 'series';
|
||||||
episodeId = `${id}:${item.episode.season}:${item.episode.episode}`;
|
const epNum = (item.episode as any).episode ?? (item.episode as any).number;
|
||||||
|
episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -197,8 +217,9 @@ export function useSimklIntegration() {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadPlaybackStatus();
|
loadPlaybackStatus();
|
||||||
fetchAndMergeSimklProgress();
|
fetchAndMergeSimklProgress();
|
||||||
|
loadUserProfile();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress]);
|
}, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]);
|
||||||
|
|
||||||
// App state listener for sync
|
// App state listener for sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -222,6 +243,8 @@ export function useSimklIntegration() {
|
||||||
stopWatching,
|
stopWatching,
|
||||||
syncAllProgress,
|
syncAllProgress,
|
||||||
fetchAndMergeSimklProgress,
|
fetchAndMergeSimklProgress,
|
||||||
continueWatching
|
continueWatching,
|
||||||
|
userSettings,
|
||||||
|
userStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Platform,
|
Platform,
|
||||||
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { makeRedirectUri, useAuthRequest, ResponseType, CodeChallengeMethod } from 'expo-auth-session';
|
import { makeRedirectUri, useAuthRequest, ResponseType, CodeChallengeMethod } from 'expo-auth-session';
|
||||||
|
|
@ -54,7 +55,9 @@ const SimklSettingsScreen: React.FC = () => {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoading,
|
isLoading,
|
||||||
checkAuthStatus,
|
checkAuthStatus,
|
||||||
refreshAuthStatus
|
refreshAuthStatus,
|
||||||
|
userSettings,
|
||||||
|
userStats
|
||||||
} = useSimklIntegration();
|
} = useSimklIntegration();
|
||||||
const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration();
|
const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration();
|
||||||
|
|
||||||
|
|
@ -167,12 +170,71 @@ const SimklSettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
) : isAuthenticated ? (
|
) : isAuthenticated ? (
|
||||||
<View style={styles.profileContainer}>
|
<View style={styles.profileContainer}>
|
||||||
<Text style={[styles.statusTitle, { color: currentTheme.colors.highEmphasis }]}>
|
<View style={styles.profileHeader}>
|
||||||
Connected
|
{userSettings?.user?.avatar ? (
|
||||||
</Text>
|
<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 }]}>
|
<Text style={[styles.statusDesc, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
Your watched items are syncing with Simkl.
|
Your watched items are syncing with Simkl.
|
||||||
</Text>
|
</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
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]}
|
style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]}
|
||||||
onPress={handleSignOut}
|
onPress={handleSignOut}
|
||||||
|
|
@ -206,6 +268,14 @@ const SimklSettingsScreen: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</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 }]}>
|
<Text style={[styles.disclaimer, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
|
||||||
Nuvio is not affiliated with Simkl.
|
Nuvio is not affiliated with Simkl.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -284,17 +354,65 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
profileContainer: {
|
profileContainer: {
|
||||||
|
alignItems: 'stretch',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
profileHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 20,
|
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: {
|
statusTitle: {
|
||||||
fontSize: 20,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: '700',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
accountType: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
statusDesc: {
|
statusDesc: {
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
marginBottom: 10,
|
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: {
|
button: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -312,6 +430,30 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
logoSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
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 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||||
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
|
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
|
||||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
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 { useSettings } from '../hooks/useSettings';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
||||||
|
|
@ -55,6 +55,7 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
||||||
|
const [userStats, setUserStats] = useState<TraktUserStats | null>(null);
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -109,8 +110,16 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
const profile = await traktService.getUserProfile();
|
const profile = await traktService.getUserProfile();
|
||||||
setUserProfile(profile);
|
setUserProfile(profile);
|
||||||
|
try {
|
||||||
|
const stats = await traktService.getUserStats();
|
||||||
|
setUserStats(stats);
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn('[TraktSettingsScreen] Failed to load stats:', statsError);
|
||||||
|
setUserStats(null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
|
setUserStats(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[TraktSettingsScreen] Error checking auth status:', error);
|
logger.error('[TraktSettingsScreen] Error checking auth status:', error);
|
||||||
|
|
@ -353,6 +362,42 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
]}>
|
]}>
|
||||||
{t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })}
|
{t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })}
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -708,14 +753,35 @@ const styles = StyleSheet.create({
|
||||||
color: '#000',
|
color: '#000',
|
||||||
},
|
},
|
||||||
statsContainer: {
|
statsContainer: {
|
||||||
marginTop: 16,
|
marginTop: 12,
|
||||||
paddingTop: 16,
|
paddingTop: 12,
|
||||||
borderTopWidth: 0.5,
|
borderTopWidth: 0.5,
|
||||||
borderTopColor: 'rgba(150,150,150,0.2)',
|
borderTopColor: 'rgba(150,150,150,0.2)',
|
||||||
},
|
},
|
||||||
joinedDate: {
|
joinedDate: {
|
||||||
fontSize: 14,
|
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: {
|
settingsSection: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,51 @@ export interface SimklPlaybackData {
|
||||||
};
|
};
|
||||||
episode?: {
|
episode?: {
|
||||||
season: number;
|
season: number;
|
||||||
episode: number;
|
// Simkl docs show `episode` in playback responses, but some APIs return `number`
|
||||||
|
episode?: number;
|
||||||
|
number?: number;
|
||||||
title: string;
|
title: string;
|
||||||
tvdb_season?: number;
|
tvdb_season?: number;
|
||||||
tvdb_number?: 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 {
|
export class SimklService {
|
||||||
private static instance: SimklService;
|
private static instance: SimklService;
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
|
|
@ -480,18 +518,41 @@ export class SimklService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaybackStatus(): Promise<SimklPlaybackData[]> {
|
public async getPlaybackStatus(): Promise<SimklPlaybackData[]> {
|
||||||
// Get both movies and episodes
|
// Docs: GET /sync/playback/{type} with {type} values `movies` or `episodes`.
|
||||||
// Simkl endpoint: /sync/playback (returns all if no type specified, or we specify type)
|
// Some docs also mention appending /movie or /episode; we try both variants for safety.
|
||||||
// Docs say /sync/playback/{type}
|
const tryEndpoints = async (endpoints: string[]): Promise<SimklPlaybackData[]> => {
|
||||||
// Let's trying getting all if possible, or fetch both. Docs say type is optional param?
|
for (const endpoint of endpoints) {
|
||||||
// Docs: /sync/playback/{type} -> actually path param seems required or at least standard.
|
try {
|
||||||
// But query params: type (optional).
|
const res = await this.apiRequest<SimklPlaybackData[]>(endpoint);
|
||||||
// Let's try fetching without path param or empty?
|
if (Array.isArray(res)) {
|
||||||
// Docs: "Retrieves all paused... optionally filter by type by appending /movie"
|
logger.log(`[SimklService] getPlaybackStatus: ${endpoint} -> ${res.length} items`);
|
||||||
// Let's assume /sync/playback works for all.
|
return res;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[SimklService] getPlaybackStatus: ${endpoint} failed`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const response = await this.apiRequest<SimklPlaybackData[]>('/sync/playback');
|
const movies = await tryEndpoints([
|
||||||
return response || [];
|
'/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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -506,4 +567,42 @@ export class SimklService {
|
||||||
}
|
}
|
||||||
return await this.apiRequest(url);
|
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;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
@ -27,6 +27,22 @@ export interface TraktUser {
|
||||||
avatar?: string;
|
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 {
|
export interface TraktWatchedItem {
|
||||||
movie?: {
|
movie?: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -1117,6 +1133,13 @@ export class TraktService {
|
||||||
return this.apiRequest<TraktUser>('/users/me?extended=full');
|
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
|
* Get the user's watched movies
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue