feat: update app structure and improve user experience; refactor media handling and enhance NAS integration

This commit is contained in:
bobbert committed 2026-04-15 01:06:05 +01:00
1 parent 4006cd93f1
commit ac9e4aee43
23 files changed
+1229 -643

No files matched your search

+1
View File
@@ -11,3 +11,4 @@
*.tmp
.env
!.env.example.txt
ServerFiles/data/
+6 -1
View File
@@ -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 (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}
registerRootComponent(App);
export default App;
+11 -13
View File
@@ -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"
}
}
+98 -182
View File
@@ -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;
+66 -29
View File
@@ -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();
-1
View File
@@ -1,6 +1,5 @@
{
"expo": {
"entryPoint": "./App.js",
"name": "MASS Photo App",
"slug": "mass-expo",
"owner": "bobthebob",
+30 -30
View File
@@ -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",
+184 -64
View File
@@ -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;
+37 -31
View File
@@ -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;
}
}
+18 -8
View File
@@ -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);
}
}
+38 -59
View File
@@ -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 <Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />;
}
return (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: placeholderSize }}>📷</Text>
</View>
);
};
const renderMainItem = ({ item }) => (
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, paddingBottom: DRAWER_PEEK }}>
{renderImageOrPlaceholder(item, 64)}
</View>
);
@@ -105,6 +106,8 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const displayMedia = currentMedia || media;
if (!media) return null;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
@@ -138,17 +141,13 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
style={styles.mainList}
/>
) : (
displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)
<View style={{ flex: 1, paddingBottom: DRAWER_PEEK }}>
{renderImageOrPlaceholder(displayMedia, 64)}
</View>
)}
{hasCarousel && (
<View style={styles.thumbCarousel}>
<View style={[styles.thumbCarousel, { bottom: DRAWER_PEEK + 8 }]}>
<FlatList
ref={thumbListRef}
data={mediaList}
@@ -166,33 +165,12 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</View>
)}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{displayMedia.sources && displayMedia.sources.length > 1
? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
: `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
}
</Text>
</View>
{displayMedia.sources && displayMedia.sources.length > 1 && (
<View style={styles.row}>
<Text style={styles.label}>Available on</Text>
<Text style={[styles.value, { color: '#8ab4f8' }]}>
{displayMedia.sources.length} services
</Text>
</View>
)}
{displayMedia.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
<Text style={styles.value}>
{new Date(displayMedia.created_date).toLocaleDateString()}
</Text>
</View>
)}
<View style={styles.drawerLayer} pointerEvents="box-none">
<MediaDetailDrawer
theme={theme}
displayMedia={displayMedia}
resolveNasMetadata={resolveNasMetadata}
/>
</View>
</View>
</Modal>
@@ -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;
+22 -41
View File
@@ -23,53 +23,34 @@ const NASSetupInstructions = () => {
{expanded && (
<View style={styles.body}>
<Text style={[styles.stepTitle, { color: theme.primary }]}>What this is</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Prerequisites</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
• 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
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Enable SSH</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 1: Run the backend</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Create a Photos Directory</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
Create a dedicated folder for your photo storage:{'\n\n'}
mkdir -p ~/photos{'\n'}
chmod 755 ~/photos
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 3: Configure Firewall (if needed)</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
If your server uses UFW, allow SSH connections:{'\n\n'}
sudo ufw allow ssh{'\n'}
sudo ufw enable
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 4: Find Your Server IP</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 5: Connect from the App</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 6: Run the Backend Server</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>Step 2: Connect from the app</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
<Text style={[styles.stepTitle, { color: theme.primary }]}>API shape</Text>
<Text style={[styles.stepText, { color: theme.textSecondary }]}>
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.
</Text>
</View>
)}
+134 -31
View File
@@ -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 }) => (
<TouchableOpacity
style={[styles.gridItem, { width: itemSize, height: itemSize, backgroundColor: theme.chipBg }]}
onPress={() => onItemPress?.(item)}
activeOpacity={0.8}
>
{item.local_path ? (
<Image
source={{ uri: item.local_path }}
style={styles.gridImage}
resizeMode="cover"
onError={(e) => 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 (
<View
style={[styles.gridItem, { width: itemSize, height: itemSize, margin: GRID_GAP / 2 }]}
pointerEvents="none"
/>
) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text>
</View>
)}
{item.media_type === 'video' && (
<View style={styles.videoOverlay}>
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View>
)}
{renderSourceBadges(item)}
</TouchableOpacity>
);
);
}
const k = keyOf(item);
const selected = selectionMode && selectedKeys?.has(k);
return (
<TouchableOpacity
style={[
styles.gridItem,
{
width: itemSize,
height: itemSize,
backgroundColor: theme.chipBg,
borderWidth: selected ? 3 : 0,
borderColor: theme.primary,
},
]}
onPress={() => {
if (selectionMode) {
onToggleSelect?.(item);
return;
}
onItemPress?.(item);
}}
onLongPress={() => onLongPressItem?.(item)}
delayLongPress={380}
activeOpacity={0.8}
>
{item.local_path ? (
<Image
source={{ uri: item.local_path }}
style={styles.gridImage}
resizeMode="cover"
onError={(e) => console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
/>
) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text>
</View>
)}
{item.media_type === 'video' && (
<View style={styles.videoOverlay}>
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View>
)}
{renderSourceBadges(item)}
{selected && (
<View style={[styles.checkBadge, { borderColor: theme.primary }]}>
<Text style={[styles.checkMark, { color: theme.primary }]}>✓</Text>
</View>
)}
</TouchableOpacity>
);
};
const renderRow = ({ item }) => {
const row = item.row || item;
return (
<View style={styles.rowWrap}>
{Array.from({ length: columns }).map((_, i) => (
<View
key={`c${i}-${row[i] ? keyOf(row[i]) : 'empty'}`}
style={{ width: itemSize, marginHorizontal: GRID_GAP / 2 }}
>
{renderCell(row[i])}
</View>
))}
</View>
);
};
if (timelineSections && timelineSections.length > 0) {
return (
<SectionList
sections={timelineSections}
keyExtractor={(item) => item.rowKey || item.row?.map((c) => (c ? keyOf(c) : '·')).join('|')}
renderItem={renderRow}
renderSectionHeader={({ section }) => (
<View style={[styles.sectionHeader, { backgroundColor: theme.background }]}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{section.title}</Text>
</View>
)}
stickySectionHeadersEnabled
contentContainerStyle={styles.gridContent}
refreshControl={refreshControl}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={ListFooterComponent}
onEndReached={onEndReached}
onEndReachedThreshold={0.4}
/>
);
}
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, i) => 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;
+2 -3
View File
@@ -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 (
<View style={[styles.topBar, { backgroundColor: theme.background }]}>
@@ -18,9 +19,7 @@ const TopBar = ({ currentUser, onSearchPress, onProfilePress, onMenuPress }) =>
style={[styles.profileButton, { backgroundColor: theme.profileBg }]}
onPress={onProfilePress}
>
<Text style={styles.profileInitial}>
{currentUser?.username?.charAt(0)?.toUpperCase() || '?'}
</Text>
<Text style={styles.profileInitial}>{profileInitial}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={onMenuPress}>
<Text style={[styles.menuDots, { color: theme.textSecondary }]}>⋮</Text>
+35 -4
View File
@@ -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 }) => {
<Text style={[styles.headerTitle, { color: theme.text }]}>Collections</Text>
</View>
{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)) ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 48, marginBottom: 12 }}>📁</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Collections</Text>
@@ -192,7 +223,7 @@ const CollectionsScreen = ({ route }) => {
contentContainerStyle={styles.listContent}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadCollections(); }} />
<RefreshControl refreshing={refreshing} onRefresh={refreshCollections} />
}
/>
)}
+205 -42
View File
@@ -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 }) => {
<View style={[styles.container, { backgroundColor: theme.background }]}>
<TopBar
currentUser={currentUser}
onSearchPress={() => navigation.navigate('Search')}
onSearchPress={() => navigation.navigate('Search', { userId })}
onProfilePress={() => navigation.navigate('Settings', { userId })}
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
<FilterChips />
<MediaSortControls value={sortMode} onChange={setSortMode} />
<SelectionToolbar
visible={selectionMode}
selectedCount={selectedKeys.size}
onAddToAlbum={() => setAlbumModalVisible(true)}
onClear={clearSelection}
/>
{hasGoogleAccount && (
<TouchableOpacity
style={[styles.importButton, { backgroundColor: theme.primary }]}
@@ -372,18 +521,23 @@ const HomeScreen = ({ navigation, route }) => {
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: theme.primary }]}
onPress={() => { setRefreshing(true); loadAllMedia(); }}
onPress={refreshMedia}
>
<Text style={styles.retryButtonText}>Refresh</Text>
</TouchableOpacity>
</View>
) : (
<PhotoGrid
data={filteredMedia}
data={sortMode === 'date_taken' ? [] : filteredMedia}
timelineSections={timelineSections}
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
onLongPressItem={onLongPressItem}
onToggleSelect={toggleSelect}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
onEndReached={loadMorePhotos}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
<RefreshControl refreshing={refreshing} onRefresh={refreshMedia} />
}
ListFooterComponent={loadingMore ? (
<View style={{ padding: 16, alignItems: 'center' }}>
@@ -393,11 +547,20 @@ const HomeScreen = ({ navigation, route }) => {
/>
)}
<AlbumPickerModal
visible={albumModalVisible}
onClose={() => setAlbumModalVisible(false)}
albums={userAlbums}
onPickAlbum={(albumId) => addSelectionToAlbum(albumId)}
onCreateAlbum={createAlbumAndAdd}
/>
<MediaViewer
visible={viewerVisible}
media={selectedMedia}
allMedia={filteredMedia}
onClose={() => setViewerVisible(false)}
resolveNasMetadata={resolveNasMetadata}
/>
</View>
);
+2 -1
View File
@@ -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;
}
+39 -18
View File
@@ -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 (
<View style={[styles.container, { backgroundColor: theme.background, justifyContent: 'center', alignItems: 'center' }]}>
<ActivityIndicator size="large" color={theme.primary} />
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<View style={styles.progressBar}>
{steps.map((_, i) => (
{ONBOARDING_STEPS.map((_, i) => (
<View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
))}
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {steps.length}</Text>
<Text style={[styles.title, { color: theme.text }]}>{steps[currentStep].title}</Text>
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
<Text style={[styles.stepLabel, { color: theme.primary }]}>Step {currentStep + 1} of {ONBOARDING_STEPS.length}</Text>
<Text style={[styles.title, { color: theme.text }]}>{ONBOARDING_STEPS[currentStep].title}</Text>
<Text style={[styles.description, { color: theme.textSecondary }]}>{ONBOARDING_STEPS[currentStep].description}</Text>
</View>
{currentStep === 0 && (
@@ -332,7 +353,7 @@ const OnboardingScreen = ({ navigation, route }) => {
>
{loading ? <ActivityIndicator color="#fff" /> : (
<Text style={styles.nextButtonText}>
{currentStep === steps.length - 1 ? 'Get Started' : 'Next'}
{currentStep === ONBOARDING_STEPS.length - 1 ? 'Get Started' : 'Next'}
</Text>
)}
</TouchableOpacity>
+6 -4
View File
@@ -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; }
+3 -1
View File
@@ -76,7 +76,9 @@ const SearchScreen = ({ route }) => {
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>Search your photos</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Search by title or description</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Search by filename and indexed metadata (bag-of-words style matching)
</Text>
</View>
)}
+23 -12
View File
@@ -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 }) => {
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
New photos from your device will be automatically uploaded to the selected services.
</Text>
{[
{ 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 (
<TouchableOpacity
@@ -417,7 +426,9 @@ const SettingsScreen = ({ navigation, route }) => {
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}
>
+257 -35
View File
@@ -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 [];
+12 -33
View File
@@ -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,