feat: add home page with resume functionality and recently added VOD

This commit is contained in:
Trevor Mears
2026-01-18 03:08:08 -08:00
parent 052178e395
commit bf111e1fca
14 changed files with 731 additions and 25 deletions
+17
View File
@@ -117,6 +117,23 @@ function initSchema() {
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
`);
// Watch History (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY, -- Composite key: user_id:item_id
user_id INTEGER NOT NULL,
item_type TEXT NOT NULL, -- 'movie', 'episode'
item_id TEXT NOT NULL, -- The original item ID (stream_id or composite)
parent_id TEXT, -- For episodes (series ID)
progress INTEGER DEFAULT 0, -- Current position in seconds
duration INTEGER DEFAULT 0, -- Total duration in seconds
updated_at INTEGER NOT NULL, -- Timestamp
data JSON -- Snapshot of item data (title, poster, etc)
);
CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
`);
console.log('[SQLite] Schema initialized');
}
+1
View File
@@ -99,6 +99,7 @@ app.use('/api/remux', require('./routes/remux'));
app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history'));
// Version endpoint
app.get('/api/version', (req, res) => {
+29
View File
@@ -288,5 +288,34 @@ router.post('/hide/all', async (req, res) => {
}
});
// Get recent movies or series
router.get('/recent', async (req, res) => {
try {
const { type, limit = 12 } = req.query;
if (!type || (type !== 'movie' && type !== 'series')) {
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
}
const db = getDb();
const recentItems = db.prepare(`
SELECT * FROM playlist_items
WHERE type = ? AND is_hidden = 0
ORDER BY added_at DESC
LIMIT ?
`).all(type, parseInt(limit));
// Parse JSON data for each item
const formatted = recentItems.map(item => ({
...item,
data: JSON.parse(item.data)
}));
res.json(formatted);
} catch (err) {
console.error(`Error getting recent ${req.query.type}:`, err);
res.status(500).json({ error: 'Failed to get recent items' });
}
});
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
const { requireAuth } = require('../auth');
// Middleware to ensure authentication
router.use(requireAuth);
/**
* GET /api/history
* Returns the watch history for the authenticated user
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const limit = parseInt(req.query.limit) || 20;
const rows = db.prepare(`
SELECT * FROM watch_history
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?
`).all(userId, limit);
const history = rows.map(row => ({
...row,
data: JSON.parse(row.data || '{}')
}));
res.json(history);
} catch (err) {
console.error('[History] Error fetching history:', err);
res.status(500).json({ error: 'Failed to fetch history' });
}
});
/**
* POST /api/history
* Saves/updates watch progress for an item
*/
router.post('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const { id, type, parentId, progress, duration, data } = req.body;
if (!id || !type) {
return res.status(400).json({ error: 'Missing required fields (id, type)' });
}
const compositeId = `${userId}:${id}`;
const timestamp = Date.now();
const stmt = db.prepare(`
INSERT INTO watch_history (id, user_id, item_type, item_id, parent_id, progress, duration, updated_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
progress = excluded.progress,
duration = excluded.duration,
updated_at = excluded.updated_at,
data = excluded.data
`);
stmt.run(
compositeId,
userId,
type,
id.toString(),
parentId ? parentId.toString() : null,
progress || 0,
duration || 0,
timestamp,
JSON.stringify(data || {})
);
res.json({ success: true, timestamp });
} catch (err) {
console.error('[History] Error saving progress:', err);
res.status(500).json({ error: 'Failed to save progress' });
}
});
/**
* DELETE /api/history/:itemId
* Removes an item from the user's watch history
*/
router.delete('/:itemId', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const itemId = req.params.itemId;
const compositeId = `${userId}:${itemId}`;
const stmt = db.prepare('DELETE FROM watch_history WHERE id = ? AND user_id = ?');
const result = stmt.run(compositeId, userId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found in history' });
}
res.json({ success: true });
} catch (err) {
console.error('[History] Error deleting history item:', err);
res.status(500).json({ error: 'Failed to delete history item' });
}
});
module.exports = router;
+8 -7
View File
@@ -28,11 +28,11 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
/**
* Probe stream with ffprobe
*/
function probeStream(url, ffprobePath, timeout = 15000) {
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-print_format', 'json',
'-show_streams',
'-show_format',
@@ -138,12 +138,13 @@ function analyzeProbeResult(probeResult, url) {
}
router.get('/', async (req, res) => {
const { url } = req.query;
const { url, ua } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffprobePath = req.app.locals.ffprobePath;
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
if (!ffprobePath) {
// No ffprobe available - assume needs transcoding to be safe
@@ -159,20 +160,20 @@ router.get('/', async (req, res) => {
}
// Check cache
const cached = probeCache.get(url);
const cached = probeCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
return res.json(cached.result);
}
console.log(`[Probe] Probing: ${url.substring(0, 80)}...`);
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
try {
const probeResult = await probeStream(url, ffprobePath);
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
// Cache result
probeCache.set(url, { result: analysis, timestamp: Date.now() });
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
+4 -7
View File
@@ -196,19 +196,16 @@ class TranscodeSession extends EventEmitter {
'-err_detect', 'ignore_err',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3',
'-seekable', '0',
// Critical for syncing copied video with transcoded audio if source has non-zero start time
'-copyts'
'-reconnect_delay_max', '3'
);
// Add seek offset if specified
args.push('-i', this.url);
// Add seek offset if specified (as output option to avoid Range requests)
if (this.options.seekOffset > 0) {
args.push('-ss', String(this.options.seekOffset));
}
args.push('-i', this.url);
// Map streams
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');