diff --git a/ServerFiles/.gitignore b/ServerFiles/.gitignore
new file mode 100644
index 0000000..1cb632c
--- /dev/null
+++ b/ServerFiles/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.config/
+*.log
+
diff --git a/ServerFiles/cli.js b/ServerFiles/cli.js
new file mode 100644
index 0000000..20b465d
--- /dev/null
+++ b/ServerFiles/cli.js
@@ -0,0 +1,342 @@
+const readline = require('readline');
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const config = require('./config');
+const passwordManager = require('./password_manager');
+const { execSync } = require('child_process');
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+});
+
+function ask(question) {
+ return new Promise(resolve => rl.question(question, resolve));
+}
+
+function clearScreen() {
+ process.stdout.write(process.platform === 'win32' ? '\x1Bc' : '\033[2J\033[H');
+}
+
+let detectedPlatform = null;
+
+function printBanner() {
+ const user = os.userInfo().username;
+ const photosPath = config.getPhotosPath() || 'NOT SET';
+ const shortPath = photosPath.length > 25 ? '...' + photosPath.slice(-22) : photosPath;
+
+ console.log('');
+ console.log('╔══════════════════════════════════════════════╗');
+ console.log('║ NAS Photo Server - CLI Client ║');
+ console.log('╠══════════════════════════════════════════════╣');
+ console.log(`║ User: ${user.padEnd(32)}║`);
+
+ if (passwordManager.hasStoredPassword()) {
+ console.log('║ Password: [STORED - Press P to view] ║');
+ } else {
+ console.log('║ Password: [NOT SET] ║');
+ }
+
+ console.log(`║ Platform: ${(detectedPlatform || 'Unknown').padEnd(32)}║`);
+ console.log(`║ Photos: ${shortPath.padEnd(32)}║`);
+ console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(32)}║`);
+ console.log('╚══════════════════════════════════════════════╝');
+ console.log('');
+}
+
+async function detectOrAskPlatform() {
+ const plat = process.platform;
+ if (plat === 'win32') {
+ detectedPlatform = 'Windows';
+ console.log('[*] Detected platform: Windows');
+ return 'windows';
+ } else if (plat === 'linux') {
+ detectedPlatform = 'Linux (Ubuntu)';
+ console.log('[*] Detected platform: Linux (assuming Ubuntu)');
+ return 'linux';
+ } else {
+ console.log('');
+ console.log('What operating system are you running?');
+ console.log(' [1] Windows');
+ console.log(' [2] Linux (Ubuntu)');
+ const choice = await ask('Select (1/2): ');
+ if (choice.trim() === '1') {
+ detectedPlatform = 'Windows';
+ return 'windows';
+ } else {
+ detectedPlatform = 'Linux (Ubuntu)';
+ return 'linux';
+ }
+ }
+}
+
+function checkDependencies() {
+ console.log('[*] Checking dependencies...');
+ try {
+ execSync('node --version', { stdio: 'ignore' });
+ console.log(' [OK] Node.js found');
+ } catch {
+ console.log(' [!!] Node.js not found. Please install Node.js.');
+ return false;
+ }
+
+ const nodeModules = path.join(__dirname, 'node_modules');
+ if (!fs.existsSync(nodeModules)) {
+ console.log('[*] Installing dependencies...');
+ try {
+ execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
+ console.log(' [OK] Dependencies installed');
+ } catch {
+ console.log(' [!!] Failed to install dependencies. Run "npm install" manually in ServerFiles/');
+ return false;
+ }
+ } else {
+ console.log(' [OK] Dependencies already installed');
+ }
+
+ return true;
+}
+
+async function setupPhotosPath(platform) {
+ const current = config.getPhotosPath();
+ if (current) {
+ console.log(`[*] Current photos path: ${current}`);
+ const change = await ask('Change photos path? (Y/N): ');
+ if (change.trim().toUpperCase() !== 'Y') return;
+ }
+
+ let defaultPath;
+ if (platform === 'windows') {
+ defaultPath = path.join(os.homedir(), 'NASPhotos');
+ } else {
+ defaultPath = path.join(os.homedir(), 'nas-photos');
+ }
+
+ console.log(`Default path: ${defaultPath}`);
+ const inputPath = await ask('Enter photos storage path (or press Enter for default): ');
+ const finalPath = inputPath.trim() || defaultPath;
+
+ if (!fs.existsSync(finalPath)) {
+ console.log(`[*] Creating directory: ${finalPath}`);
+ fs.mkdirSync(finalPath, { recursive: true });
+ }
+
+ config.setPhotosPath(finalPath);
+ console.log(`[OK] Photos path set to: ${finalPath}`);
+}
+
+async function handlePassword() {
+ console.log('');
+ console.log('╔══════════════════════════════════════════════╗');
+ console.log('║ Password Management ║');
+ console.log('╠══════════════════════════════════════════════╣');
+
+ if (passwordManager.hasStoredPassword()) {
+ console.log('║ A password is currently stored. ║');
+ console.log('║ [V] View password ║');
+ console.log('║ [C] Change password ║');
+ console.log('║ [D] Delete password ║');
+ console.log('║ [B] Back ║');
+ console.log('╚══════════════════════════════════════════════╝');
+
+ const choice = await ask('Select option: ');
+ switch (choice.trim().toUpperCase()) {
+ case 'V': {
+ const pw = passwordManager.retrievePassword();
+ if (pw) {
+ console.log('');
+ console.log(` User: ${os.userInfo().username}`);
+ console.log(` Password: ${pw}`);
+ console.log('');
+ } else {
+ console.log('[!!] Could not decrypt password. It may have been created on another machine.');
+ }
+ await ask('Press Enter to continue...');
+ break;
+ }
+ case 'C': {
+ const newPw = await ask('Enter new password: ');
+ if (newPw.trim().length > 0) {
+ passwordManager.storePassword(newPw.trim());
+ console.log('[OK] Password updated.');
+ console.log(` New password: ${newPw.trim()}`);
+ } else {
+ console.log('[!] Empty password not saved.');
+ }
+ await ask('Press Enter to continue...');
+ break;
+ }
+ case 'D': {
+ const confirm = await ask('Are you sure? (Y/N): ');
+ if (confirm.trim().toUpperCase() === 'Y') {
+ passwordManager.deletePassword();
+ console.log('[OK] Password deleted.');
+ }
+ await ask('Press Enter to continue...');
+ break;
+ }
+ default:
+ break;
+ }
+ } else {
+ console.log('║ No password is stored. ║');
+ console.log('╚══════════════════════════════════════════════╝');
+ const store = await ask('Do you want to store a password? (Y/N): ');
+ if (store.trim().toUpperCase() === 'Y') {
+ const pw = await ask('Enter password: ');
+ if (pw.trim().length > 0) {
+ passwordManager.storePassword(pw.trim());
+ console.log(`[OK] Password stored successfully.`);
+ console.log(` Your password: ${pw.trim()}`);
+ } else {
+ console.log('[!] Empty password not saved.');
+ }
+ await ask('Press Enter to continue...');
+ }
+ }
+}
+
+function showConnectionInfo() {
+ const interfaces = os.networkInterfaces();
+ const addresses = [];
+
+ for (const [name, nets] of Object.entries(interfaces)) {
+ for (const net of nets) {
+ if (net.family === 'IPv4' && !net.internal) {
+ addresses.push({ name, address: net.address });
+ }
+ }
+ }
+
+ console.log('');
+ console.log('╔══════════════════════════════════════════════╗');
+ console.log('║ Connection Information ║');
+ console.log('╠══════════════════════════════════════════════╣');
+ console.log(`║ User: ${os.userInfo().username.padEnd(35)}║`);
+
+ if (passwordManager.hasStoredPassword()) {
+ console.log('║ Pass: [STORED] ║');
+ } else {
+ console.log('║ Pass: [NOT SET] ║');
+ }
+
+ console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(35)}║`);
+ console.log('║ ║');
+
+ if (addresses.length > 0) {
+ console.log('║ Network Addresses: ║');
+ for (const addr of addresses) {
+ const line = ` ${addr.name}: http://${addr.address}:${config.DEFAULT_PORT}`;
+ console.log(`║${line.padEnd(46)}║`);
+ }
+ } else {
+ console.log('║ No network interfaces found ║');
+ }
+
+ console.log('╚══════════════════════════════════════════════╝');
+}
+
+async function startServerInteractive() {
+ const photosPath = config.getPhotosPath();
+ if (!photosPath) {
+ console.log('[!!] Photos path not set. Please configure it first.');
+ await ask('Press Enter to continue...');
+ return false;
+ }
+
+ const portInput = await ask(`Enter port (default ${config.DEFAULT_PORT}): `);
+ const port = parseInt(portInput.trim()) || config.DEFAULT_PORT;
+
+ console.log(`[*] Starting NAS server on port ${port}...`);
+ console.log(`[*] Photos path: ${photosPath}`);
+ console.log('[*] Press Ctrl+C to stop the server.');
+ console.log('');
+
+ const { startServer } = require('./server');
+ startServer(port);
+ return true; // Server takes over the process
+}
+
+async function mainMenu(platform) {
+ while (true) {
+ clearScreen();
+ printBanner();
+ console.log(' [1] Start NAS Server');
+ console.log(' [2] Configure Photos Path');
+ console.log(' [3] Password Management (P)');
+ console.log(' [4] Show Connection Info');
+ console.log(' [5] Exit');
+ console.log('');
+
+ const choice = await ask('Select option: ');
+
+ switch (choice.trim().toUpperCase()) {
+ case '1': {
+ const started = await startServerInteractive();
+ if (started) return; // Server is now running
+ break;
+ }
+ case '2':
+ await setupPhotosPath(platform);
+ break;
+ case '3':
+ case 'P':
+ await handlePassword();
+ break;
+ case '4':
+ showConnectionInfo();
+ await ask('\nPress Enter to continue...');
+ break;
+ case '5':
+ console.log('Goodbye!');
+ rl.close();
+ process.exit(0);
+ default:
+ break;
+ }
+ }
+}
+
+async function main() {
+ clearScreen();
+ console.log('Welcome to NAS Photo Server Setup');
+ console.log('');
+
+ const platform = await detectOrAskPlatform();
+ console.log('');
+
+ if (!checkDependencies()) {
+ rl.close();
+ process.exit(1);
+ }
+ console.log('');
+
+ // First-time setup: photos path
+ if (!config.getPhotosPath()) {
+ console.log('[!] No photos path configured. Let\'s set one up.');
+ await setupPhotosPath(platform);
+ console.log('');
+ }
+
+ // Ask about password on first run
+ if (!passwordManager.hasStoredPassword()) {
+ const storePw = await ask('Do you want to store a password? (Y/N): ');
+ if (storePw.trim().toUpperCase() === 'Y') {
+ const pw = await ask('Enter password: ');
+ if (pw.trim().length > 0) {
+ passwordManager.storePassword(pw.trim());
+ console.log(`[OK] Password stored. Your password: ${pw.trim()}`);
+ }
+ }
+ console.log('');
+ }
+
+ await mainMenu(platform);
+}
+
+main().catch(err => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
+
diff --git a/ServerFiles/config.js b/ServerFiles/config.js
new file mode 100644
index 0000000..30bc184
--- /dev/null
+++ b/ServerFiles/config.js
@@ -0,0 +1,56 @@
+const path = require('path');
+const fs = require('fs');
+
+const CONFIG_DIR = path.join(__dirname, '.config');
+const PHOTOS_PATH_FILE = path.join(__dirname, 'nas_config.txt');
+const DB_PATH = path.join(CONFIG_DIR, 'nas.db');
+const PASSWORD_FILE = path.join(CONFIG_DIR, '.credentials');
+const DEFAULT_PORT = 9500;
+
+function ensureConfigDir() {
+ if (!fs.existsSync(CONFIG_DIR)) {
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
+ }
+}
+
+function getPhotosPath() {
+ if (!fs.existsSync(PHOTOS_PATH_FILE)) return null;
+
+ let raw = fs.readFileSync(PHOTOS_PATH_FILE, 'utf-8');
+
+ // Strip UTF-8 BOM
+ raw = raw.replace(/^\uFEFF/, '');
+ // Strip ALL control characters (CR, LF, null, tabs, zero-width spaces, etc.)
+ // except forward-slash and backslash which are path separators
+ raw = raw.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\r\n]/g, '');
+ // Strip zero-width / non-breaking unicode junk that can sneak in from copy-paste
+ raw = raw.replace(/[\u200B\u200C\u200D\uFEFF\u00A0]/g, '');
+ raw = raw.trim();
+
+ // Take only the first line in case there are multiple lines
+ raw = raw.split('\n')[0].trim();
+
+ if (!raw) return null;
+
+ // Return the path exactly as written — do NOT use path.resolve()
+ // because it mangles Linux paths on Windows and vice versa
+ return raw;
+}
+
+function setPhotosPath(p) {
+ // Clean and write as plain UTF-8 with unix line ending, no BOM
+ const clean = p.replace(/[\r\n\x00]/g, '').trim();
+ fs.writeFileSync(PHOTOS_PATH_FILE, clean, 'utf-8');
+}
+
+module.exports = {
+ CONFIG_DIR,
+ PHOTOS_PATH_FILE,
+ DB_PATH,
+ PASSWORD_FILE,
+ DEFAULT_PORT,
+ ensureConfigDir,
+ getPhotosPath,
+ setPhotosPath,
+};
+
diff --git a/ServerFiles/db.js b/ServerFiles/db.js
new file mode 100644
index 0000000..e6559e6
--- /dev/null
+++ b/ServerFiles/db.js
@@ -0,0 +1,320 @@
+const Database = require('better-sqlite3');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const config = require('./config');
+
+let db = null;
+
+function getDb() {
+ if (db) return db;
+ config.ensureConfigDir();
+ db = new Database(config.DB_PATH);
+ db.pragma('journal_mode = WAL');
+ initSchema();
+ return db;
+}
+
+function initSchema() {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ filename TEXT NOT NULL,
+ filepath TEXT NOT NULL,
+ sha256_hash TEXT NOT NULL,
+ file_size INTEGER NOT NULL,
+ mime_type TEXT NOT NULL,
+ indexed_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ UNIQUE(user_id, sha256_hash)
+ );
+
+ CREATE TABLE IF NOT EXISTS albums (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ UNIQUE(user_id, name)
+ );
+
+ CREATE TABLE IF NOT EXISTS album_photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ album_id INTEGER NOT NULL,
+ photo_id INTEGER NOT NULL,
+ added_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE,
+ FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
+ UNIQUE(album_id, photo_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
+ CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(sha256_hash);
+ CREATE INDEX IF NOT EXISTS idx_albums_user ON albums(user_id);
+ CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id);
+ `);
+}
+
+function ensureUser(username) {
+ const d = getDb();
+ const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ if (existing) return existing.id;
+ const result = d.prepare('INSERT INTO users (username) VALUES (?)').run(username);
+ return Number(result.lastInsertRowid);
+}
+
+function hashFile(filePath) {
+ const buffer = fs.readFileSync(filePath);
+ return crypto.createHash('sha256').update(buffer).digest('hex');
+}
+
+function getMimeType(ext) {
+ const map = {
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
+ '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
+ '.heic': 'image/heic', '.heif': 'image/heif',
+ '.tiff': 'image/tiff', '.tif': 'image/tiff', '.svg': 'image/svg+xml',
+ };
+ return map[ext.toLowerCase()] || 'application/octet-stream';
+}
+
+const SUPPORTED_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif', '.tiff', '.tif', '.svg'];
+
+function indexPhotosForUser(username, photosBasePath) {
+ const d = getDb();
+ const userId = ensureUser(username);
+
+ const userDir = path.join(photosBasePath, username);
+ if (!fs.existsSync(userDir)) {
+ fs.mkdirSync(userDir, { recursive: true });
+ return { indexed: 0, skipped: 0, errors: 0, total: 0 };
+ }
+
+ const files = getAllMediaFiles(userDir);
+
+ let indexed = 0, skipped = 0, errors = 0;
+
+ const insertStmt = d.prepare(`
+ INSERT OR IGNORE INTO photos (user_id, filename, filepath, sha256_hash, file_size, mime_type)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `);
+
+ for (const fullPath of files) {
+ try {
+ const stat = fs.statSync(fullPath);
+ const hash = hashFile(fullPath);
+ const ext = path.extname(fullPath);
+ const mime = getMimeType(ext);
+ const filename = path.basename(fullPath);
+
+ const result = insertStmt.run(userId, filename, fullPath, hash, stat.size, mime);
+ if (result.changes > 0) {
+ indexed++;
+ } else {
+ skipped++;
+ }
+ } catch (err) {
+ errors++;
+ }
+ }
+
+ return { indexed, skipped, errors, total: files.length };
+}
+
+// Recursively find all media files in a directory
+function getAllMediaFiles(dir) {
+ let results = [];
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results = results.concat(getAllMediaFiles(fullPath));
+ } else {
+ const ext = path.extname(entry.name).toLowerCase();
+ if (SUPPORTED_EXTS.includes(ext)) {
+ results.push(fullPath);
+ }
+ }
+ }
+ return results;
+}
+
+function getPhotoIndex(username) {
+ const d = getDb();
+ const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ if (!user) return [];
+ return d.prepare(`
+ SELECT id, filename, sha256_hash, file_size, mime_type, indexed_at
+ FROM photos WHERE user_id = ? ORDER BY indexed_at DESC
+ `).all(user.id);
+}
+
+function getPhotoById(photoId) {
+ const d = getDb();
+ return d.prepare(`
+ SELECT p.*, u.username FROM photos p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.id = ?
+ `).get(photoId);
+}
+
+function getPhotoByHash(hash) {
+ const d = getDb();
+ return d.prepare(`
+ SELECT p.*, u.username FROM photos p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.sha256_hash = ?
+ `).get(hash);
+}
+
+function getAllUsers() {
+ const d = getDb();
+ return d.prepare('SELECT * FROM users ORDER BY username').all();
+}
+
+function removeOrphanedEntries(username, photosBasePath) {
+ const d = getDb();
+ const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ if (!user) return 0;
+
+ const photos = d.prepare('SELECT id, filepath FROM photos WHERE user_id = ?').all(user.id);
+ let removed = 0;
+ const deleteStmt = d.prepare('DELETE FROM photos WHERE id = ?');
+
+ for (const photo of photos) {
+ if (!fs.existsSync(photo.filepath)) {
+ deleteStmt.run(photo.id);
+ removed++;
+ }
+ }
+ return removed;
+}
+
+function deletePhoto(photoId) {
+ const d = getDb();
+ const photo = getPhotoById(photoId);
+ if (!photo) return { success: false, error: 'Photo not found' };
+
+ // Remove from disk
+ if (fs.existsSync(photo.filepath)) {
+ fs.unlinkSync(photo.filepath);
+ }
+
+ // Remove from album_photos first (cascade might not work with all builds)
+ d.prepare('DELETE FROM album_photos WHERE photo_id = ?').run(photoId);
+ d.prepare('DELETE FROM photos WHERE id = ?').run(photoId);
+ return { success: true };
+}
+
+// ─── Album operations ───
+
+function createAlbum(username, albumName) {
+ const d = getDb();
+ const userId = ensureUser(username);
+ try {
+ const result = d.prepare('INSERT INTO albums (user_id, name) VALUES (?, ?)').run(userId, albumName);
+ return { success: true, albumId: Number(result.lastInsertRowid) };
+ } catch (err) {
+ if (err.message.includes('UNIQUE')) {
+ return { success: false, error: 'Album already exists' };
+ }
+ throw err;
+ }
+}
+
+function getAlbums(username) {
+ const d = getDb();
+ const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ if (!user) return [];
+
+ const albums = d.prepare('SELECT * FROM albums WHERE user_id = ? ORDER BY created_at DESC').all(user.id);
+ return albums.map(album => {
+ const count = d.prepare('SELECT COUNT(*) as cnt FROM album_photos WHERE album_id = ?').get(album.id);
+ const cover = d.prepare(`
+ SELECT p.id, p.filename, p.sha256_hash FROM album_photos ap
+ JOIN photos p ON ap.photo_id = p.id
+ WHERE ap.album_id = ? ORDER BY ap.added_at DESC LIMIT 1
+ `).get(album.id);
+ return { ...album, photoCount: count.cnt, coverPhoto: cover || null };
+ });
+}
+
+function getAlbumPhotos(albumId) {
+ const d = getDb();
+ return d.prepare(`
+ SELECT p.id, p.filename, p.sha256_hash, p.file_size, p.mime_type, p.indexed_at
+ FROM album_photos ap
+ JOIN photos p ON ap.photo_id = p.id
+ WHERE ap.album_id = ?
+ ORDER BY ap.added_at DESC
+ `).all(albumId);
+}
+
+function addPhotoToAlbum(albumId, photoId) {
+ const d = getDb();
+ try {
+ d.prepare('INSERT OR IGNORE INTO album_photos (album_id, photo_id) VALUES (?, ?)').run(albumId, photoId);
+ return { success: true };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+}
+
+function removePhotoFromAlbum(albumId, photoId) {
+ const d = getDb();
+ d.prepare('DELETE FROM album_photos WHERE album_id = ? AND photo_id = ?').run(albumId, photoId);
+ return { success: true };
+}
+
+function deleteAlbum(albumId) {
+ const d = getDb();
+ d.prepare('DELETE FROM album_photos WHERE album_id = ?').run(albumId);
+ d.prepare('DELETE FROM albums WHERE id = ?').run(albumId);
+ return { success: true };
+}
+
+function renameAlbum(albumId, newName) {
+ const d = getDb();
+ try {
+ d.prepare('UPDATE albums SET name = ? WHERE id = ?').run(newName, albumId);
+ return { success: true };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+}
+
+function closeDb() {
+ if (db) {
+ db.close();
+ db = null;
+ }
+}
+
+module.exports = {
+ getDb,
+ ensureUser,
+ hashFile,
+ indexPhotosForUser,
+ getPhotoIndex,
+ getPhotoById,
+ getPhotoByHash,
+ getAllUsers,
+ removeOrphanedEntries,
+ deletePhoto,
+ createAlbum,
+ getAlbums,
+ getAlbumPhotos,
+ addPhotoToAlbum,
+ removePhotoFromAlbum,
+ deleteAlbum,
+ renameAlbum,
+ closeDb,
+};
+
diff --git a/ServerFiles/nas_config.txt b/ServerFiles/nas_config.txt
index 5acf427..8ad8882 100644
--- a/ServerFiles/nas_config.txt
+++ b/ServerFiles/nas_config.txt
@@ -1,2 +1 @@
-/home/user/photos
-
+/home/bob/photos
\ No newline at end of file
diff --git a/ServerFiles/package.json b/ServerFiles/package.json
index a061fb1..821cd09 100644
--- a/ServerFiles/package.json
+++ b/ServerFiles/package.json
@@ -1,31 +1,24 @@
{
- "name": "media-app-sftp-backend",
+ "name": "nas-photo-server",
"version": "1.0.0",
- "description": "SFTP Backend API for Media Aggregation App",
+ "description": "Self-contained NAS Photo Server with REST API and CLI",
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "nodemon server.js",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "cli": "node cli.js",
+ "dev": "node cli.js"
},
"keywords": [
- "sftp",
- "backend",
- "api",
- "express"
+ "nas",
+ "photo-server",
+ "rest-api",
+ "base64"
],
"author": "",
"license": "MIT",
"dependencies": {
- "body-parser": "^2.2.2",
+ "better-sqlite3": "^11.7.0",
"cors": "^2.8.6",
- "dotenv": "^16.6.1",
- "express": "^4.22.1",
- "express-fileupload": "^1.4.0",
- "jsonwebtoken": "^9.0.2",
- "node-fetch": "^2.7.0"
- },
- "devDependencies": {
- "nodemon": "^3.0.1"
+ "express": "^4.22.1"
}
}
diff --git a/ServerFiles/password_manager.js b/ServerFiles/password_manager.js
new file mode 100644
index 0000000..3ca7ff9
--- /dev/null
+++ b/ServerFiles/password_manager.js
@@ -0,0 +1,101 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const config = require('./config');
+
+const ALGORITHM = 'aes-256-gcm';
+const KEY_LENGTH = 32;
+const IV_LENGTH = 16;
+const SALT_LENGTH = 32;
+
+function deriveKey(passphrase, salt) {
+ return crypto.pbkdf2Sync(passphrase, salt, 100000, KEY_LENGTH, 'sha512');
+}
+
+function getMachineId() {
+ const os = require('os');
+ const raw = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.userInfo().username}`;
+ return crypto.createHash('sha256').update(raw).digest('hex');
+}
+
+function encrypt(plaintext) {
+ const machineKey = getMachineId();
+ const salt = crypto.randomBytes(SALT_LENGTH);
+ const key = deriveKey(machineKey, salt);
+ const iv = crypto.randomBytes(IV_LENGTH);
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+
+ let encrypted = cipher.update(plaintext, 'utf-8', 'hex');
+ encrypted += cipher.final('hex');
+ const authTag = cipher.getAuthTag();
+
+ return {
+ salt: salt.toString('hex'),
+ iv: iv.toString('hex'),
+ authTag: authTag.toString('hex'),
+ data: encrypted,
+ };
+}
+
+function decrypt(encObj) {
+ const machineKey = getMachineId();
+ const salt = Buffer.from(encObj.salt, 'hex');
+ const key = deriveKey(machineKey, salt);
+ const iv = Buffer.from(encObj.iv, 'hex');
+ const authTag = Buffer.from(encObj.authTag, 'hex');
+
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
+ decipher.setAuthTag(authTag);
+
+ let decrypted = decipher.update(encObj.data, 'hex', 'utf-8');
+ decrypted += decipher.final('utf-8');
+ return decrypted;
+}
+
+function storePassword(password) {
+ config.ensureConfigDir();
+ const encrypted = encrypt(password);
+ const content = JSON.stringify(encrypted);
+
+ fs.writeFileSync(config.PASSWORD_FILE, content, { encoding: 'utf-8', mode: 0o600 });
+
+ // On Windows, hide the file with attrib
+ if (process.platform === 'win32') {
+ try {
+ const { execSync } = require('child_process');
+ execSync(`attrib +h "${config.PASSWORD_FILE}"`, { stdio: 'ignore' });
+ } catch (_) {}
+ }
+
+ return true;
+}
+
+function retrievePassword() {
+ if (!fs.existsSync(config.PASSWORD_FILE)) return null;
+ try {
+ const content = fs.readFileSync(config.PASSWORD_FILE, 'utf-8');
+ const encObj = JSON.parse(content);
+ return decrypt(encObj);
+ } catch (err) {
+ return null;
+ }
+}
+
+function hasStoredPassword() {
+ return fs.existsSync(config.PASSWORD_FILE);
+}
+
+function deletePassword() {
+ if (fs.existsSync(config.PASSWORD_FILE)) {
+ fs.unlinkSync(config.PASSWORD_FILE);
+ return true;
+ }
+ return false;
+}
+
+module.exports = {
+ storePassword,
+ retrievePassword,
+ hasStoredPassword,
+ deletePassword,
+};
+
diff --git a/ServerFiles/server.js b/ServerFiles/server.js
index 9bc3045..bd91602 100644
--- a/ServerFiles/server.js
+++ b/ServerFiles/server.js
@@ -1,376 +1,384 @@
const express = require('express');
-const jwt = require('jsonwebtoken');
const cors = require('cors');
-const path = require('path');
-const os = require('os');
const fs = require('fs');
-const util = require('util');
-const { execFile } = require('child_process');
-const fileUpload = require('express-fileupload');
-require('dotenv').config({ path: path.join(__dirname, '.env') });
-
-const fetch = require('node-fetch');
-const execFileAsync = util.promisify(execFile);
-
-const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
-
-// Read the default NAS photo directory from the config file
-const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
-let DEFAULT_PHOTO_DIR = '/home/user/photos';
-try {
- const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim();
- if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim();
-} catch (_) {
- // Config file missing — keep the hard-coded default
-}
-
-// SSH/SFTP shell helpers
-
-function runCommand(cmd, args, options = {}) {
- return execFileAsync(cmd, args, {
- timeout: 30000,
- maxBuffer: 10 * 1024 * 1024,
- ...options,
- });
-}
-
-function buildScpArgs(conn, source, dest) {
- const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS];
- if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath);
- args.push(source, dest);
- return args;
-}
-
-function buildSftpArgs(conn, batchFilePath) {
- const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS, '-b', batchFilePath];
- if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath);
- args.push(`${conn.username}@${conn.host}`);
- return args;
-}
-
-async function runSftpBatch(conn, batchCommands) {
- const batchFilePath = path.join(
- os.tmpdir(),
- `sftp_batch_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`
- );
- fs.writeFileSync(batchFilePath, batchCommands.join('\n'), 'utf8');
- try {
- const args = buildSftpArgs(conn, batchFilePath);
- if (conn.password) {
- const sshpass = process.env.SSHPASS_PATH || 'sshpass';
- return await runCommand(sshpass, ['-p', conn.password, 'sftp', ...args]);
- }
- return await runCommand('sftp', args);
- } finally {
- fs.unlinkSync(batchFilePath);
- }
-}
-
-// Express setup
+const path = require('path');
+const crypto = require('crypto');
+const config = require('./config');
+const db = require('./db');
const app = express();
-app.use(express.json());
-app.use(express.urlencoded({ extended: true }));
-app.use(fileUpload());
app.use(cors());
-
-const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
-const connections = new Map();
+app.use(express.json({ limit: '100mb' }));
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
-// JWT verification middleware
-
-function verifyToken(req, res, next) {
- const authHeader = req.headers.authorization;
- const token = authHeader?.split(' ')[1];
- if (!token) return res.status(401).json({ error: 'No token provided' });
-
- try {
- const decoded = jwt.verify(token, SECRET_KEY);
- req.connectionId = decoded.connectionId;
- req.connection = connections.get(decoded.connectionId);
- if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
- next();
- } catch (err) {
- res.status(401).json({ error: 'Invalid token: ' + err.message });
+function getPhotosPath() {
+ const p = config.getPhotosPath();
+ if (!p) throw new Error('Photos path not configured. Set it in nas_config.txt');
+ if (!fs.existsSync(p)) {
+ fs.mkdirSync(p, { recursive: true });
}
+ return p;
}
-// ─── REST Endpoints ──────────────────────────────────────────────
+function sanitizeUsername(username) {
+ return username.trim().replace(/[^a-zA-Z0-9_\-.]/g, '_');
+}
-// Health check
+// ─── Health ──────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
- res.json({
- status: 'ok',
- timestamp: new Date().toISOString(),
- activeConnections: connections.size,
- defaultPhotoDir: DEFAULT_PHOTO_DIR,
- });
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
-// Return the current NAS photo directory read from nas_config.txt
-
-app.get('/api/config/photo-dir', (req, res) => {
- res.json({ photoDir: DEFAULT_PHOTO_DIR });
-});
-
-// Establish an SFTP connection (stores credentials, returns a JWT)
-
-app.post('/api/sftp/connect', (req, res) => {
- const { host, port = 22, username, password } = req.body;
- if (!host || !username || !password) {
- return res.status(400).json({ error: 'Missing required fields: host, username, password' });
- }
-
- const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- connections.set(connectionId, { host, port, username, password, createdAt: new Date() });
-
- const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' });
- console.log(`Connection prepared: ${host}:${port} (${connectionId})`);
-
- res.json({
- success: true,
- connectionToken: token,
- message: `Connection prepared for ${host}:${port}`,
- defaultPhotoDir: DEFAULT_PHOTO_DIR,
- });
-});
-
-// List directory contents
-
-app.post('/api/sftp/list', verifyToken, async (req, res) => {
- const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
- const conn = req.connection;
+// ─── User Registration ──────────────────────────────────────
+// Device sends its username; NAS registers and creates user folder
+app.post('/api/register', (req, res) => {
try {
- const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]);
- const files = result.stdout
- .split('\n')
- .filter(Boolean)
- .map((line) => {
- const parts = line.split(/\s+/);
- return {
- name: parts.pop(),
- isDirectory: parts[0]?.charAt(0) === 'd',
- size: parseInt(parts[4], 10),
- modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`),
- permissions: parts[0],
- };
- });
-
- res.json({ path: dirPath, files, count: files.length });
+ const { username } = req.body;
+ if (!username || typeof username !== 'string' || username.trim().length === 0) {
+ return res.status(400).json({ error: 'Username is required' });
+ }
+ const sanitized = sanitizeUsername(username);
+ const userId = db.ensureUser(sanitized);
+ const photosPath = getPhotosPath();
+ const userDir = path.join(photosPath, sanitized);
+ if (!fs.existsSync(userDir)) {
+ fs.mkdirSync(userDir, { recursive: true });
+ }
+ res.json({ success: true, userId, username: sanitized });
} catch (err) {
- console.error('List directory error:', err.message);
- res.status(500).json({ error: 'Failed to list directory: ' + err.message });
+ res.status(500).json({ error: err.message });
}
});
-// Download a file from the NAS
-
-app.get('/api/sftp/download', verifyToken, async (req, res) => {
- const { path: filePath } = req.query;
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
-
- const conn = req.connection;
- const filename = path.basename(filePath);
- const tempFilePath = path.join(os.tmpdir(), filename);
+// ─── Index (NAS builds its database) ────────────────────────
+app.post('/api/index', (req, res) => {
try {
- await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
- res.download(tempFilePath, filename, () => {
- try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
+ const { username } = req.body;
+ if (!username) return res.status(400).json({ error: 'Username is required' });
+
+ const photosPath = getPhotosPath();
+ const sanitized = sanitizeUsername(username);
+
+ const removed = db.removeOrphanedEntries(sanitized, photosPath);
+ const result = db.indexPhotosForUser(sanitized, photosPath);
+
+ res.json({
+ success: true,
+ username: sanitized,
+ indexed: result.indexed,
+ skipped: result.skipped,
+ errors: result.errors,
+ totalFiles: result.total,
+ orphansRemoved: removed,
});
} catch (err) {
- console.error('Download error:', err.message);
- res.status(500).json({ error: 'Download failed: ' + err.message });
+ res.status(500).json({ error: err.message });
}
});
-// Upload a file to the NAS
-
-app.post('/api/sftp/upload', verifyToken, async (req, res) => {
- const { remotePath } = req.body;
- if (!remotePath || !req.files?.file) {
- return res.status(400).json({ error: 'Missing remotePath or file' });
- }
-
- const file = req.files.file;
- const conn = req.connection;
- const tempFilePath = path.join(os.tmpdir(), file.name);
+// ─── Photo List (POST fetch cycle) ─────────────────────────
+app.post('/api/photos', (req, res) => {
try {
- await file.mv(tempFilePath);
- await runCommand(
- 'scp',
- buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`)
- );
- fs.unlinkSync(tempFilePath);
- console.log(`Uploaded: ${remotePath}`);
- res.json({ success: true, remotePath, message: 'File uploaded' });
- } catch (err) {
- console.error('Upload error:', err.message);
- res.status(500).json({ error: 'Upload failed: ' + err.message });
- }
-});
+ const { username } = req.body;
+ if (!username) return res.status(400).json({ error: 'Username is required' });
-// Create a directory on the NAS
+ const sanitized = sanitizeUsername(username);
+ const photos = db.getPhotoIndex(sanitized);
-app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
- const { path: dirPath } = req.body;
- if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
-
- try {
- await runSftpBatch(req.connection, [`mkdir ${dirPath}`]);
- console.log(`Created directory: ${dirPath}`);
- res.json({ success: true, path: dirPath, message: 'Directory created' });
- } catch (err) {
- console.error('Mkdir error:', err.message);
- res.status(500).json({ error: 'Failed to create directory: ' + err.message });
- }
-});
-
-// Delete a file on the NAS
-
-app.post('/api/sftp/delete', verifyToken, async (req, res) => {
- const { path: filePath } = req.body;
- if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
-
- try {
- await runSftpBatch(req.connection, [`rm ${filePath}`]);
- console.log(`Deleted: ${filePath}`);
- res.json({ success: true, path: filePath, message: 'File deleted' });
- } catch (err) {
- console.error('Delete error:', err.message);
- res.status(500).json({ error: 'Failed to delete file: ' + err.message });
- }
-});
-
-// Disconnect from the NAS
-
-app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
- connections.delete(req.connectionId);
- console.log(`Disconnected: ${req.connectionId}`);
- res.json({ success: true, message: 'Disconnected from SFTP server' });
-});
-
-// ─── Google Photos → NAS Proxy Endpoints ─────────────────────────
-
-// Fetch media items from Google Photos on behalf of the mobile client
-
-app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
- const { googleAccessToken, pageSize = 50, pageToken } = req.body;
- if (!googleAccessToken) {
- return res.status(400).json({ error: 'Google access token is required' });
- }
-
- try {
- const params = new URLSearchParams({ pageSize: String(pageSize) });
- if (pageToken) params.append('pageToken', pageToken);
-
- const response = await fetch(
- `https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`,
- { headers: { Authorization: `Bearer ${googleAccessToken}` } }
- );
-
- if (!response.ok) {
- const errText = await response.text();
- return res.status(response.status).json({ error: 'Google API error', details: errText });
- }
-
- const data = await response.json();
- const mediaItems = (data.mediaItems || []).map((item) => ({
- id: item.id,
- filename: item.filename,
- mimeType: item.mimeType,
- baseUrl: item.baseUrl,
- productUrl: item.productUrl,
- creationTime: item.mediaMetadata?.creationTime,
- width: item.mediaMetadata?.width,
- height: item.mediaMetadata?.height,
- }));
-
- console.log(`Fetched ${mediaItems.length} Google Photos items via NAS proxy`);
- res.json({ success: true, mediaItems, nextPageToken: data.nextPageToken || null, count: mediaItems.length });
- } catch (err) {
- console.error('Google Photos fetch error:', err.message);
- res.status(500).json({ error: 'Failed to fetch Google Photos: ' + err.message });
- }
-});
-
-// Download a Google Photos item and save it directly to the NAS
-
-app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
- const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
- const conn = req.connection;
-
- if (!googleAccessToken || !baseUrl || !remotePath) {
- return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' });
- }
-
- try {
- const imageResponse = await fetch(`${baseUrl}=d`, {
- headers: { Authorization: `Bearer ${googleAccessToken}` },
+ res.json({
+ success: true,
+ username: sanitized,
+ count: photos.length,
+ photos: photos.map(p => ({
+ id: p.id,
+ filename: p.filename,
+ sha256Hash: p.sha256_hash,
+ fileSize: p.file_size,
+ mimeType: p.mime_type,
+ indexedAt: p.indexed_at,
+ })),
});
- if (!imageResponse.ok) {
- return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' });
- }
-
- const buffer = await imageResponse.buffer();
- const safeName = filename || `gphoto_${Date.now()}.jpg`;
- const tempFilePath = path.join(os.tmpdir(), safeName);
- fs.writeFileSync(tempFilePath, buffer);
-
- const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
- await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
- fs.unlinkSync(tempFilePath);
-
- console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`);
- res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
} catch (err) {
- console.error('Download-to-NAS error:', err.message);
- res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message });
+ res.status(500).json({ error: err.message });
}
});
-// Sync status (placeholder for future batch sync)
+// ─── Fetch Single Photo as Base64 with hash ─────────────────
-app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
- res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
+app.post('/api/photo/fetch', (req, res) => {
+ try {
+ const { photoId, hash } = req.body;
+ if (!photoId && !hash) {
+ return res.status(400).json({ error: 'photoId or hash is required' });
+ }
+
+ let photo;
+ if (photoId) {
+ photo = db.getPhotoById(photoId);
+ } else {
+ photo = db.getPhotoByHash(hash);
+ }
+
+ if (!photo) return res.status(404).json({ error: 'Photo not found' });
+ if (!fs.existsSync(photo.filepath)) {
+ return res.status(404).json({ error: 'Photo file missing from disk' });
+ }
+
+ const fileBuffer = fs.readFileSync(photo.filepath);
+ const base64Data = fileBuffer.toString('base64');
+ const transportHash = crypto.createHash('sha256').update(base64Data).digest('hex');
+
+ res.json({
+ success: true,
+ photo: {
+ id: photo.id,
+ filename: photo.filename,
+ mimeType: photo.mime_type,
+ fileSize: photo.file_size,
+ sha256Hash: photo.sha256_hash,
+ transportHash,
+ base64: base64Data,
+ },
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
});
-// ─── Lifecycle ───────────────────────────────────────────────────
+// ─── Upload Photo from Device to NAS ────────────────────────
-process.on('SIGINT', () => {
- console.log('\nShutting down...');
- connections.forEach((_, id) => connections.delete(id));
- process.exit(0);
+app.post('/api/photo/upload', (req, res) => {
+ try {
+ const { username, filename, base64, transportHash } = req.body;
+ if (!username || !filename || !base64) {
+ return res.status(400).json({ error: 'username, filename, and base64 are required' });
+ }
+
+ if (transportHash) {
+ const computed = crypto.createHash('sha256').update(base64).digest('hex');
+ if (computed !== transportHash) {
+ return res.status(400).json({ error: 'Transport integrity check failed' });
+ }
+ }
+
+ const sanitized = sanitizeUsername(username);
+ const photosPath = getPhotosPath();
+ const userDir = path.join(photosPath, sanitized);
+ if (!fs.existsSync(userDir)) {
+ fs.mkdirSync(userDir, { recursive: true });
+ }
+
+ const safeFilename = filename.replace(/[^a-zA-Z0-9_\-.]/g, '_');
+ const destPath = path.join(userDir, safeFilename);
+
+ const buffer = Buffer.from(base64, 'base64');
+ fs.writeFileSync(destPath, buffer);
+
+ db.ensureUser(sanitized);
+ const result = db.indexPhotosForUser(sanitized, photosPath);
+
+ res.json({ success: true, filename: safeFilename, indexed: result.indexed });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
});
+// ─── Delete Photo from NAS ──────────────────────────────────
+
+app.post('/api/photo/delete', (req, res) => {
+ try {
+ const { photoId } = req.body;
+ if (!photoId) return res.status(400).json({ error: 'photoId is required' });
+
+ const result = db.deletePhoto(photoId);
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── Refresh (re-index + return list) ───────────────────────
+
+app.post('/api/refresh', (req, res) => {
+ try {
+ const { username } = req.body;
+ if (!username) return res.status(400).json({ error: 'Username is required' });
+
+ const sanitized = sanitizeUsername(username);
+ const photosPath = getPhotosPath();
+
+ db.ensureUser(sanitized);
+ db.removeOrphanedEntries(sanitized, photosPath);
+ db.indexPhotosForUser(sanitized, photosPath);
+ const photos = db.getPhotoIndex(sanitized);
+
+ res.json({
+ success: true,
+ username: sanitized,
+ count: photos.length,
+ photos: photos.map(p => ({
+ id: p.id,
+ filename: p.filename,
+ sha256Hash: p.sha256_hash,
+ fileSize: p.file_size,
+ mimeType: p.mime_type,
+ indexedAt: p.indexed_at,
+ })),
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── Album Endpoints ────────────────────────────────────────
+
+app.post('/api/albums', (req, res) => {
+ try {
+ const { username } = req.body;
+ if (!username) return res.status(400).json({ error: 'Username is required' });
+ const sanitized = sanitizeUsername(username);
+ const albums = db.getAlbums(sanitized);
+ res.json({ success: true, albums });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/create', (req, res) => {
+ try {
+ const { username, name } = req.body;
+ if (!username || !name) return res.status(400).json({ error: 'username and name are required' });
+ const sanitized = sanitizeUsername(username);
+ const result = db.createAlbum(sanitized, name.trim());
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/photos', (req, res) => {
+ try {
+ const { albumId } = req.body;
+ if (!albumId) return res.status(400).json({ error: 'albumId is required' });
+ const photos = db.getAlbumPhotos(albumId);
+ res.json({
+ success: true,
+ photos: photos.map(p => ({
+ id: p.id,
+ filename: p.filename,
+ sha256Hash: p.sha256_hash,
+ fileSize: p.file_size,
+ mimeType: p.mime_type,
+ indexedAt: p.indexed_at,
+ })),
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/add-photo', (req, res) => {
+ try {
+ const { albumId, photoId } = req.body;
+ if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' });
+ const result = db.addPhotoToAlbum(albumId, photoId);
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/remove-photo', (req, res) => {
+ try {
+ const { albumId, photoId } = req.body;
+ if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' });
+ const result = db.removePhotoFromAlbum(albumId, photoId);
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/delete', (req, res) => {
+ try {
+ const { albumId } = req.body;
+ if (!albumId) return res.status(400).json({ error: 'albumId is required' });
+ const result = db.deleteAlbum(albumId);
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/album/rename', (req, res) => {
+ try {
+ const { albumId, name } = req.body;
+ if (!albumId || !name) return res.status(400).json({ error: 'albumId and name are required' });
+ const result = db.renameAlbum(albumId, name.trim());
+ res.json(result);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── Users ──────────────────────────────────────────────────
+
+app.get('/api/users', (req, res) => {
+ try {
+ const users = db.getAllUsers();
+ res.json({ success: true, users });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── Error handling ─────────────────────────────────────────
+
app.use((err, req, res, _next) => {
console.error('Server error:', err.message);
- res.status(500).json({
- error: 'Internal server error',
- message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error',
- });
+ res.status(500).json({ error: 'Internal server error' });
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found', path: req.path });
});
-const PORT = process.env.PORT || 3001;
-app.listen(PORT, () => {
- console.log(`\n SFTP Backend Server`);
- console.log(` Port: ${PORT}`);
- console.log(` URL: http://localhost:${PORT}`);
- console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`);
- console.log(` Status: Ready\n`);
+// ─── Lifecycle ──────────────────────────────────────────────
+
+function startServer(port) {
+ const p = port || config.DEFAULT_PORT;
+ app.listen(p, '0.0.0.0', () => {
+ console.log(`[NAS Server] Running on http://0.0.0.0:${p}`);
+ console.log(`[NAS Server] Photos path: ${config.getPhotosPath() || 'NOT SET'}`);
+ });
+}
+
+if (require.main === module) {
+ const photosPath = config.getPhotosPath();
+ if (!photosPath) {
+ console.error('ERROR: Photos path not set. Write the path to your photos directory in nas_config.txt');
+ process.exit(1);
+ }
+ // Auto-create the photos directory if it doesn't exist yet
+ if (!fs.existsSync(photosPath)) {
+ fs.mkdirSync(photosPath, { recursive: true });
+ console.log(`[NAS Server] Created photos directory: ${photosPath}`);
+ }
+ startServer();
+}
+
+process.on('SIGINT', () => {
+ console.log('\nShutting down...');
+ db.closeDb();
+ process.exit(0);
});
-module.exports = app;
-
+module.exports = { app, startServer };
diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js
index 8707648..ebdf5d8 100644
--- a/src/API/NASAPI.js
+++ b/src/API/NASAPI.js
@@ -1,135 +1,198 @@
import axios from 'axios';
-// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
-const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
+// REST client that communicates with the NAS Photo Server (ServerFiles/server.js)
+const DEFAULT_PORT = '9500';
class NASAPI {
- constructor(backendUrl = DEFAULT_BACKEND_URL) {
- this.backendUrl = backendUrl;
- this.connectionToken = null;
+ constructor() {
+ this.baseUrl = null;
+ this.username = null;
this.isConnected = false;
- this.credentials = null;
}
- // Establish a connection via the backend; falls back to local-only config if the backend is unreachable
- async connect(host, port, username, password, photoFolder = '/Photos') {
- if (!host || !username || !password) throw new Error('Host, username, and password are required');
+ configure(host, port, username) {
+ this.baseUrl = `http://${host}:${port || DEFAULT_PORT}`;
+ this.username = username;
+ }
- this.credentials = { host, port: port || 22, username, photoFolder };
+ async connect(host, port, username) {
+ if (!host || !username) throw new Error('Host and username are required');
+ this.configure(host, port, username);
+
+ // Register user on NAS — NAS creates user folder and DB entry
+ const response = await axios.post(
+ `${this.baseUrl}/api/register`,
+ { username: this.username },
+ { timeout: 10000 },
+ );
+
+ if (response.data?.success) {
+ this.isConnected = true;
+ return {
+ success: true,
+ message: `Connected to NAS at ${host}`,
+ userId: response.data.userId,
+ username: response.data.username,
+ };
+ }
+ throw new Error('Failed to register with NAS');
+ }
+
+ async healthCheck() {
+ if (!this.baseUrl) return false;
try {
- const response = await axios.post(
- `${this.backendUrl}/api/sftp/connect`,
- { host, port: port || 22, username, password },
- { timeout: 10000 }
- );
- if (response.data?.success) {
- this.connectionToken = response.data.connectionToken;
- this.isConnected = true;
- return { success: true, message: `Connected to ${host}` };
- }
- } catch (_) {
- console.log('Backend not available, storing NAS credentials locally');
- }
-
- this.isConnected = true;
- return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials };
- }
-
- getHeaders() {
- if (!this.connectionToken) return {};
- return { Authorization: `Bearer ${this.connectionToken}` };
- }
-
- async listDirectory(path = '/') {
- if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
- const response = await axios.post(
- `${this.backendUrl}/api/sftp/list`, { path },
- { headers: this.getHeaders(), timeout: 15000 }
- );
- return response.data;
- }
-
- // Filter directory listing for supported photo/video files
- async getPhotos(folder = null) {
- const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
- if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
-
- try {
- const listing = await this.listDirectory(targetFolder);
- const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
- const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
-
- const mediaItems = (listing.files || [])
- .filter((file) => {
- const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
- return photoExts.includes(ext) || videoExts.includes(ext);
- })
- .map((file) => {
- const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
- return {
- source_service: 'NAS',
- source_id: `nas_${file.name}`,
- title: file.name,
- remote_path: `${targetFolder}/${file.name}`,
- media_type: videoExts.includes(ext) ? 'video' : 'photo',
- created_date: file.modifyTime,
- size: file.size,
- };
- });
-
- return { mediaItems, folder: targetFolder };
- } catch (error) {
- console.error('Get photos error:', error.message);
- return { mediaItems: [], folder: targetFolder };
+ const response = await axios.get(`${this.baseUrl}/api/health`, { timeout: 5000 });
+ return response.data?.status === 'ok';
+ } catch {
+ return false;
}
}
- async uploadFile(localPath, remotePath) {
- if (!this.connectionToken) throw new Error('Backend not connected — cannot upload');
- const formData = new FormData();
- formData.append('remotePath', remotePath);
- formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
-
+ // Full refresh cycle: POST to re-index + fetch updated photo list
+ async refreshPhotos() {
+ if (!this.username) throw new Error('Not connected');
const response = await axios.post(
- `${this.backendUrl}/api/sftp/upload`, formData,
- { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
+ `${this.baseUrl}/api/refresh`,
+ { username: this.username },
+ { timeout: 30000 },
);
return response.data;
}
- async createDirectory(remotePath) {
- if (!this.connectionToken) throw new Error('Backend not connected');
+ // Fetch photo index only (no re-index)
+ async getPhotos() {
+ if (!this.username) throw new Error('Not connected');
const response = await axios.post(
- `${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
- { headers: this.getHeaders(), timeout: 10000 }
+ `${this.baseUrl}/api/photos`,
+ { username: this.username },
+ { timeout: 15000 },
);
return response.data;
}
- async deleteFile(remotePath) {
- if (!this.connectionToken) throw new Error('Backend not connected');
+ // Fetch a single photo as base64 with hash verification
+ async fetchPhoto(photoId) {
const response = await axios.post(
- `${this.backendUrl}/api/sftp/delete`, { path: remotePath },
- { headers: this.getHeaders(), timeout: 10000 }
+ `${this.baseUrl}/api/photo/fetch`,
+ { photoId },
+ { timeout: 60000 },
);
- return response.data;
- }
-
- async disconnect() {
- if (this.connectionToken) {
- try {
- await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
- } catch (_) { /* ignore */ }
+ if (!response.data?.success || !response.data?.photo) {
+ throw new Error('Failed to fetch photo');
}
- this.connectionToken = null;
+ return response.data.photo;
+ }
+
+ // Upload a photo from device to NAS as base64
+ async uploadPhoto(filename, base64Data) {
+ if (!this.username) throw new Error('Not connected');
+ const response = await axios.post(
+ `${this.baseUrl}/api/photo/upload`,
+ { username: this.username, filename, base64: base64Data },
+ { timeout: 120000 },
+ );
+ return response.data;
+ }
+
+ // Delete a photo from NAS
+ async deletePhoto(photoId) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/photo/delete`,
+ { photoId },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ // Re-index only
+ async reIndex() {
+ if (!this.username) throw new Error('Not connected');
+ const response = await axios.post(
+ `${this.baseUrl}/api/index`,
+ { username: this.username },
+ { timeout: 30000 },
+ );
+ return response.data;
+ }
+
+ // ─── Album operations ───
+
+ async getAlbums() {
+ if (!this.username) throw new Error('Not connected');
+ const response = await axios.post(
+ `${this.baseUrl}/api/albums`,
+ { username: this.username },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ async createAlbum(name) {
+ if (!this.username) throw new Error('Not connected');
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/create`,
+ { username: this.username, name },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ async getAlbumPhotos(albumId) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/photos`,
+ { albumId },
+ { timeout: 15000 },
+ );
+ return response.data;
+ }
+
+ async addPhotoToAlbum(albumId, photoId) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/add-photo`,
+ { albumId, photoId },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ async removePhotoFromAlbum(albumId, photoId) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/remove-photo`,
+ { albumId, photoId },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ async deleteAlbum(albumId) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/delete`,
+ { albumId },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ async renameAlbum(albumId, name) {
+ const response = await axios.post(
+ `${this.baseUrl}/api/album/rename`,
+ { albumId, name },
+ { timeout: 10000 },
+ );
+ return response.data;
+ }
+
+ disconnect() {
+ this.baseUrl = null;
+ this.username = null;
this.isConnected = false;
- this.credentials = null;
return { success: true };
}
getConnectionInfo() {
- return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null;
+ if (!this.baseUrl) return null;
+ return { baseUrl: this.baseUrl, username: this.username, isConnected: this.isConnected };
}
}
diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js
index 6b50d31..ada779e 100644
--- a/src/AUTH/NASAUTH.js
+++ b/src/AUTH/NASAUTH.js
@@ -1,33 +1,34 @@
import { AuthServiceBase } from './AuthServiceBase';
import NASAPI from '../API/NASAPI';
-// NAS SFTP authentication — connects to the backend server via NASAPI
+// NAS authentication — connects to the NAS REST server via NASAPI
class NASAUTH extends AuthServiceBase {
constructor() {
super();
this.nasAPI = new NASAPI();
this.host = null;
- this.port = 22;
+ this.port = null;
this.username = null;
- this.password = null;
- this.photoFolder = null;
this.isConnected = false;
}
- async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
+ async authenticate(host, port = 9500, username) {
try {
- if (!host || !username || !password) throw new Error('Host, username, and password are required');
+ if (!host || !username) throw new Error('Host and username are required');
this.host = host;
- this.port = port || 22;
+ this.port = port || 9500;
this.username = username;
- this.password = password;
- this.photoFolder = photoFolder;
- await this.nasAPI.connect(host, this.port, username, password, photoFolder);
+ await this.nasAPI.connect(host, this.port, username);
this.isConnected = true;
- return { success: true, message: `NAS configured: ${host}`, host: this.host, username: this.username };
+ return {
+ success: true,
+ message: `NAS connected: ${host}`,
+ host: this.host,
+ username: this.username,
+ };
} catch (error) {
console.error('NAS authentication error:', error.message);
this.isConnected = false;
@@ -41,23 +42,21 @@ class NASAUTH extends AuthServiceBase {
}
async refreshToken() {
- if (this.host && this.username && this.password) {
- return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder);
+ if (this.host && this.username) {
+ return await this.authenticate(this.host, this.port, this.username);
}
throw new Error('No NAS credentials available for reconnection');
}
async revokeAccess() {
try {
- await this.nasAPI.disconnect();
+ this.nasAPI.disconnect();
} catch (error) {
console.error('Disconnect error:', error.message);
}
this.isConnected = false;
this.host = null;
this.username = null;
- this.password = null;
- this.photoFolder = null;
return { success: true, message: 'NAS connection closed' };
}
@@ -67,35 +66,75 @@ class NASAUTH extends AuthServiceBase {
host: this.host,
username: this.username,
port: this.port,
- photoFolder: this.photoFolder,
connectedAt: new Date().toISOString(),
- serverType: 'NAS SFTP',
+ serverType: 'NAS REST',
};
}
- async listDirectory(remotePath = '/') {
+ // Full refresh: re-index on NAS + return photo list
+ async refreshPhotos() {
if (!this.isConnected) throw new Error('Not connected to NAS');
- return await this.nasAPI.listDirectory(remotePath);
+ return await this.nasAPI.refreshPhotos();
}
- async uploadFile(localPath, remotePath) {
+ // Fetch photo list only
+ async getPhotos() {
if (!this.isConnected) throw new Error('Not connected to NAS');
- return await this.nasAPI.uploadFile(localPath, remotePath);
+ return await this.nasAPI.getPhotos();
}
- async getPhotos(photoFolder = null) {
+ // Fetch single photo as base64 data URI
+ async fetchPhoto(photoId) {
if (!this.isConnected) throw new Error('Not connected to NAS');
- return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
+ return await this.nasAPI.fetchPhoto(photoId);
}
- async createDirectory(remotePath) {
+ // Upload photo
+ async uploadPhoto(filename, base64Data) {
if (!this.isConnected) throw new Error('Not connected to NAS');
- return await this.nasAPI.createDirectory(remotePath);
+ return await this.nasAPI.uploadPhoto(filename, base64Data);
}
- async deleteFile(remotePath) {
+ // Delete photo from NAS
+ async deletePhoto(photoId) {
if (!this.isConnected) throw new Error('Not connected to NAS');
- return await this.nasAPI.deleteFile(remotePath);
+ return await this.nasAPI.deletePhoto(photoId);
+ }
+
+ // Health check
+ async healthCheck() {
+ return await this.nasAPI.healthCheck();
+ }
+
+ // Album operations
+ async getAlbums() {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.getAlbums();
+ }
+
+ async createAlbum(name) {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.createAlbum(name);
+ }
+
+ async getAlbumPhotos(albumId) {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.getAlbumPhotos(albumId);
+ }
+
+ async addPhotoToAlbum(albumId, photoId) {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.addPhotoToAlbum(albumId, photoId);
+ }
+
+ async removePhotoFromAlbum(albumId, photoId) {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.removePhotoFromAlbum(albumId, photoId);
+ }
+
+ async deleteAlbum(albumId) {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ return await this.nasAPI.deleteAlbum(albumId);
}
}
diff --git a/src/UI/EmptyState.js b/src/UI/EmptyState.js
new file mode 100644
index 0000000..815e3f0
--- /dev/null
+++ b/src/UI/EmptyState.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+// Reusable empty-state placeholder shown when a list has no items
+const EmptyState = ({ icon = '📭', title, message, actionLabel, onAction }) => {
+ const { theme } = useTheme();
+
+ return (
+
+ {icon}
+ {title}
+ {message ? (
+ {message}
+ ) : null}
+ {actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, paddingTop: 60 },
+ icon: { fontSize: 56, marginBottom: 16 },
+ title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' },
+ message: { fontSize: 13, textAlign: 'center', lineHeight: 20, marginBottom: 20 },
+ button: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
+ buttonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
+});
+
+export default EmptyState;
+
diff --git a/src/UI/FilterChips.js b/src/UI/FilterChips.js
new file mode 100644
index 0000000..38c346d
--- /dev/null
+++ b/src/UI/FilterChips.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import { ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+// Reusable horizontal filter chip row — used in HomeScreen, AlbumsScreen, etc.
+const FilterChips = ({ filters, selectedKey, onSelect }) => {
+ const { theme } = useTheme();
+
+ return (
+
+ onSelect('all')}
+ >
+
+ All
+
+
+ {filters.map((f) => (
+ onSelect(f.key)}
+ >
+ {f.icon ? {f.icon} : null}
+
+ {f.name}
+
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { maxHeight: 40 },
+ row: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
+ chip: {
+ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
+ borderRadius: 16, gap: 4, height: 28,
+ },
+ chipIcon: { fontSize: 11 },
+ chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
+});
+
+export default FilterChips;
+
diff --git a/src/UI/ScreenHeader.js b/src/UI/ScreenHeader.js
new file mode 100644
index 0000000..3cc6afe
--- /dev/null
+++ b/src/UI/ScreenHeader.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+// Reusable inline header bar with back arrow and title
+const ScreenHeader = ({ title, subtitle, onBack, rightAction }) => {
+ const { theme } = useTheme();
+
+ return (
+
+ {onBack ? (
+
+ ←
+
+ ) : (
+
+ )}
+
+ {title}
+ {subtitle ? {subtitle} : null}
+
+ {rightAction || }
+
+ );
+};
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingTop: 52,
+ paddingBottom: 12,
+ borderBottomWidth: 1,
+ },
+ backButton: { width: 32 },
+ backArrow: { fontSize: 24 },
+ titleWrap: { flex: 1, alignItems: 'center' },
+ title: { fontSize: 18, fontWeight: '700' },
+ subtitle: { fontSize: 12, marginTop: 2 },
+});
+
+export default ScreenHeader;
+
diff --git a/src/UI/ServiceHelpers.js b/src/UI/ServiceHelpers.js
new file mode 100644
index 0000000..a1d00a1
--- /dev/null
+++ b/src/UI/ServiceHelpers.js
@@ -0,0 +1,13 @@
+// Shared service helpers — avoids duplicating icon/name maps across components and screens
+
+export const getServiceIcon = (type) =>
+ ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
+
+export const getServiceName = (type) =>
+ ({
+ DEVICE: 'On Device',
+ GOOGLE_PHOTOS: 'Google Photos',
+ ICLOUD_PHOTOS: 'iCloud Photos',
+ NAS: 'NAS Storage',
+ }[type] || type);
+
diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js
index 8575906..b081873 100644
--- a/src/components/MediaViewer.js
+++ b/src/components/MediaViewer.js
@@ -4,26 +4,25 @@ import {
Dimensions, FlatList,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
+import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const THUMB_SIZE = 60;
const THUMB_GAP = 4;
-const getServiceIcon = (type) =>
- ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
-const getServiceName = (type) =>
- ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
-
/**
- * Fullscreen media viewer with thumbnail carousel.
+ * Fullscreen media viewer with thumbnail carousel and action toolbar.
*
* Props:
- * visible – boolean to show/hide the modal
- * media – currently selected media item
- * allMedia – (optional) full array of media items for carousel navigation
- * onClose – callback when user dismisses the viewer
+ * visible – boolean to show/hide the modal
+ * media – currently selected media item
+ * allMedia – (optional) full array of media items for carousel navigation
+ * onClose – callback when user dismisses the viewer
+ * onDelete – (optional) callback(item) to delete the current item
+ * onAddToAlbum – (optional) callback(item) to add current item to an album
+ * onShare – (optional) callback(item) to share current item
*/
-const MediaViewer = ({ visible, media, allMedia, onClose }) => {
+const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum, onShare }) => {
const { theme } = useTheme();
const mainListRef = useRef(null);
const thumbListRef = useRef(null);
@@ -185,6 +184,28 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
{/* Info panel */}
+ {/* Action toolbar */}
+
+ {onShare && (
+ onShare(displayMedia)}>
+ 📤
+ Share
+
+ )}
+ {onAddToAlbum && (
+ onAddToAlbum(displayMedia)}>
+ 📁
+ Album
+
+ )}
+ {onDelete && (
+ onDelete(displayMedia)}>
+ 🗑️
+ Delete
+
+ )}
+
+
{displayMedia.title || 'Photo'}
Source
@@ -211,6 +232,24 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
)}
+ {displayMedia.sha256Hash && (
+
+ SHA-256
+
+ {displayMedia.sha256Hash.substring(0, 24)}…
+
+
+ )}
+ {displayMedia.fileSize > 0 && (
+
+ Size
+
+ {displayMedia.fileSize > 1048576
+ ? (displayMedia.fileSize / 1048576).toFixed(1) + ' MB'
+ : (displayMedia.fileSize / 1024).toFixed(1) + ' KB'}
+
+
+ )}
@@ -255,6 +294,13 @@ const styles = StyleSheet.create({
padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16,
},
+ actionBar: {
+ flexDirection: 'row', justifyContent: 'space-around', marginBottom: 12,
+ paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.15)',
+ },
+ actionBtn: { alignItems: 'center', paddingHorizontal: 16 },
+ actionEmoji: { fontSize: 20, marginBottom: 4 },
+ actionText: { color: '#e8eaed', fontSize: 11, fontWeight: '500' },
title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
label: { fontSize: 12, color: '#9aa0a6' },
diff --git a/src/components/NASSetupInstructions.js b/src/components/NASSetupInstructions.js
index f962779..43eeecd 100644
--- a/src/components/NASSetupInstructions.js
+++ b/src/components/NASSetupInstructions.js
@@ -25,51 +25,41 @@ const NASSetupInstructions = () => {
Prerequisites
- • An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'}
- • A user account with sudo access on the server{'\n'}
- • The server's IP address and SSH port (default: 22)
+ • A Windows or Ubuntu machine to run the NAS server{'\n'}
+ • Node.js installed on the NAS machine{'\n'}
+ • The NAS machine's IP address on your local network
- Step 1: Enable SSH
+ Step 1: Copy the ServerFiles folder
- If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'}
- sudo apt update && sudo apt install openssh-server{'\n'}
- sudo systemctl enable ssh && sudo systemctl start ssh
+ The ServerFiles folder is self-contained. Copy it to any machine you want to use as a NAS.
- Step 2: Create a Photos Directory
+ Step 2: Run the CLI client
- Create a dedicated folder for your photo storage:{'\n\n'}
- mkdir -p ~/photos{'\n'}
- chmod 755 ~/photos
+ Open a terminal in the ServerFiles folder and run:{'\n\n'}
+ node cli.js{'\n\n'}
+ The CLI will ask whether you are on Windows or Linux (Ubuntu), set a photos storage path, and optionally store a password.
- Step 3: Configure Firewall (if needed)
+ Step 3: Start the server
- If your server uses UFW, allow SSH connections:{'\n\n'}
- sudo ufw allow ssh{'\n'}
- sudo ufw enable
+ From the CLI menu, select "Start NAS Server". The server starts on port 9500 by default.{'\n\n'}
+ Alternatively run: node server.js
- Step 4: Find Your Server IP
+ Step 4: Connect from the App
- Run this on your server to find its local IP:{'\n\n'}
- hostname -I{'\n\n'}
- Use that IP address in the "Server Address" field above.
+ Enter the NAS machine IP, port (default 9500), and your username.{'\n\n'}
+ No password or photo folder path is needed — the NAS manages storage location and user folders automatically.
- Step 5: Connect from the App
+ How it works
- Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'}
- The app's backend server will handle the SFTP connection securely.
-
-
- Step 6: Run the Backend Server
-
- The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'}
- npm install{'\n'}
- node server.js{'\n\n'}
- Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001).
+ • Your username is sent to the NAS, which creates a folder for you{'\n'}
+ • The NAS indexes all photos in your folder and stores SHA-256 hashes{'\n'}
+ • Photos are sent to the app as base64-encoded data with integrity hashes{'\n'}
+ • Pull-to-refresh triggers a full re-index on the NAS
)}
@@ -98,4 +88,3 @@ const styles = StyleSheet.create({
});
export default NASSetupInstructions;
-
diff --git a/src/components/PhotoActionSheet.js b/src/components/PhotoActionSheet.js
new file mode 100644
index 0000000..f2458eb
--- /dev/null
+++ b/src/components/PhotoActionSheet.js
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+/**
+ * Action sheet overlay for photo operations (delete, add to album, info, etc.)
+ *
+ * Props:
+ * visible – boolean
+ * onClose – dismiss callback
+ * actions – array of { label, icon, onPress, destructive? }
+ */
+const PhotoActionSheet = ({ visible, onClose, actions = [] }) => {
+ const { theme } = useTheme();
+
+ return (
+
+
+
+
+ {actions.map((action, i) => (
+ { onClose(); action.onPress?.(); }}
+ activeOpacity={0.6}
+ >
+ {action.icon ? {action.icon} : null}
+
+ {action.label}
+
+
+ ))}
+
+ Cancel
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ },
+ sheet: {
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ paddingBottom: 30,
+ paddingTop: 8,
+ },
+ handle: {
+ width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginBottom: 8,
+ },
+ actionRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ gap: 14,
+ },
+ actionIcon: { fontSize: 20 },
+ actionLabel: { fontSize: 16, fontWeight: '500' },
+ cancelRow: {
+ alignItems: 'center',
+ paddingVertical: 14,
+ marginHorizontal: 16,
+ marginTop: 8,
+ borderRadius: 12,
+ },
+ cancelText: { fontSize: 16, fontWeight: '600' },
+});
+
+export default PhotoActionSheet;
+
diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js
index c7b252e..4f535ee 100644
--- a/src/components/PhotoGrid.js
+++ b/src/components/PhotoGrid.js
@@ -3,6 +3,7 @@ import {
View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
+import { getServiceIcon } from '../UI/ServiceHelpers';
const { width } = Dimensions.get('window');
const GRID_GAP = 2;
@@ -10,6 +11,7 @@ const GRID_GAP = 2;
const PhotoGrid = ({
data,
onItemPress,
+ onItemLongPress,
columns = 3,
refreshControl,
ListHeaderComponent,
@@ -20,8 +22,6 @@ const PhotoGrid = ({
const { theme } = useTheme();
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
- const getServiceIcon = (type) =>
- ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
@@ -51,6 +51,7 @@ const PhotoGrid = ({
onItemPress?.(item)}
+ onLongPress={() => onItemLongPress?.(item)}
activeOpacity={0.8}
>
{item.local_path ? (
diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js
index 811b1fa..47f2c6a 100644
--- a/src/screens/AlbumsScreen.js
+++ b/src/screens/AlbumsScreen.js
@@ -1,12 +1,17 @@
import React, { useState, useCallback } from 'react';
import {
StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity,
- Dimensions, Image, ActivityIndicator, RefreshControl,
+ Dimensions, Image, ActivityIndicator, RefreshControl, Alert, TextInput, Modal,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
+import NASAUTH from '../AUTH/NASAUTH';
import MediaViewer from '../components/MediaViewer';
+import PhotoActionSheet from '../components/PhotoActionSheet';
+import ScreenHeader from '../UI/ScreenHeader';
+import EmptyState from '../UI/EmptyState';
+import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
const { width } = Dimensions.get('window');
const ALBUM_COLUMNS = 2;
@@ -24,14 +29,30 @@ const CollectionsScreen = ({ route }) => {
const [selectedMedia, setSelectedMedia] = useState(null);
const [viewerVisible, setViewerVisible] = useState(false);
+ // Create album modal
+ const [createModalVisible, setCreateModalVisible] = useState(false);
+ const [newAlbumName, setNewAlbumName] = useState('');
+
+
+ // Action sheet
+ const [actionSheetVisible, setActionSheetVisible] = useState(false);
+ const [actionTarget, setActionTarget] = useState(null);
+
useFocusEffect(
useCallback(() => {
if (userId) loadCollections();
}, [userId])
);
- const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
- const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
+ const getNasAuth = async () => {
+ const accounts = await DatabaseService.getLinkedAccounts(userId);
+ const nasAccount = accounts.find((a) => a.service_type === 'NAS');
+ if (!nasAccount) return null;
+ const auth = new NASAUTH();
+ const d = nasAccount.account_data;
+ await auth.authenticate(d.host, d.port, d.username);
+ return auth;
+ };
const loadCollections = async () => {
try {
@@ -39,7 +60,7 @@ const CollectionsScreen = ({ route }) => {
const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = [];
- // ─── Utility albums (top) ───
+ // ─── Utility albums ───
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
@@ -68,6 +89,36 @@ const CollectionsScreen = ({ route }) => {
});
}
+ // ─── NAS Albums (from NAS server) ───
+ const nasAccount = accounts.find((a) => a.service_type === 'NAS');
+ if (nasAccount) {
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ const result = await auth.getAlbums();
+ if (result?.albums?.length) {
+ const nasAlbumEntries = result.albums.map((a) => ({
+ name: a.name,
+ icon: '📁',
+ count: a.photoCount || 0,
+ coverImage: null,
+ media: [],
+ type: 'NAS_ALBUM',
+ nasAlbumId: a.id,
+ }));
+ sectionList.push({
+ title: 'NAS Albums',
+ icon: '💾',
+ data: [{ albums: nasAlbumEntries }],
+ });
+ }
+ await auth.revokeAccess();
+ }
+ } catch (err) {
+ console.error('NAS album fetch error:', err);
+ }
+ }
+
setSections(sectionList);
} catch (err) {
console.error('Error loading collections:', err);
@@ -77,27 +128,171 @@ const CollectionsScreen = ({ route }) => {
}
};
- // Album detail view
+ const openAlbum = async (album) => {
+ setSelectedAlbum(album);
+
+ if (album.type === 'NAS_ALBUM' && album.nasAlbumId) {
+ // Fetch NAS album photos
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ const result = await auth.getAlbumPhotos(album.nasAlbumId);
+ const items = (result?.photos || []).map((p) => ({
+ source_service: 'NAS',
+ source_id: `nas_${p.id}`,
+ title: p.filename,
+ media_type: 'photo',
+ sha256Hash: p.sha256Hash,
+ fileSize: p.fileSize,
+ nas_photo_id: p.id,
+ local_path: null,
+ }));
+ setAlbumMedia(items);
+ await auth.revokeAccess();
+ }
+ } catch (err) {
+ console.error('NAS album photos error:', err);
+ setAlbumMedia([]);
+ }
+ } else {
+ setAlbumMedia(album.media || []);
+ }
+ };
+
+ // ─── Create NAS Album ───
+ const handleCreateAlbum = async () => {
+ if (!newAlbumName.trim()) {
+ Alert.alert('Error', 'Please enter an album name');
+ return;
+ }
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ const result = await auth.createAlbum(newAlbumName.trim());
+ await auth.revokeAccess();
+ if (result?.success) {
+ setCreateModalVisible(false);
+ setNewAlbumName('');
+ loadCollections();
+ Alert.alert('Created', `Album "${newAlbumName.trim()}" created on NAS`);
+ } else {
+ Alert.alert('Error', result?.error || 'Failed to create album');
+ }
+ }
+ } catch (err) {
+ Alert.alert('Error', err.message);
+ }
+ };
+
+ // ─── Delete NAS Album ───
+ const handleDeleteAlbum = (album) => {
+ if (!album.nasAlbumId) return;
+ Alert.alert('Delete Album', `Delete "${album.name}"? Photos will not be deleted.`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete', style: 'destructive',
+ onPress: async () => {
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ await auth.deleteAlbum(album.nasAlbumId);
+ await auth.revokeAccess();
+ setSelectedAlbum(null);
+ loadCollections();
+ }
+ } catch (err) {
+ Alert.alert('Error', err.message);
+ }
+ },
+ },
+ ]);
+ };
+
+ // ─── Remove photo from NAS album ───
+ const handleRemoveFromAlbum = (item) => {
+ if (!selectedAlbum?.nasAlbumId || !item.nas_photo_id) return;
+ Alert.alert('Remove from Album', `Remove "${item.title}" from this album?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove', style: 'destructive',
+ onPress: async () => {
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ await auth.removePhotoFromAlbum(selectedAlbum.nasAlbumId, item.nas_photo_id);
+ await auth.revokeAccess();
+ setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id));
+ }
+ } catch (err) {
+ Alert.alert('Error', err.message);
+ }
+ },
+ },
+ ]);
+ };
+
+ // ─── Delete photo from NAS entirely ───
+ const handleDeletePhoto = (item) => {
+ if (!item.nas_photo_id) return;
+ Alert.alert('Delete Photo', `Permanently delete "${item.title}" from NAS?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete', style: 'destructive',
+ onPress: async () => {
+ try {
+ const auth = await getNasAuth();
+ if (auth) {
+ await auth.nasAPI.deletePhoto(item.nas_photo_id);
+ await auth.revokeAccess();
+ setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id));
+ setViewerVisible(false);
+ }
+ } catch (err) {
+ Alert.alert('Error', err.message);
+ }
+ },
+ },
+ ]);
+ };
+
+ const handlePhotoLongPress = (item) => {
+ setActionTarget(item);
+ setActionSheetVisible(true);
+ };
+
+ const getPhotoActions = () => {
+ if (!actionTarget) return [];
+ const acts = [
+ { label: 'View', icon: '👁️', onPress: () => { setSelectedMedia(actionTarget); setViewerVisible(true); } },
+ ];
+ if (selectedAlbum?.nasAlbumId && actionTarget.nas_photo_id) {
+ acts.push({ label: 'Remove from Album', icon: '📤', onPress: () => handleRemoveFromAlbum(actionTarget) });
+ }
+ if (actionTarget.nas_photo_id) {
+ acts.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionTarget) });
+ }
+ return acts;
+ };
+
+ // ─── Album detail view ───
if (selectedAlbum) {
const gridItemSize = (width - 6) / 3;
+ const isNasAlbum = !!selectedAlbum.nasAlbumId;
+
return (
-
- { setSelectedAlbum(null); setAlbumMedia([]); }}>
- ←
-
-
- {selectedAlbum.name}
-
- {selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''}
-
-
-
+ { setSelectedAlbum(null); setAlbumMedia([]); }}
+ rightAction={isNasAlbum ? (
+ handleDeleteAlbum(selectedAlbum)}>
+ Delete
+
+ ) : undefined}
+ />
{albumMedia.length === 0 ? (
-
- 📭
- No items in this collection
-
+
) : (
{
{ setSelectedMedia(item); setViewerVisible(true); }}
+ onLongPress={() => handlePhotoLongPress(item)}
activeOpacity={0.8}
>
{item.local_path ? (
@@ -116,16 +312,28 @@ const CollectionsScreen = ({ route }) => {
)}
)}
- keyExtractor={(item, i) => item.id?.toString() || i.toString()}
+ keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()}
numColumns={3}
contentContainerStyle={styles.gridContent}
/>
)}
- setViewerVisible(false)} />
+ setViewerVisible(false)}
+ onDelete={handleDeletePhoto}
+ />
+ setActionSheetVisible(false)}
+ actions={getPhotoActions()}
+ />
);
}
+ // ─── Loading ───
if (loading) {
return (
@@ -135,13 +343,15 @@ const CollectionsScreen = ({ route }) => {
);
}
+ // ─── Album card renderer ───
const renderAlbumRow = ({ item }) => (
{item.albums.map((album, idx) => (
{ setSelectedAlbum(album); setAlbumMedia(album.media); }}
+ onPress={() => openAlbum(album)}
+ onLongPress={() => album.nasAlbumId && handleDeleteAlbum(album)}
activeOpacity={0.8}
>
@@ -172,20 +382,28 @@ const CollectionsScreen = ({ route }) => {
);
};
+ const hasNas = sections.some((s) => s.title === 'NAS Storage' || s.title === 'NAS Albums');
+
return (
Collections
+ {hasNas && (
+ setCreateModalVisible(true)}
+ >
+ + Album
+
+ )}
{sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
-
- 📁
- No Collections
-
- Collections from your connected services will appear here
-
-
+
) : (
{
}
/>
)}
+
+ {/* Create NAS Album Modal */}
+ setCreateModalVisible(false)}>
+ setCreateModalVisible(false)}>
+
+ Create NAS Album
+
+
+ setCreateModalVisible(false)}>
+ Cancel
+
+
+ Create
+
+
+
+
+
);
};
@@ -206,42 +449,35 @@ const CollectionsScreen = ({ route }) => {
const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
- header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
+ header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700' },
+ createBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 16 },
+ createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
- // Section headers
- sectionHeader: {
- flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
- },
+ sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8 },
sectionIcon: { fontSize: 20 },
sectionTitle: { fontSize: 16, fontWeight: '700' },
- // Album grid
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: {},
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
albumCoverImage: { width: '100%', height: '100%' },
- albumCoverPlaceholder: {
- width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center',
- },
+ albumCoverPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// Album detail
- albumHeader: {
- flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
- paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
- },
- backArrow: { fontSize: 24 },
- albumHeaderInfo: { flex: 1 },
- albumHeaderTitle: { fontSize: 20, fontWeight: '700' },
- albumHeaderCount: { fontSize: 12 },
gridContent: { paddingBottom: 20 },
gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
- // Empty / Loading
loadingText: { marginTop: 12, fontSize: 14 },
- emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
- emptyText: { fontSize: 13, textAlign: 'center' },
+ // Create album modal
+ modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.5)' },
+ modalCard: { width: '80%', borderRadius: 16, padding: 20 },
+ modalTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 },
+ modalInput: { borderWidth: 1, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16 },
+ modalActions: { flexDirection: 'row', gap: 10 },
+ modalBtn: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1, borderColor: 'transparent' },
+ modalBtnText: { fontSize: 14, fontWeight: '600' },
});
export default CollectionsScreen;
diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js
index a615533..8d80951 100644
--- a/src/screens/HomeScreen.js
+++ b/src/screens/HomeScreen.js
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
- StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
+ StyleSheet, View, Text, ActivityIndicator, RefreshControl, Alert,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
@@ -12,6 +12,10 @@ import { File } from 'expo-file-system';
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
+import PhotoActionSheet from '../components/PhotoActionSheet';
+import FilterChips from '../UI/FilterChips';
+import EmptyState from '../UI/EmptyState';
+import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers';
import { logError } from '../services/ErrorHandler';
import { TouchableOpacity } from 'react-native';
@@ -19,11 +23,8 @@ 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 = [];
@@ -36,15 +37,12 @@ const mergeMediaAcrossServices = (items) => {
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;
}
@@ -53,7 +51,6 @@ const mergeMediaAcrossServices = (items) => {
}
}
- // 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();
@@ -89,6 +86,9 @@ const HomeScreen = ({ navigation, route }) => {
const [googleSessionId, setGoogleSessionId] = useState(null);
const [loadingMore, setLoadingMore] = useState(false);
const [importing, setImporting] = useState(false);
+ // Action sheet state
+ const [actionSheetVisible, setActionSheetVisible] = useState(false);
+ const [actionSheetTarget, setActionSheetTarget] = useState(null);
useFocusEffect(
useCallback(() => {
@@ -118,7 +118,7 @@ const HomeScreen = ({ navigation, route }) => {
try {
const newMedia = [];
- // ─── Device photos (local camera roll, screenshots, etc.) ───
+ // ─── Device photos ───
try {
const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) {
@@ -130,18 +130,15 @@ const HomeScreen = ({ navigation, route }) => {
console.error('Device media fetch error:', err);
}
- // ─── Previously imported Google Photos (stored in DB) ───
+ // ─── Previously imported Google Photos ───
try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) {
- // Validate cached files still exist — clear stale paths
const validated = googleMedia.map((item) => {
if (item.local_path) {
try {
const f = new File(item.local_path);
- if (!f.exists || f.size === 0) {
- return { ...item, local_path: null };
- }
+ if (!f.exists || f.size === 0) return { ...item, local_path: null };
} catch (_) {
return { ...item, local_path: null };
}
@@ -154,15 +151,29 @@ const HomeScreen = ({ navigation, route }) => {
console.error('DB Google media fetch error:', err);
}
- // ─── NAS fetch ───
+ // ─── NAS fetch (REST refresh cycle) ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) {
try {
- const nasAuth = new NASAUTH();
const nasData = nasAccount.account_data;
- await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder);
- const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
- if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems);
+ const nasAuth = new NASAUTH();
+ await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
+ const result = await nasAuth.refreshPhotos();
+ if (result?.photos?.length) {
+ const nasItems = result.photos.map((p) => ({
+ source_service: 'NAS',
+ source_id: `nas_${p.id}`,
+ title: p.filename,
+ media_type: p.mimeType?.startsWith('image/') ? 'photo' : 'video',
+ created_date: p.indexedAt,
+ size: p.fileSize,
+ sha256Hash: p.sha256Hash,
+ nas_photo_id: p.id,
+ // No local_path — NAS photos are fetched on-demand as base64
+ local_path: null,
+ }));
+ newMedia.push(...nasItems);
+ }
await nasAuth.revokeAccess();
} catch (err) {
console.error('NAS fetch error:', err);
@@ -175,9 +186,6 @@ const HomeScreen = ({ navigation, route }) => {
console.log('iCloud: placeholder — would fetch photos here');
}
- // ─── 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) {
@@ -188,7 +196,7 @@ const HomeScreen = ({ navigation, route }) => {
}
};
- // ─── Import from Google Photos (opens picker in browser) ───
+ // ─── Import from Google Photos ───
const importGooglePhotos = async () => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (!googleAccount) {
@@ -206,7 +214,6 @@ const HomeScreen = ({ navigation, route }) => {
let result = await googleAuth.pickPhotos();
- // If 403 (stale scopes), auto re-auth and retry
if (result?.error?.code === 403) {
console.log('Stale token — re-authenticating…');
await googleAuth.revokeAccess();
@@ -223,21 +230,15 @@ const HomeScreen = ({ navigation, route }) => {
if (result?.error) {
logError('Import Google Photos', result.error.message);
} else if (result?.mediaItems?.length) {
- // Save imported items to DB so they persist across app restarts
for (const item of result.mediaItems) {
await DatabaseService.saveMediaItem(userId, item);
}
-
setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null);
-
- // Merge into current view with cross-service matching
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
-
console.log(`Imported ${result.mediaItems.length} photos from Google`);
}
- // Persist refreshed tokens
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: googleAuth.accessToken,
@@ -256,7 +257,6 @@ const HomeScreen = ({ navigation, route }) => {
const filterMedia = () => {
let filtered = allMedia;
if (selectedFilter !== 'all') {
- // 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))
@@ -314,11 +314,93 @@ const HomeScreen = ({ navigation, route }) => {
}
};
- const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
- const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
+ // ─── Photo actions ───
+
+ const handleDeletePhoto = (item) => {
+ const sources = item.sources || [item.source_service];
+ const actions = [];
+
+ if (sources.includes('DEVICE')) {
+ actions.push('your device');
+ }
+ if (sources.includes('NAS') || item.nas_photo_id) {
+ actions.push('the NAS');
+ }
+ if (sources.includes('GOOGLE_PHOTOS')) {
+ actions.push('Google Photos (DB record)');
+ }
+
+ Alert.alert(
+ 'Delete Photo',
+ `Delete "${item.title || 'this photo'}" from ${actions.join(' and ')}?`,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ // Delete from NAS if applicable
+ if (item.nas_photo_id) {
+ const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
+ if (nasAccount) {
+ const nasAuth = new NASAUTH();
+ const nasData = nasAccount.account_data;
+ await nasAuth.authenticate(nasData.host, nasData.port, nasData.username);
+ await nasAuth.deletePhoto(item.nas_photo_id);
+ await nasAuth.revokeAccess();
+ }
+ }
+ // Remove from local DB
+ if (item.id && typeof item.id === 'number') {
+ await DatabaseService.deleteMediaItem?.(item.id);
+ }
+ // Remove from state
+ setAllMedia((prev) => prev.filter((m) =>
+ (m.source_id || m.id) !== (item.source_id || item.id)
+ ));
+ setViewerVisible(false);
+ Alert.alert('Deleted', 'Photo has been removed.');
+ } catch (err) {
+ Alert.alert('Error', 'Failed to delete: ' + err.message);
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ const handleLongPress = (item) => {
+ setActionSheetTarget(item);
+ setActionSheetVisible(true);
+ };
+
+ const getActionSheetActions = () => {
+ if (!actionSheetTarget) return [];
+ const actions = [
+ { label: 'View Details', icon: 'ℹ️', onPress: () => { setSelectedMedia(actionSheetTarget); setViewerVisible(true); } },
+ ];
+ if (actionSheetTarget.nas_photo_id || actionSheetTarget.source_service === 'NAS') {
+ actions.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) });
+ }
+ if (actionSheetTarget.source_service === 'DEVICE') {
+ actions.push({ label: 'Delete from Device', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) });
+ }
+ return actions;
+ };
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
+ // Build filter list
+ const sourceFilters = [
+ { key: 'DEVICE', icon: getServiceIcon('DEVICE'), name: getServiceName('DEVICE') },
+ ...linkedAccounts.map((acc) => ({
+ key: acc.service_type,
+ icon: getServiceIcon(acc.service_type),
+ name: getServiceName(acc.service_type),
+ })),
+ ];
+
if (loading && allMedia.length === 0) {
return (
@@ -328,39 +410,6 @@ const HomeScreen = ({ navigation, route }) => {
);
}
- // Build the list of available source filters
- const sourceFilters = [
- { key: 'DEVICE', icon: '📱', name: 'On Device' },
- ...linkedAccounts.map((acc) => ({
- key: acc.service_type,
- icon: getServiceIcon(acc.service_type),
- name: getServiceName(acc.service_type),
- })),
- ];
-
- const FilterChips = () => (
-
- setSelectedFilter('all')}
- >
- All
-
- {sourceFilters.map((f) => (
- setSelectedFilter(f.key)}
- >
- {f.icon}
-
- {f.name}
-
-
- ))}
-
- );
-
return (
{
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
-
+
{/* Import from Google Photos button */}
{hasGoogleAccount && (
@@ -392,25 +441,22 @@ const HomeScreen = ({ navigation, route }) => {
)}
{filteredMedia.length === 0 ? (
-
- 📸
- No Photos Yet
-
- {hasGoogleAccount
+
- { setRefreshing(true); loadAllMedia(); }}
- >
- Refresh
-
-
+ : 'Grant media library permission or take some photos to get started'
+ }
+ actionLabel="Refresh"
+ onAction={() => { setRefreshing(true); loadAllMedia(); }}
+ />
) : (
{ setSelectedMedia(item); setViewerVisible(true); }}
+ onItemLongPress={handleLongPress}
onEndReached={loadMorePhotos}
refreshControl={
{ setRefreshing(true); loadAllMedia(); }} />
@@ -428,6 +474,13 @@ const HomeScreen = ({ navigation, route }) => {
media={selectedMedia}
allMedia={filteredMedia}
onClose={() => setViewerVisible(false)}
+ onDelete={handleDeletePhoto}
+ />
+
+ setActionSheetVisible(false)}
+ actions={getActionSheetActions()}
/>
);
@@ -437,16 +490,6 @@ const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
loadingText: { marginTop: 12, fontSize: 14 },
- // Filter chips
- filterContainer: { maxHeight: 40 },
- filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
- chip: {
- flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
- borderRadius: 16, gap: 4, height: 28,
- },
- chipIcon: { fontSize: 11 },
- chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
- // Import button
importButton: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
@@ -454,11 +497,6 @@ const styles = StyleSheet.create({
},
importIcon: { fontSize: 16 },
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
- // Empty
- emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
- emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
- retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
- retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
});
export default HomeScreen;
diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js
index d34fde8..747fd4e 100644
--- a/src/screens/OnboardingScreen.js
+++ b/src/screens/OnboardingScreen.js
@@ -33,10 +33,8 @@ const OnboardingScreen = ({ navigation, route }) => {
// NAS config
const [nasHost, setNasHost] = useState('');
- const [nasPort, setNasPort] = useState('22');
+ const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
- const [nasPassword, setNasPassword] = useState('');
- const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Auto-backup checklist (multi-select)
const [autoBackupServices, setAutoBackupServices] = useState([]);
@@ -102,8 +100,8 @@ const OnboardingScreen = ({ navigation, route }) => {
};
const handleNasAuth = async () => {
- if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
- Alert.alert('Error', 'Please fill in all NAS connection fields');
+ if (!nasHost.trim() || !nasUsername.trim()) {
+ Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
try {
@@ -111,14 +109,13 @@ const OnboardingScreen = ({ navigation, route }) => {
setNasHealth('checking');
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
- nasHost.trim(), parseInt(nasPort) || 22,
- nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
+ nasHost.trim(), parseInt(nasPort) || 9500,
+ nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
- port: parseInt(nasPort) || 22,
+ port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
- photoFolder: nasPhotoFolder.trim(),
});
setNasConnected(true);
setNasHealth('healthy');
@@ -189,7 +186,7 @@ const OnboardingScreen = ({ navigation, route }) => {
description="Access your iCloud photo library (placeholder)"
enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} />
setNasEnabled(!nasEnabled)} />
{/* NAS setup instructions dropdown */}
@@ -254,19 +251,11 @@ const OnboardingScreen = ({ navigation, route }) => {
Port
+ placeholder="9500" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} />
Username
-
- Password
-
-
- Photos Folder
-
+ placeholder="Your username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} />
{
const userId = route?.params?.userId;
@@ -25,10 +26,8 @@ const SettingsScreen = ({ navigation, route }) => {
// NAS edit fields
const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState('');
- const [nasPort, setNasPort] = useState('22');
+ const [nasPort, setNasPort] = useState('9500');
const [nasUsername, setNasUsername] = useState('');
- const [nasPassword, setNasPassword] = useState('');
- const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
useFocusEffect(
useCallback(() => {
@@ -46,9 +45,8 @@ const SettingsScreen = ({ navigation, route }) => {
const nasAcc = allAccounts.find((a) => a.service_type === 'NAS');
if (nasAcc?.account_data) {
setNasHost(nasAcc.account_data.host || '');
- setNasPort(String(nasAcc.account_data.port || 22));
+ setNasPort(String(nasAcc.account_data.port || 9500));
setNasUsername(nasAcc.account_data.username || '');
- setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
}
// Check health for each enabled account
@@ -88,9 +86,10 @@ const SettingsScreen = ({ navigation, route }) => {
if (acc.service_type === 'NAS') {
const nasAuth = new NASAUTH();
const d = acc.account_data || {};
- await nasAuth.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
+ await nasAuth.authenticate(d.host, d.port, d.username);
+ const healthy = await nasAuth.healthCheck();
await nasAuth.revokeAccess();
- return 'healthy';
+ return healthy ? 'healthy' : 'error';
}
if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
@@ -191,22 +190,21 @@ const SettingsScreen = ({ navigation, route }) => {
};
const saveNasConfig = async () => {
- if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) {
- Alert.alert('Error', 'Please fill in all NAS fields');
+ if (!nasHost.trim() || !nasUsername.trim()) {
+ Alert.alert('Error', 'Please fill in the NAS host and username');
return;
}
setLoading(true);
try {
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
- nasHost.trim(), parseInt(nasPort) || 22,
- nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
+ nasHost.trim(), parseInt(nasPort) || 9500,
+ nasUsername.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
- port: parseInt(nasPort) || 22,
+ port: parseInt(nasPort) || 9500,
username: nasUsername.trim(),
- photoFolder: nasPhotoFolder.trim(),
});
setEditingNas(false);
await loadData();
@@ -225,11 +223,6 @@ const SettingsScreen = ({ navigation, route }) => {
]);
};
- const getServiceIcon = (type) =>
- ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
- const getServiceName = (type) =>
- ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
-
const themeOptions = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
{ key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' },
@@ -348,16 +341,10 @@ const SettingsScreen = ({ navigation, route }) => {
value={nasHost} onChangeText={setNasHost} placeholder="192.168.1.100" placeholderTextColor={theme.textTertiary} />
Port
+ value={nasPort} onChangeText={setNasPort} keyboardType="numeric" placeholder="9500" placeholderTextColor={theme.textTertiary} />
Username
- Password
-
- Photos Folder
-
+ value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholder="Your username" placeholderTextColor={theme.textTertiary} />
{loading ? : Save Configuration}
diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js
index 8655ce0..16e47b2 100644
--- a/src/services/DatabaseService.js
+++ b/src/services/DatabaseService.js
@@ -384,6 +384,17 @@ const DatabaseService = {
}
},
+ deleteMediaItem: async (mediaId) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return false;
+ await db.runAsync('DELETE FROM media WHERE id = ?', [mediaId]);
+ return true;
+ } catch (error) {
+ logError('Delete Media Item', error);
+ return false;
+ }
+ },
+
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => {