vidrip/backend/src/db/database.ts

305 lines
9.4 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
import { Channel, Playlist, Video, Config } from '../types';
const dbPath = path.join(__dirname, '../../data.db');
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
export function initDatabase() {
// Create base tables first (without foreign keys that reference tables not yet created)
db.exec(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
channelId TEXT NOT NULL,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
lastChecked TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
playlistId TEXT NOT NULL,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
lastChecked TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL
);
`);
// Check if videos table exists and if it has playlistId column
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='videos'").all() as Array<{ name: string }>;
const videosTableExists = tables.length > 0;
if (videosTableExists) {
// Videos table exists - check if it needs migration
const tableInfo = db.prepare("PRAGMA table_info(videos)").all() as Array<{ name: string }>;
const hasPlaylistId = tableInfo.some(col => col.name === 'playlistId');
if (!hasPlaylistId) {
console.log('Migrating database: Adding playlistId column to videos table...');
db.exec('ALTER TABLE videos ADD COLUMN playlistId INTEGER');
console.log('Migration completed successfully');
}
} else {
// Videos table doesn't exist - create it with playlistId
db.exec(`
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
playlistId INTEGER,
videoId TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
duration INTEGER,
thumbnail TEXT,
uploadDate TEXT,
status TEXT NOT NULL DEFAULT 'pending',
filePath TEXT,
fileSize INTEGER,
addedAt TEXT NOT NULL DEFAULT (datetime('now')),
downloadedAt TEXT,
error TEXT,
FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE
);
`);
}
// Create indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
CREATE INDEX IF NOT EXISTS idx_videos_channelId ON videos(channelId);
CREATE INDEX IF NOT EXISTS idx_videos_playlistId ON videos(playlistId);
`);
// Initialize default config
const defaultConfig = {
intervalMinutes: '180', // 180 minutes = 3 hours
varianceMinutes: '30',
maxConcurrentDownloads: '1',
enabled: 'true'
};
const insertConfig = db.prepare(
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
);
Object.entries(defaultConfig).forEach(([key, value]) => {
insertConfig.run(key, value);
});
// Migration: Convert intervalHours to intervalMinutes if it exists
try {
const intervalHours = configOperations.get('intervalHours');
if (intervalHours) {
const hours = parseFloat(intervalHours);
const minutes = hours * 60;
console.log(`Migrating config: Converting ${hours} hours to ${minutes} minutes`);
configOperations.set('intervalMinutes', minutes.toString());
// Remove old key
db.prepare('DELETE FROM config WHERE key = ?').run('intervalHours');
}
} catch (error) {
// Ignore migration errors
}
console.log('Database initialized');
}
// Channel operations
export const channelOperations = {
getAll: () => {
return db.prepare('SELECT * FROM channels ORDER BY addedAt DESC').all() as Channel[];
},
getById: (id: number) => {
return db.prepare('SELECT * FROM channels WHERE id = ?').get(id) as Channel | undefined;
},
create: (url: string, name: string, channelId: string) => {
const stmt = db.prepare(
'INSERT INTO channels (url, name, channelId) VALUES (?, ?, ?)'
);
const result = stmt.run(url, name, channelId);
return result.lastInsertRowid as number;
},
update: (id: number, data: Partial<Channel>) => {
const fields = Object.keys(data).filter(k => k !== 'id');
const values = fields.map(k => data[k as keyof Channel]);
const setClause = fields.map(f => `${f} = ?`).join(', ');
const stmt = db.prepare(`UPDATE channels SET ${setClause} WHERE id = ?`);
return stmt.run(...values, id);
},
delete: (id: number) => {
return db.prepare('DELETE FROM channels WHERE id = ?').run(id);
},
updateLastChecked: (id: number) => {
return db.prepare(
"UPDATE channels SET lastChecked = datetime('now') WHERE id = ?"
).run(id);
}
};
// Playlist operations
export const playlistOperations = {
getAll: () => {
return db.prepare('SELECT * FROM playlists ORDER BY addedAt DESC').all() as Playlist[];
},
getById: (id: number) => {
return db.prepare('SELECT * FROM playlists WHERE id = ?').get(id) as Playlist | undefined;
},
create: (url: string, name: string, playlistId: string) => {
const stmt = db.prepare(
'INSERT INTO playlists (url, name, playlistId) VALUES (?, ?, ?)'
);
const result = stmt.run(url, name, playlistId);
return result.lastInsertRowid as number;
},
update: (id: number, data: Partial<Playlist>) => {
const fields = Object.keys(data).filter(k => k !== 'id');
const values = fields.map(k => data[k as keyof Playlist]);
const setClause = fields.map(f => `${f} = ?`).join(', ');
const stmt = db.prepare(`UPDATE playlists SET ${setClause} WHERE id = ?`);
return stmt.run(...values, id);
},
delete: (id: number) => {
return db.prepare('DELETE FROM playlists WHERE id = ?').run(id);
},
updateLastChecked: (id: number) => {
return db.prepare(
"UPDATE playlists SET lastChecked = datetime('now') WHERE id = ?"
).run(id);
}
};
// Video operations
export const videoOperations = {
getAll: () => {
return db.prepare('SELECT * FROM videos ORDER BY addedAt DESC').all() as Video[];
},
getById: (id: number) => {
return db.prepare('SELECT * FROM videos WHERE id = ?').get(id) as Video | undefined;
},
getByChannelId: (channelId: number) => {
return db.prepare('SELECT * FROM videos WHERE channelId = ? ORDER BY addedAt DESC')
.all(channelId) as Video[];
},
getByPlaylistId: (playlistId: number) => {
return db.prepare('SELECT * FROM videos WHERE playlistId = ? ORDER BY addedAt DESC')
.all(playlistId) as Video[];
},
getByStatus: (status: string) => {
return db.prepare('SELECT * FROM videos WHERE status = ? ORDER BY addedAt ASC')
.all(status) as Video[];
},
search: (query: string, status?: string) => {
const searchPattern = `%${query}%`;
if (status) {
return db.prepare(
'SELECT * FROM videos WHERE title LIKE ? COLLATE NOCASE AND status = ? ORDER BY addedAt DESC'
).all(searchPattern, status) as Video[];
} else {
return db.prepare(
'SELECT * FROM videos WHERE title LIKE ? COLLATE NOCASE ORDER BY addedAt DESC'
).all(searchPattern) as Video[];
}
},
getNextPending: () => {
return db.prepare('SELECT * FROM videos WHERE status = ? ORDER BY addedAt ASC LIMIT 1')
.get('pending') as Video | undefined;
},
create: (video: Omit<Video, 'id' | 'addedAt' | 'downloadedAt'>) => {
const stmt = db.prepare(`
INSERT INTO videos (channelId, playlistId, videoId, title, url, duration, thumbnail, uploadDate, status, filePath, fileSize, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
video.channelId,
video.playlistId,
video.videoId,
video.title,
video.url,
video.duration,
video.thumbnail,
video.uploadDate,
video.status,
video.filePath,
video.fileSize,
video.error
);
return result.lastInsertRowid as number;
},
updateStatus: (id: number, status: string, error?: string) => {
const errorValue = error !== undefined ? error : null;
return db.prepare('UPDATE videos SET status = ?, error = ? WHERE id = ?')
.run(status, errorValue, id);
},
markCompleted: (id: number, filePath: string, fileSize: number) => {
return db.prepare(`
UPDATE videos
SET status = 'completed', filePath = ?, fileSize = ?, downloadedAt = datetime('now')
WHERE id = ?
`).run(filePath, fileSize, id);
},
delete: (id: number) => {
return db.prepare('DELETE FROM videos WHERE id = ?').run(id);
}
};
// Config operations
export const configOperations = {
get: (key: string) => {
const result = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as Config | undefined;
return result?.value;
},
set: (key: string, value: string) => {
return db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(key, value);
},
getAll: () => {
const configs = db.prepare('SELECT * FROM config').all() as Config[];
const result: Record<string, string> = {};
configs.forEach(c => {
result[c.key] = c.value;
});
return result;
}
};
export default db;