mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge branch 'tapframe:main' into main
This commit is contained in:
commit
b93d4ac3bc
15 changed files with 1762 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1433
src/i18n/locales/ca.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -653,6 +653,7 @@
|
|||
"dutch_nl": "Dutch (Netherlands)",
|
||||
"romanian": "Romanian",
|
||||
"albanian": "Albanian",
|
||||
"catalan": "Catalan",
|
||||
"account": "Account",
|
||||
"content_discovery": "Content & Discovery",
|
||||
"appearance": "Appearance",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue