diff --git a/.gitignore b/.gitignore
index c4961db..45a0ee8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@
*.tmp
.env
!.env.example.txt
+ServerFiles/data/
diff --git a/App.js b/App.js
index c9e9006..e8b05cf 100644
--- a/App.js
+++ b/App.js
@@ -4,6 +4,7 @@ import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, Text, TouchableOpacity } from 'react-native';
+import { registerRootComponent } from 'expo';
import * as WebBrowser from 'expo-web-browser';
import { ThemeProvider, useTheme } from './src/services/ThemeContext';
import DatabaseService from './src/services/DatabaseService';
@@ -132,10 +133,14 @@ function AppInner() {
);
}
-export default function App() {
+function App() {
return (
);
}
+
+registerRootComponent(App);
+
+export default App;
diff --git a/ServerFiles/package.json b/ServerFiles/package.json
index a061fb1..e520185 100644
--- a/ServerFiles/package.json
+++ b/ServerFiles/package.json
@@ -1,7 +1,7 @@
{
- "name": "media-app-sftp-backend",
- "version": "1.0.0",
- "description": "SFTP Backend API for Media Aggregation App",
+ "name": "media-app-nas-backend",
+ "version": "2.0.0",
+ "description": "NAS REST backend (CRUDL vault, base64 storage) for Media Aggregation App",
"main": "server.js",
"scripts": {
"start": "node server.js",
@@ -9,23 +9,21 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
- "sftp",
- "backend",
- "api",
+ "nas",
+ "rest",
+ "crud",
"express"
],
"author": "",
"license": "MIT",
"dependencies": {
- "body-parser": "^2.2.2",
"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"
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1",
+ "jsonwebtoken": "^9.0.3",
+ "node-fetch": "^3.3.2"
},
"devDependencies": {
- "nodemon": "^3.0.1"
+ "nodemon": "^3.1.14"
}
}
diff --git a/ServerFiles/server.js b/ServerFiles/server.js
index 3cda39c..4f929a7 100644
--- a/ServerFiles/server.js
+++ b/ServerFiles/server.js
@@ -2,69 +2,15 @@ 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 nasVaultStore = require('./nasVaultStore');
-const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
-
-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 (_) {}
-
-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);
- }
-}
+const fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args));
const app = express();
-app.use(express.json());
-app.use(express.urlencoded({ extended: true }));
-app.use(fileUpload());
+app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '150mb' }));
+app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '150mb' }));
app.use(cors());
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
@@ -75,16 +21,18 @@ app.use((req, res, next) => {
next();
});
-function verifyToken(req, res, next) {
+function verifyNasSession(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);
+ const conn = connections.get(decoded.connectionId);
+ if (!conn) return res.status(401).json({ error: 'Session expired or not found' });
req.connectionId = decoded.connectionId;
- req.connection = connections.get(decoded.connectionId);
- if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
+ req.connection = conn;
+ req.vaultKey = conn.vaultKey;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token: ' + err.message });
@@ -95,141 +43,103 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
- activeConnections: connections.size,
- defaultPhotoDir: DEFAULT_PHOTO_DIR,
+ activeSessions: connections.size,
+ nasMode: 'rest-crudl',
});
});
-app.get('/api/config/photo-dir', (req, res) => {
- res.json({ photoDir: DEFAULT_PHOTO_DIR });
-});
-
-app.post('/api/sftp/connect', (req, res) => {
+/**
+ * Open a NAS client session (REST vault). Credentials scope the vault; media is stored
+ * server-side as base64 with preserved metadata (no SFTP / exifr pipeline).
+ */
+app.post('/api/nas/sessions', (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 vaultKey = nasVaultStore.vaultKeyFromCredentials(host, username);
+ const connectionId = `nas_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
+ connections.set(connectionId, {
+ host,
+ port: Number(port) || 22,
+ username,
+ password,
+ vaultKey,
+ createdAt: new Date(),
+ });
const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' });
- console.log(`Connection prepared: ${host}:${port} (${connectionId})`);
+ console.log(`NAS session: vault=${vaultKey.slice(0, 12)}… (${connectionId})`);
res.json({
success: true,
connectionToken: token,
- message: `Connection prepared for ${host}:${port}`,
- defaultPhotoDir: DEFAULT_PHOTO_DIR,
+ message: 'NAS REST session established',
});
});
-app.post('/api/sftp/list', verifyToken, async (req, res) => {
- const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
- const conn = req.connection;
-
+/** List — GET /api/nas/media?offset=&limit=&include=metadata|full */
+app.get('/api/nas/media', verifyNasSession, (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 offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+ const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100));
+ const include = req.query.include === 'full' ? 'full' : 'metadata';
+ const { items, total } = nasVaultStore.list(req.vaultKey, { offset, limit, include });
+ res.json({ items, total, offset, limit, include });
} catch (err) {
- console.error('List directory error:', err.message);
- res.status(500).json({ error: 'Failed to list directory: ' + err.message });
+ console.error('NAS list error:', err.message);
+ res.status(500).json({ error: err.message || 'List failed' });
}
});
-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);
+/** Read — GET /api/nas/media/:id?include=metadata|full */
+app.get('/api/nas/media/:id', verifyNasSession, (req, res) => {
+ const row = nasVaultStore.getById(req.vaultKey, req.params.id);
+ if (!row) return res.status(404).json({ error: 'Media not found' });
+ const include = req.query.include === 'full' ? 'full' : 'metadata';
+ res.json(nasVaultStore.shapeRow(row, include));
+});
+/** Create — POST /api/nas/media */
+app.post('/api/nas/media', verifyNasSession, (req, res) => {
try {
- await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
- res.download(tempFilePath, filename, () => {
- try { fs.unlinkSync(tempFilePath); } catch (_) {}
- });
+ const { filename, mimeType, base64, metadata } = req.body || {};
+ if (!base64) return res.status(400).json({ error: 'Missing base64 in body' });
+ const created = nasVaultStore.create(req.vaultKey, { filename, mimeType, base64, metadata });
+ res.status(201).json(created);
} catch (err) {
- console.error('Download error:', err.message);
- res.status(500).json({ error: 'Download failed: ' + err.message });
+ console.error('NAS create error:', err.message);
+ res.status(400).json({ error: err.message || 'Create failed' });
}
});
-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);
-
+/** Update — PUT /api/nas/media/:id */
+app.put('/api/nas/media/:id', verifyNasSession, (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' });
+ const updated = nasVaultStore.update(req.vaultKey, req.params.id, req.body || {});
+ if (!updated) return res.status(404).json({ error: 'Media not found' });
+ res.json(updated);
} catch (err) {
- console.error('Upload error:', err.message);
- res.status(500).json({ error: 'Upload failed: ' + err.message });
+ console.error('NAS update error:', err.message);
+ res.status(400).json({ error: err.message || 'Update failed' });
}
});
-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 — DELETE /api/nas/media/:id */
+app.delete('/api/nas/media/:id', verifyNasSession, (req, res) => {
+ const ok = nasVaultStore.remove(req.vaultKey, req.params.id);
+ if (!ok) return res.status(404).json({ error: 'Media not found' });
+ res.status(204).send();
});
-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 });
- }
-});
-
-app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
+/** End session (does not delete vault data) */
+app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => {
connections.delete(req.connectionId);
- console.log(`Disconnected: ${req.connectionId}`);
- res.json({ success: true, message: 'Disconnected from SFTP server' });
+ res.json({ success: true, message: 'Session closed' });
});
-app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
+app.post('/api/google-photos/fetch', verifyNasSession, async (req, res) => {
const { googleAccessToken, pageSize = 50, pageToken } = req.body;
if (!googleAccessToken) {
return res.status(400).json({ error: 'Google access token is required' });
@@ -269,12 +179,11 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
}
});
-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' });
+/** Saves a Google image into the NAS vault as base64 with metadata (CRUD Create). */
+app.post('/api/google-photos/download-to-nas', verifyNasSession, async (req, res) => {
+ const { googleAccessToken, baseUrl, filename, metadata = {} } = req.body;
+ if (!googleAccessToken || !baseUrl) {
+ return res.status(400).json({ error: 'googleAccessToken and baseUrl are required' });
}
try {
@@ -285,30 +194,39 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) =>
return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' });
}
- const buffer = await imageResponse.buffer();
+ const arrayBuffer = await imageResponse.arrayBuffer();
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
const safeName = filename || `gphoto_${Date.now()}.jpg`;
- const tempFilePath = path.join(os.tmpdir(), safeName);
- fs.writeFileSync(tempFilePath, buffer);
+ const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
- const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
- await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
- fs.unlinkSync(tempFilePath);
+ const created = nasVaultStore.create(req.vaultKey, {
+ filename: safeName,
+ mimeType,
+ base64,
+ metadata: {
+ ...metadata,
+ source: 'google_photos_offload',
+ width: metadata.width,
+ height: metadata.height,
+ dateTaken: metadata.creationTime || metadata.dateTaken,
+ },
+ });
- console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`);
- res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
+ console.log(`Saved Google Photo to NAS vault: ${created.id}`);
+ res.json({ success: true, media: created, message: 'Photo stored in NAS vault' });
} catch (err) {
- console.error('Download-to-NAS error:', err.message);
- res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message });
+ console.error('Google → NAS error:', err.message);
+ res.status(500).json({ error: 'Failed to save photo to NAS vault: ' + err.message });
}
});
-app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
+app.post('/api/google-photos/sync-status', verifyNasSession, (req, res) => {
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
});
process.on('SIGINT', () => {
console.log('\nShutting down...');
- connections.forEach((_, id) => connections.delete(id));
+ connections.clear();
process.exit(0);
});
@@ -326,12 +244,10 @@ app.use((req, res) => {
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`);
+ console.log(`\n NAS REST backend (CRUDL + base64 vault)`);
+ console.log(` Port: ${PORT}`);
+ console.log(` URL: http://localhost:${PORT}`);
+ console.log(` Status: Ready\n`);
});
module.exports = app;
-
diff --git a/TEST/testNASAPI.js b/TEST/testNASAPI.js
index 357092c..2776e86 100644
--- a/TEST/testNASAPI.js
+++ b/TEST/testNASAPI.js
@@ -1,7 +1,7 @@
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
async function main() {
- console.log('\n--- NAS Backend API Test ---');
+ console.log('\n--- NAS REST backend test ---');
console.log(`Backend: ${BACKEND_URL}\n`);
console.log('[1] Health check...');
@@ -9,38 +9,31 @@ async function main() {
const res = await fetch(`${BACKEND_URL}/api/health`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
- console.log(` Status: ${data.status}, connections: ${data.activeConnections}, photo dir: ${data.defaultPhotoDir}\n`);
+ console.log(` Status: ${data.status}, activeSessions: ${data.activeSessions}, mode: ${data.nasMode}\n`);
} catch (err) {
console.log(` Failed: ${err.message}`);
- console.log(' Start the backend first: cd ServerFiles && node server.js\n');
+ console.log(' Start the backend first: cd ServerFiles && npm start\n');
process.exit(1);
}
- const host = process.env.NAS_HOST;
+ const host = process.env.NAS_HOST || '127.0.0.1';
const port = parseInt(process.env.NAS_PORT || '22', 10);
- const username = process.env.NAS_USER;
- const password = process.env.NAS_PASS;
- const folder = process.env.NAS_FOLDER || '/home/user/photos';
-
- if (!host || !username || !password) {
- console.log('[2-4] Skipped — set NAS_HOST, NAS_USER, NAS_PASS to run connection tests.\n');
- console.log('--- Tests complete (partial) ---\n');
- return;
- }
+ const username = process.env.NAS_USER || 'demo';
+ const password = process.env.NAS_PASS || 'demo';
let connectionToken = null;
- console.log(`[2] SFTP connect to ${host}:${port}...`);
+ console.log(`[2] POST /api/nas/sessions (${host}, ${username})...`);
try {
- const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, {
+ const res = await fetch(`${BACKEND_URL}/api/nas/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, port, username, password }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
- if (!data.success) throw new Error(data.error || 'Connect failed');
+ if (!data.success) throw new Error(data.error || 'Session failed');
connectionToken = data.connectionToken;
- console.log(` Connected.\n`);
+ console.log(' Session OK.\n');
} catch (err) {
console.log(` Failed: ${err.message}\n`);
process.exit(1);
@@ -51,29 +44,73 @@ async function main() {
Authorization: `Bearer ${connectionToken}`,
};
- console.log(`[3] List directory "${folder}"...`);
+ console.log('[3] GET /api/nas/media (list)...');
try {
- const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
- method: 'POST',
- headers: authHeaders,
- body: JSON.stringify({ path: folder }),
+ const res = await fetch(`${BACKEND_URL}/api/nas/media?limit=10&include=metadata`, {
+ headers: { Authorization: `Bearer ${connectionToken}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
- console.log(` Found ${data.count || 0} items in ${data.path}`);
- (data.files || []).slice(0, 5).forEach((f, i) => {
- console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`);
- });
- console.log();
+ console.log(` total=${data.total}, returned=${(data.items || []).length}\n`);
} catch (err) {
console.log(` Failed: ${err.message}\n`);
}
- console.log('[4] Disconnect...');
+ console.log('[4] POST /api/nas/media (tiny PNG create)...');
+ let createdId = null;
try {
- const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
+ const onePxPng =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
+ const res = await fetch(`${BACKEND_URL}/api/nas/media`, {
method: 'POST',
headers: authHeaders,
+ body: JSON.stringify({
+ filename: 'test-pixel.png',
+ mimeType: 'image/png',
+ base64: onePxPng,
+ metadata: { DateTimeOriginal: '2020:01:01 12:00:00', test: true },
+ }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
+ const data = await res.json();
+ createdId = data.id;
+ console.log(` Created id=${createdId}\n`);
+ } catch (err) {
+ console.log(` Failed: ${err.message}\n`);
+ }
+
+ if (createdId) {
+ console.log('[5] GET /api/nas/media/:id ...');
+ try {
+ const res = await fetch(
+ `${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}?include=metadata`,
+ { headers: { Authorization: `Bearer ${connectionToken}` } }
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ console.log(` filename=${data.filename}, byteLength=${data.byteLength}\n`);
+ } catch (err) {
+ console.log(` Failed: ${err.message}\n`);
+ }
+
+ console.log('[6] DELETE /api/nas/media/:id ...');
+ try {
+ const res = await fetch(`${BACKEND_URL}/api/nas/media/${encodeURIComponent(createdId)}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${connectionToken}` },
+ });
+ if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
+ console.log(' Deleted.\n');
+ } catch (err) {
+ console.log(` Failed: ${err.message}\n`);
+ }
+ }
+
+ console.log('[7] DELETE /api/nas/sessions/current ...');
+ try {
+ const res = await fetch(`${BACKEND_URL}/api/nas/sessions/current`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${connectionToken}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
diff --git a/app.json b/app.json
index 7f7c250..bbbcd66 100644
--- a/app.json
+++ b/app.json
@@ -1,6 +1,5 @@
{
"expo": {
- "entryPoint": "./App.js",
"name": "MASS Photo App",
"slug": "mass-expo",
"owner": "bobthebob",
diff --git a/package.json b/package.json
index f6c5e70..de3cebd 100644
--- a/package.json
+++ b/package.json
@@ -3,42 +3,42 @@
"version": "1.0.0",
"main": "App.js",
"scripts": {
- "start": "expo start",
- "android": "expo run:android",
- "ios": "expo run:ios",
- "web": "expo start --web",
+ "start": "npx expo start",
+ "android": "npx expo run:android",
+ "ios": "npx expo run:ios",
+ "web": "npx expo start --web",
"backend": "node ServerFiles/server.js"
},
"dependencies": {
- "@react-navigation/bottom-tabs": "^6.5.20",
- "@react-navigation/native": "^6.1.18",
- "@react-navigation/native-stack": "^6.9.26",
- "axios": "^1.7.9",
- "expo": "~54.0.33",
- "expo-application": "~7.0.8",
- "expo-auth-session": "~7.0.10",
- "expo-blur": "~15.0.8",
- "expo-crypto": "~15.0.8",
- "expo-file-system": "~19.0.21",
- "expo-image-picker": "~17.0.10",
- "expo-linking": "~8.0.11",
- "expo-media-library": "~18.2.1",
- "expo-secure-store": "~15.0.8",
- "expo-sqlite": "~16.0.10",
- "expo-status-bar": "~3.0.9",
- "expo-system-ui": "^55.0.9",
- "expo-web-browser": "~15.0.10",
- "react": "19.1.0",
- "react-native": "0.81.5",
- "react-native-gesture-handler": "~2.28.0",
- "react-native-reanimated": "~4.1.1",
- "react-native-safe-area-context": "~5.6.0",
- "react-native-screens": "~4.16.0",
- "react-native-worklets": "0.5.1"
+ "@react-navigation/bottom-tabs": "^7.15.9",
+ "@react-navigation/native": "^7.2.2",
+ "@react-navigation/native-stack": "^7.14.11",
+ "axios": "^1.15.0",
+ "expo": "~55.0.15",
+ "expo-application": "~55.0.14",
+ "expo-auth-session": "~55.0.14",
+ "expo-blur": "~55.0.14",
+ "expo-crypto": "~55.0.14",
+ "expo-file-system": "~55.0.16",
+ "expo-image-picker": "~55.0.18",
+ "expo-linking": "~55.0.13",
+ "expo-media-library": "~55.0.14",
+ "expo-secure-store": "~55.0.13",
+ "expo-sqlite": "~55.0.15",
+ "expo-status-bar": "~55.0.5",
+ "expo-system-ui": "^55.0.15",
+ "expo-web-browser": "~55.0.14",
+ "react": "19.2.0",
+ "react-native": "0.83.4",
+ "react-native-gesture-handler": "~2.30.0",
+ "react-native-reanimated": "4.2.1",
+ "react-native-safe-area-context": "~5.6.2",
+ "react-native-screens": "~4.23.0",
+ "react-native-worklets": "0.7.2"
},
"devDependencies": {
"@babel/core": "^7.29.0",
- "babel-preset-expo": "~12.0.0"
+ "babel-preset-expo": "~55.0.17"
},
"overrides": {
"jscodeshift": "0.16.1",
diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js
index 69eff9a..6dffbfc 100644
--- a/src/API/NASAPI.js
+++ b/src/API/NASAPI.js
@@ -1,7 +1,33 @@
import axios from 'axios';
+import { File } from 'expo-file-system';
const DEFAULT_BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
+function arrayBufferToBase64(buffer) {
+ const bytes = new Uint8Array(buffer);
+ const chunk = 0x8000;
+ let binary = '';
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
+ }
+ return typeof btoa !== 'undefined' ? btoa(binary) : Buffer.from(bytes).toString('base64');
+}
+
+function guessMimeFromName(name = '') {
+ const ext = `.${name.toLowerCase().split('.').pop()}`;
+ const map = {
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.heic': 'image/heic',
+ '.mp4': 'video/mp4',
+ '.mov': 'video/quicktime',
+ };
+ return map[ext] || 'application/octet-stream';
+}
+
class NASAPI {
constructor(backendUrl = DEFAULT_BACKEND_URL) {
this.backendUrl = backendUrl;
@@ -10,6 +36,14 @@ class NASAPI {
this.credentials = null;
}
+ getHeaders() {
+ if (!this.connectionToken) return {};
+ return { Authorization: `Bearer ${this.connectionToken}` };
+ }
+
+ /**
+ * Opens a REST session (vault scoped by host + username).
+ */
async connect(host, port, username, password, photoFolder = '/Photos') {
if (!host || !username || !password) throw new Error('Host, username, and password are required');
@@ -17,107 +51,193 @@ class NASAPI {
try {
const response = await axios.post(
- `${this.backendUrl}/api/sftp/connect`,
+ `${this.backendUrl}/api/nas/sessions`,
{ host, port: port || 22, username, password },
{ timeout: 10000 }
);
- if (response.data?.success) {
+ if (response.data?.success && response.data.connectionToken) {
this.connectionToken = response.data.connectionToken;
this.isConnected = true;
- return { success: true, message: `Connected to ${host}` };
+ return { success: true, message: `Connected to NAS REST API (${host})` };
}
} catch (_) {
- console.log('Backend not available, storing NAS credentials locally');
+ console.log('NAS backend not available, credentials stored locally only');
}
this.isConnected = true;
- return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials };
+ return {
+ success: true,
+ message: `NAS configured: ${host} (backend session pending)`,
+ connectionData: this.credentials,
+ };
}
- getHeaders() {
- if (!this.connectionToken) return {};
- return { Authorization: `Bearer ${this.connectionToken}` };
+ /** List — GET /api/nas/media */
+ async listMedia({ offset = 0, limit = 100, include = 'metadata' } = {}) {
+ if (!this.connectionToken) return { items: [], total: 0 };
+ const response = await axios.get(`${this.backendUrl}/api/nas/media`, {
+ headers: this.getHeaders(),
+ params: { offset, limit, include },
+ timeout: 120000,
+ });
+ return response.data;
}
- async listDirectory(path = '/') {
- if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' };
+ /** Read — GET /api/nas/media/:id */
+ async getMedia(id, { include = 'metadata' } = {}) {
+ if (!this.connectionToken) throw new Error('Backend not connected');
+ const response = await axios.get(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, {
+ headers: this.getHeaders(),
+ params: { include },
+ timeout: 120000,
+ });
+ return response.data;
+ }
+
+ /** Create — POST /api/nas/media */
+ async createMedia({ filename, mimeType, base64, metadata = {} }) {
+ if (!this.connectionToken) throw new Error('Backend not connected');
const response = await axios.post(
- `${this.backendUrl}/api/sftp/list`, { path },
- { headers: this.getHeaders(), timeout: 15000 }
+ `${this.backendUrl}/api/nas/media`,
+ { filename, mimeType, base64, metadata },
+ { headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 }
);
return response.data;
}
- async getPhotos(folder = null) {
- const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
- if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
+ /** Update — PUT /api/nas/media/:id */
+ async updateMedia(id, payload) {
+ if (!this.connectionToken) throw new Error('Backend not connected');
+ const response = await axios.put(
+ `${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`,
+ payload,
+ { headers: { ...this.getHeaders(), 'Content-Type': 'application/json' }, timeout: 120000 }
+ );
+ return response.data;
+ }
+
+ /** Delete — DELETE /api/nas/media/:id */
+ async deleteMedia(id) {
+ if (!this.connectionToken) throw new Error('Backend not connected');
+ await axios.delete(`${this.backendUrl}/api/nas/media/${encodeURIComponent(id)}`, {
+ headers: this.getHeaders(),
+ timeout: 30000,
+ });
+ return { success: true };
+ }
+
+ /**
+ * Maps vault rows to app media items. `include=full` loads data URLs for thumbnails.
+ */
+ 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 { items = [], total = 0 } = await this.listMedia({ offset: 0, limit: 200, include: 'full' });
+ const mediaItems = items.map((row) => {
+ const meta = row.metadata || {};
+ const dateTaken = meta.dateTaken || meta.DateTimeOriginal || meta.creationTime || row.createdAt;
+ const isVideo = (row.mimeType || '').startsWith('video');
+ const dateTakenIso = meta.dateTaken
+ || (typeof dateTaken === 'string' ? dateTaken : null)
+ || (dateTaken != null ? new Date(dateTaken).toISOString() : null);
+ return {
+ source_service: 'NAS',
+ source_id: row.id,
+ title: row.filename,
+ remote_path: row.id,
+ media_type: isVideo ? 'video' : 'photo',
+ created_date: dateTakenIso || dateTaken,
+ size: row.byteLength,
+ metadata_json: JSON.stringify({
+ width: meta.width,
+ height: meta.height,
+ cameraMake: meta.cameraMake || meta.Make,
+ cameraModel: meta.cameraModel || meta.Model,
+ dateTaken: dateTakenIso,
+ mimeType: row.mimeType,
+ ...meta,
+ }),
+ local_path: row.dataUrl || null,
+ };
+ });
- 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 };
+ return { mediaItems, folder: targetFolder, total };
} catch (error) {
- console.error('Get photos error:', error.message);
+ console.error('NAS getPhotos error:', error.message);
return { mediaItems: [], folder: targetFolder };
}
}
+ /** Flat shape expected by MediaMetadataService.enrichNasItem */
+ async getFileMetadata(mediaId) {
+ const row = await this.getMedia(mediaId, { include: 'metadata' });
+ const m = row.metadata || {};
+ return {
+ width: m.width,
+ height: m.height,
+ ExifImageWidth: m.width,
+ ExifImageHeight: m.height,
+ Make: m.cameraMake || m.Make,
+ Model: m.cameraModel || m.Model,
+ DateTimeOriginal: m.dateTaken || m.DateTimeOriginal || m.CreateDate || row.createdAt,
+ dateTaken: m.dateTaken,
+ CreateDate: m.CreateDate,
+ MIMEType: row.mimeType,
+ fileSize: row.byteLength,
+ };
+ }
+
+ async listDirectory(_path = '/') {
+ const { items = [] } = await this.listMedia({ limit: 500, include: 'metadata' });
+ return {
+ path: '/',
+ files: items.map((i) => ({
+ name: i.filename,
+ isDirectory: false,
+ size: i.byteLength,
+ modifyTime: i.updatedAt,
+ id: i.id,
+ })),
+ count: items.length,
+ };
+ }
+
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' });
-
- const response = await axios.post(
- `${this.backendUrl}/api/sftp/upload`, formData,
- { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
- );
- return response.data;
+ const file = new File(localPath);
+ if (!file.exists) throw new Error('Local file not found');
+ const buf = await file.arrayBuffer();
+ const base64 = arrayBufferToBase64(buf);
+ const name = (remotePath && remotePath.split('/').pop()) || file.name || 'upload';
+ return this.createMedia({
+ filename: name,
+ mimeType: guessMimeFromName(name),
+ base64,
+ metadata: { uploadedFrom: localPath },
+ });
}
- async createDirectory(remotePath) {
- if (!this.connectionToken) throw new Error('Backend not connected');
- const response = await axios.post(
- `${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
- { headers: this.getHeaders(), timeout: 10000 }
- );
- return response.data;
+ async createDirectory(_remotePath) {
+ return { success: true, message: 'REST vault has no directories; use POST /api/nas/media' };
}
- async deleteFile(remotePath) {
- if (!this.connectionToken) throw new Error('Backend not connected');
- const response = await axios.post(
- `${this.backendUrl}/api/sftp/delete`, { path: remotePath },
- { headers: this.getHeaders(), timeout: 10000 }
- );
- return response.data;
+ async deleteFile(mediaId) {
+ return this.deleteMedia(mediaId);
}
async disconnect() {
if (this.connectionToken) {
try {
- await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
- } catch (_) {}
+ await axios.delete(`${this.backendUrl}/api/nas/sessions/current`, {
+ headers: this.getHeaders(),
+ timeout: 5000,
+ });
+ } catch (_) {
+ /* session may already be invalid */
+ }
}
this.connectionToken = null;
this.isConnected = false;
diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js
index b4ff691..93f86b9 100644
--- a/src/AUTH/GoogleAUTH.js
+++ b/src/AUTH/GoogleAUTH.js
@@ -3,6 +3,7 @@ import * as WebBrowser from 'expo-web-browser';
import * as Application from 'expo-application';
import { File, Directory, Paths } from 'expo-file-system';
import { AuthServiceBase } from './AuthServiceBase';
+import MediaMetadataService from '../services/MediaMetadataService';
import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession();
@@ -29,6 +30,8 @@ const PICKER_API = 'https://photospicker.googleapis.com/v1';
const CACHE_DIR = new Directory(Paths.cache, 'google_photos');
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
+const EMPTY_PICK_RESULT = { mediaItems: [], nextPageToken: null, sessionId: null };
+const TOKEN_FALLBACK_EXPIRES_MS = 3600 * 1000;
class GoogleAUTH extends AuthServiceBase {
constructor() {
@@ -39,12 +42,26 @@ class GoogleAUTH extends AuthServiceBase {
this.email = null;
}
+ _setTokenState(tokenData) {
+ this.accessToken = tokenData.access_token;
+ this.refreshToken = tokenData.refresh_token || this.refreshToken || null;
+ this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
+ }
+
+ _buildPickerError(code, message, sessionId = null) {
+ return {
+ ...EMPTY_PICK_RESULT,
+ sessionId,
+ error: { code, message },
+ };
+ }
+
async authenticate() {
if (!ACTIVE_CLIENT_ID) {
console.warn('Google Auth: no Android client ID — using placeholder mode');
this.accessToken = 'placeholder_google_token_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now();
- this.expiresAt = Date.now() + 3600000;
+ this.expiresAt = Date.now() + TOKEN_FALLBACK_EXPIRES_MS;
this.email = 'google-user@placeholder.com';
return {
success: true,
@@ -99,9 +116,7 @@ class GoogleAUTH extends AuthServiceBase {
throw err;
}
- this.accessToken = tokenData.access_token;
- this.refreshToken = tokenData.refresh_token || null;
- this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
+ this._setTokenState(tokenData);
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
@@ -154,8 +169,7 @@ class GoogleAUTH extends AuthServiceBase {
throw err;
}
- this.accessToken = data.access_token;
- this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
+ this._setTokenState(data);
return this.accessToken;
}
@@ -201,6 +215,13 @@ class GoogleAUTH extends AuthServiceBase {
return await res.json();
}
+ _parseFileExtension(item) {
+ if (item.mime_type?.includes('png')) return '.png';
+ if (item.mime_type?.includes('gif')) return '.gif';
+ if (item.mime_type?.includes('webp')) return '.webp';
+ return '.jpg';
+ }
+
async _waitForSession(sessionId) {
const token = await this.getAccessToken();
const maxAttempts = 20;
@@ -245,13 +266,6 @@ class GoogleAUTH extends AuthServiceBase {
}
const data = await res.json();
-
- if (data.mediaItems?.length) {
- console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
- } else {
- console.log('Picker returned no mediaItems. Keys:', Object.keys(data));
- }
-
return data;
}
@@ -267,6 +281,8 @@ class GoogleAUTH extends AuthServiceBase {
const creationTime = file.mediaFileMetadata?.creationTime
|| item.mediaFileMetadata?.creationTime
|| null;
+ const mw = file.mediaFileMetadata?.width ?? item.mediaFileMetadata?.width;
+ const mh = file.mediaFileMetadata?.height ?? item.mediaFileMetadata?.height;
if (idx === 0) {
console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`);
@@ -283,8 +299,10 @@ class GoogleAUTH extends AuthServiceBase {
media_type: isVideo ? 'video' : 'photo',
mime_type: mimeType,
created_date: creationTime,
+ width: mw,
+ height: mh,
};
- });
+ }).map((m) => MediaMetadataService.enrichGooglePickerItem(m));
}
async downloadPickerPhotos(items) {
@@ -312,10 +330,7 @@ class GoogleAUTH extends AuthServiceBase {
try {
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
- const ext = item.mime_type?.includes('png') ? '.png'
- : item.mime_type?.includes('gif') ? '.gif'
- : item.mime_type?.includes('webp') ? '.webp'
- : '.jpg';
+ const ext = this._parseFileExtension(item);
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
if (destFile.exists && destFile.size > 1024) {
@@ -369,16 +384,10 @@ class GoogleAUTH extends AuthServiceBase {
const session = await this._createPickerSession();
if (session?.error) {
- return {
- mediaItems: [], nextPageToken: null, sessionId: null,
- error: { code: session.error.code, message: session.error.message },
- };
+ return this._buildPickerError(session.error.code, session.error.message);
}
if (!session?.id || !session?.pickerUri) {
- return {
- mediaItems: [], nextPageToken: null, sessionId: null,
- error: { code: 0, message: 'Failed to create Picker session' },
- };
+ return this._buildPickerError(0, 'Failed to create Picker session');
}
console.log('Picker session created:', session.id);
@@ -393,10 +402,7 @@ class GoogleAUTH extends AuthServiceBase {
const ready = await this._waitForSession(session.id);
if (!ready) {
- return {
- mediaItems: [], nextPageToken: null, sessionId: session.id,
- error: { code: 0, message: 'No photos were selected or the picker was dismissed' },
- };
+ return this._buildPickerError(0, 'No photos were selected or the picker was dismissed', session.id);
}
const result = await this._fetchPickerMediaItems(session.id, 100, null);
@@ -413,7 +419,7 @@ class GoogleAUTH extends AuthServiceBase {
};
} catch (error) {
logError('Pick Photos', error);
- return { mediaItems: [], nextPageToken: null, sessionId: null };
+ return EMPTY_PICK_RESULT;
}
}
diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js
index c8e777a..8bd78fe 100644
--- a/src/AUTH/NASAUTH.js
+++ b/src/AUTH/NASAUTH.js
@@ -13,6 +13,10 @@ class NASAUTH extends AuthServiceBase {
this.isConnected = false;
}
+ ensureConnected() {
+ if (!this.isConnected) throw new Error('Not connected to NAS');
+ }
+
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
try {
if (!host || !username || !password) throw new Error('Host, username, and password are required');
@@ -35,7 +39,7 @@ class NASAUTH extends AuthServiceBase {
}
async getAccessToken() {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return { host: this.host, username: this.username, isConnected: this.isConnected };
}
@@ -61,39 +65,45 @@ class NASAUTH extends AuthServiceBase {
}
async getUserInfo() {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return {
host: this.host,
username: this.username,
port: this.port,
photoFolder: this.photoFolder,
connectedAt: new Date().toISOString(),
- serverType: 'NAS SFTP',
+ serverType: 'NAS REST (CRUDL)',
};
}
async listDirectory(remotePath = '/') {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return await this.nasAPI.listDirectory(remotePath);
}
async uploadFile(localPath, remotePath) {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return await this.nasAPI.uploadFile(localPath, remotePath);
}
async getPhotos(photoFolder = null) {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
}
+ /** Fetches EXIF/size metadata for a remote file via the REST backend (see ServerFiles/server.js). */
+ async fetchFileMetadata(remotePath) {
+ this.ensureConnected();
+ return await this.nasAPI.getFileMetadata(remotePath);
+ }
+
async createDirectory(remotePath) {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return await this.nasAPI.createDirectory(remotePath);
}
async deleteFile(remotePath) {
- if (!this.isConnected) throw new Error('Not connected to NAS');
+ this.ensureConnected();
return await this.nasAPI.deleteFile(remotePath);
}
}
diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js
index 214ca92..c7a5525 100644
--- a/src/components/MediaViewer.js
+++ b/src/components/MediaViewer.js
@@ -4,16 +4,14 @@ import {
Dimensions, FlatList,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
+import MediaDetailDrawer from './MediaDetailDrawer';
+
+const DRAWER_PEEK = 124;
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);
-const MediaViewer = ({ visible, media, allMedia, onClose }) => {
+const MediaViewer = ({ visible, media, allMedia, onClose, resolveNasMetadata }) => {
const { theme } = useTheme();
const mainListRef = useRef(null);
const thumbListRef = useRef(null);
@@ -70,17 +68,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
}
};
- if (!media) return null;
+ const renderImageOrPlaceholder = (item, placeholderSize) => {
+ if (item.local_path) {
+ return ;
+ }
+ return (
+
+ 📷
+
+ );
+ };
const renderMainItem = ({ item }) => (
-
- {item.local_path ? (
-
- ) : (
-
- 📷
-
- )}
+
+ {renderImageOrPlaceholder(item, 64)}
);
@@ -105,6 +106,8 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const displayMedia = currentMedia || media;
+ if (!media) return null;
+
return (
@@ -138,17 +141,13 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
style={styles.mainList}
/>
) : (
- displayMedia.local_path ? (
-
- ) : (
-
- 📷
-
- )
+
+ {renderImageOrPlaceholder(displayMedia, 64)}
+
)}
{hasCarousel && (
-
+
{
)}
-
- {displayMedia.title || 'Photo'}
-
- Source
-
- {displayMedia.sources && displayMedia.sources.length > 1
- ? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
- : `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
- }
-
-
- {displayMedia.sources && displayMedia.sources.length > 1 && (
-
- Available on
-
- {displayMedia.sources.length} services
-
-
- )}
- {displayMedia.created_date && (
-
- Date
-
- {new Date(displayMedia.created_date).toLocaleDateString()}
-
-
- )}
+
+
@@ -201,6 +179,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const styles = StyleSheet.create({
container: { flex: 1 },
+ drawerLayer: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 50,
+ elevation: 50,
+ justifyContent: 'flex-end',
+ },
closeButton: {
position: 'absolute', top: 44, right: 16, zIndex: 20,
width: 36, height: 36, borderRadius: 18,
@@ -217,7 +201,10 @@ const styles = StyleSheet.create({
fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
thumbCarousel: {
- position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
+ position: 'absolute', left: 0, right: 0,
+ /* Below MediaDetailDrawer (drawer zIndex 50) so expanded metadata sits on top. */
+ zIndex: 8,
+ elevation: 8,
backgroundColor: 'rgba(0,0,0,0.4)',
paddingVertical: 8,
},
@@ -231,14 +218,6 @@ const styles = StyleSheet.create({
width: '100%', height: '100%', backgroundColor: '#333',
justifyContent: 'center', alignItems: 'center',
},
- infoPanel: {
- padding: 16, paddingBottom: 32,
- borderTopLeftRadius: 16, borderTopRightRadius: 16,
- },
- title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 },
- row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
- label: { fontSize: 12, color: '#9aa0a6' },
- value: { fontSize: 12, color: '#e8eaed', fontWeight: '500' },
});
export default MediaViewer;
diff --git a/src/components/NASSetupInstructions.js b/src/components/NASSetupInstructions.js
index f962779..df13b6f 100644
--- a/src/components/NASSetupInstructions.js
+++ b/src/components/NASSetupInstructions.js
@@ -23,53 +23,34 @@ const NASSetupInstructions = () => {
{expanded && (
+ What this is
+
+ The app talks to your machine as a REST “NAS client”: photos live in a vault on the Node server (base64 + JSON metadata), not over SFTP directory listing. Host, port, username, and password open a session and choose which vault file your media is stored in.
+
+
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)
+ • Node.js on the machine running the backend{'\n'}
+ • A reachable URL for that backend (LAN IP or tunnel){'\n'}
+ • Same host/username pair always maps to the same on-disk vault
- Step 1: Enable SSH
+ Step 1: Run the backend
- 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
-
-
- Step 2: Create a Photos Directory
-
- Create a dedicated folder for your photo storage:{'\n\n'}
- mkdir -p ~/photos{'\n'}
- chmod 755 ~/photos
-
-
- Step 3: Configure Firewall (if needed)
-
- If your server uses UFW, allow SSH connections:{'\n\n'}
- sudo ufw allow ssh{'\n'}
- sudo ufw enable
-
-
- Step 4: Find Your Server IP
-
- 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.
-
-
- Step 5: Connect from the App
-
- 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'}
+ From the ServerFiles folder:{'\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).
+ npm start{'\n\n'}
+ Optional: set JWT_SECRET and JSON_BODY_LIMIT in .env for production.
+
+
+ Step 2: Connect from the app
+
+ Enter the backend host (e.g. your PC LAN IP), port 22 is accepted for compatibility, username, password, and a folder label if shown — listing uses the REST vault (CRUDL), not that path on disk. Set EXPO_PUBLIC_BACKEND_URL so the app points at http://YOUR_IP:3001.
+
+
+ API shape
+
+ POST /api/nas/sessions → JWT. Then GET/POST/PUT/DELETE /api/nas/media for list/create/read/update/delete. Metadata you send on create is returned on read and used by the app for sorting and the detail drawer.
)}
diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js
index 3476803..ba92966 100644
--- a/src/components/PhotoGrid.js
+++ b/src/components/PhotoGrid.js
@@ -1,16 +1,24 @@
import React from 'react';
import {
- View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet,
+ View, Text, FlatList, SectionList, TouchableOpacity, Image, Dimensions, StyleSheet,
} from 'react-native';
import { useTheme } from '../services/ThemeContext';
+import MediaMetadataService from '../services/MediaMetadataService';
const { width } = Dimensions.get('window');
const GRID_GAP = 2;
+const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
const PhotoGrid = ({
data,
- onItemPress,
+ timelineSections,
columns = 3,
+ onItemPress,
+ onLongPressItem,
+ onToggleSelect,
+ selectionMode,
+ selectedKeys,
+ getItemKey,
refreshControl,
ListHeaderComponent,
ListEmptyComponent,
@@ -21,7 +29,7 @@ const PhotoGrid = ({
const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) =>
- ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
+ (SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE);
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
@@ -45,38 +53,113 @@ const PhotoGrid = ({
);
};
- const renderItem = ({ item }) => (
- onItemPress?.(item)}
- activeOpacity={0.8}
- >
- {item.local_path ? (
- console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
+ const keyOf = (item) => (getItemKey ? getItemKey(item) : MediaMetadataService.mediaFingerprint(item));
+
+ const renderCell = (item) => {
+ if (!item) {
+ return (
+
- ) : (
-
- 📷
-
- )}
- {item.media_type === 'video' && (
-
- ▶
-
- )}
- {renderSourceBadges(item)}
-
- );
+ );
+ }
+ const k = keyOf(item);
+ const selected = selectionMode && selectedKeys?.has(k);
+ return (
+ {
+ if (selectionMode) {
+ onToggleSelect?.(item);
+ return;
+ }
+ onItemPress?.(item);
+ }}
+ onLongPress={() => onLongPressItem?.(item)}
+ delayLongPress={380}
+ activeOpacity={0.8}
+ >
+ {item.local_path ? (
+ console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
+ />
+ ) : (
+
+ 📷
+
+ )}
+ {item.media_type === 'video' && (
+
+ ▶
+
+ )}
+ {renderSourceBadges(item)}
+ {selected && (
+
+ ✓
+
+ )}
+
+ );
+ };
+
+ const renderRow = ({ item }) => {
+ const row = item.row || item;
+ return (
+
+ {Array.from({ length: columns }).map((_, i) => (
+
+ {renderCell(row[i])}
+
+ ))}
+
+ );
+ };
+
+ if (timelineSections && timelineSections.length > 0) {
+ return (
+ item.rowKey || item.row?.map((c) => (c ? keyOf(c) : '·')).join('|')}
+ renderItem={renderRow}
+ renderSectionHeader={({ section }) => (
+
+ {section.title}
+
+ )}
+ stickySectionHeadersEnabled
+ contentContainerStyle={styles.gridContent}
+ refreshControl={refreshControl}
+ ListHeaderComponent={ListHeaderComponent}
+ ListEmptyComponent={ListEmptyComponent}
+ ListFooterComponent={ListFooterComponent}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={0.4}
+ />
+ );
+ }
return (
item.id?.toString() || item.source_id || i.toString()}
+ renderItem={({ item }) => renderCell(item)}
+ keyExtractor={(item, i) => keyOf(item) || i.toString()}
numColumns={columns}
contentContainerStyle={styles.gridContent}
refreshControl={refreshControl}
@@ -91,6 +174,19 @@ const PhotoGrid = ({
const styles = StyleSheet.create({
gridContent: { paddingBottom: 20 },
+ rowWrap: {
+ flexDirection: 'row',
+ flexWrap: 'nowrap',
+ justifyContent: 'flex-start',
+ paddingHorizontal: GRID_GAP / 2,
+ marginBottom: GRID_GAP,
+ },
+ sectionHeader: {
+ paddingHorizontal: 14,
+ paddingTop: 12,
+ paddingBottom: 8,
+ },
+ sectionTitle: { fontSize: 15, fontWeight: '700' },
gridItem: {
margin: GRID_GAP / 2,
position: 'relative',
@@ -112,7 +208,14 @@ const styles = StyleSheet.create({
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
flexDirection: 'row', gap: 2,
},
+ checkBadge: {
+ position: 'absolute', top: 4, left: 4,
+ width: 24, height: 24, borderRadius: 12,
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ borderWidth: 2,
+ justifyContent: 'center', alignItems: 'center',
+ },
+ checkMark: { fontSize: 14, fontWeight: '800' },
});
export default PhotoGrid;
-
diff --git a/src/components/TopBar.js b/src/components/TopBar.js
index d6582e3..b15f45b 100644
--- a/src/components/TopBar.js
+++ b/src/components/TopBar.js
@@ -4,6 +4,7 @@ import { useTheme } from '../services/ThemeContext';
const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) => {
const { theme } = useTheme();
+ const profileInitial = currentUser?.username?.charAt(0)?.toUpperCase() || '?';
return (
@@ -18,9 +19,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
onPress={onProfilePress}
>
-
- {currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
-
+ {profileInitial}
⋮
diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js
index 26661fd..d6c4eab 100644
--- a/src/screens/AlbumsScreen.js
+++ b/src/screens/AlbumsScreen.js
@@ -6,12 +6,15 @@ import {
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
+import MediaMetadataService from '../services/MediaMetadataService';
import MediaViewer from '../components/MediaViewer';
const { width } = Dimensions.get('window');
const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
+const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
+const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
const CollectionsScreen = ({ route }) => {
const userId = route?.params?.userId;
@@ -30,8 +33,13 @@ const CollectionsScreen = ({ route }) => {
}, [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 getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
+ const getServiceName = (type) => SERVICE_NAMES[type] || type;
+
+ const refreshCollections = () => {
+ setRefreshing(true);
+ loadCollections();
+ };
const loadCollections = async () => {
try {
@@ -39,6 +47,28 @@ const CollectionsScreen = ({ route }) => {
const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = [];
+ const userAlbumRows = await DatabaseService.getUserAlbums(userId);
+ const myAlbums = [];
+ for (const al of userAlbumRows) {
+ const entries = await DatabaseService.getAlbumEntries(userId, al.id);
+ const resolved = MediaMetadataService.resolveAlbumEntriesToMediaItems(entries, allMedia);
+ myAlbums.push({
+ name: al.name,
+ icon: '📁',
+ count: resolved.length,
+ coverImage: resolved[0]?.local_path || null,
+ media: resolved,
+ type: 'user_album',
+ });
+ }
+ if (myAlbums.length > 0) {
+ sectionList.push({
+ title: 'My albums',
+ icon: '📂',
+ data: [{ albums: myAlbums }],
+ });
+ }
+
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
@@ -175,7 +205,8 @@ const CollectionsScreen = ({ route }) => {
Collections
- {sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? (
+ {sections.length === 0
+ || !sections.some((sec) => sec.data?.[0]?.albums?.some((a) => (a.count || 0) > 0)) ? (
📁
No Collections
@@ -192,7 +223,7 @@ const CollectionsScreen = ({ route }) => {
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
refreshControl={
- { setRefreshing(true); loadCollections(); }} />
+
}
/>
)}
diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js
index 70ad2ee..d4fc76e 100644
--- a/src/screens/HomeScreen.js
+++ b/src/screens/HomeScreen.js
@@ -1,6 +1,6 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
- StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
+ StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, Alert, TouchableOpacity,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
@@ -12,8 +12,28 @@ import { File } from 'expo-file-system';
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
+import SelectionToolbar from '../components/SelectionToolbar';
+import AlbumPickerModal from '../components/AlbumPickerModal';
+import MediaSortControls from '../components/MediaSortControls';
+import MediaMetadataService from '../services/MediaMetadataService';
import { logError } from '../services/ErrorHandler';
-import { TouchableOpacity } from 'react-native';
+
+const SERVICE_ICONS = { DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
+const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' };
+
+/** Keeps device library timestamps and aligns `created_date` with resolved capture time after merges. */
+const mergeCaptureFields = (target, donor) => {
+ if (donor.source_service === 'DEVICE' && donor.creationTime != null) {
+ target.creationTime = donor.creationTime ?? target.creationTime;
+ }
+ if (target.creationTime == null && donor.creationTime != null) {
+ target.creationTime = donor.creationTime;
+ }
+ if (donor.modifyTime != null) target.modifyTime = donor.modifyTime ?? target.modifyTime;
+ const iso = MediaMetadataService.resolveCreatedDateIsoForStorage(target);
+ if (iso) target.created_date = iso;
+};
+
const mergeMediaAcrossServices = (items) => {
const byFilename = new Map();
const noFilename = [];
@@ -36,6 +56,15 @@ const mergeMediaAcrossServices = (items) => {
if (!existing.local_path && item.local_path) {
existing.local_path = item.local_path;
}
+ existing.width = existing.width || item.width;
+ existing.height = existing.height || item.height;
+ if (item.metadata_json) {
+ existing.metadata_json = MediaMetadataService.mergeMetadataJson(
+ existing.metadata_json,
+ item.metadata_json
+ );
+ }
+ mergeCaptureFields(existing, item);
} else {
byFilename.set(key, { ...item, sources: [item.source_service] });
}
@@ -49,14 +78,48 @@ const mergeMediaAcrossServices = (items) => {
if (!existing.sources.includes(src)) existing.sources.push(src);
}
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
+ existing.width = existing.width || item.width;
+ existing.height = existing.height || item.height;
+ if (item.metadata_json) {
+ existing.metadata_json = MediaMetadataService.mergeMetadataJson(
+ existing.metadata_json,
+ item.metadata_json
+ );
+ }
+ mergeCaptureFields(existing, item);
} else {
bySourceId.set(key, item);
}
}
- return Array.from(bySourceId.values());
+ return Array.from(bySourceId.values()).map((row) => {
+ mergeCaptureFields(row, row);
+ return row;
+ });
};
+const buildGoogleAuth = (accountData = {}) => {
+ const googleAuth = new GoogleAUTH();
+ googleAuth.accessToken = accountData.accessToken || null;
+ googleAuth.refreshToken = accountData.refreshToken || null;
+ googleAuth.expiresAt = accountData.expiresAt || 0;
+ return googleAuth;
+};
+
+const normalizeGoogleMedia = (items = []) =>
+ items.map((item) => {
+ if (!item.local_path) return item;
+ try {
+ const file = new File(item.local_path);
+ if (!file.exists || file.size === 0) {
+ return { ...item, local_path: null };
+ }
+ return item;
+ } catch (_) {
+ return { ...item, local_path: null };
+ }
+ });
+
const HomeScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme } = useTheme();
@@ -75,6 +138,21 @@ const HomeScreen = ({ navigation, route }) => {
const [googleSessionId, setGoogleSessionId] = useState(null);
const [loadingMore, setLoadingMore] = useState(false);
const [importing, setImporting] = useState(false);
+ const [sortMode, setSortMode] = useState('date_taken');
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [selectedKeys, setSelectedKeys] = useState(() => new Set());
+ const [albumModalVisible, setAlbumModalVisible] = useState(false);
+ const [userAlbums, setUserAlbums] = useState([]);
+
+ const selectedItems = useMemo(() => {
+ if (!selectionMode || selectedKeys.size === 0) return [];
+ return filteredMedia.filter((m) => selectedKeys.has(MediaMetadataService.mediaFingerprint(m)));
+ }, [selectionMode, selectedKeys, filteredMedia]);
+
+ const timelineSections = useMemo(() => {
+ if (sortMode !== 'date_taken' || !filteredMedia.length) return null;
+ return MediaMetadataService.groupMediaByDayForTimeline(filteredMedia, 3);
+ }, [filteredMedia, sortMode]);
useFocusEffect(
useCallback(() => {
@@ -84,9 +162,9 @@ const HomeScreen = ({ navigation, route }) => {
useEffect(() => {
if (currentUser) loadAllMedia();
- }, [currentUser]);
+ }, [currentUser, linkedAccounts]);
- useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
+ useEffect(() => { filterMedia(); }, [selectedFilter, allMedia, sortMode]);
const loadUserData = async () => {
try {
@@ -94,6 +172,8 @@ const HomeScreen = ({ navigation, route }) => {
setCurrentUser(user);
const accounts = await DatabaseService.getLinkedAccounts(userId);
setLinkedAccounts(accounts);
+ const albums = await DatabaseService.getUserAlbums(userId);
+ setUserAlbums(albums);
} catch (err) {
console.error('Error loading user data:', err);
}
@@ -108,6 +188,8 @@ const HomeScreen = ({ navigation, route }) => {
const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) {
newMedia.push(...deviceResult.mediaItems);
+ const chunk = deviceResult.mediaItems.slice(0, 50);
+ void Promise.all(chunk.map((m) => DatabaseService.saveMediaItem(userId, m))).catch(() => {});
}
setDeviceEndCursor(deviceResult.endCursor);
setDeviceHasMore(deviceResult.hasNextPage);
@@ -118,20 +200,7 @@ const HomeScreen = ({ navigation, route }) => {
try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) {
- 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 };
- }
- } catch (_) {
- return { ...item, local_path: null };
- }
- }
- return item;
- });
- newMedia.push(...validated);
+ newMedia.push(...normalizeGoogleMedia(googleMedia));
}
} catch (err) {
console.error('DB Google media fetch error:', err);
@@ -166,6 +235,11 @@ const HomeScreen = ({ navigation, route }) => {
}
};
+ const refreshMedia = useCallback(() => {
+ setRefreshing(true);
+ loadAllMedia();
+ }, [loadAllMedia]);
+
const importGooglePhotos = async () => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (!googleAccount) {
@@ -176,10 +250,7 @@ const HomeScreen = ({ navigation, route }) => {
setImporting(true);
try {
const data = googleAccount.account_data || {};
- const googleAuth = new GoogleAUTH();
- googleAuth.accessToken = data.accessToken || null;
- googleAuth.refreshToken = data.refreshToken || null;
- googleAuth.expiresAt = data.expiresAt || 0;
+ const googleAuth = buildGoogleAuth(data);
let result = await googleAuth.pickPhotos();
@@ -199,9 +270,7 @@ const HomeScreen = ({ navigation, route }) => {
if (result?.error) {
logError('Import Google Photos', result.error.message);
} else if (result?.mediaItems?.length) {
- for (const item of result.mediaItems) {
- await DatabaseService.saveMediaItem(userId, item);
- }
+ await Promise.all(result.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null);
@@ -234,8 +303,84 @@ const HomeScreen = ({ navigation, route }) => {
(m.sources && m.sources.includes(selectedFilter))
);
}
- filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
- setFilteredMedia(filtered);
+ setFilteredMedia(MediaMetadataService.sortMediaItems(filtered, sortMode));
+ };
+
+ const resolveNasMetadata = useCallback(
+ async (item) => {
+ const nasId = item.remote_path || item.source_id;
+ if (!nasId || item.source_service !== 'NAS') return null;
+ const acc = linkedAccounts.find((a) => a.service_type === 'NAS');
+ if (!acc?.account_data) return null;
+ const nas = new NASAUTH();
+ const d = acc.account_data;
+ try {
+ await nas.authenticate(d.host, d.port, d.username, d.password, d.photoFolder);
+ const raw = await nas.fetchFileMetadata(nasId);
+ await nas.revokeAccess();
+ if (!raw) return null;
+ const enriched = MediaMetadataService.enrichNasItem(item, raw);
+ return MediaMetadataService.parseMetadata(enriched.metadata_json);
+ } catch (e) {
+ console.warn('NAS metadata:', e?.message);
+ try {
+ await nas.revokeAccess();
+ } catch {
+ /* */
+ }
+ return null;
+ }
+ },
+ [linkedAccounts]
+ );
+
+ const clearSelection = () => {
+ setSelectionMode(false);
+ setSelectedKeys(new Set());
+ };
+
+ const toggleSelect = (item) => {
+ const k = MediaMetadataService.mediaFingerprint(item);
+ setSelectedKeys((prev) => {
+ const n = new Set(prev);
+ if (n.has(k)) n.delete(k);
+ else n.add(k);
+ return n;
+ });
+ };
+
+ const onLongPressItem = (item) => {
+ setSelectionMode(true);
+ setSelectedKeys((prev) => new Set(prev).add(MediaMetadataService.mediaFingerprint(item)));
+ };
+
+ const addSelectionToAlbum = async (albumId) => {
+ const items = [...selectedItems];
+ for (const m of items) {
+ await DatabaseService.addMediaToAlbum(userId, albumId, m);
+ }
+ Alert.alert('Album', `Added ${items.length} item(s) to album`);
+ clearSelection();
+ setAlbumModalVisible(false);
+ const albums = await DatabaseService.getUserAlbums(userId);
+ setUserAlbums(albums);
+ };
+
+ const createAlbumAndAdd = async (name) => {
+ const id = await DatabaseService.createAlbum(userId, name);
+ if (!id) {
+ Alert.alert('Error', 'Could not create album');
+ return;
+ }
+ const items = [...selectedItems];
+ for (const m of items) {
+ await DatabaseService.addMediaToAlbum(userId, id, m);
+ }
+ Alert.alert('Album', `Added ${items.length} item(s) to “${name.trim()}”`);
+ clearSelection();
+ setAlbumModalVisible(false);
+ const albums = await DatabaseService.getUserAlbums(userId);
+ setUserAlbums(albums);
};
const loadMorePhotos = async () => {
@@ -259,17 +404,12 @@ const HomeScreen = ({ navigation, route }) => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (googleAccount) {
const data = googleAccount.account_data || {};
- const googleAuth = new GoogleAUTH();
- googleAuth.accessToken = data.accessToken || null;
- googleAuth.refreshToken = data.refreshToken || null;
- googleAuth.expiresAt = data.expiresAt || 0;
+ const googleAuth = buildGoogleAuth(data);
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
if (photos?.mediaItems?.length) {
moreMedia.push(...photos.mediaItems);
- for (const item of photos.mediaItems) {
- await DatabaseService.saveMediaItem(userId, item);
- }
+ await Promise.all(photos.mediaItems.map((item) => DatabaseService.saveMediaItem(userId, item)));
}
setGoogleNextPage(photos?.nextPageToken || null);
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
@@ -286,8 +426,8 @@ 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);
+ const getServiceIcon = (type) => SERVICE_ICONS[type] || SERVICE_ICONS.DEVICE;
+ const getServiceName = (type) => SERVICE_NAMES[type] || type;
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
@@ -336,13 +476,22 @@ const HomeScreen = ({ navigation, route }) => {
navigation.navigate('Search')}
+ onSearchPress={() => navigation.navigate('Search', { userId })}
onProfilePress={() => navigation.navigate('Settings', { userId })}
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
+
+
+ setAlbumModalVisible(true)}
+ onClear={clearSelection}
+ />
+
{hasGoogleAccount && (
{
{ setRefreshing(true); loadAllMedia(); }}
+ onPress={refreshMedia}
>
Refresh
) : (
{ setSelectedMedia(item); setViewerVisible(true); }}
+ onLongPressItem={onLongPressItem}
+ onToggleSelect={toggleSelect}
+ selectionMode={selectionMode}
+ selectedKeys={selectedKeys}
onEndReached={loadMorePhotos}
refreshControl={
- { setRefreshing(true); loadAllMedia(); }} />
+
}
ListFooterComponent={loadingMore ? (
@@ -393,11 +547,20 @@ const HomeScreen = ({ navigation, route }) => {
/>
)}
+ setAlbumModalVisible(false)}
+ albums={userAlbums}
+ onPickAlbum={(albumId) => addSelectionToAlbum(albumId)}
+ onCreateAlbum={createAlbumAndAdd}
+ />
+
setViewerVisible(false)}
+ resolveNasMetadata={resolveNasMetadata}
/>
);
diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js
index 5f290f0..5a1a609 100644
--- a/src/screens/LoginScreen.js
+++ b/src/screens/LoginScreen.js
@@ -7,6 +7,7 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
const APP_ICON = require('../../assets/icon.png');
+const isBlank = (value) => !value?.trim();
const LoginScreen = ({ navigation }) => {
const { theme } = useTheme();
@@ -15,7 +16,7 @@ const LoginScreen = ({ navigation }) => {
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
- if (!identifier.trim() || !password.trim()) {
+ if (isBlank(identifier) || isBlank(password)) {
Alert.alert('Error', 'Please enter your username/email and password');
return;
}
diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js
index b482204..2bba23e 100644
--- a/src/screens/OnboardingScreen.js
+++ b/src/screens/OnboardingScreen.js
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import {
StyleSheet, View, Text, ScrollView, TouchableOpacity, Alert,
ActivityIndicator, Switch, TextInput,
@@ -10,9 +10,27 @@ import NASAUTH from '../AUTH/NASAUTH';
import NASSetupInstructions from '../components/NASSetupInstructions';
import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
+const SERVICES = {
+ GOOGLE_PHOTOS: { name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' },
+ ICLOUD_PHOTOS: { name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' },
+ NAS: { name: 'NAS Storage', type: 'NAS', icon: '💾' },
+};
+const ONBOARDING_STEPS = [
+ { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
+ { title: 'Connect Services', description: 'Set up each selected service' },
+ { title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
+];
+
const OnboardingScreen = ({ navigation, route }) => {
- const { userId } = route.params;
+ const userId = route?.params?.userId;
const { theme } = useTheme();
+
+ useEffect(() => {
+ if (userId == null) {
+ navigation.replace('Login');
+ }
+ }, [userId, navigation]);
+
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
@@ -36,17 +54,11 @@ const OnboardingScreen = ({ navigation, route }) => {
const [autoBackupServices, setAutoBackupServices] = useState([]);
- const steps = [
- { title: 'Choose Services', description: 'Select which photo services to connect (1-3)' },
- { title: 'Connect Services', description: 'Set up each selected service' },
- { title: 'Automatic Backup', description: 'Choose where new photos are backed up' },
- ];
-
const getConnectedServices = () => {
const services = [];
- if (googleEnabled && googleConnected) services.push({ name: 'Google Photos', type: 'GOOGLE_PHOTOS', icon: '📷' });
- if (iosEnabled && iosConnected) services.push({ name: 'iCloud Photos', type: 'ICLOUD_PHOTOS', icon: '🍎' });
- if (nasEnabled && nasConnected) services.push({ name: 'NAS Storage', type: 'NAS', icon: '💾' });
+ if (googleEnabled && googleConnected) services.push(SERVICES.GOOGLE_PHOTOS);
+ if (iosEnabled && iosConnected) services.push(SERVICES.ICLOUD_PHOTOS);
+ if (nasEnabled && nasConnected) services.push(SERVICES.NAS);
return services;
};
@@ -104,14 +116,15 @@ const OnboardingScreen = ({ navigation, route }) => {
try {
setLoading(true);
setNasHealth('checking');
+ const parsedNasPort = parseInt(nasPort, 10) || 22;
const nasAuth = new NASAUTH();
await nasAuth.authenticate(
- nasHost.trim(), parseInt(nasPort) || 22,
+ nasHost.trim(), parsedNasPort,
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
- port: parseInt(nasPort) || 22,
+ port: parsedNasPort,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(),
});
@@ -158,19 +171,27 @@ const OnboardingScreen = ({ navigation, route }) => {
}
};
+ if (userId == null) {
+ return (
+
+
+
+ );
+ }
+
return (
- {steps.map((_, i) => (
+ {ONBOARDING_STEPS.map((_, i) => (
))}
- Step {currentStep + 1} of {steps.length}
- {steps[currentStep].title}
- {steps[currentStep].description}
+ Step {currentStep + 1} of {ONBOARDING_STEPS.length}
+ {ONBOARDING_STEPS[currentStep].title}
+ {ONBOARDING_STEPS[currentStep].description}
{currentStep === 0 && (
@@ -332,7 +353,7 @@ const OnboardingScreen = ({ navigation, route }) => {
>
{loading ? : (
- {currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
+ {currentStep === ONBOARDING_STEPS.length - 1 ? 'Get Started' : 'Next'}
)}
diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js
index d75cfae..aaba2e9 100644
--- a/src/screens/RegisterScreen.js
+++ b/src/screens/RegisterScreen.js
@@ -7,6 +7,8 @@ import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
const APP_ICON = require('../../assets/icon.png');
+const isBlank = (value) => !value?.trim();
+const isEmailValid = (value) => value.includes('@') && value.includes('.');
const RegisterScreen = ({ navigation }) => {
const { theme } = useTheme();
@@ -17,11 +19,11 @@ const RegisterScreen = ({ navigation }) => {
const [loading, setLoading] = useState(false);
const handleRegister = async () => {
- if (!username.trim()) { Alert.alert('Error', 'Please enter a username'); return; }
+ if (isBlank(username)) { Alert.alert('Error', 'Please enter a username'); return; }
if (username.trim().length < 3) { Alert.alert('Error', 'Username must be at least 3 characters'); return; }
- if (!email.trim()) { Alert.alert('Error', 'Please enter an email'); return; }
- if (!email.includes('@') || !email.includes('.')) { Alert.alert('Error', 'Please enter a valid email address'); return; }
- if (!password.trim()) { Alert.alert('Error', 'Please enter a password'); return; }
+ if (isBlank(email)) { Alert.alert('Error', 'Please enter an email'); return; }
+ if (!isEmailValid(email)) { Alert.alert('Error', 'Please enter a valid email address'); return; }
+ if (isBlank(password)) { Alert.alert('Error', 'Please enter a password'); return; }
if (password.length < 6) { Alert.alert('Error', 'Password must be at least 6 characters'); return; }
if (password !== confirmPassword) { Alert.alert('Error', 'Passwords do not match'); return; }
diff --git a/src/screens/SearchScreen.js b/src/screens/SearchScreen.js
index b434117..0acc496 100644
--- a/src/screens/SearchScreen.js
+++ b/src/screens/SearchScreen.js
@@ -76,7 +76,9 @@ const SearchScreen = ({ route }) => {
🔍
Search your photos
- Search by title or description
+
+ Search by filename and indexed metadata (bag-of-words style matching)
+
)}
diff --git a/src/screens/SettingsScreen.js b/src/screens/SettingsScreen.js
index 17ce7b7..86d187d 100644
--- a/src/screens/SettingsScreen.js
+++ b/src/screens/SettingsScreen.js
@@ -11,6 +11,14 @@ import ServiceHealthIndicator from '../components/ServiceHealthIndicator';
import NASAUTH from '../AUTH/NASAUTH';
import GoogleAUTH from '../AUTH/GoogleAUTH';
+const SERVICE_ICONS = { GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' };
+const SERVICE_NAMES = { GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' };
+const AUTO_BACKUP_SERVICES = [
+ { type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
+ { type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
+ { type: 'NAS', name: 'NAS Storage', icon: '💾' },
+];
+
const SettingsScreen = ({ navigation, route }) => {
const userId = route?.params?.userId;
const { theme, themeName, setThemeName } = useTheme();
@@ -93,10 +101,14 @@ const SettingsScreen = ({ navigation, route }) => {
}
};
+ const updateServiceHealth = (serviceType, status) => {
+ setServiceHealth((prev) => ({ ...prev, [serviceType]: status }));
+ };
+
const recheckHealth = async (acc) => {
- setServiceHealth((prev) => ({ ...prev, [acc.service_type]: 'checking' }));
+ updateServiceHealth(acc.service_type, 'checking');
const status = await checkSingleHealth(acc);
- setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
+ updateServiceHealth(acc.service_type, status);
};
const toggleService = async (accountId, currentEnabled) => {
@@ -188,13 +200,14 @@ const SettingsScreen = ({ navigation, route }) => {
setLoading(true);
try {
const nasAuth = new NASAUTH();
+ const parsedNasPort = parseInt(nasPort, 10) || 22;
await nasAuth.authenticate(
- nasHost.trim(), parseInt(nasPort) || 22,
+ nasHost.trim(), parsedNasPort,
nasUsername.trim(), nasPassword, nasPhotoFolder.trim()
);
await DatabaseService.linkAccount(userId, 'NAS', {
host: nasHost.trim(),
- port: parseInt(nasPort) || 22,
+ port: parsedNasPort,
username: nasUsername.trim(),
photoFolder: nasPhotoFolder.trim(),
});
@@ -216,9 +229,9 @@ const SettingsScreen = ({ navigation, route }) => {
};
const getServiceIcon = (type) =>
- ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
+ (SERVICE_ICONS[type] || '📱');
const getServiceName = (type) =>
- ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
+ (SERVICE_NAMES[type] || type);
const themeOptions = [
{ key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' },
@@ -379,11 +392,7 @@ const SettingsScreen = ({ navigation, route }) => {
New photos from your device will be automatically uploaded to the selected services.
- {[
- { type: 'GOOGLE_PHOTOS', name: 'Google Photos', icon: '📷' },
- { type: 'ICLOUD_PHOTOS', name: 'iCloud Photos', icon: '🍎' },
- { type: 'NAS', name: 'NAS Storage', icon: '💾' },
- ].map((svc) => {
+ {AUTO_BACKUP_SERVICES.map((svc) => {
const isConnected = accounts.some((a) => a.service_type === svc.type && a.is_enabled);
return (
{
style={[styles.checkRow, { borderBottomWidth: 0 }]}
onPress={() => {
setAutoBackup([]);
- DatabaseService.setAutoBackupServices(userId, []);
+ DatabaseService.setAutoBackupServices(userId, []).catch((err) => {
+ console.error('Error saving auto backup:', err);
+ });
}}
activeOpacity={0.7}
>
diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js
index 0e950a2..c23cb3a 100644
--- a/src/services/DatabaseService.js
+++ b/src/services/DatabaseService.js
@@ -1,9 +1,55 @@
import * as SQLite from 'expo-sqlite';
import CredentialEncryption from './CredentialEncryption';
+import MediaMetadataService from './MediaMetadataService';
import { logError } from './ErrorHandler';
let db = null;
let initPromise = null;
+const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
+const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
+
+const hasValue = (value) => value || value === 0;
+const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) });
+const toNullable = (value) => value || null;
+const normalizeMetaString = (itemOrParams) => {
+ if (!itemOrParams) return null;
+ if (itemOrParams.metadata && typeof itemOrParams.metadata === 'object') {
+ return JSON.stringify(itemOrParams.metadata);
+ }
+ if (itemOrParams.metadata_json != null && typeof itemOrParams.metadata_json === 'object') {
+ return JSON.stringify(itemOrParams.metadata_json);
+ }
+ return toNullable(itemOrParams.metadata_json);
+};
+const buildMediaInsertParams = (itemOrParams) => {
+ const metaStr = normalizeMetaString(itemOrParams);
+ const searchText =
+ itemOrParams.search_text ||
+ MediaMetadataService.buildSearchText(
+ {
+ title: itemOrParams.title,
+ description: itemOrParams.description,
+ source_service: itemOrParams.sourceService || itemOrParams.source_service,
+ local_path: itemOrParams.localPath || itemOrParams.local_path,
+ remote_path: itemOrParams.remotePath || itemOrParams.remote_path,
+ },
+ metaStr
+ );
+ return [
+ itemOrParams.userId,
+ itemOrParams.sourceService || itemOrParams.source_service,
+ toNullable(itemOrParams.sourceId || itemOrParams.source_id),
+ toNullable(itemOrParams.title),
+ toNullable(itemOrParams.description),
+ toNullable(itemOrParams.localPath || itemOrParams.local_path),
+ toNullable(itemOrParams.remotePath || itemOrParams.remote_path),
+ toNullable(itemOrParams.mediaType || itemOrParams.media_type),
+ toNullable(itemOrParams.createdDate || itemOrParams.created_date),
+ metaStr,
+ searchText,
+ ];
+};
const DatabaseService = {
initDatabase: async () => {
@@ -61,6 +107,31 @@ const DatabaseService = {
try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {}
try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
+ try { await db.execAsync('ALTER TABLE media ADD COLUMN metadata_json TEXT'); } catch (_) {}
+ try { await db.execAsync('ALTER TABLE media ADD COLUMN search_text TEXT'); } catch (_) {}
+
+ await db.execAsync(`
+ CREATE TABLE IF NOT EXISTS user_albums (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id)
+ );
+ CREATE TABLE IF NOT EXISTS album_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ album_id INTEGER NOT NULL,
+ media_fingerprint TEXT NOT NULL,
+ source_service TEXT,
+ title_snapshot TEXT,
+ local_path TEXT,
+ remote_path TEXT,
+ metadata_json TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(album_id) REFERENCES user_albums(id) ON DELETE CASCADE,
+ UNIQUE(album_id, media_fingerprint)
+ );
+ `);
console.log('Database initialized successfully');
return true;
@@ -84,6 +155,11 @@ const DatabaseService = {
return true;
},
+ runMediaInsert: async (params) => {
+ const result = await db.runAsync(MEDIA_INSERT_SQL, buildMediaInsertParams(params));
+ return result.lastInsertRowId;
+ },
+
createUser: async (username, email, password) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
@@ -132,7 +208,7 @@ const DatabaseService = {
getUserById: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
- if (!userId && userId !== 0) {
+ if (!hasValue(userId)) {
logError('Get User', 'User ID is required');
return null;
}
@@ -175,7 +251,7 @@ const DatabaseService = {
getLinkedAccounts: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
- if (!userId && userId !== 0) {
+ if (!hasValue(userId)) {
logError('Get Linked Accounts', 'User ID is required');
return [];
}
@@ -183,10 +259,7 @@ const DatabaseService = {
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId]
);
- return rows.map((row) => ({
- ...row,
- account_data: CredentialEncryption.decrypt(row.account_data),
- }));
+ return rows.map(withDecryptedAccount);
} catch (error) {
logError('Get Linked Accounts', error);
return [];
@@ -201,7 +274,7 @@ const DatabaseService = {
[userId, serviceType]
);
if (!row) return null;
- return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
+ return withDecryptedAccount(row);
} catch (error) {
logError('Get Linked Account By Service', error);
return null;
@@ -226,7 +299,7 @@ const DatabaseService = {
[userId]
);
if (!row) return null;
- return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
+ return withDecryptedAccount(row);
} catch (error) {
logError('Get Default Service', error);
return null;
@@ -261,10 +334,7 @@ const DatabaseService = {
'SELECT * FROM linked_accounts WHERE user_id = ?',
[userId]
);
- return rows.map((row) => ({
- ...row,
- account_data: CredentialEncryption.decrypt(row.account_data),
- }));
+ return rows.map(withDecryptedAccount);
} catch (error) {
logError('Get All Linked Accounts', error);
return [];
@@ -278,13 +348,17 @@ const DatabaseService = {
logError('Add Media', 'userId and sourceService are required');
return null;
}
- const result = await db.runAsync(
- `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [userId, sourceService, sourceId || null, title || null, description || null,
- localPath || null, remotePath || null, mediaType || null, createdDate || null]
- );
- return result.lastInsertRowId;
+ return await DatabaseService.runMediaInsert({
+ userId,
+ sourceService,
+ sourceId,
+ title,
+ description,
+ localPath,
+ remotePath,
+ mediaType,
+ createdDate,
+ });
} catch (error) {
logError('Add Media', error);
return null;
@@ -297,22 +371,59 @@ const DatabaseService = {
if (!userId || !item?.source_service)
return null;
+ const incomingMeta = normalizeMetaString(item);
+ const mergedFromIncoming = incomingMeta
+ ? MediaMetadataService.mergeMetadataJson('{}', incomingMeta)
+ : null;
+
if (item.source_id) {
const existing = await db.getFirstAsync(
- 'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
+ 'SELECT * FROM media WHERE user_id = ? AND source_id = ?',
[userId, item.source_id]
);
- if (existing) return existing.id;
+ if (existing) {
+ const mergedMeta = incomingMeta
+ ? MediaMetadataService.mergeMetadataJson(existing.metadata_json, incomingMeta)
+ : existing.metadata_json;
+ const searchText = MediaMetadataService.buildSearchText(
+ { ...existing, ...item, title: item.title || existing.title },
+ mergedMeta
+ );
+ const rowForCapture = {
+ ...existing,
+ ...item,
+ title: item.title || existing.title,
+ metadata_json: mergedMeta,
+ creationTime: item.creationTime ?? existing.creationTime,
+ modifyTime: item.modifyTime ?? existing.modifyTime,
+ };
+ const createdForDb =
+ MediaMetadataService.resolveCreatedDateIsoForStorage(rowForCapture)
+ ?? toNullable(item.created_date)
+ ?? toNullable(existing.created_date);
+ await db.runAsync(
+ `UPDATE media SET metadata_json = ?, search_text = ?,
+ local_path = COALESCE(?, local_path), remote_path = COALESCE(?, remote_path),
+ title = COALESCE(?, title), created_date = ?
+ WHERE id = ?`,
+ [
+ mergedMeta,
+ searchText,
+ toNullable(item.local_path),
+ toNullable(item.remote_path),
+ toNullable(item.title),
+ createdForDb,
+ existing.id,
+ ]
+ );
+ return existing.id;
+ }
}
- const result = await db.runAsync(
- `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [userId, item.source_service, item.source_id || null, item.title || null,
- item.description || null, item.local_path || null, item.remote_path || null,
- item.media_type || null, item.created_date || null]
- );
- return result.lastInsertRowId;
+ const insertRow = { ...item, userId, metadata_json: mergedFromIncoming };
+ const resolvedInsert = MediaMetadataService.resolveCreatedDateIsoForStorage(insertRow);
+ if (resolvedInsert) insertRow.created_date = resolvedInsert;
+ return await DatabaseService.runMediaInsert(insertRow);
} catch (error) {
logError('Save Media Item', error);
return null;
@@ -322,12 +433,12 @@ const DatabaseService = {
getMediaByUser: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
- if (!userId && userId !== 0) {
+ if (!hasValue(userId)) {
logError('Get Media', 'User ID is required');
return [];
}
return await db.getAllAsync(
- 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
+ 'SELECT * FROM media WHERE user_id = ? ORDER BY datetime(created_date) DESC',
[userId]
);
} catch (error) {
@@ -352,17 +463,128 @@ const DatabaseService = {
searchMedia: async (userId, query) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
- const searchTerm = `%${query}%`;
- return await db.getAllAsync(
- 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
- [userId, searchTerm, searchTerm]
+ const q = (query || '').trim();
+ if (!q) return [];
+ const searchTerm = `%${q}%`;
+ const likeRows = await db.getAllAsync(
+ `SELECT * FROM media WHERE user_id = ? AND (
+ title LIKE ? OR description LIKE ? OR search_text LIKE ? OR metadata_json LIKE ?
+ )`,
+ [userId, searchTerm, searchTerm, searchTerm, searchTerm]
);
+ const ranked = MediaMetadataService.rankMediaBySearchQuery(likeRows, q);
+ if (ranked.length > 0) return ranked;
+ const allRows = await db.getAllAsync('SELECT * FROM media WHERE user_id = ?', [userId]);
+ return MediaMetadataService.rankMediaBySearchQuery(allRows, q);
} catch (error) {
logError('Search Media', error);
return [];
}
},
+ createAlbum: async (userId, name) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return null;
+ if (!userId || !name?.trim()) return null;
+ const result = await db.runAsync(
+ 'INSERT INTO user_albums (user_id, name) VALUES (?, ?)',
+ [userId, name.trim()]
+ );
+ return result.lastInsertRowId;
+ } catch (error) {
+ logError('Create Album', error);
+ return null;
+ }
+ },
+
+ getUserAlbums: async (userId) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return [];
+ return await db.getAllAsync(
+ 'SELECT * FROM user_albums WHERE user_id = ? ORDER BY datetime(created_at) DESC',
+ [userId]
+ );
+ } catch (error) {
+ logError('Get User Albums', error);
+ return [];
+ }
+ },
+
+ getAlbumEntryCount: async (albumId) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return 0;
+ const row = await db.getFirstAsync(
+ 'SELECT COUNT(*) as c FROM album_entries WHERE album_id = ?',
+ [albumId]
+ );
+ return row?.c ?? 0;
+ } catch (error) {
+ logError('Album count', error);
+ return 0;
+ }
+ },
+
+ addMediaToAlbum: async (userId, albumId, item) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return false;
+ const album = await db.getFirstAsync(
+ 'SELECT id FROM user_albums WHERE id = ? AND user_id = ?',
+ [albumId, userId]
+ );
+ if (!album || !item) return false;
+ const fp = MediaMetadataService.mediaFingerprint(item);
+ const metaStr = normalizeMetaString(item) || '{}';
+ await db.runAsync(
+ `INSERT OR REPLACE INTO album_entries
+ (album_id, media_fingerprint, source_service, title_snapshot, local_path, remote_path, metadata_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [
+ albumId,
+ fp,
+ item.source_service || null,
+ item.title || null,
+ item.local_path || null,
+ item.remote_path || null,
+ metaStr,
+ ]
+ );
+ return true;
+ } catch (error) {
+ logError('Add Media To Album', error);
+ return false;
+ }
+ },
+
+ getAlbumEntries: async (userId, albumId) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return [];
+ const album = await db.getFirstAsync(
+ 'SELECT id FROM user_albums WHERE id = ? AND user_id = ?',
+ [albumId, userId]
+ );
+ if (!album) return [];
+ return await db.getAllAsync(
+ 'SELECT * FROM album_entries WHERE album_id = ? ORDER BY datetime(created_at) DESC',
+ [albumId]
+ );
+ } catch (error) {
+ logError('Get Album Entries', error);
+ return [];
+ }
+ },
+
+ removeAlbum: async (userId, albumId) => {
+ try {
+ if (!(await DatabaseService.ensureDb())) return;
+ await db.runAsync(
+ 'DELETE FROM user_albums WHERE id = ? AND user_id = ?',
+ [albumId, userId]
+ );
+ } catch (error) {
+ logError('Remove Album', error);
+ }
+ },
+
getAutoBackupServices: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
diff --git a/src/services/DeviceMediaService.js b/src/services/DeviceMediaService.js
index ab03ea2..cf0fd1c 100644
--- a/src/services/DeviceMediaService.js
+++ b/src/services/DeviceMediaService.js
@@ -1,4 +1,7 @@
import * as MediaLibrary from 'expo-media-library';
+import MediaMetadataService from './MediaMetadataService';
+
+const EMPTY_MEDIA_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
const DeviceMediaService = {
requestPermission: async () => {
@@ -13,7 +16,7 @@ const DeviceMediaService = {
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
- return { mediaItems: [], endCursor: null, hasNextPage: false };
+ return EMPTY_MEDIA_PAGE;
}
const mediaTypeFilter =
@@ -30,21 +33,9 @@ const DeviceMediaService = {
const result = await MediaLibrary.getAssetsAsync(options);
- const mediaItems = result.assets.map((asset) => ({
- source_service: 'DEVICE',
- source_id: asset.id,
- title: asset.filename || 'Untitled',
- description: '',
- local_path: asset.uri,
- remote_path: '',
- media_type: asset.mediaType === 'video' ? 'video' : 'photo',
- created_date: asset.creationTime
- ? new Date(asset.creationTime).toISOString()
- : null,
- width: asset.width,
- height: asset.height,
- duration: asset.duration || 0,
- }));
+ const mediaItems = await Promise.all(
+ result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
+ );
return {
mediaItems,
@@ -71,11 +62,11 @@ const DeviceMediaService = {
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
- return { mediaItems: [], endCursor: null, hasNextPage: false };
+ return EMPTY_MEDIA_PAGE;
}
const album = await MediaLibrary.getAlbumAsync(albumId);
- if (!album) return { mediaItems: [], endCursor: null, hasNextPage: false };
+ if (!album) return EMPTY_MEDIA_PAGE;
const options = {
first: pageSize,
@@ -86,21 +77,9 @@ const DeviceMediaService = {
const result = await MediaLibrary.getAssetsAsync(options);
- const mediaItems = result.assets.map((asset) => ({
- source_service: 'DEVICE',
- source_id: asset.id,
- title: asset.filename || 'Untitled',
- description: '',
- local_path: asset.uri,
- remote_path: '',
- media_type: asset.mediaType === 'video' ? 'video' : 'photo',
- created_date: asset.creationTime
- ? new Date(asset.creationTime).toISOString()
- : null,
- width: asset.width,
- height: asset.height,
- duration: asset.duration || 0,
- }));
+ const mediaItems = await Promise.all(
+ result.assets.map((a) => MediaMetadataService.deviceAssetToMediaItem(a))
+ );
return {
mediaItems,