Merge branch 'tapframe:main' into main

This commit is contained in:
milicevicivan 2026-02-21 22:05:09 +01:00 committed by GitHub
commit b93d4ac3bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1762 additions and 66 deletions

View file

@ -1817,15 +1817,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
try {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// For series episodes, only remove the specific episode's local progress
// Don't add a base tombstone which would block all episodes of the series
// For series episodes, remove only that episode's progress.
// Do not wipe all series entries.
const isEpisode = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode;
if (isEpisode) {
// Only remove local progress for this specific episode (no base tombstone)
await storageService.removeAllWatchProgressForContent(
const episodeId = `${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`;
await storageService.removeWatchProgress(
selectedItem.id,
selectedItem.type,
{ addBaseTombstone: false }
episodeId
);
} else {
// For movies or whole series, add the base tombstone

View file

@ -23,4 +23,5 @@ export const LOCALES = [
{ code: 'nl-NL', key: 'dutch_nl' },
{ code: 'ro', key: 'romanian' },
{ code: 'sq', key: 'albanian' },
{ code: 'ca', key: 'catalan' },
];

1433
src/i18n/locales/ca.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -653,6 +653,7 @@
"dutch_nl": "Dutch (Netherlands)",
"romanian": "Romanian",
"albanian": "Albanian",
"catalan": "Catalan",
"account": "Account",
"content_discovery": "Content & Discovery",
"appearance": "Appearance",

View file

@ -23,6 +23,7 @@ import fil from './locales/fil.json';
import nlNL from './locales/nl-NL.json';
import ro from './locales/ro.json';
import sq from './locales/sq.json';
import ca from './locales/ca.json';
export const resources = {
en: { translation: en },
@ -49,4 +50,5 @@ export const resources = {
'nl-NL': { translation: nlNL },
ro: { translation: ro },
sq: { translation: sq },
ca: { translation: ca },
};

View file

@ -696,8 +696,14 @@ const AIChatScreen: React.FC = () => {
if (error instanceof Error) {
if (error.message.includes('not configured')) {
errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.';
} else if (/401|unauthorized|invalid api key|authentication/i.test(error.message)) {
errorMessage = 'OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.';
} else if (/insufficient|credit|quota|429/i.test(error.message)) {
errorMessage = 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.';
} else if (/model|provider|endpoint|unsupported|unavailable|not found/i.test(error.message)) {
errorMessage = 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.';
} else if (error.message.includes('API request failed')) {
errorMessage = 'Failed to connect to AI service. Please check your internet connection and API key.';
errorMessage = 'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.';
}
}

View file

@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
const DEFAULT_OPENROUTER_MODEL = 'openrouter/free';
const AISettingsScreen: React.FC = () => {
const { t } = useTranslation();
@ -75,6 +76,8 @@ const AISettingsScreen: React.FC = () => {
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [isKeySet, setIsKeySet] = useState(false);
const [useDefaultModel, setUseDefaultModel] = useState(true);
const [customModel, setCustomModel] = useState('');
useEffect(() => {
loadApiKey();
@ -82,11 +85,21 @@ const AISettingsScreen: React.FC = () => {
const loadApiKey = async () => {
try {
const savedKey = await mmkvStorage.getItem('openrouter_api_key');
const [savedKey, savedModel] = await Promise.all([
mmkvStorage.getItem('openrouter_api_key'),
mmkvStorage.getItem('openrouter_model'),
]);
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
}
if (savedModel && savedModel.trim()) {
setUseDefaultModel(false);
setCustomModel(savedModel.trim());
} else {
setUseDefaultModel(true);
setCustomModel('');
}
} catch (error) {
if (__DEV__) console.error('Error loading OpenRouter API key:', error);
}
@ -106,6 +119,11 @@ const AISettingsScreen: React.FC = () => {
setLoading(true);
try {
await mmkvStorage.setItem('openrouter_api_key', apiKey.trim());
if (useDefaultModel || !customModel.trim()) {
await mmkvStorage.removeItem('openrouter_model');
} else {
await mmkvStorage.setItem('openrouter_model', customModel.trim());
}
setIsKeySet(true);
openAlert(t('common.success'), t('ai_settings.success_saved'));
} catch (error) {
@ -253,6 +271,44 @@ const AISettingsScreen: React.FC = () => {
autoCorrect={false}
/>
<View style={styles.modelSection}>
<View style={styles.modelHeader}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
Model
</Text>
<Switch
value={useDefaultModel}
onValueChange={setUseDefaultModel}
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }}
thumbColor={useDefaultModel ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
ios_backgroundColor={currentTheme.colors.elevation2}
/>
</View>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
{useDefaultModel
? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).`
: 'Use a custom OpenRouter model ID (useful for paid plans).'}
</Text>
{!useDefaultModel && (
<TextInput
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2
}
]}
value={customModel}
onChangeText={setCustomModel}
placeholder="e.g. openai/gpt-4o-mini"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
/>
)}
</View>
<View style={styles.buttonContainer}>
{!isKeySet ? (
<TouchableOpacity
@ -468,6 +524,15 @@ const styles = StyleSheet.create({
apiKeySection: {
gap: 12,
},
modelSection: {
gap: 8,
marginTop: 4,
},
modelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
label: {
fontSize: 16,
fontWeight: '600',

View file

@ -74,6 +74,8 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
// Constants
const CATALOG_SETTINGS_KEY = 'catalog_settings';
const MAX_CONCURRENT_CATALOG_REQUESTS = 4;
const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000;
// In-memory cache for catalog settings to avoid repeated MMKV reads
let cachedCatalogSettings: Record<string, boolean> | null = null;
@ -134,6 +136,7 @@ const HomeScreen = () => {
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
const [hintVisible, setHintVisible] = useState(false);
const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false);
const totalCatalogsRef = useRef(0);
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
const insets = useSafeAreaInsets();
@ -185,6 +188,7 @@ const HomeScreen = () => {
if (isFetchingRef.current) return;
isFetchingRef.current = true;
setLoadingScreenTimedOut(false);
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
@ -210,6 +214,7 @@ const HomeScreen = () => {
catalogService.getAllAddons(),
stremioService.getInstalledAddonsAsync()
]);
const manifestByAddonId = new Map(addonManifests.map((manifest: any) => [manifest.id, manifest]));
// Set hasAddons state based on whether we have any addons - ensure on main thread
InteractionManager.runAfterInteractions(() => {
@ -220,14 +225,20 @@ const HomeScreen = () => {
let catalogIndex = 0;
const catalogQueue: (() => Promise<void>)[] = [];
// Launch all catalog loaders in parallel
const launchAllCatalogs = () => {
while (catalogQueue.length > 0) {
const catalogLoader = catalogQueue.shift();
if (catalogLoader) {
catalogLoader();
// Launch loaders with bounded concurrency to reduce startup pressure
const launchCatalogLoaders = () => {
const workerCount = Math.min(MAX_CONCURRENT_CATALOG_REQUESTS, catalogQueue.length);
const workers = Array.from({ length: workerCount }, async () => {
while (catalogQueue.length > 0) {
const catalogLoader = catalogQueue.shift();
if (!catalogLoader) return;
await catalogLoader();
}
}
});
void Promise.all(workers).catch((error) => {
if (__DEV__) console.warn('[HomeScreen] Catalog loader worker failed:', error);
});
};
for (const addon of addons) {
@ -243,7 +254,7 @@ const HomeScreen = () => {
const catalogLoader = async () => {
try {
const manifest = addonManifests.find((a: any) => a.id === addon.id);
const manifest = manifestByAddonId.get(addon.id);
if (!manifest) return;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
@ -345,8 +356,8 @@ const HomeScreen = () => {
setCatalogs(new Array(catalogIndex).fill(null));
});
// Start all catalog requests in parallel
launchAllCatalogs();
// Start catalog requests with bounded concurrency
launchCatalogLoaders();
} catch (error) {
if (__DEV__) console.error('[HomeScreen] Error in progressive catalog loading:', error);
InteractionManager.runAfterInteractions(() => {
@ -356,14 +367,29 @@ const HomeScreen = () => {
}
}, []);
// Hard cap for initial home loading spinner.
// Keeps Home responsive even if one or more catalog addons are slow.
useEffect(() => {
if (!(catalogsLoading && loadedCatalogCount === 0)) {
return;
}
const timer = setTimeout(() => {
setLoadingScreenTimedOut(true);
}, HOME_LOADING_SCREEN_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [catalogsLoading, loadedCatalogCount]);
// Only count feature section as loading if it's enabled in settings
// For catalogs, we show them progressively, so loading should be false as soon as we have any content
const isLoading = useMemo(() => {
if (loadingScreenTimedOut) return false;
// Exit loading as soon as at least one catalog is ready, regardless of featured
if (loadedCatalogCount > 0) return false;
const heroLoading = showHeroSection ? featuredLoading : false;
return heroLoading && (catalogsLoading && loadedCatalogCount === 0);
}, [showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]);
}, [loadingScreenTimedOut, showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]);
// Update global loading state
useEffect(() => {
@ -1482,4 +1508,3 @@ const HomeScreenWithFocusSync = (props: any) => {
};
export default React.memo(HomeScreenWithFocusSync);

View file

@ -27,7 +27,6 @@ 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',
@ -76,9 +75,10 @@ const SimklSettingsScreen: React.FC = () => {
{
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
redirectUri: redirectUri,
responseType: ResponseType.Code,
// codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant
usePKCE: true,
codeChallengeMethod: CodeChallengeMethod.S256,
},
discovery
);
@ -90,12 +90,12 @@ const SimklSettingsScreen: React.FC = () => {
// Handle the response from the auth request
useEffect(() => {
if (response) {
if (response.type === 'success') {
if (response.type === 'success' && request?.codeVerifier) {
const { code } = response.params;
setIsExchangingCode(true);
logger.log('[SimklSettingsScreen] Auth code received, exchanging...');
simklService.exchangeCodeForToken(code)
simklService.exchangeCodeForToken(code, request.codeVerifier)
.then(success => {
if (success) {
refreshAuthStatus();
@ -109,11 +109,14 @@ const SimklSettingsScreen: React.FC = () => {
openAlert(t('common.error'), t('simkl.auth_error_generic'));
})
.finally(() => setIsExchangingCode(false));
} else if (response.type === 'success') {
logger.error('[SimklSettingsScreen] Missing PKCE code verifier on successful auth response');
openAlert(t('common.error'), t('simkl.auth_error_msg'));
} else if (response.type === 'error') {
openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown')));
}
}
}, [response, refreshAuthStatus]);
}, [response, refreshAuthStatus, request?.codeVerifier, t]);
const handleSignIn = () => {
if (!SIMKL_CLIENT_ID) {

View file

@ -1181,7 +1181,8 @@ const TMDBSettingsScreen = () => {
{ code: 'sl', label: 'Slovenščina', native: 'Slovenian' },
{ code: 'mk', label: 'Македонски', native: 'Macedonian' },
{ code: 'fil', label: 'Filipino', native: 'Filipino' },
{ code: 'sq', label: 'Shqipe', native: 'Albanian' },
{ code: 'sq', label: 'Shqipe', native: 'Albanian' },
{ code: 'ca', label: 'Català', native: 'Catalan' },
];
const filteredLanguages = languages.filter(({ label, code, native }) =>

View file

@ -61,7 +61,8 @@ const AVAILABLE_LANGUAGES = [
{ code: 'mk', name: 'Macedonian' },
{ code: 'fil', name: 'Filipino' },
{ code: 'ro', name: 'Romanian' },
{ code: 'sq', name: 'Albanian' },
{ code: 'sq', name: 'Albanian' },
{ code: 'ca', name: 'Catalan' },
];
const SUBTITLE_SOURCE_OPTIONS = [

View file

@ -126,10 +126,18 @@ interface OpenRouterResponse {
};
}
interface OpenRouterErrorResponse {
error?: {
message?: string;
code?: string;
};
}
class AIService {
private static instance: AIService;
private apiKey: string | null = null;
private baseUrl = 'https://openrouter.ai/api/v1';
private defaultModel = 'openrouter/free';
private constructor() { }
@ -151,12 +159,41 @@ class AIService {
}
async isConfigured(): Promise<boolean> {
if (!this.apiKey) {
await this.initialize();
}
// Always refresh from storage so key changes in settings are picked up immediately.
await this.initialize();
return !!this.apiKey;
}
private async getPreferredModels(): Promise<string[]> {
const configuredModel = (await mmkvStorage.getItem('openrouter_model'))?.trim();
if (!configuredModel) {
return [this.defaultModel];
}
return [configuredModel];
}
private async parseErrorResponse(response: Response): Promise<{
statusLine: string;
message: string;
raw: string;
}> {
const raw = await response.text();
let message = '';
try {
const parsed = JSON.parse(raw) as OpenRouterErrorResponse;
message = parsed.error?.message || '';
} catch {
message = raw;
}
return {
statusLine: `${response.status} ${response.statusText}`,
message: (message || '').trim(),
raw,
};
}
private createSystemPrompt(context: ContentContext): string {
const isSeries = 'episodesBySeason' in (context as any);
const isEpisode = !isSeries && 'showTitle' in (context as any);
@ -349,6 +386,9 @@ Answer questions about this movie using only the verified database information a
});
}
const model = (await this.getPreferredModels())[0];
if (__DEV__) console.log('[AIService] Using model:', model);
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
@ -358,7 +398,7 @@ Answer questions about this movie using only the verified database information a
'X-Title': 'Nuvio - AI Chat',
},
body: JSON.stringify({
model: 'xiaomi/mimo-v2-flash:free',
model,
messages,
max_tokens: 1000,
temperature: 0.7,
@ -369,9 +409,17 @@ Answer questions about this movie using only the verified database information a
});
if (!response.ok) {
const errorText = await response.text();
if (__DEV__) console.error('[AIService] API Error:', response.status, errorText);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
const parsedError = await this.parseErrorResponse(response);
if (__DEV__) {
console.error('[AIService] API Error:', {
model,
status: parsedError.statusLine,
message: parsedError.message || parsedError.raw,
});
}
throw new Error(`API request failed: ${parsedError.statusLine} - ${parsedError.message || parsedError.raw || 'Request failed'}`);
}
const data: OpenRouterResponse = await response.json();

View file

@ -301,7 +301,7 @@ export class SimklService {
* Exchange code for access token
* Simkl tokens do not expire
*/
public async exchangeCodeForToken(code: string): Promise<boolean> {
public async exchangeCodeForToken(code: string, codeVerifier: string): Promise<boolean> {
await this.ensureInitialized();
try {
@ -315,7 +315,8 @@ export class SimklService {
client_id: SIMKL_CLIENT_ID,
client_secret: SIMKL_CLIENT_SECRET,
redirect_uri: SIMKL_REDIRECT_URI,
grant_type: 'authorization_code'
grant_type: 'authorization_code',
code_verifier: codeVerifier
})
});
@ -937,4 +938,4 @@ export class SimklService {
return null;
}
}
}
}

View file

@ -80,6 +80,29 @@ class StorageService {
return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
private normalizeContinueWatchingEpisodeRemoveId(id: string, episodeId?: string): string | undefined {
if (!episodeId) return undefined;
const normalizedId = id?.trim();
const normalizedEpisodeId = episodeId.trim();
if (!normalizedId || !normalizedEpisodeId) return undefined;
const colonMatch = normalizedEpisodeId.match(/(?:^|:)(\d+):(\d+)$/);
if (colonMatch) {
return `${normalizedId}:${colonMatch[1]}:${colonMatch[2]}`;
}
const sxeMatch = normalizedEpisodeId.match(/s(\d+)e(\d+)/i);
if (sxeMatch) {
return `${normalizedId}:${sxeMatch[1]}:${sxeMatch[2]}`;
}
if (normalizedEpisodeId.startsWith(`${normalizedId}:`)) {
return normalizedEpisodeId;
}
return `${normalizedId}:${normalizedEpisodeId}`;
}
public async addWatchProgressTombstone(
id: string,
type: string,
@ -274,12 +297,30 @@ class StorageService {
try {
const removedMap = await this.getContinueWatchingRemoved();
const removedKey = this.buildWpKeyString(id, type);
const removedAt = removedMap[removedKey];
const removeCandidates: Array<{ removeId: string; key: string }> = [];
if (removedAt != null && timestamp > removedAt) {
logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`);
await this.removeContinueWatchingRemoved(id, type);
const baseRemoveId = (id || '').trim();
if (baseRemoveId) {
removeCandidates.push({
removeId: baseRemoveId,
key: this.buildWpKeyString(baseRemoveId, type),
});
}
const episodeRemoveId = this.normalizeContinueWatchingEpisodeRemoveId(id, episodeId);
if (episodeRemoveId && episodeRemoveId !== baseRemoveId) {
removeCandidates.push({
removeId: episodeRemoveId,
key: this.buildWpKeyString(episodeRemoveId, type),
});
}
for (const candidate of removeCandidates) {
const removedAt = removedMap[candidate.key];
if (removedAt != null && timestamp > removedAt) {
logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${candidate.key}`);
await this.removeContinueWatchingRemoved(candidate.removeId, type);
}
}
} catch (e) {
// Ignore error checks for restoration to prevent blocking save

View file

@ -121,6 +121,8 @@ class SupabaseSyncService {
private appStateSub: { remove: () => void } | null = null;
private lastForegroundPullAt = 0;
private readonly foregroundPullCooldownMs = 30000;
private pendingWatchProgressDeleteKeys = new Set<string>();
private watchProgressDeleteTimer: ReturnType<typeof setTimeout> | null = null;
private pendingPushTimers: Record<PushTarget, ReturnType<typeof setTimeout> | null> = {
plugins: null,
@ -544,7 +546,10 @@ class SupabaseSyncService {
catalogService.onLibraryRemove(() => this.schedulePush('library'));
storageService.subscribeToWatchProgressUpdates(() => this.schedulePush('watch_progress'));
storageService.onWatchProgressRemoved(() => this.schedulePush('watch_progress'));
storageService.onWatchProgressRemoved((id, type, episodeId) => {
this.schedulePush('watch_progress');
this.scheduleWatchProgressDelete(id, type, episodeId);
});
watchedService.subscribeToWatchedUpdates(() => this.schedulePush('watched_items'));
@ -593,6 +598,91 @@ class SupabaseSyncService {
}, DEFAULT_SYNC_DEBOUNCE_MS);
}
private scheduleWatchProgressDelete(id: string, type: string, episodeId?: string): void {
if (!this.isConfigured() || this.suppressPushes) return;
const keys = this.resolveWatchProgressDeleteKeys(id, type, episodeId);
if (keys.length === 0) return;
keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key));
if (this.watchProgressDeleteTimer) {
clearTimeout(this.watchProgressDeleteTimer);
}
this.watchProgressDeleteTimer = setTimeout(() => {
this.watchProgressDeleteTimer = null;
this.flushWatchProgressDeletes().catch((error) => {
logger.error('[SupabaseSyncService] watch progress delete flush failed:', error);
});
}, DEFAULT_SYNC_DEBOUNCE_MS);
}
private async flushWatchProgressDeletes(): Promise<void> {
if (!this.isConfigured() || this.suppressPushes) return;
const keys = Array.from(this.pendingWatchProgressDeleteKeys);
if (keys.length === 0) return;
this.pendingWatchProgressDeleteKeys.clear();
await this.initialize();
if (!this.session) {
keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key));
return;
}
const traktConnected = await this.isTraktConnected();
if (traktConnected) return;
try {
logger.log(`[SupabaseSyncService] flushWatchProgressDeletes: deleting ${keys.length} keys`);
await this.callRpc<void>('sync_delete_watch_progress', { p_keys: keys });
} catch (error) {
keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key));
throw error;
}
}
private resolveWatchProgressDeleteKeys(id: string, type: string, episodeId?: string): string[] {
const contentId = (id || '').trim();
const contentType = (type || '').trim().toLowerCase();
if (!contentId || !contentType) return [];
const keys = new Set<string>();
if (contentType === 'movie') {
keys.add(contentId);
return Array.from(keys);
}
// Always delete the series mirror key when removing series progress.
keys.add(contentId);
const normalizedEpisodeId = (episodeId || '').trim();
if (normalizedEpisodeId) {
const parsed = this.parseSeasonEpisodeFromEpisodeId(normalizedEpisodeId);
if (parsed) {
keys.add(`${contentId}_s${parsed.season}e${parsed.episode}`);
} else {
// Fallback for any non-standard legacy progress_key format.
keys.add(`${contentId}_${normalizedEpisodeId}`);
}
}
return Array.from(keys);
}
private parseSeasonEpisodeFromEpisodeId(
episodeId: string
): { season: number; episode: number } | null {
const match = episodeId.match(/(?:^|:)(\d+):(\d+)$/);
if (!match) return null;
const season = Number(match[1]);
const episode = Number(match[2]);
if (!Number.isFinite(season) || !Number.isFinite(episode)) return null;
return { season, episode };
}
private async executeScheduledPush(target: PushTarget): Promise<void> {
await this.initialize();
if (!this.session) return;
@ -1194,29 +1284,7 @@ class SupabaseSyncService {
);
}
// Reconcile removals only when remote has at least one entry to avoid wiping local
// data if backend temporarily returns an empty set.
if (remoteSet.size > 0) {
const allLocal = await storageService.getAllWatchProgress();
let removedCount = 0;
for (const [key] of Object.entries(allLocal)) {
const parsed = this.parseWatchProgressKey(key);
if (!parsed) continue;
const localSig = `${parsed.contentType}:${parsed.contentId}:${parsed.season ?? ''}:${parsed.episode ?? ''}`;
if (remoteSet.has(localSig)) continue;
const episodeId = parsed.contentType === 'series' && parsed.season != null && parsed.episode != null
? `${parsed.contentId}:${parsed.season}:${parsed.episode}`
: undefined;
await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId);
removedCount += 1;
}
logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: removedLocalExtras=${removedCount}`);
} else {
logger.log('[SupabaseSyncService] pullWatchProgressToLocal: remote set empty, skipped local prune');
}
logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: merged ${(rows || []).length} remote entries (no local prune)`);
}
private async pushWatchProgressFromLocal(): Promise<void> {