feat: add home page with resume functionality and recently added VOD
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}, ` +
|
||||
|
||||
@@ -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?');
|
||||
|
||||
Reference in New Issue
Block a user