From 86f0fde656601da65edd818ace235086b5d0e7f2 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 9 Aug 2025 00:07:10 +0530 Subject: [PATCH] added account support, Initial Commit --- .cursor/mcp.json | 16 + package-lock.json | 122 +++ package.json | 3 + .../home/ContinueWatchingSection.tsx | 14 +- src/contexts/AccountContext.tsx | 89 ++ src/contexts/ThemeContext.tsx | 61 +- src/hooks/useLibrary.ts | 52 +- src/hooks/useSettings.ts | 19 +- src/navigation/AppNavigator.tsx | 24 +- src/screens/AuthScreen.tsx | 647 +++++++++++++++ src/screens/SettingsScreen.tsx | 23 + src/services/AccountService.ts | 60 ++ src/services/SyncService.ts | 782 ++++++++++++++++++ src/services/catalogService.ts | 86 +- src/services/localScraperService.ts | 14 +- src/services/storageService.ts | 130 ++- src/services/stremioService.ts | 36 +- src/services/supabaseClient.ts | 20 + 18 files changed, 2100 insertions(+), 98 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 src/contexts/AccountContext.tsx create mode 100644 src/screens/AuthScreen.tsx create mode 100644 src/services/AccountService.ts create mode 100644 src/services/SyncService.ts create mode 100644 src/services/supabaseClient.ts diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..c9f1291 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest", + + "--project-ref=utypxyhwcekefvhyguxp" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "sbp_a6d99ace66d78d514d6435c4eb58ea9ff6f9d6c7" + } + } + } + } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dc85729..723b846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^6.15.1", "@shopify/flash-list": "^2.0.2", + "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.11.0", @@ -46,6 +47,7 @@ "react": "18.3.1", "react-native": "0.76.9", "react-native-gesture-handler": "~2.20.2", + "react-native-get-random-values": "^1.11.0", "react-native-image-colors": "^2.5.0", "react-native-immersive-mode": "^2.0.2", "react-native-paper": "^5.13.1", @@ -53,6 +55,7 @@ "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", + "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-vlc-media-player": "^1.0.87", "react-native-wheel-color-picker": "^1.3.1" @@ -4432,6 +4435,80 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.0.tgz", + "integrity": "sha512-SEIWApsxyoAe68WU2/5PCCuBwa11LL4Bb8K3r2FHCt3ROpaTthmDiWEhnLMGayP05N4QeYrMk0kyTZOwid/Hjw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.10.4.tgz", + "integrity": "sha512-cvL02GarJVFcNoWe36VBybQqTVRq6wQSOCvTS64C+eyuxOruFIm1utZAY0xi2qKtHJO3EjKaj8iWJKySusDmAQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.54.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.54.0.tgz", + "integrity": "sha512-DLw83YwBfAaFiL3oWV26+sHRdeCGtxmIKccjh/Pndze3BWM4fZghzYKhk3ElOQU8Bluq4AkkCJ5bM5Szl/sfRg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.15.0", + "@supabase/storage-js": "^2.10.4" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -4998,6 +5075,12 @@ "@types/node": "*" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -5055,6 +5138,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -8043,6 +8135,12 @@ "license": "MIT", "optional": true }, + "node_modules/fast-base64-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", + "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12346,6 +12444,18 @@ "react-native": "*" } }, + "node_modules/react-native-get-random-values": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", + "license": "MIT", + "dependencies": { + "fast-base64-decode": "^1.0.0" + }, + "peerDependencies": { + "react-native": ">=0.56" + } + }, "node_modules/react-native-image-colors": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.5.0.tgz", @@ -12520,6 +12630,18 @@ "react-native-svg": ">=12.0.0" } }, + "node_modules/react-native-url-polyfill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", + "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==", + "license": "MIT", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-vector-icons": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz", diff --git a/package.json b/package.json index dba5a75..af842c3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^6.15.1", "@shopify/flash-list": "^2.0.2", + "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.11.0", @@ -47,6 +48,7 @@ "react": "18.3.1", "react-native": "0.76.9", "react-native-gesture-handler": "~2.20.2", + "react-native-get-random-values": "^1.11.0", "react-native-image-colors": "^2.5.0", "react-native-immersive-mode": "^2.0.2", "react-native-paper": "^5.13.1", @@ -54,6 +56,7 @@ "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", + "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-vlc-media-player": "^1.0.87", "react-native-wheel-color-picker": "^1.3.1" diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 9ed120f..817de32 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -66,11 +66,9 @@ const { width } = Dimensions.get('window'); const posterLayout = calculatePosterLayout(width); const POSTER_WIDTH = posterLayout.posterWidth; -// Function to validate IMDB ID format -const isValidImdbId = (id: string): boolean => { - // IMDB IDs should start with 'tt' followed by 7-10 digits - const imdbPattern = /^tt\d{7,10}$/; - return imdbPattern.test(id); +// Allow any known id formats (imdb 'tt...', kitsu 'kitsu:...', tmdb 'tmdb:...', or others) +const isSupportedId = (id: string): boolean => { + return typeof id === 'string' && id.length > 0; }; // Function to check if an episode has been released @@ -181,10 +179,8 @@ const ContinueWatchingSection = React.forwardRef((props, re // Second pass: process each content group with batched API calls const contentPromises = Object.values(contentGroups).map(async (group) => { try { - // Validate IMDB ID format before attempting to fetch - if (!isValidImdbId(group.id)) { - return; - } + // Allow any ID; meta resolution will try Cinemeta first, then other addons + if (!isSupportedId(group.id)) return; // Get metadata once per content const cachedData = await getCachedMetadata(group.type, group.id); diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx new file mode 100644 index 0000000..9dac746 --- /dev/null +++ b/src/contexts/AccountContext.tsx @@ -0,0 +1,89 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import accountService, { AuthUser } from '../services/AccountService'; +import supabase from '../services/supabaseClient'; +import syncService from '../services/SyncService'; + +type AccountContextValue = { + user: AuthUser | null; + loading: boolean; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, password: string) => Promise; + signOut: () => Promise; +}; + +const AccountContext = createContext(undefined); + +export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Initial session + (async () => { + const u = await accountService.getCurrentUser(); + setUser(u); + setLoading(false); + syncService.init(); + if (u) { + await syncService.migrateLocalScopeToUser(); + await syncService.subscribeRealtime(); + await Promise.all([ + syncService.fullPull(), + syncService.fullPush(), + ]); + } + })(); + + // Auth state listener + const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => { + const u = session?.user ? { id: session.user.id, email: session.user.email ?? undefined } : null; + setUser(u); + if (u) { + await syncService.migrateLocalScopeToUser(); + await syncService.subscribeRealtime(); + await Promise.all([ + syncService.fullPull(), + syncService.fullPush(), + ]); + } else { + syncService.unsubscribeRealtime(); + } + }); + + return () => { + subscription.subscription.unsubscribe(); + }; + }, []); + + const value = useMemo(() => ({ + user, + loading, + signIn: async (email: string, password: string) => { + const { error } = await accountService.signInWithEmail(email, password); + return error || null; + }, + signUp: async (email: string, password: string) => { + const { error } = await accountService.signUpWithEmail(email, password); + return error || null; + }, + signOut: async () => { + await accountService.signOut(); + setUser(null); + } + }), [user, loading]); + + return ( + + {children} + + ); +}; + +export const useAccount = (): AccountContextValue => { + const ctx = useContext(AccountContext); + if (!ctx) throw new Error('useAccount must be used within AccountProvider'); + return ctx; +}; + +export default AccountContext; + diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 54c550b..b3f46c2 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { settingsEmitter } from '../hooks/useSettings'; import { colors as defaultColors } from '../styles/colors'; // Define the Theme interface @@ -154,7 +155,7 @@ interface ThemeContextProps { // Create the context const ThemeContext = createContext(undefined); -// Storage keys +// Storage keys (kept for backward compatibility). Primary source of truth is app_settings const CURRENT_THEME_KEY = 'current_theme'; const CUSTOM_THEMES_KEY = 'custom_themes'; @@ -163,34 +164,29 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const [currentTheme, setCurrentThemeState] = useState(DEFAULT_THEMES[0]); const [availableThemes, setAvailableThemes] = useState(DEFAULT_THEMES); - // Load themes from AsyncStorage on mount + // Load themes from app_settings (scoped), with legacy fallbacks useEffect(() => { const loadThemes = async () => { try { - // Load current theme ID - const savedThemeId = await AsyncStorage.getItem(CURRENT_THEME_KEY); - - // Load custom themes - const customThemesJson = await AsyncStorage.getItem(CUSTOM_THEMES_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const appSettingsJson = await AsyncStorage.getItem(`@user:${scope}:app_settings`); + const appSettings = appSettingsJson ? JSON.parse(appSettingsJson) : {}; + const savedThemeId = appSettings.themeId || (await AsyncStorage.getItem(CURRENT_THEME_KEY)); + const customThemesJson = appSettings.customThemes ? JSON.stringify(appSettings.customThemes) : await AsyncStorage.getItem(CUSTOM_THEMES_KEY); const customThemes = customThemesJson ? JSON.parse(customThemesJson) : []; - - // Combine default and custom themes const allThemes = [...DEFAULT_THEMES, ...customThemes]; setAvailableThemes(allThemes); - - // Set current theme if (savedThemeId) { const theme = allThemes.find(t => t.id === savedThemeId); - if (theme) { - setCurrentThemeState(theme); - } + if (theme) setCurrentThemeState(theme); } } catch (error) { console.error('Failed to load themes:', error); } }; - loadThemes(); + // Stop live refresh from remote; only refresh on app restart or local changes + return () => {}; }, []); // Set current theme @@ -198,7 +194,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const theme = availableThemes.find(t => t.id === themeId); if (theme) { setCurrentThemeState(theme); + // Persist into scoped app_settings and legacy key for backward compat + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const key = `@user:${scope}:app_settings`; + let settings = {} as any; + try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {} + settings.themeId = themeId; + await AsyncStorage.setItem(key, JSON.stringify(settings)); await AsyncStorage.setItem(CURRENT_THEME_KEY, themeId); + // Do not emit global settings sync for themes (sync on app restart only) } }; @@ -220,7 +224,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const updatedCustomThemes = [...customThemes, newTheme]; const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes]; - // Save to storage + // Save to storage (scoped app_settings + legacy key) + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const key = `@user:${scope}:app_settings`; + let settings = {} as any; + try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {} + settings.customThemes = updatedCustomThemes; + await AsyncStorage.setItem(key, JSON.stringify(settings)); await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes)); // Update state @@ -229,6 +239,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Set as current theme setCurrentThemeState(newTheme); await AsyncStorage.setItem(CURRENT_THEME_KEY, id); + // Do not emit global settings sync for themes } catch (error) { console.error('Failed to add custom theme:', error); } @@ -250,7 +261,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Update available themes const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes]; - // Save to storage + // Save to storage (scoped app_settings + legacy key) + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const key = `@user:${scope}:app_settings`; + let settings = {} as any; + try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {} + settings.customThemes = updatedCustomThemes; + await AsyncStorage.setItem(key, JSON.stringify(settings)); await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes)); // Update state @@ -260,6 +277,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { if (currentTheme.id === updatedTheme.id) { setCurrentThemeState(updatedTheme); } + // Do not emit global settings sync for themes } catch (error) { console.error('Failed to update custom theme:', error); } @@ -279,7 +297,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const customThemes = availableThemes.filter(t => t.isEditable && t.id !== themeId); const updatedAllThemes = [...DEFAULT_THEMES, ...customThemes]; - // Save to storage + // Save to storage (scoped app_settings + legacy key) + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const key = `@user:${scope}:app_settings`; + let settings = {} as any; + try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {} + settings.customThemes = customThemes; + await AsyncStorage.setItem(key, JSON.stringify(settings)); await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(customThemes)); // Update state @@ -290,6 +314,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { setCurrentThemeState(DEFAULT_THEMES[0]); await AsyncStorage.setItem(CURRENT_THEME_KEY, DEFAULT_THEMES[0].id); } + // Do not emit global settings sync for themes } catch (error) { console.error('Failed to delete custom theme:', error); } diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 5164359..2cf4cba 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { StreamingContent } from '../types/metadata'; import { catalogService } from '../services/catalogService'; -const LIBRARY_STORAGE_KEY = 'stremio-library'; +const LEGACY_LIBRARY_STORAGE_KEY = 'stremio-library'; export const useLibrary = () => { const [libraryItems, setLibraryItems] = useState([]); @@ -13,7 +13,17 @@ export const useLibrary = () => { const loadLibraryItems = useCallback(async () => { try { setLoading(true); - const storedItems = await AsyncStorage.getItem(LIBRARY_STORAGE_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + let storedItems = await AsyncStorage.getItem(scopedKey); + if (!storedItems) { + // migrate legacy into scoped + const legacy = await AsyncStorage.getItem(LEGACY_LIBRARY_STORAGE_KEY); + if (legacy) { + await AsyncStorage.setItem(scopedKey, legacy); + storedItems = legacy; + } + } if (storedItems) { const parsedItems = JSON.parse(storedItems); @@ -40,8 +50,11 @@ export const useLibrary = () => { acc[`${item.type}:${item.id}`] = item; return acc; }, {} as Record); - - await AsyncStorage.setItem(LIBRARY_STORAGE_KEY, JSON.stringify(itemsObject)); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(itemsObject)); + // keep legacy for backward-compat + await AsyncStorage.setItem(LEGACY_LIBRARY_STORAGE_KEY, JSON.stringify(itemsObject)); } catch (error) { console.error('Error saving library items:', error); } @@ -49,18 +62,33 @@ export const useLibrary = () => { // Add item to library const addToLibrary = useCallback(async (item: StreamingContent) => { - const updatedItems = [...libraryItems, { ...item, inLibrary: true }]; - setLibraryItems(updatedItems); - await saveLibraryItems(updatedItems); - return true; + try { + await catalogService.addToLibrary({ ...item, inLibrary: true }); + return true; + } catch (e) { + console.error('Error adding to library via catalogService:', e); + // Fallback local write + const updatedItems = [...libraryItems, { ...item, inLibrary: true }]; + setLibraryItems(updatedItems); + await saveLibraryItems(updatedItems); + return true; + } }, [libraryItems, saveLibraryItems]); // Remove item from library const removeFromLibrary = useCallback(async (id: string) => { - const updatedItems = libraryItems.filter(item => item.id !== id); - setLibraryItems(updatedItems); - await saveLibraryItems(updatedItems); - return true; + try { + const type = libraryItems.find(i => i.id === id)?.type || 'movie'; + await catalogService.removeFromLibrary(type, id); + return true; + } catch (e) { + console.error('Error removing from library via catalogService:', e); + // Fallback local write + const updatedItems = libraryItems.filter(item => item.id !== id); + setLibraryItems(updatedItems); + await saveLibraryItems(updatedItems); + return true; + } }, [libraryItems, saveLibraryItems]); // Toggle item in library diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 19c6f20..6c7650b 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -20,6 +20,13 @@ class SettingsEventEmitter { // Singleton instance for app-wide access export const settingsEmitter = new SettingsEventEmitter(); +export interface CustomThemeDef { + id: string; + name: string; + colors: { primary: string; secondary: string; darkBackground: string }; + isEditable: boolean; +} + export interface AppSettings { enableDarkMode: boolean; enableNotifications: boolean; @@ -48,6 +55,9 @@ export interface AppSettings { excludedQualities: string[]; // Array of quality strings to exclude (e.g., ['2160p', '4K', '1080p', '720p']) // Playback behavior alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85% + // Theme settings + themeId: string; + customThemes: CustomThemeDef[]; } export const DEFAULT_SETTINGS: AppSettings = { @@ -78,6 +88,9 @@ export const DEFAULT_SETTINGS: AppSettings = { excludedQualities: [], // No qualities excluded by default // Playback behavior defaults alwaysResume: false, + // Theme defaults + themeId: 'default', + customThemes: [], }; const SETTINGS_STORAGE_KEY = 'app_settings'; @@ -98,7 +111,8 @@ export const useSettings = () => { const loadSettings = async () => { try { - const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const storedSettings = await AsyncStorage.getItem(`@user:${scope}:${SETTINGS_STORAGE_KEY}`); if (storedSettings) { const parsedSettings = JSON.parse(storedSettings); // Merge with defaults to ensure all properties exist @@ -118,7 +132,8 @@ export const useSettings = () => { ) => { const newSettings = { ...settings, [key]: value }; try { - await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + await AsyncStorage.setItem(`@user:${scope}:${SETTINGS_STORAGE_KEY}`, JSON.stringify(newSettings)); setSettings(newSettings); console.log(`Setting updated: ${key}`, value); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 4a1f8f4..6c92a1d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,6 +39,8 @@ import LogoSourceSettings from '../screens/LogoSourceSettings'; import ThemeScreen from '../screens/ThemeScreen'; import ProfilesScreen from '../screens/ProfilesScreen'; import OnboardingScreen from '../screens/OnboardingScreen'; +import AuthScreen from '../screens/AuthScreen'; +import { AccountProvider, useAccount } from '../contexts/AccountContext'; import PluginsScreen from '../screens/PluginsScreen'; // Stack navigator types @@ -654,8 +656,9 @@ const customFadeInterpolator = ({ current, layouts }: any) => { }; // Stack Navigator -const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => { +const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => { const { currentTheme } = useTheme(); + const { user, loading } = useAccount(); // Handle Android-specific optimizations useEffect(() => { @@ -713,6 +716,17 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack }), }} > + {!loading && !user && ( + + )} ( + + + +); + export default AppNavigator; \ No newline at end of file diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx new file mode 100644 index 0000000..ef51b63 --- /dev/null +++ b/src/screens/AuthScreen.tsx @@ -0,0 +1,647 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../contexts/ThemeContext'; +import { useAccount } from '../contexts/AccountContext'; +import { useNavigation } from '@react-navigation/native'; +import * as Haptics from 'expo-haptics'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { width, height } = Dimensions.get('window'); + +const AuthScreen: React.FC = () => { + const { currentTheme } = useTheme(); + const { signIn, signUp } = useAccount(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [mode, setMode] = useState<'signin' | 'signup'>('signin'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Subtle, performant animations + const introOpacity = useRef(new Animated.Value(0)).current; + const introTranslateY = useRef(new Animated.Value(10)).current; + const cardOpacity = useRef(new Animated.Value(0)).current; + const cardTranslateY = useRef(new Animated.Value(12)).current; + const ctaScale = useRef(new Animated.Value(1)).current; + const titleOpacity = useRef(new Animated.Value(1)).current; + const titleTranslateY = useRef(new Animated.Value(0)).current; + const ctaTextOpacity = useRef(new Animated.Value(1)).current; + const ctaTextTranslateY = useRef(new Animated.Value(0)).current; + const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup + const [switchWidth, setSwitchWidth] = useState(0); + const toastOpacity = useRef(new Animated.Value(0)).current; + const toastTranslateY = useRef(new Animated.Value(16)).current; + const [toast, setToast] = useState<{ visible: boolean; message: string; type: 'success' | 'error' | 'info' }>({ visible: false, message: '', type: 'info' }); + const [headerHeight, setHeaderHeight] = useState(0); + const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden + + useEffect(() => { + Animated.parallel([ + Animated.timing(introOpacity, { + toValue: 1, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(introTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(cardOpacity, { + toValue: 1, + duration: 360, + delay: 90, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(cardTranslateY, { + toValue: 0, + duration: 360, + delay: 90, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + }, [cardOpacity, cardTranslateY, introOpacity, introTranslateY]); + + // Animate on mode change + useEffect(() => { + Animated.timing(modeAnim, { + toValue: mode === 'signin' ? 0 : 1, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + + Animated.sequence([ + Animated.parallel([ + Animated.timing(titleOpacity, { toValue: 0, duration: 120, useNativeDriver: true }), + Animated.timing(titleTranslateY, { toValue: -6, duration: 120, useNativeDriver: true }), + Animated.timing(ctaTextOpacity, { toValue: 0, duration: 120, useNativeDriver: true }), + Animated.timing(ctaTextTranslateY, { toValue: -4, duration: 120, useNativeDriver: true }), + ]), + Animated.parallel([ + Animated.timing(titleOpacity, { toValue: 1, duration: 180, useNativeDriver: true }), + Animated.timing(titleTranslateY, { toValue: 0, duration: 180, useNativeDriver: true }), + Animated.timing(ctaTextOpacity, { toValue: 1, duration: 180, useNativeDriver: true }), + Animated.timing(ctaTextTranslateY, { toValue: 0, duration: 180, useNativeDriver: true }), + ]), + ]).start(); + }, [mode, ctaTextOpacity, ctaTextTranslateY, modeAnim, titleOpacity, titleTranslateY]); + + // Hide/show header when keyboard toggles + useEffect(() => { + const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const onShow = () => { + Animated.timing(headerHideAnim, { + toValue: 1, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }; + const onHide = () => { + Animated.timing(headerHideAnim, { + toValue: 0, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }; + const subShow = Keyboard.addListener(showEvt, onShow); + const subHide = Keyboard.addListener(hideEvt, onHide); + return () => { + subShow.remove(); + subHide.remove(); + }; + }, [headerHideAnim]); + + const isEmailValid = useMemo(() => /\S+@\S+\.\S+/.test(email.trim()), [email]); + const isPasswordValid = useMemo(() => password.length >= 6, [password]); + const canSubmit = isEmailValid && isPasswordValid; + + const handleSubmit = async () => { + if (loading) return; + if (!isEmailValid) { + const msg = 'Enter a valid email address'; + setError(msg); + showToast(msg, 'error'); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); + return; + } + if (!isPasswordValid) { + const msg = 'Password must be at least 6 characters'; + setError(msg); + showToast(msg, 'error'); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); + return; + } + setLoading(true); + setError(null); + const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password); + if (err) { + setError(err); + showToast(err, 'error'); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); + } else { + const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful'; + showToast(msg, 'success'); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + } + setLoading(false); + }; + + const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => { + setToast({ visible: true, message, type }); + toastOpacity.setValue(0); + toastTranslateY.setValue(16); + Animated.parallel([ + Animated.timing(toastOpacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(toastTranslateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + ]).start(() => { + setTimeout(() => { + Animated.parallel([ + Animated.timing(toastOpacity, { toValue: 0, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(toastTranslateY, { toValue: 16, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + ]).start(() => setToast(prev => ({ ...prev, visible: false }))); + }, 2200); + }); + }; + + return ( + + {Platform.OS !== 'android' ? ( + + ) : ( + + )} + + {/* Background Pattern (iOS only) */} + {Platform.OS !== 'android' && ( + + {Array.from({ length: 20 }).map((_, i) => ( + + ))} + + )} + + + {/* Header outside KeyboardAvoidingView to avoid being overlapped */} + setHeaderHeight(e.nativeEvent.layout.height)} + style={[ + styles.header, + { + opacity: Animated.multiply( + introOpacity, + headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }) + ), + transform: [ + { + translateY: Animated.add( + introTranslateY, + headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -12] }) + ), + }, + ], + }, + ]} + > + {navigation.canGoBack() && ( + navigation.goBack()} style={[styles.backButton, Platform.OS === 'android' ? { top: Math.max(insets.top + 6, 18) } : null]} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + )} + + {mode === 'signin' ? 'Welcome back' : 'Create your account'} + + + Sync your addons, progress and settings across devices + + + + + {/* Main Card */} + + + + {/* Mode Toggle */} + setSwitchWidth(e.nativeEvent.layout.width)} + style={[styles.switchRow, { backgroundColor: Platform.OS === 'android' ? '#1a1a1a' : 'rgba(255,255,255,0.04)' }]} + > + {/* Animated indicator */} + + setMode('signin')} + activeOpacity={0.8} + > + + Sign In + + + setMode('signup')} + activeOpacity={0.8} + > + + Sign Up + + + + + {/* Email Input */} + + + + + + + {Platform.OS !== 'android' && isEmailValid && ( + + )} + + + + {/* Password Input */} + + + + + + + setShowPassword(p => !p)} style={styles.eyeButton}> + + + {Platform.OS !== 'android' && isPasswordValid && ( + + )} + + + + {/* Error */} + {!!error && ( + + + {error} + + )} + + {/* Submit Button */} + + { + Animated.spring(ctaScale, { + toValue: 0.98, + useNativeDriver: true, + speed: 20, + bounciness: 0, + }).start(); + }} + onPressOut={() => { + Animated.spring(ctaScale, { + toValue: 1, + useNativeDriver: true, + speed: 20, + bounciness: 6, + }).start(); + }} + activeOpacity={0.85} + disabled={loading} + > + {loading ? ( + + ) : ( + + {mode === 'signin' ? 'Sign In' : 'Create Account'} + + )} + + + + {/* Switch Mode */} + setMode(mode === 'signin' ? 'signup' : 'signin')} + activeOpacity={0.7} + style={{ marginTop: 16 }} + > + + {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} + + {mode === 'signin' ? 'Sign up' : 'Sign in'} + + + + + + {/* Toast */} + {toast.visible && ( + + + {toast.message} + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + backgroundPattern: { + ...StyleSheet.absoluteFillObject, + zIndex: 0, + }, + patternDot: { + position: 'absolute', + width: 2, + height: 2, + backgroundColor: '#fff', + borderRadius: 1, + }, + header: { + alignItems: 'center', + paddingTop: 64, + paddingBottom: 8, + }, + logoContainer: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + logo: { + width: 180, + height: 54, + zIndex: 2, + }, + logoGlow: { + position: 'absolute', + width: 200, + height: 70, + backgroundColor: 'rgba(229, 9, 20, 0.1)', + borderRadius: 35, + zIndex: 1, + }, + heading: { + fontSize: 28, + fontWeight: '800', + letterSpacing: -0.5, + marginBottom: 4, + }, + subheading: { + fontSize: 13, + lineHeight: 18, + textAlign: 'center', + paddingHorizontal: 20, + marginTop: 1, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, + paddingBottom: 28, + }, + card: { + width: '100%', + maxWidth: 400, + padding: 24, + borderRadius: 20, + borderWidth: 1, + elevation: 20, + }, + switchRow: { + flexDirection: 'row', + borderRadius: 14, + padding: 3, + marginBottom: 24, + position: 'relative', + overflow: 'hidden', + }, + switchIndicator: { + position: 'absolute', + top: 3, + bottom: 3, + left: 3, + borderRadius: 12, + }, + switchButton: { + flex: 1, + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + elevation: 0, + }, + switchText: { + fontWeight: '700', + fontSize: 15, + letterSpacing: 0.2, + }, + inputContainer: { + marginBottom: 16, + }, + inputRow: { + height: 56, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 14, + paddingHorizontal: 4, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + input: { + flex: 1, + fontSize: 16, + paddingVertical: 0, + fontWeight: '500', + }, + eyeButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + marginRight: 4, + }, + errorRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + paddingHorizontal: 4, + }, + errorText: { + color: '#ff6b6b', + marginLeft: 8, + fontSize: 13, + fontWeight: '500', + }, + ctaButton: { + height: 56, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + elevation: 0, + }, + ctaText: { + color: '#fff', + fontWeight: '700', + fontSize: 16, + letterSpacing: 0.3, + }, + backButton: { + position: 'absolute', + left: 16, + top: 8, + }, + toast: { + position: 'absolute', + bottom: 24, + left: 20, + right: 20, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + toastText: { + color: '#fff', + fontWeight: '600', + flex: 1, + }, + switchModeText: { + textAlign: 'center', + fontSize: 14, + fontWeight: '500', + }, +}); + +export default AuthScreen; + diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 91cd907..07cdda5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -26,6 +26,7 @@ import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; import { useTraktContext } from '../contexts/TraktContext'; import { useTheme } from '../contexts/ThemeContext'; +import { useAccount } from '../contexts/AccountContext'; import { catalogService } from '../services/catalogService'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Sentry from '@sentry/react-native'; @@ -135,6 +136,7 @@ const SettingsScreen: React.FC = () => { const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); + const { user, signOut } = useAccount(); // Add a useEffect to check authentication status on focus useEffect(() => { @@ -295,6 +297,27 @@ const SettingsScreen: React.FC = () => { > {/* Account Section */} + {user ? ( + <> + + + + ) : ( + navigation.navigate('Account')} + /> + )} { + const { data, error } = await supabase.auth.signUp({ email, password }); + if (error) return { error: error.message }; + const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined; + if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id); + return { user }; + } + + async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return { error: error.message }; + const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined; + if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id); + return { user }; + } + + async signOut(): Promise { + await supabase.auth.signOut(); + await AsyncStorage.setItem(USER_SCOPE_KEY, 'local'); + } + + async getCurrentUser(): Promise { + const { data } = await supabase.auth.getUser(); + const u = data.user; + if (!u) return null; + return { id: u.id, email: u.email ?? undefined }; + } + + async getCurrentUserIdScoped(): Promise { + const user = await this.getCurrentUser(); + if (user?.id) return user.id; + // Guest scope + const scope = (await AsyncStorage.getItem(USER_SCOPE_KEY)) || 'local'; + if (!scope) await AsyncStorage.setItem(USER_SCOPE_KEY, 'local'); + return scope || 'local'; + } +} + +export const accountService = AccountService.getInstance(); +export default accountService; + diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts new file mode 100644 index 0000000..cdd4297 --- /dev/null +++ b/src/services/SyncService.ts @@ -0,0 +1,782 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import supabase from './supabaseClient'; +import accountService from './AccountService'; +import { storageService } from './storageService'; +import { addonEmitter, ADDON_EVENTS, stremioService } from './stremioService'; +import { catalogService, StreamingContent } from './catalogService'; +// import localScraperService from './localScraperService'; +import { settingsEmitter } from '../hooks/useSettings'; +import { logger } from '../utils/logger'; + +type WatchProgressRow = { + user_id: string; + media_type: string; + media_id: string; + episode_id: string; + current_time_seconds: number; + duration_seconds: number; + last_updated_ms: number; + trakt_synced?: boolean; + trakt_last_synced_ms?: number | null; + trakt_progress_percent?: number | null; +}; + +const SYNC_QUEUE_KEY = '@sync_queue'; + +class SyncService { + private static instance: SyncService; + private syncing = false; + private suppressPush = false; + private realtimeChannels: any[] = []; + private pullDebounceTimer: NodeJS.Timeout | null = null; + private addonsPollInterval: NodeJS.Timeout | null = null; + private suppressLibraryPush: boolean = false; + private libraryUnsubscribe: (() => void) | null = null; + + static getInstance(): SyncService { + if (!SyncService.instance) SyncService.instance = new SyncService(); + return SyncService.instance; + } + + init(): void { + // Watch progress updates + storageService.subscribeToWatchProgressUpdates(() => { + if (this.suppressPush) return; + logger.log('[Sync] watch_progress local change → push'); + this.pushWatchProgress().catch(() => undefined); + }); + storageService.onWatchProgressRemoved((id, type, episodeId) => { + if (this.suppressPush) return; + logger.log(`[Sync] watch_progress removed → soft delete ${type}:${id}:${episodeId || ''}`); + this.softDeleteWatchProgress(type, id, episodeId).catch(() => undefined); + }); + + // Addon order and changes + addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, () => { logger.log('[Sync] addon order changed → push'); this.pushAddons(); }); + addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, () => { logger.log('[Sync] addon added → push'); this.pushAddons(); }); + addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, () => { logger.log('[Sync] addon removed → push'); this.pushAddons(); }); + + // Settings updates: no realtime push; sync only on app restart + logger.log('[Sync] init completed (listeners wired; settings push disabled)'); + + // Library local change → push + if (this.libraryUnsubscribe) { + try { this.libraryUnsubscribe(); } catch {} + this.libraryUnsubscribe = null; + } + const unsubAdd = catalogService.onLibraryAdd((item) => { + if (this.suppressLibraryPush) return; + logger.log(`[Sync] library add → push ${item.type}:${item.id}`); + this.pushLibraryAdd(item).catch(() => undefined); + }); + const unsubRem = catalogService.onLibraryRemove((type, id) => { + if (this.suppressLibraryPush) return; + logger.log(`[Sync] library remove → push ${type}:${id}`); + this.pushLibraryRemove(type, id).catch(() => undefined); + }); + this.libraryUnsubscribe = () => { try { unsubAdd(); unsubRem(); } catch {} }; + } + + subscribeRealtime = async (): Promise => { + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + + const addChannel = (table: string, handler: (payload: any) => void) => { + const channel = supabase + .channel(`rt-${table}`) + .on('postgres_changes', { event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` }, handler) + .subscribe(); + this.realtimeChannels.push(channel); + logger.log(`[Sync] Realtime subscribed: ${table}`); + }; + + // Watch progress: apply granular updates (ignore self-caused pushes via suppressPush) + addChannel('watch_progress', async (payload) => { + try { + const row = (payload.new || payload.old); + if (!row) return; + const type = row.media_type as string; + const id = row.media_id as string; + const episodeId = (payload.eventType === 'DELETE') ? (row.episode_id || '') : (row.episode_id || ''); + this.suppressPush = true; + const deletedAt = (row as any).deleted_at; + if (payload.eventType === 'DELETE' || deletedAt) { + await storageService.removeWatchProgress(id, type, episodeId || undefined); + // Record tombstone with remote timestamp if available + try { + const remoteUpdated = (row as any).updated_at ? new Date((row as any).updated_at).getTime() : Date.now(); + await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated); + } catch {} + } else { + await storageService.setWatchProgress( + id, + type, + { + currentTime: row.current_time_seconds || 0, + duration: row.duration_seconds || 0, + lastUpdated: row.last_updated_ms || Date.now(), + traktSynced: row.trakt_synced ?? undefined, + traktLastSynced: row.trakt_last_synced_ms ?? undefined, + traktProgress: row.trakt_progress_percent ?? undefined, + }, + episodeId || undefined + ); + } + } catch {} + finally { + this.suppressPush = false; + } + }); + + const debouncedPull = (payload?: any) => { + if (payload?.table) logger.log(`[Sync][rt] change on ${payload.table} → debounced fullPull`); + if (this.pullDebounceTimer) clearTimeout(this.pullDebounceTimer); + this.pullDebounceTimer = setTimeout(() => { + logger.log('[Sync] fullPull (debounced) start'); + this.fullPull() + .then(() => logger.log('[Sync] fullPull (debounced) done')) + .catch((e) => console.warn('[Sync] fullPull (debounced) error', e)); + }, 300); + }; + + // Addons: just re-pull snapshot quickly + addChannel('installed_addons', () => debouncedPull({ table: 'installed_addons' })); + // Library realtime: apply row-level changes + addChannel('user_library', async (payload) => { + try { + const row = (payload.new || payload.old); + if (!row) return; + const mediaType = (row.media_type as string) === 'movie' ? 'movie' : 'series'; + const mediaId = row.media_id as string; + this.suppressLibraryPush = true; + const deletedAt = (row as any).deleted_at; + if (payload.eventType === 'DELETE' || deletedAt) { + await catalogService.removeFromLibrary(mediaType, mediaId); + logger.log(`[Sync][rt] user_library DELETE ${mediaType}:${mediaId}`); + } else { + const content: StreamingContent = { + id: mediaId, + type: mediaType, + name: (row.title as string) || mediaId, + poster: (row.poster_url as string) || '', + inLibrary: true, + year: row.year ?? undefined, + } as any; + await catalogService.addToLibrary(content); + logger.log(`[Sync][rt] user_library ${payload.eventType} ${mediaType}:${mediaId}`); + } + } catch (e) { + console.warn('[Sync][rt] user_library handler error', e); + } finally { + this.suppressLibraryPush = false; + } + }); + // Excluded: local_scrapers, scraper_repository from realtime sync + logger.log('[Sync] Realtime subscriptions active'); + + // Fallback polling for addons (in case realtime isn't enabled) + if (this.addonsPollInterval) clearInterval(this.addonsPollInterval); + this.addonsPollInterval = setInterval(async () => { + try { + const u = await accountService.getCurrentUser(); + if (!u) return; + // Compare excluding preinstalled + const exclude = new Set(['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3']); + const localIds = new Set( + (await stremioService.getInstalledAddonsAsync()) + .map((a: any) => a.id) + .filter((id: string) => !exclude.has(id)) + ); + const { data: remote } = await supabase + .from('installed_addons') + .select('addon_id') + .eq('user_id', u.id); + const remoteIds = new Set( + ((remote || []) as any[]) + .map(r => r.addon_id as string) + .filter((id: string) => !exclude.has(id)) + ); + if (localIds.size !== remoteIds.size) { + logger.log('[Sync][poll] addons mismatch by count → pull snapshot'); + await this.pullAddonsSnapshot(u.id); + return; + } + for (const id of remoteIds) { + if (!localIds.has(id)) { + logger.log('[Sync][poll] addons mismatch by set → pull snapshot'); + await this.pullAddonsSnapshot(u.id); + break; + } + } + } catch (e) { + // silent + } + }, 14400000); + }; + + unsubscribeRealtime = (): void => { + try { + logger.log(`[Sync] Realtime unsubscribe (${this.realtimeChannels.length})`); + for (const ch of this.realtimeChannels) { + try { ch.unsubscribe?.(); } catch {} + } + } finally { + this.realtimeChannels = []; + if (this.addonsPollInterval) { + clearInterval(this.addonsPollInterval); + this.addonsPollInterval = null; + } + if (this.libraryUnsubscribe) { + try { this.libraryUnsubscribe(); } catch {} + this.libraryUnsubscribe = null; + } + } + }; + + async migrateLocalScopeToUser(): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + const keys = await AsyncStorage.getAllKeys(); + const migrations: Array> = []; + const moveKey = async (from: string, to: string) => { + const val = await AsyncStorage.getItem(from); + if (val == null) return; + const exists = await AsyncStorage.getItem(to); + if (!exists) { + await AsyncStorage.setItem(to, val); + } else { + // Prefer the one with newer lastUpdated if JSON + try { + const a = JSON.parse(val); + const b = JSON.parse(exists); + const aLU = a?.lastUpdated ?? 0; + const bLU = b?.lastUpdated ?? 0; + if (aLU > bLU) await AsyncStorage.setItem(to, val); + } catch { + // Keep existing if equal + } + } + await AsyncStorage.removeItem(from); + }; + + // Watch progress/content durations/subtitles/app settings + for (const k of keys) { + if (k.startsWith('@user:local:@watch_progress:')) { + const suffix = k.replace('@user:local:@watch_progress:', ''); + migrations.push(moveKey(k, `@user:${userId}:@watch_progress:${suffix}`)); + } else if (k.startsWith('@user:local:@content_duration:')) { + const suffix = k.replace('@user:local:@content_duration:', ''); + migrations.push(moveKey(k, `@user:${userId}:@content_duration:${suffix}`)); + } else if (k === '@user:local:@subtitle_settings') { + migrations.push(moveKey(k, `@user:${userId}:@subtitle_settings`)); + } else if (k === 'app_settings') { + migrations.push(moveKey('app_settings', `@user:${userId}:app_settings`)); + } else if (k === '@user:local:app_settings') { + migrations.push(moveKey(k, `@user:${userId}:app_settings`)); + } else if (k === '@user:local:stremio-addons') { + migrations.push(moveKey(k, `@user:${userId}:stremio-addons`)); + } else if (k === '@user:local:stremio-addon-order') { + migrations.push(moveKey(k, `@user:${userId}:stremio-addon-order`)); + } else if (k === '@user:local:local-scrapers') { + migrations.push(moveKey(k, `@user:${userId}:local-scrapers`)); + } else if (k === '@user:local:scraper-repository-url') { + migrations.push(moveKey(k, `@user:${userId}:scraper-repository-url`)); + } else if (k === '@user:local:stremio-library') { + migrations.push((async () => { + const val = (await AsyncStorage.getItem(k)) || '{}'; + await moveKey(k, `@user:${userId}:stremio-library`); + try { + const parsed = JSON.parse(val) as Record; + const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length; + if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true'); + } catch {} + })()); + } else if (k === 'stremio-library') { + migrations.push((async () => { + const val = (await AsyncStorage.getItem(k)) || '{}'; + await moveKey(k, `@user:${userId}:stremio-library`); + try { + const parsed = JSON.parse(val) as Record; + const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length; + if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true'); + } catch {} + })()); + } + } + // Migrate legacy theme keys into scoped app_settings + try { + const legacyThemeId = await AsyncStorage.getItem('current_theme'); + const legacyCustomThemesJson = await AsyncStorage.getItem('custom_themes'); + const scopedSettingsKey = `@user:${userId}:app_settings`; + let scopedSettings: any = {}; + try { scopedSettings = JSON.parse((await AsyncStorage.getItem(scopedSettingsKey)) || '{}'); } catch {} + let changed = false; + if (legacyThemeId && scopedSettings.themeId !== legacyThemeId) { + scopedSettings.themeId = legacyThemeId; + changed = true; + } + if (legacyCustomThemesJson) { + const legacyCustomThemes = JSON.parse(legacyCustomThemesJson); + if (Array.isArray(legacyCustomThemes)) { + scopedSettings.customThemes = legacyCustomThemes; + changed = true; + } + } + if (changed) { + await AsyncStorage.setItem(scopedSettingsKey, JSON.stringify(scopedSettings)); + } + } catch {} + await Promise.all(migrations); + logger.log(`[Sync] migrateLocalScopeToUser done (moved ~${migrations.length} keys)`); + } + + async fullPush(): Promise { + logger.log('[Sync] fullPush start'); + await Promise.allSettled([ + this.pushWatchProgress(), + // Settings push only at app start/sign-in handled by fullPush itself; keep here OK + this.pushSettings(), + this.pushAddons(), + // Excluded: this.pushLocalScrapers(), + this.pushLibrary(), + ]); + logger.log('[Sync] fullPush done'); + } + + async fullPull(): Promise { + logger.log('[Sync] fullPull start'); + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + + await Promise.allSettled([ + (async () => { + logger.log('[Sync] pull watch_progress'); + const { data: wp } = await supabase + .from('watch_progress') + .select('*') + .eq('user_id', userId) + .is('deleted_at', null); + if (wp && Array.isArray(wp)) { + const remoteActiveKeys = new Set(); + for (const row of wp as any[]) { + await storageService.setWatchProgress( + row.media_id, + row.media_type, + { + currentTime: row.current_time_seconds, + duration: row.duration_seconds, + lastUpdated: row.last_updated_ms, + traktSynced: row.trakt_synced ?? undefined, + traktLastSynced: row.trakt_last_synced_ms ?? undefined, + traktProgress: row.trakt_progress_percent ?? undefined, + }, + row.episode_id || undefined + ); + remoteActiveKeys.add(`${row.media_type}|${row.media_id}|${row.episode_id || ''}`); + } + // Remove any local progress not present on server (server is source of truth) + try { + const allLocal = await storageService.getAllWatchProgress(); + for (const [key] of Object.entries(allLocal)) { + const parts = key.split(':'); + const type = parts[0]; + const id = parts[1]; + const ep = parts[2] || ''; + const k = `${type}|${id}|${ep}`; + if (!remoteActiveKeys.has(k)) { + this.suppressPush = true; + await storageService.removeWatchProgress(id, type, ep || undefined); + this.suppressPush = false; + } + } + } catch {} + } + })(), + (async () => { + logger.log('[Sync] pull user_settings'); + const { data: us } = await supabase + .from('user_settings') + .select('*') + .eq('user_id', userId) + .single(); + if (us) { + await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(us.app_settings || {})); + await AsyncStorage.setItem('app_settings', JSON.stringify(us.app_settings || {})); + await storageService.saveSubtitleSettings(us.subtitle_settings || {}); + } + })(), + this.pullAddonsSnapshot(userId), + this.pullLibrary(userId), + ]); + logger.log('[Sync] fullPull done'); + } + + private async pullLibrary(userId: string): Promise { + try { + logger.log('[Sync] pull user_library'); + const { data, error } = await supabase + .from('user_library') + .select('media_type, media_id, title, poster_url, year, deleted_at, updated_at') + .eq('user_id', userId); + if (error) { + console.warn('[SyncService] pull library error', error); + return; + } + const obj: Record = {}; + for (const row of (data || []) as any[]) { + if (row.deleted_at) continue; + const key = `${row.media_type}:${row.media_id}`; + obj[key] = { + id: row.media_id, + type: row.media_type, + name: row.title || row.media_id, + poster: row.poster_url || '', + year: row.year || undefined, + inLibrary: true, + }; + } + await AsyncStorage.setItem(`@user:${userId}:stremio-library`, JSON.stringify(obj)); + await AsyncStorage.setItem('stremio-library', JSON.stringify(obj)); + logger.log(`[Sync] pull user_library wrote items=${Object.keys(obj).length}`); + } catch (e) { + console.warn('[SyncService] pullLibrary exception', e); + } + } + + private async pushLibrary(): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + try { + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const json = + (await AsyncStorage.getItem(`@user:${scope}:stremio-library`)) || + (await AsyncStorage.getItem('stremio-library')) || '{}'; + const itemsObj = JSON.parse(json) as Record; + const entries = Object.values(itemsObj) as any[]; + logger.log(`[Sync] push user_library entries=${entries.length}`); + const initialized = (await AsyncStorage.getItem(`@user:${user.id}:library_initialized`)) === 'true'; + // If not initialized and local entries are 0, attempt to import from server first + if (!initialized && entries.length === 0) { + logger.log('[Sync] user_library not initialized and local empty → pulling before deletions'); + await this.pullLibrary(user.id); + const post = (await AsyncStorage.getItem(`@user:${user.id}:stremio-library`)) || '{}'; + const postObj = JSON.parse(post) as Record; + const postEntries = Object.values(postObj) as any[]; + if (postEntries.length > 0) { + await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true'); + } + } + // Upsert rows + if (entries.length > 0) { + const rows = entries.map((it) => ({ + user_id: user.id, + media_type: it.type === 'movie' ? 'movie' : 'series', + media_id: it.id, + title: it.name || it.title || it.id, + poster_url: it.poster || it.poster_url || null, + year: normalizeYear(it.year), + updated_at: new Date().toISOString(), + })); + const { error: upErr } = await supabase + .from('user_library') + .upsert(rows, { onConflict: 'user_id,media_type,media_id' }); + if (upErr) console.warn('[SyncService] push library upsert error', upErr); + else await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true'); + } + // No computed deletions; removals happen only via explicit user action (soft delete) + } catch (e) { + console.warn('[SyncService] pushLibrary exception', e); + } + } + + private async pushLibraryAdd(item: StreamingContent): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + try { + const row = { + user_id: user.id, + media_type: item.type === 'movie' ? 'movie' : 'series', + media_id: item.id, + title: (item as any).name || (item as any).title || item.id, + poster_url: (item as any).poster || null, + year: normalizeYear((item as any).year), + deleted_at: null as any, + updated_at: new Date().toISOString(), + }; + const { error } = await supabase.from('user_library').upsert(row, { onConflict: 'user_id,media_type,media_id' }); + if (error) console.warn('[SyncService] pushLibraryAdd error', error); + } catch (e) { + console.warn('[SyncService] pushLibraryAdd exception', e); + } + } + + private async pushLibraryRemove(type: string, id: string): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + try { + const { error } = await supabase + .from('user_library') + .update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() }) + .eq('user_id', user.id) + .eq('media_type', type === 'movie' ? 'movie' : 'series') + .eq('media_id', id); + if (error) console.warn('[SyncService] pushLibraryRemove error', error); + } catch (e) { + console.warn('[SyncService] pushLibraryRemove exception', e); + } + } + + private async pullAddonsSnapshot(userId: string): Promise { + logger.log('[Sync] pull installed_addons'); + const { data: addons, error: addonsErr } = await supabase + .from('installed_addons') + .select('*') + .eq('user_id', userId) + .order('position', { ascending: true }); + if (addonsErr) { + console.warn('[SyncService] pull addons error', addonsErr); + return; + } + if (!(addons && Array.isArray(addons))) return; + + // Start from currently installed (to preserve pre-installed like Cinemeta/OpenSubtitles) + const map = new Map(); + + for (const a of addons as any[]) { + try { + let manifest = a.manifest_data; + if (!manifest) { + const urlToUse = a.original_url || a.url; + if (urlToUse) { + manifest = await stremioService.getManifest(urlToUse); + } + } + if (!manifest) { + manifest = { + id: a.addon_id, + name: a.name || a.addon_id, + version: a.version || '1.0.0', + description: a.description || '', + url: a.url || a.original_url || '', + originalUrl: a.original_url || a.url || '', + catalogs: [], + resources: [], + types: [], + }; + } + manifest.id = a.addon_id; + map.set(a.addon_id, manifest); + } catch (e) { + console.warn('[SyncService] failed to fetch manifest for', a.addon_id, e); + } + } + + // Always include preinstalled regardless of server + try { map.set('com.linvo.cinemeta', await stremioService.getManifest('https://v3-cinemeta.strem.io/manifest.json')); } catch {} + try { map.set('org.stremio.opensubtitlesv3', await stremioService.getManifest('https://opensubtitles-v3.strem.io/manifest.json')); } catch {} + + (stremioService as any).installedAddons = map; + const order = (addons as any[]).map(a => a.addon_id); + const ensureFront = (arr: string[], id: string) => { + const idx = arr.indexOf(id); + if (idx === -1) arr.unshift(id); + else if (idx > 0) { arr.splice(idx, 1); arr.unshift(id); } + }; + ensureFront(order, 'com.linvo.cinemeta'); + ensureFront(order, 'org.stremio.opensubtitlesv3'); + // Keep order strictly from server after preinstalled + // Do not append missing local-only ids to avoid resurrecting removed addons + (stremioService as any).addonOrder = order; + await (stremioService as any).saveInstalledAddons(); + await (stremioService as any).saveAddonOrder(); + } + + async pushWatchProgress(): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + const unsynced = await storageService.getUnsyncedProgress(); + logger.log(`[Sync] push watch_progress rows=${unsynced.length}`); + const rows: any[] = unsynced.map(({ id, type, episodeId, progress }) => ({ + user_id: userId, + media_type: type, + media_id: id, + episode_id: episodeId || '', + current_time_seconds: Math.floor(progress.currentTime || 0), + duration_seconds: Math.floor(progress.duration || 0), + last_updated_ms: progress.lastUpdated || Date.now(), + trakt_synced: progress.traktSynced ?? undefined, + trakt_last_synced_ms: progress.traktLastSynced ?? undefined, + trakt_progress_percent: progress.traktProgress ?? undefined, + deleted_at: null, + updated_at: new Date().toISOString(), + })); + if (rows.length > 0) { + // Prevent resurrecting remotely-deleted rows when server has newer update + try { + const keys = rows.map(r => ({ media_type: r.media_type, media_id: r.media_id, episode_id: r.episode_id })); + const { data: remote } = await supabase + .from('watch_progress') + .select('media_type,media_id,episode_id,deleted_at,updated_at') + .eq('user_id', userId) + .in('media_type', keys.map(k => k.media_type)) + .in('media_id', keys.map(k => k.media_id)) + .in('episode_id', keys.map(k => k.episode_id)); + const shouldSkip = new Set(); + if (remote) { + for (const r of remote as any[]) { + const key = `${r.media_type}|${r.media_id}|${r.episode_id || ''}`; + if (r.deleted_at && r.updated_at) { + const remoteUpdatedMs = new Date(r.updated_at as string).getTime(); + // Find matching local row + const local = rows.find(x => x.media_type === r.media_type && x.media_id === r.media_id && x.episode_id === (r.episode_id || '')); + const localUpdatedMs = local?.last_updated_ms ?? 0; + if (remoteUpdatedMs >= localUpdatedMs) { + shouldSkip.add(key); + // also write a tombstone locally + try { await storageService.addWatchProgressTombstone(r.media_id, r.media_type, r.episode_id || undefined, remoteUpdatedMs); } catch {} + } + } + } + } + if (shouldSkip.size > 0) { + logger.log(`[Sync] push watch_progress skipping resurrect count=${shouldSkip.size}`); + } + // Filter rows to upsert + const filteredRows = rows.filter(r => !shouldSkip.has(`${r.media_type}|${r.media_id}|${r.episode_id}`)); + if (filteredRows.length > 0) { + const { error } = await supabase + .from('watch_progress') + .upsert(filteredRows, { onConflict: 'user_id,media_type,media_id,episode_id' }); + if (error) console.warn('[SyncService] push watch_progress error', error); + else logger.log('[Sync] push watch_progress upsert ok'); + } + } catch (e) { + // Fallback to normal upsert if pre-check fails + const { error } = await supabase + .from('watch_progress') + .upsert(rows, { onConflict: 'user_id,media_type,media_id,episode_id' }); + if (error) console.warn('[SyncService] push watch_progress error', error); + else logger.log('[Sync] push watch_progress upsert ok'); + } + } + + // Deletions occur only on explicit remove; no bulk deletions here + } + + private async softDeleteWatchProgress(type: string, id: string, episodeId?: string): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + try { + const { error } = await supabase + .from('watch_progress') + .update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() }) + .eq('user_id', user.id) + .eq('media_type', type) + .eq('media_id', id) + .eq('episode_id', episodeId || ''); + if (error) console.warn('[SyncService] softDeleteWatchProgress error', error); + } catch (e) { + console.warn('[SyncService] softDeleteWatchProgress exception', e); + } + } + + async pushSettings(): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + logger.log('[Sync] push user_settings start'); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const appSettingsJson = + (await AsyncStorage.getItem(`@user:${scope}:app_settings`)) || + (await AsyncStorage.getItem('app_settings')) || + '{}'; + const appSettings = JSON.parse(appSettingsJson); + const subtitleSettings = (await storageService.getSubtitleSettings()) || {}; + const { error } = await supabase.from('user_settings').upsert({ + user_id: userId, + app_settings: appSettings, + subtitle_settings: subtitleSettings, + }); + if (error) console.warn('[SyncService] push settings error', error); + else logger.log('[Sync] push user_settings ok'); + } + + async pushAddons(): Promise { + const user = await accountService.getCurrentUser(); + if (!user) return; + const userId = user.id; + const addons = await stremioService.getInstalledAddonsAsync(); + logger.log(`[Sync] push installed_addons count=${addons.length}`); + const order = (stremioService as any).addonOrder as string[]; + const rows = addons.map((a: any) => ({ + user_id: userId, + addon_id: a.id, + name: a.name, + url: a.url, + original_url: a.originalUrl, + version: a.version, + description: a.description, + position: Math.max(0, order.indexOf(a.id)), + manifest_data: a, + })); + // Delete remote addons that no longer exist locally (excluding pre-installed to be safe) + try { + const { data: remote, error: rErr } = await supabase + .from('installed_addons') + .select('addon_id') + .eq('user_id', userId); + if (!rErr && remote) { + const localIds = new Set(addons.map((a: any) => a.id)); + const toDelete = (remote as any[]) + .map(r => r.addon_id as string) + .filter(id => !localIds.has(id) && id !== 'com.linvo.cinemeta' && id !== 'org.stremio.opensubtitlesv3'); + logger.log(`[Sync] push installed_addons deletions=${toDelete.length}`); + if (toDelete.length > 0) { + const del = await supabase + .from('installed_addons') + .delete() + .eq('user_id', userId) + .in('addon_id', toDelete); + if (del.error) console.warn('[SyncService] delete addons error', del.error); + } + } + } catch (e) { + console.warn('[SyncService] deletion sync for addons failed', e); + } + const { error } = await supabase.from('installed_addons').upsert(rows, { onConflict: 'user_id,addon_id' }); + if (error) console.warn('[SyncService] push addons error', error); + } + + // Excluded: pushLocalScrapers (local scrapers are device-local only) +} + +export const syncService = SyncService.getInstance(); +export default syncService; + +// Small helper to batch delete operations +function chunkArray(arr: T[], size: number): T[][] { + const res: T[][] = []; + for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size)); + return res; +} + +// Normalize year values to integer or null +function normalizeYear(value: any): number | null { + if (value == null) return null; + if (typeof value === 'number' && Number.isInteger(value)) return value; + if (typeof value === 'string') { + // Extract first 4 consecutive digits + const m = value.match(/\d{4}/); + if (m) { + const y = parseInt(m[0], 10); + if (y >= 1900 && y <= 2100) return y; + return y; + } + } + return null; +} + diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index e256735..af1f5e5 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -86,12 +86,14 @@ const CATALOG_SETTINGS_KEY = 'catalog_settings'; class CatalogService { private static instance: CatalogService; - private readonly LIBRARY_KEY = 'stremio-library'; + private readonly LEGACY_LIBRARY_KEY = 'stremio-library'; private readonly RECENT_CONTENT_KEY = 'stremio-recent-content'; private library: Record = {}; private recentContent: StreamingContent[] = []; private readonly MAX_RECENT_ITEMS = 20; private librarySubscribers: ((items: StreamingContent[]) => void)[] = []; + private libraryAddListeners: ((item: StreamingContent) => void)[] = []; + private libraryRemoveListeners: ((type: string, id: string) => void)[] = []; private constructor() { this.loadLibrary(); @@ -107,7 +109,16 @@ class CatalogService { private async loadLibrary(): Promise { try { - const storedLibrary = await AsyncStorage.getItem(this.LIBRARY_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + let storedLibrary = (await AsyncStorage.getItem(scopedKey)); + if (!storedLibrary) { + // Fallback: read legacy and migrate into scoped + storedLibrary = await AsyncStorage.getItem(this.LEGACY_LIBRARY_KEY); + if (storedLibrary) { + await AsyncStorage.setItem(scopedKey, storedLibrary); + } + } if (storedLibrary) { this.library = JSON.parse(storedLibrary); } @@ -118,7 +129,10 @@ class CatalogService { private async saveLibrary(): Promise { try { - await AsyncStorage.setItem(this.LIBRARY_KEY, JSON.stringify(this.library)); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(this.library)); + await AsyncStorage.setItem(this.LEGACY_LIBRARY_KEY, JSON.stringify(this.library)); } catch (error) { logger.error('Failed to save library:', error); } @@ -623,6 +637,20 @@ class CatalogService { this.librarySubscribers.forEach(callback => callback(items)); } + public onLibraryAdd(listener: (item: StreamingContent) => void): () => void { + this.libraryAddListeners.push(listener); + return () => { + this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener); + }; + } + + public onLibraryRemove(listener: (type: string, id: string) => void): () => void { + this.libraryRemoveListeners.push(listener); + return () => { + this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener); + }; + } + public getLibraryItems(): StreamingContent[] { return Object.values(this.library); } @@ -646,17 +674,18 @@ class CatalogService { this.library[key] = content; this.saveLibrary(); this.notifyLibrarySubscribers(); + try { this.libraryAddListeners.forEach(l => l(content)); } catch {} // Auto-setup notifications for series when added to library - if (content.type === 'series') { - try { - const { notificationService } = await import('./notificationService'); - await notificationService.updateNotificationsForSeries(content.id); - console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); - } catch (error) { - console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); - } - } + // if (content.type === 'series') { + // try { + // const { notificationService } = await import('./notificationService'); + // await notificationService.updateNotificationsForSeries(content.id); + // console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); + // } catch (error) { + // console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); + // } + // } } public async removeFromLibrary(type: string, id: string): Promise { @@ -664,24 +693,25 @@ class CatalogService { delete this.library[key]; this.saveLibrary(); this.notifyLibrarySubscribers(); + try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch {} // Cancel notifications for series when removed from library - if (type === 'series') { - try { - const { notificationService } = await import('./notificationService'); - // Cancel all notifications for this series - const scheduledNotifications = await notificationService.getScheduledNotifications(); - const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); - - for (const notification of seriesToCancel) { - await notificationService.cancelNotification(notification.id); - } - - console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); - } catch (error) { - console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); - } - } + // if (type === 'series') { + // try { + // const { notificationService } = await import('./notificationService'); + // // Cancel all notifications for this series + // const scheduledNotifications = await notificationService.getScheduledNotifications(); + // const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); + // + // for (const notification of seriesToCancel) { + // await notificationService.cancelNotification(notification.id); + // } + // + // console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); + // } catch (error) { + // console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); + // } + // } } private addToRecentContent(content: StreamingContent): void { diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index ea8b24c..696c74b 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -71,13 +71,14 @@ class LocalScraperService { try { // Load repository URL - const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const storedRepoUrl = await AsyncStorage.getItem(`@user:${scope}:${this.REPOSITORY_KEY}`); if (storedRepoUrl) { this.repositoryUrl = storedRepoUrl; } // Load installed scrapers - const storedScrapers = await AsyncStorage.getItem(this.STORAGE_KEY); + const storedScrapers = await AsyncStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`); if (storedScrapers) { const scrapers: ScraperInfo[] = JSON.parse(storedScrapers); const validScrapers: ScraperInfo[] = []; @@ -166,7 +167,8 @@ class LocalScraperService { // Set repository URL async setRepositoryUrl(url: string): Promise { this.repositoryUrl = url; - await AsyncStorage.setItem(this.REPOSITORY_KEY, url); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + await AsyncStorage.setItem(`@user:${scope}:${this.REPOSITORY_KEY}`, url); logger.log('[LocalScraperService] Repository URL set to:', url); } @@ -326,8 +328,9 @@ class LocalScraperService { // Save installed scrapers to storage private async saveInstalledScrapers(): Promise { try { + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; const scrapers = Array.from(this.installedScrapers.values()); - await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(scrapers)); + await AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(scrapers)); } catch (error) { logger.error('[LocalScraperService] Failed to save scrapers:', error); } @@ -699,7 +702,8 @@ class LocalScraperService { this.scraperCode.clear(); // Clear from storage - await AsyncStorage.removeItem(this.STORAGE_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + await AsyncStorage.removeItem(`@user:${scope}:${this.STORAGE_KEY}`); // Clear cached code const keys = await AsyncStorage.getAllKeys(); diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 0f7189b..4761a67 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -15,7 +15,9 @@ class StorageService { private readonly WATCH_PROGRESS_KEY = '@watch_progress:'; private readonly CONTENT_DURATION_KEY = '@content_duration:'; private readonly SUBTITLE_SETTINGS_KEY = '@subtitle_settings'; + private readonly WP_TOMBSTONES_KEY = '@wp_tombstones'; private watchProgressSubscribers: (() => void)[] = []; + private watchProgressRemoveListeners: ((id: string, type: string, episodeId?: string) => void)[] = []; private notificationDebounceTimer: NodeJS.Timeout | null = null; private lastNotificationTime: number = 0; private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce @@ -30,12 +32,79 @@ class StorageService { return StorageService.instance; } - private getWatchProgressKey(id: string, type: string, episodeId?: string): string { - return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + private async getUserScope(): Promise { + try { + const scope = await AsyncStorage.getItem('@user:current'); + return scope || 'local'; + } catch { + return 'local'; + } } - private getContentDurationKey(id: string, type: string, episodeId?: string): string { - return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + private async getWatchProgressKeyScoped(id: string, type: string, episodeId?: string): Promise { + const scope = await this.getUserScope(); + return `@user:${scope}:${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + private async getContentDurationKeyScoped(id: string, type: string, episodeId?: string): Promise { + const scope = await this.getUserScope(); + return `@user:${scope}:${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + private async getSubtitleSettingsKeyScoped(): Promise { + const scope = await this.getUserScope(); + return `@user:${scope}:${this.SUBTITLE_SETTINGS_KEY}`; + } + + private async getTombstonesKeyScoped(): Promise { + const scope = await this.getUserScope(); + return `@user:${scope}:${this.WP_TOMBSTONES_KEY}`; + } + + private buildWpKeyString(id: string, type: string, episodeId?: string): string { + return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + public async addWatchProgressTombstone( + id: string, + type: string, + episodeId?: string, + deletedAtMs?: number + ): Promise { + try { + const key = await this.getTombstonesKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + const map = JSON.parse(json) as Record; + map[this.buildWpKeyString(id, type, episodeId)] = deletedAtMs || Date.now(); + await AsyncStorage.setItem(key, JSON.stringify(map)); + } catch {} + } + + public async clearWatchProgressTombstone( + id: string, + type: string, + episodeId?: string + ): Promise { + try { + const key = await this.getTombstonesKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + const map = JSON.parse(json) as Record; + const k = this.buildWpKeyString(id, type, episodeId); + if (map[k] != null) { + delete map[k]; + await AsyncStorage.setItem(key, JSON.stringify(map)); + } + } catch {} + } + + public async getWatchProgressTombstones(): Promise> { + try { + const key = await this.getTombstonesKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + return JSON.parse(json) as Record; + } catch { + return {}; + } } public async setContentDuration( @@ -45,7 +114,7 @@ class StorageService { episodeId?: string ): Promise { try { - const key = this.getContentDurationKey(id, type, episodeId); + const key = await this.getContentDurationKeyScoped(id, type, episodeId); await AsyncStorage.setItem(key, duration.toString()); } catch (error) { logger.error('Error setting content duration:', error); @@ -58,7 +127,7 @@ class StorageService { episodeId?: string ): Promise { try { - const key = this.getContentDurationKey(id, type, episodeId); + const key = await this.getContentDurationKeyScoped(id, type, episodeId); const data = await AsyncStorage.getItem(key); return data ? parseFloat(data) : null; } catch (error) { @@ -99,7 +168,16 @@ class StorageService { episodeId?: string ): Promise { try { - const key = this.getWatchProgressKey(id, type, episodeId); + const key = await this.getWatchProgressKeyScoped(id, type, episodeId); + // Do not resurrect if tombstone exists and is newer than this progress + try { + const tombstones = await this.getWatchProgressTombstones(); + const tombKey = this.buildWpKeyString(id, type, episodeId); + const tombAt = tombstones[tombKey]; + if (tombAt && (progress.lastUpdated == null || progress.lastUpdated <= tombAt)) { + return; + } + } catch {} // Check if progress has actually changed significantly const existingProgress = await this.getWatchProgress(id, type, episodeId); @@ -113,7 +191,8 @@ class StorageService { } } - await AsyncStorage.setItem(key, JSON.stringify(progress)); + const updated = { ...progress, lastUpdated: Date.now() }; + await AsyncStorage.setItem(key, JSON.stringify(updated)); // Use debounced notification to reduce spam this.debouncedNotifySubscribers(); @@ -164,13 +243,21 @@ class StorageService { }; } + public onWatchProgressRemoved(listener: (id: string, type: string, episodeId?: string) => void): () => void { + this.watchProgressRemoveListeners.push(listener); + return () => { + const index = this.watchProgressRemoveListeners.indexOf(listener); + if (index > -1) this.watchProgressRemoveListeners.splice(index, 1); + }; + } + public async getWatchProgress( id: string, type: string, episodeId?: string ): Promise { try { - const key = this.getWatchProgressKey(id, type, episodeId); + const key = await this.getWatchProgressKeyScoped(id, type, episodeId); const data = await AsyncStorage.getItem(key); return data ? JSON.parse(data) : null; } catch (error) { @@ -185,10 +272,13 @@ class StorageService { episodeId?: string ): Promise { try { - const key = this.getWatchProgressKey(id, type, episodeId); + const key = await this.getWatchProgressKeyScoped(id, type, episodeId); await AsyncStorage.removeItem(key); + await this.addWatchProgressTombstone(id, type, episodeId); // Notify subscribers this.notifyWatchProgressSubscribers(); + // Emit explicit remove event for sync layer + try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch {} } catch (error) { logger.error('Error removing watch progress:', error); } @@ -196,12 +286,14 @@ class StorageService { public async getAllWatchProgress(): Promise> { try { + const scope = await this.getUserScope(); + const prefix = `@user:${scope}:${this.WATCH_PROGRESS_KEY}`; const keys = await AsyncStorage.getAllKeys(); - const watchProgressKeys = keys.filter(key => key.startsWith(this.WATCH_PROGRESS_KEY)); + const watchProgressKeys = keys.filter(key => key.startsWith(prefix)); const pairs = await AsyncStorage.multiGet(watchProgressKeys); return pairs.reduce((acc, [key, value]) => { if (value) { - acc[key.replace(this.WATCH_PROGRESS_KEY, '')] = JSON.parse(value); + acc[key.replace(prefix, '')] = JSON.parse(value); } return acc; }, {} as Record); @@ -223,7 +315,7 @@ class StorageService { exactTime?: number ): Promise { try { - const existingProgress = await this.getWatchProgress(id, type, episodeId); + const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { // Preserve the highest Trakt progress and currentTime values to avoid accidental regressions const highestTraktProgress = (() => { @@ -272,6 +364,12 @@ class StorageService { }> = []; for (const [key, progress] of Object.entries(allProgress)) { + // Skip if tombstoned and tombstone is newer + const tombstones = await this.getWatchProgressTombstones(); + const tombAt = tombstones[key]; + if (tombAt && (progress.lastUpdated == null || progress.lastUpdated <= tombAt)) { + continue; + } // Check if needs sync (either never synced or local progress is newer) const needsSync = !progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); @@ -424,7 +522,8 @@ class StorageService { public async saveSubtitleSettings(settings: Record): Promise { try { - await AsyncStorage.setItem(this.SUBTITLE_SETTINGS_KEY, JSON.stringify(settings)); + const key = await this.getSubtitleSettingsKeyScoped(); + await AsyncStorage.setItem(key, JSON.stringify(settings)); } catch (error) { logger.error('Error saving subtitle settings:', error); } @@ -432,7 +531,8 @@ class StorageService { public async getSubtitleSettings(): Promise | null> { try { - const data = await AsyncStorage.getItem(this.SUBTITLE_SETTINGS_KEY); + const key = await this.getSubtitleSettingsKeyScoped(); + const data = await AsyncStorage.getItem(key); return data ? JSON.parse(data) : null; } catch (error) { logger.error('Error loading subtitle settings:', error); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index ad5f76d..ebccca1 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -199,7 +199,8 @@ class StremioService { if (this.initialized) return; try { - const storedAddons = await AsyncStorage.getItem(this.STORAGE_KEY); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const storedAddons = await AsyncStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`); if (storedAddons) { const parsed = JSON.parse(storedAddons); @@ -301,7 +302,7 @@ class StremioService { } // Load addon order if exists - const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY); + const storedOrder = await AsyncStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`); if (storedOrder) { this.addonOrder = JSON.parse(storedOrder); // Filter out any ids that aren't in installedAddons @@ -388,7 +389,8 @@ class StremioService { private async saveInstalledAddons(): Promise { try { const addonsArray = Array.from(this.installedAddons.values()); - await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + await AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)); } catch (error) { logger.error('Failed to save addons:', error); } @@ -396,7 +398,8 @@ class StremioService { private async saveAddonOrder(): Promise { try { - await AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)); + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + await AsyncStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder)); } catch (error) { logger.error('Failed to save addon order:', error); } @@ -470,9 +473,26 @@ class StremioService { getInstalledAddons(): Manifest[] { // Return addons in the specified order - return this.addonOrder + const result = this.addonOrder .filter(id => this.installedAddons.has(id)) .map(id => this.installedAddons.get(id)!); + // Ensure pre-installed presence + const cinId = 'com.linvo.cinemeta'; + const osId = 'org.stremio.opensubtitlesv3'; + if (!result.find(a => a.id === cinId) && this.installedAddons.has(cinId)) { + result.unshift(this.installedAddons.get(cinId)!); + } + if (!result.find(a => a.id === osId) && this.installedAddons.has(osId)) { + // Put OpenSubtitles right after Cinemeta if possible, else at start + const cinIdx = result.findIndex(a => a.id === cinId); + const osManifest = this.installedAddons.get(osId)!; + if (cinIdx >= 0) { + result.splice(cinIdx + 1, 0, osManifest); + } else { + result.unshift(osManifest); + } + } + return result; } async getInstalledAddonsAsync(): Promise { @@ -749,8 +769,10 @@ class StremioService { // Check if local scrapers are enabled and execute them first try { - // Load settings from AsyncStorage directly - const settingsJson = await AsyncStorage.getItem('app_settings'); + // Load settings from AsyncStorage directly (scoped with fallback) + const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; + const settingsJson = (await AsyncStorage.getItem(`@user:${scope}:app_settings`)) + || (await AsyncStorage.getItem('app_settings')); const settings: AppSettings = settingsJson ? JSON.parse(settingsJson) : DEFAULT_SETTINGS; if (settings.enableLocalScrapers) { diff --git a/src/services/supabaseClient.ts b/src/services/supabaseClient.ts new file mode 100644 index 0000000..e4548bf --- /dev/null +++ b/src/services/supabaseClient.ts @@ -0,0 +1,20 @@ +import 'react-native-url-polyfill/auto'; +import 'react-native-get-random-values'; +import { createClient } from '@supabase/supabase-js'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + + +const SUPABASE_URL = 'https://utypxyhwcekefvhyguxp.supabase.co'; +const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV0eXB4eWh3Y2VrZWZ2aHlndXhwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ2NjE4NTksImV4cCI6MjA3MDIzNzg1OX0.kc76fjHLjq6a5tNLsQh6KxS4uGp0ngl_ipQBte6KZuA'; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: true, + storage: AsyncStorage as unknown as Storage, + autoRefreshToken: true, + detectSessionInUrl: false, + }, +}); + +export default supabase; +