diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 63827ad..c8bb260 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -1,6 +1,7 @@ import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; +import * as FileSystem from 'expo-file-system'; import { AuthServiceBase } from './AuthServiceBase'; import { logError } from '../services/ErrorHandler'; @@ -29,6 +30,8 @@ const SCOPES = [ const PICKER_API = 'https://photospicker.googleapis.com/v1'; +const CACHE_DIR = `${FileSystem.cacheDirectory}google_photos/`; + const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; class GoogleAUTH extends AuthServiceBase { @@ -276,6 +279,10 @@ class GoogleAUTH extends AuthServiceBase { /** * Normalise Picker API media items to the app's standard format. + * + * NOTE: The Picker API's baseUrl requires an Authorization header to + * fetch the image. We store it in `remote_path` / `picker_base_url` + * so the caller can download it to a local cache file for rendering. */ _normalisePickerItems(items) { return (items || []).map((item) => { @@ -286,14 +293,80 @@ class GoogleAUTH extends AuthServiceBase { source_id: item.id || file.id, title: file.filename || 'Untitled', description: '', - local_path: file.baseUrl ? `${file.baseUrl}=w400-h400` : null, + // local_path is null until we download it with the auth token + local_path: null, remote_path: file.baseUrl || '', + picker_base_url: file.baseUrl || null, media_type: isVideo ? 'video' : 'photo', + mime_type: file.mimeType || null, created_date: file.mediaFileMetadata?.creationTime || null, }; }); } + /** + * Download Picker images to local cache so can render them. + * + * The Picker API's baseUrl requires an Authorization header. React + * Native's can't add custom headers reliably, so we download + * each photo to a cache file and set local_path to the file:// URI. + * + * @param {Array} items — normalised items from _normalisePickerItems + * @returns {Array} — same items with local_path populated + */ + async downloadPickerPhotos(items) { + const token = await this.getAccessToken(); + + // Ensure cache directory exists + const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true }); + } + + const results = []; + for (const item of items) { + if (!item.picker_base_url) { + results.push(item); + continue; + } + + try { + // Use the source_id as a safe filename + const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_'); + const ext = item.mime_type?.includes('png') ? '.png' + : item.mime_type?.includes('gif') ? '.gif' + : item.mime_type?.includes('webp') ? '.webp' + : '.jpg'; + const localUri = `${CACHE_DIR}${safeId}${ext}`; + + // Skip download if already cached + const fileInfo = await FileSystem.getInfoAsync(localUri); + if (fileInfo.exists && fileInfo.size > 0) { + results.push({ ...item, local_path: localUri }); + continue; + } + + // Download with auth header — append =d to get the full-res download + const downloadUrl = `${item.picker_base_url}=d`; + const download = await FileSystem.downloadAsync(downloadUrl, localUri, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (download.status === 200) { + results.push({ ...item, local_path: download.uri }); + } else { + logError('Download Picker Photo', `HTTP ${download.status} for ${item.title}`); + results.push(item); + } + } catch (err) { + logError('Download Picker Photo', err); + results.push(item); + } + } + + return results; + } + /** * Full interactive flow: create session → open picker in browser → * wait for user to select photos → fetch the selected items. @@ -339,11 +412,13 @@ class GoogleAUTH extends AuthServiceBase { }; } - // 4. Fetch selected items + // 4. Fetch selected items and download to local cache const result = await this._fetchPickerMediaItems(session.id, 100, null); - const mediaItems = this._normalisePickerItems(result.mediaItems); + let mediaItems = this._normalisePickerItems(result.mediaItems); - console.log(`Picker returned ${mediaItems.length} selected items`); + console.log(`Picker returned ${mediaItems.length} selected items, downloading…`); + mediaItems = await this.downloadPickerPhotos(mediaItems); + console.log(`Downloaded ${mediaItems.filter((m) => m.local_path).length} photos to cache`); return { mediaItems, @@ -366,7 +441,8 @@ class GoogleAUTH extends AuthServiceBase { } const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken); - const mediaItems = this._normalisePickerItems(result.mediaItems); + let mediaItems = this._normalisePickerItems(result.mediaItems); + mediaItems = await this.downloadPickerPhotos(mediaItems); return { mediaItems, diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index 2ab57a2..8575906 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { Modal, View, Text, TouchableOpacity, Image, StyleSheet, Dimensions, FlatList, @@ -57,22 +57,22 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { }, 100); }, [visible, media]); - const onMainScroll = useCallback( - (event) => { - const offsetX = event.nativeEvent.contentOffset.x; - const idx = Math.round(offsetX / SCREEN_WIDTH); - if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) { - setActiveIndex(idx); - setCurrentMedia(mediaList[idx]); - if (thumbListRef.current) { - try { - thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); - } catch (e) { /* ignore */ } - } + const activeIndexRef = useRef(0); + + const onMainScroll = (event) => { + const offsetX = event.nativeEvent.contentOffset.x; + const idx = Math.round(offsetX / SCREEN_WIDTH); + if (idx >= 0 && idx < mediaList.length && idx !== activeIndexRef.current) { + activeIndexRef.current = idx; + setActiveIndex(idx); + setCurrentMedia(mediaList[idx]); + if (thumbListRef.current) { + try { + thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); + } catch (_) { /* ignore */ } } - }, - [activeIndex, mediaList, thumbListRef] - ); + } + }; const onThumbPress = (index) => { setActiveIndex(index); @@ -189,9 +189,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { Source - {getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)} + {displayMedia.sources && displayMedia.sources.length > 1 + ? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ') + : `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}` + } + {displayMedia.sources && displayMedia.sources.length > 1 && ( + + Available on + + {displayMedia.sources.length} services + + + )} {displayMedia.created_date && ( Date diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index 6442285..4b56f3a 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -21,7 +21,31 @@ const PhotoGrid = ({ const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const getServiceIcon = (type) => - ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + + const renderSourceBadges = (item) => { + const sources = item.sources && item.sources.length > 0 + ? item.sources + : [item.source_service]; + + if (sources.length === 1) { + // Single source — simple badge + return ( + + {getServiceIcon(sources[0])} + + ); + } + + // Multiple sources — show all icons with a subtle indicator + return ( + + {sources.map((src, i) => ( + {getServiceIcon(src)} + ))} + + ); + }; const renderItem = ({ item }) => ( ▶ )} - - {getServiceIcon(item.source_service)} - + {renderSourceBadges(item)} ); @@ -82,6 +104,11 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, }, + multiSourceBadge: { + position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', + borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, + flexDirection: 'row', gap: 2, + }, }); export default PhotoGrid; diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index ea8588b..49099f0 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -14,6 +14,62 @@ import MediaViewer from '../components/MediaViewer'; import { logError } from '../services/ErrorHandler'; import { TouchableOpacity } from 'react-native'; +/** + * Merge media items from different services. + * Items with the same filename (title) are merged into a single entry + * with a `sources` array listing every service it appears in. + * The best local_path is kept (prefer file:// from DEVICE over a cached + * Picker download, since device URIs are always fresh). + */ +const mergeMediaAcrossServices = (items) => { + // First pass: group by normalised filename + const byFilename = new Map(); + const noFilename = []; + + for (const item of items) { + const key = item.title?.toLowerCase().trim(); + if (!key || key === 'untitled') { + noFilename.push({ ...item, sources: [item.source_service] }); + continue; + } + + if (byFilename.has(key)) { + const existing = byFilename.get(key); + // Add this service to the sources list + if (!existing.sources.includes(item.source_service)) { + existing.sources.push(item.source_service); + } + // Prefer a local device path over a cached download + if (item.source_service === 'DEVICE' && item.local_path) { + existing.local_path = item.local_path; + } + // Keep the best local_path if the existing one is missing + if (!existing.local_path && item.local_path) { + existing.local_path = item.local_path; + } + } else { + byFilename.set(key, { ...item, sources: [item.source_service] }); + } + } + + // Second pass: also deduplicate by source_id for items with different titles + const bySourceId = new Map(); + for (const item of [...byFilename.values(), ...noFilename]) { + const key = item.source_id || item.id || item.local_path || Math.random().toString(); + if (bySourceId.has(key)) { + const existing = bySourceId.get(key); + for (const src of (item.sources || [])) { + if (!existing.sources.includes(src)) existing.sources.push(src); + } + if (!existing.local_path && item.local_path) existing.local_path = item.local_path; + } else { + bySourceId.set(key, item); + } + } + + return Array.from(bySourceId.values()); +}; + const HomeScreen = ({ navigation, route }) => { const userId = route?.params?.userId; const { theme } = useTheme(); @@ -102,11 +158,11 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } - // Deduplicate - const unique = Array.from( - new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - setAllMedia(unique); + // ─── Merge & cross-reference across services ─── + // Match items across sources by filename so we can show "this photo + // exists on Device AND Google Photos" etc. + const merged = mergeMediaAcrossServices(newMedia); + setAllMedia(merged); } catch (err) { console.error('Error loading media:', err); } finally { @@ -158,13 +214,8 @@ const HomeScreen = ({ navigation, route }) => { setGoogleNextPage(result.nextPageToken || null); setGoogleSessionId(result.sessionId || null); - // Merge into current view - setAllMedia((prev) => { - const combined = [...prev, ...result.mediaItems]; - return Array.from( - new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - }); + // Merge into current view with cross-service matching + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); console.log(`Imported ${result.mediaItems.length} photos from Google`); } @@ -188,7 +239,11 @@ const HomeScreen = ({ navigation, route }) => { const filterMedia = () => { let filtered = allMedia; if (selectedFilter !== 'all') { - filtered = allMedia.filter((m) => m.source_service === selectedFilter); + // Check both source_service and the sources array for cross-service items + filtered = allMedia.filter((m) => + m.source_service === selectedFilter || + (m.sources && m.sources.includes(selectedFilter)) + ); } filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); setFilteredMedia(filtered); @@ -233,12 +288,7 @@ const HomeScreen = ({ navigation, route }) => { } if (moreMedia.length) { - setAllMedia((prev) => { - const combined = [...prev, ...moreMedia]; - return Array.from( - new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - }); + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia])); } } catch (err) { console.error('Error loading more photos:', err); diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 4674ee0..8655ce0 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -1,11 +1,19 @@ +// noinspection SqlNoDataSourceInspection,SqlResolve + +/** + * Database service — all tables are created at runtime by expo-sqlite. + * The IDE cannot resolve them statically; the SQL warnings are safe to ignore. + */ import * as SQLite from 'expo-sqlite'; import CredentialEncryption from './CredentialEncryption'; +import { logError } from './ErrorHandler'; let db = null; let initPromise = null; const DatabaseService = { - // Open the database and create all tables + // ─── Initialisation ──────────────────────────────────────── + initDatabase: async () => { if (initPromise) return initPromise; @@ -14,12 +22,17 @@ const DatabaseService = { if (db) return true; if (typeof SQLite.openDatabaseAsync !== 'function') { - throw new Error('SQLite openDatabaseAsync API not available'); + logError('DB Init', 'SQLite openDatabaseAsync API not available'); + return false; } db = await SQLite.openDatabaseAsync('PhotosApp.db'); - if (!db) throw new Error('SQLite openDatabaseAsync returned null'); + if (!db) { + logError('DB Init', 'SQLite openDatabaseAsync returned null'); + return false; + } + // Create all tables in one batch await db.execAsync(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -27,10 +40,7 @@ const DatabaseService = { email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - await db.execAsync(` + ); CREATE TABLE IF NOT EXISTS linked_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -40,10 +50,7 @@ const DatabaseService = { is_default INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) - ) - `); - - await db.execAsync(` + ); CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -58,20 +65,20 @@ const DatabaseService = { synced INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) - ) + ); `); - // Add optional columns (safe migration — errors ignored if columns already exist) - try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {} - try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {} + // Safe migrations — errors ignored if columns already exist + try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {} + try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {} console.log('Database initialized successfully'); return true; } catch (error) { - console.error('Database initialization error:', error); + logError('DB Init', error); db = null; initPromise = null; - throw error; + return false; } })(); @@ -80,12 +87,18 @@ const DatabaseService = { ensureDb: async () => { await DatabaseService.initDatabase(); - if (!db) throw new Error('Database not initialized'); + if (!db) { + logError('DB', 'Database not initialized'); + return false; + } + return true; }, + // ─── Users ───────────────────────────────────────────────── + createUser: async (username, email, password) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const hashedPassword = await CredentialEncryption.hashPassword(password); const result = await db.runAsync( 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', @@ -93,98 +106,111 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error creating user:', error); - throw error; + logError('Create User', error); + return null; } }, - // Authenticate by username or email loginUser: async (identifier, password) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( 'SELECT * FROM users WHERE username = ? OR email = ?', [identifier, identifier] ); if (!row) return null; - const valid = await CredentialEncryption.verifyPassword(password, row.password); - if (!valid) return null; - - return row; + return valid ? row : null; } catch (error) { - console.error('Error logging in user:', error); - throw error; + logError('Login User', error); + return null; } }, userExists: async (username, email) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return false; const row = await db.getFirstAsync( 'SELECT id FROM users WHERE username = ? OR email = ?', [username, email] ); return !!row; } catch (error) { - console.error('Error checking if user exists:', error); - throw error; + logError('User Exists', error); + return false; } }, getUserById: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId && userId !== 0) { + logError('Get User', 'User ID is required'); + return null; + } return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null; } catch (error) { - console.error('Error getting user:', error); - throw error; + logError('Get User', error); + return null; } }, - // Link an external service account (Google, iCloud, NAS) to a local user + // ─── Linked Accounts ────────────────────────────────────── + linkAccount: async (userId, serviceType, accountData, isDefault = false) => { try { - await DatabaseService.ensureDb(); - if (!userId) throw new Error('User ID is required'); - if (!serviceType) throw new Error('Service type is required'); - if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !serviceType || !accountData || typeof accountData !== 'object') { + logError('Link Account', 'userId, serviceType, and accountData object are required'); + return null; + } const encryptedData = CredentialEncryption.encrypt(accountData); - if (!encryptedData) throw new Error('Encryption returned empty data'); + if (!encryptedData) { + logError('Link Account', 'Encryption returned empty data'); + return null; + } // Replace any existing link for the same service type - await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]); - + await db.runAsync( + 'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', + [userId, serviceType] + ); const result = await db.runAsync( 'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)', [userId, serviceType, encryptedData, isDefault ? 1 : 0] ); return result.lastInsertRowId; } catch (error) { - console.error('Error linking account:', error); - throw new Error('Failed to link account: ' + error.message); + logError('Link Account', error); + return null; } }, getLinkedAccounts: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); + if (!(await DatabaseService.ensureDb())) return []; + if (!userId && userId !== 0) { + logError('Get Linked Accounts', 'User ID is required'); + return []; + } const rows = await db.getAllAsync( - 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId] + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', + [userId] ); - return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); + return rows.map((row) => ({ + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + })); } catch (error) { - console.error('Error getting linked accounts:', error); - throw error; + logError('Get Linked Accounts', error); + return []; } }, getLinkedAccountByService: async (userId, serviceType) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1', [userId, serviceType] @@ -192,40 +218,83 @@ const DatabaseService = { if (!row) return null; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; } catch (error) { - console.error('Error getting linked account:', error); - throw error; + logError('Get Linked Account By Service', error); + return null; } }, setDefaultService: async (userId, accountId) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return; await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]); } catch (error) { - console.error('Error setting default service:', error); - throw error; + logError('Set Default Service', error); } }, getDefaultService: async (userId) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( - 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', [userId] + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', + [userId] ); if (!row) return null; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; } catch (error) { - console.error('Error getting default service:', error); - throw error; + logError('Get Default Service', error); + return null; } }, + toggleAccountEnabled: async (accountId, enabled) => { + try { + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync( + 'UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', + [enabled ? 1 : 0, accountId] + ); + } catch (error) { + logError('Toggle Account', error); + } + }, + + removeLinkedAccount: async (accountId) => { + try { + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]); + } catch (error) { + logError('Remove Account', error); + } + }, + + getAllLinkedAccounts: async (userId) => { + try { + if (!(await DatabaseService.ensureDb())) return []; + const rows = await db.getAllAsync( + 'SELECT * FROM linked_accounts WHERE user_id = ?', + [userId] + ); + return rows.map((row) => ({ + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + })); + } catch (error) { + logError('Get All Linked Accounts', error); + return []; + } + }, + + // ─── Media ───────────────────────────────────────────────── + addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => { try { - await DatabaseService.ensureDb(); - if (!userId || !sourceService) throw new Error('userId and sourceService are required'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !sourceService) { + logError('Add Media', 'userId and sourceService are required'); + return null; + } const result = await db.runAsync( `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -234,8 +303,8 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error adding media:', error); - throw error; + logError('Add Media', error); + return null; } }, @@ -245,10 +314,10 @@ const DatabaseService = { */ saveMediaItem: async (userId, item) => { try { - await DatabaseService.ensureDb(); - if (!userId || !item.source_service) return; + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !item?.source_service) return null; - // Check for existing entry to avoid duplicates + // Skip if already exists if (item.source_id) { const existing = await db.getFirstAsync( 'SELECT id FROM media WHERE user_id = ? AND source_id = ?', @@ -266,108 +335,94 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error saving media item:', error); + logError('Save Media Item', error); + return null; } }, getMediaByUser: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); - return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]); + if (!(await DatabaseService.ensureDb())) return []; + if (!userId && userId !== 0) { + logError('Get Media', 'User ID is required'); + return []; + } + return await db.getAllAsync( + 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', + [userId] + ); } catch (error) { - console.error('Error getting media:', error); - throw error; + logError('Get Media', error); + return []; } }, getMediaByService: async (userId, serviceType) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return []; return await db.getAllAsync( 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC', [userId, serviceType] ); } catch (error) { - console.error('Error getting media by service:', error); - throw error; + logError('Get Media By Service', error); + return []; } }, searchMedia: async (userId, query) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return []; const searchTerm = `%${query}%`; return await db.getAllAsync( 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC', [userId, searchTerm, searchTerm] ); } catch (error) { - console.error('Error searching media:', error); - throw error; + logError('Search Media', error); + return []; } }, + // ─── Settings ────────────────────────────────────────────── + getAutoBackupServices: async (userId) => { try { - await DatabaseService.ensureDb(); - const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]); + if (!(await DatabaseService.ensureDb())) return []; + const row = await db.getFirstAsync( + 'SELECT auto_backup_services FROM users WHERE id = ?', + [userId] + ); if (!row?.auto_backup_services) return []; return JSON.parse(row.auto_backup_services); } catch (error) { - console.error('Error getting auto backup services:', error); + logError('Get Auto Backup', error); return []; } }, setAutoBackupServices: async (userId, services) => { try { - await DatabaseService.ensureDb(); - await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]); + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync( + 'UPDATE users SET auto_backup_services = ? WHERE id = ?', + [JSON.stringify(services), userId] + ); } catch (error) { - console.error('Error setting auto backup services:', error); - throw error; + logError('Set Auto Backup', error); } }, - toggleAccountEnabled: async (accountId, enabled) => { - try { - await DatabaseService.ensureDb(); - await db.runAsync('UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', [enabled ? 1 : 0, accountId]); - } catch (error) { - console.error('Error toggling account:', error); - throw error; - } - }, - - removeLinkedAccount: async (accountId) => { - try { - await DatabaseService.ensureDb(); - await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]); - } catch (error) { - console.error('Error removing account:', error); - throw error; - } - }, - - getAllLinkedAccounts: async (userId) => { - try { - await DatabaseService.ensureDb(); - const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]); - return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); - } catch (error) { - console.error('Error getting all linked accounts:', error); - throw error; - } - }, + // ─── Lifecycle ───────────────────────────────────────────── closeDatabase: async () => { try { if (db?.closeAsync) await db.closeAsync(); + } catch (error) { + logError('Close DB', error); + } finally { db = null; initPromise = null; - } catch (error) { - console.error('Error closing database:', error); } }, };