feat: enhance database service with centralized error logging and improve media handling across services

This commit is contained in:
bobbert committed 2026-02-25 00:14:00 +00:00
1 parent 04046702d3
commit ad0cc1a7f5
5 files changed
+384 -165

No files matched your search

+81 -5
View File
@@ -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 <Image> can render them.
*
* The Picker API's baseUrl requires an Authorization header. React
* Native's <Image> 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,
+28 -17
View File
@@ -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 }) => {
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{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)}`
}
</Text>
</View>
{displayMedia.sources && displayMedia.sources.length > 1 && (
<View style={styles.row}>
<Text style={styles.label}>Available on</Text>
<Text style={[styles.value, { color: '#8ab4f8' }]}>
{displayMedia.sources.length} services
</Text>
</View>
)}
{displayMedia.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
+31 -4
View File
@@ -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 (
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
</View>
);
}
// Multiple sources — show all icons with a subtle indicator
return (
<View style={styles.multiSourceBadge}>
{sources.map((src, i) => (
<Text key={src + i} style={{ fontSize: 9 }}>{getServiceIcon(src)}</Text>
))}
</View>
);
};
const renderItem = ({ item }) => (
<TouchableOpacity
@@ -41,9 +65,7 @@ const PhotoGrid = ({
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View>
)}
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
{renderSourceBadges(item)}
</TouchableOpacity>
);
@@ -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;
+69 -19
View File
@@ -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);
+175 -120
View File
@@ -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);
}
},
};