849 lines
24 KiB
TypeScript
849 lines
24 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
|
|
const DB_PATH = "./blastoise.db";
|
|
const SESSION_EXPIRY_DAYS = 7;
|
|
const GUEST_SESSION_EXPIRY_HOURS = 24;
|
|
|
|
const db = new Database(DB_PATH);
|
|
|
|
// Initialize tables
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER DEFAULT 0,
|
|
is_guest INTEGER DEFAULT 0,
|
|
created_at INTEGER DEFAULT (unixepoch())
|
|
)
|
|
`);
|
|
|
|
// Migration: add is_guest column if it doesn't exist
|
|
try {
|
|
db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`);
|
|
} catch {}
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
user_agent TEXT,
|
|
ip_address TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
// Migration: add columns if they don't exist
|
|
try {
|
|
db.run(`ALTER TABLE sessions ADD COLUMN user_agent TEXT`);
|
|
} catch {}
|
|
try {
|
|
db.run(`ALTER TABLE sessions ADD COLUMN ip_address TEXT`);
|
|
} catch {}
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS permissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
resource_id TEXT,
|
|
permission TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE(user_id, resource_type, resource_id, permission)
|
|
)
|
|
`);
|
|
|
|
// Types
|
|
export interface User {
|
|
id: number;
|
|
username: string;
|
|
password_hash: string;
|
|
is_admin: boolean;
|
|
is_guest: boolean;
|
|
created_at: number;
|
|
}
|
|
|
|
export interface Session {
|
|
token: string;
|
|
user_id: number;
|
|
expires_at: number;
|
|
created_at: number;
|
|
user_agent: string | null;
|
|
ip_address: string | null;
|
|
}
|
|
|
|
export interface Permission {
|
|
id: number;
|
|
user_id: number;
|
|
resource_type: string;
|
|
resource_id: string | null;
|
|
permission: string;
|
|
}
|
|
|
|
export interface Track {
|
|
id: string;
|
|
title: string | null;
|
|
artist: string | null;
|
|
album: string | null;
|
|
duration: number;
|
|
size: number;
|
|
created_at: number;
|
|
}
|
|
|
|
// User functions
|
|
export async function createUser(username: string, password: string): Promise<User> {
|
|
const password_hash = await Bun.password.hash(password);
|
|
|
|
// First user becomes admin
|
|
const userCount = db.query("SELECT COUNT(*) as count FROM users WHERE is_guest = 0").get() as { count: number };
|
|
const is_admin = userCount.count === 0 ? 1 : 0;
|
|
|
|
const result = db.query(
|
|
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, ?, 0) RETURNING *"
|
|
).get(username, password_hash, is_admin) as any;
|
|
|
|
return { ...result, is_admin: !!result.is_admin, is_guest: false };
|
|
}
|
|
|
|
export function createGuestUser(ipAddress: string, userAgent: string): User {
|
|
const guestId = crypto.randomUUID().slice(0, 8);
|
|
const username = `guest_${guestId}`;
|
|
const password_hash = ""; // No password for guests
|
|
|
|
const result = db.query(
|
|
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1) RETURNING *"
|
|
).get(username, password_hash) as any;
|
|
|
|
return { ...result, is_admin: false, is_guest: true };
|
|
}
|
|
|
|
export function findUserById(id: number): User | null {
|
|
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
|
|
if (!result) return null;
|
|
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
|
|
}
|
|
|
|
export function findUserByUsername(username: string): User | null {
|
|
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
|
|
if (!result) return null;
|
|
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
|
|
}
|
|
|
|
export async function validatePassword(user: User, password: string): Promise<boolean> {
|
|
return Bun.password.verify(password, user.password_hash);
|
|
}
|
|
|
|
// Session functions
|
|
export function createSession(userId: number, userAgent?: string, ipAddress?: string, isGuest: boolean = false): string {
|
|
const token = crypto.randomUUID();
|
|
const expirySeconds = isGuest
|
|
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
|
|
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
|
|
const expires_at = Math.floor(Date.now() / 1000) + expirySeconds;
|
|
|
|
db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)")
|
|
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
|
|
|
|
return token;
|
|
}
|
|
|
|
export function createGuestSession(userAgent?: string, ipAddress?: string): { user: User, token: string } {
|
|
const user = createGuestUser(ipAddress ?? "unknown", userAgent ?? "unknown");
|
|
const token = createSession(user.id, userAgent, ipAddress, true);
|
|
return { user, token };
|
|
}
|
|
|
|
export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const session = db.query(
|
|
"SELECT * FROM sessions WHERE token = ? AND expires_at > ?"
|
|
).get(token, now) as Session | null;
|
|
|
|
if (!session) return null;
|
|
|
|
const user = findUserById(session.user_id);
|
|
if (!user) return null;
|
|
|
|
// Invalidate if BOTH ip and user agent changed (potential session hijack)
|
|
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
|
|
const ipChanged = session.ip_address !== currentIpAddress;
|
|
const uaChanged = session.user_agent !== currentUserAgent;
|
|
if (ipChanged && uaChanged) {
|
|
console.log(`[AUTH] Session invalidated (ip+ua changed): session=${token} old_ip=${session.ip_address} new_ip=${currentIpAddress}`);
|
|
deleteSession(token);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Sliding expiration - extend on each use
|
|
const expirySeconds = user.is_guest
|
|
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
|
|
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
|
|
const newExpiry = now + expirySeconds;
|
|
db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token);
|
|
|
|
return findUserById(session.user_id);
|
|
}
|
|
|
|
export function deleteSession(token: string): void {
|
|
db.query("DELETE FROM sessions WHERE token = ?").run(token);
|
|
}
|
|
|
|
export function deleteExpiredSessions(): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now);
|
|
}
|
|
|
|
// Permission functions
|
|
export function hasPermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): boolean {
|
|
const user = findUserById(userId);
|
|
if (!user) return false;
|
|
if (user.is_admin) return true;
|
|
|
|
const result = db.query(`
|
|
SELECT 1 FROM permissions
|
|
WHERE user_id = ?
|
|
AND resource_type = ?
|
|
AND (resource_id = ? OR resource_id IS NULL)
|
|
AND permission = ?
|
|
LIMIT 1
|
|
`).get(userId, resourceType, resourceId, permission);
|
|
|
|
return !!result;
|
|
}
|
|
|
|
export function grantPermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): void {
|
|
db.query(`
|
|
INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(userId, resourceType, resourceId, permission);
|
|
}
|
|
|
|
export function revokePermission(
|
|
userId: number,
|
|
resourceType: string,
|
|
resourceId: string | null,
|
|
permission: string
|
|
): void {
|
|
db.query(`
|
|
DELETE FROM permissions
|
|
WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ?
|
|
`).run(userId, resourceType, resourceId, permission);
|
|
}
|
|
|
|
export function getUserPermissions(userId: number): Permission[] {
|
|
return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[];
|
|
}
|
|
|
|
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
|
const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[];
|
|
return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false }));
|
|
}
|
|
|
|
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
|
return db.query(
|
|
"SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?"
|
|
).all(userId, Math.floor(Date.now() / 1000)) as Omit<Session, 'token'>[];
|
|
}
|
|
|
|
// Cleanup expired sessions periodically
|
|
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
|
|
|
// Channel tables
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS channels (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
created_by INTEGER,
|
|
is_default INTEGER DEFAULT 0,
|
|
current_index INTEGER DEFAULT 0,
|
|
started_at INTEGER DEFAULT (unixepoch() * 1000),
|
|
paused INTEGER DEFAULT 0,
|
|
paused_at REAL DEFAULT 0,
|
|
playback_mode TEXT DEFAULT 'repeat-all',
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS channel_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
channel_id TEXT NOT NULL,
|
|
track_id TEXT NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
UNIQUE(channel_id, position)
|
|
)
|
|
`);
|
|
|
|
// Create index for faster queue lookups
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_channel_queue_channel ON channel_queue(channel_id)`);
|
|
|
|
// Migration: add playback_mode column to channels
|
|
try {
|
|
db.run(`ALTER TABLE channels ADD COLUMN playback_mode TEXT DEFAULT 'repeat-all'`);
|
|
} catch {}
|
|
|
|
// Channel types
|
|
export interface ChannelRow {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
created_by: number | null;
|
|
is_default: number;
|
|
current_index: number;
|
|
started_at: number;
|
|
paused: number;
|
|
paused_at: number;
|
|
playback_mode: string;
|
|
created_at: number;
|
|
}
|
|
|
|
export interface ChannelQueueRow {
|
|
id: number;
|
|
channel_id: string;
|
|
track_id: string;
|
|
position: number;
|
|
}
|
|
|
|
// Channel CRUD functions
|
|
export function saveChannel(channel: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
createdBy: number | null;
|
|
isDefault: boolean;
|
|
currentIndex: number;
|
|
startedAt: number;
|
|
paused: boolean;
|
|
pausedAt: number;
|
|
playbackMode: string;
|
|
}): void {
|
|
db.query(`
|
|
INSERT INTO channels (id, name, description, created_by, is_default, current_index, started_at, paused, paused_at, playback_mode)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
description = excluded.description,
|
|
current_index = excluded.current_index,
|
|
started_at = excluded.started_at,
|
|
paused = excluded.paused,
|
|
paused_at = excluded.paused_at,
|
|
playback_mode = excluded.playback_mode
|
|
`).run(
|
|
channel.id,
|
|
channel.name,
|
|
channel.description,
|
|
channel.createdBy,
|
|
channel.isDefault ? 1 : 0,
|
|
channel.currentIndex,
|
|
channel.startedAt,
|
|
channel.paused ? 1 : 0,
|
|
channel.pausedAt,
|
|
channel.playbackMode
|
|
);
|
|
}
|
|
|
|
export function updateChannelState(channelId: string, state: {
|
|
currentIndex: number;
|
|
startedAt: number;
|
|
paused: boolean;
|
|
pausedAt: number;
|
|
playbackMode: string;
|
|
}): void {
|
|
db.query(`
|
|
UPDATE channels
|
|
SET current_index = ?, started_at = ?, paused = ?, paused_at = ?, playback_mode = ?
|
|
WHERE id = ?
|
|
`).run(state.currentIndex, state.startedAt, state.paused ? 1 : 0, state.pausedAt, state.playbackMode, channelId);
|
|
}
|
|
|
|
export function loadChannel(id: string): ChannelRow | null {
|
|
return db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null;
|
|
}
|
|
|
|
export function loadAllChannels(): ChannelRow[] {
|
|
return db.query("SELECT * FROM channels").all() as ChannelRow[];
|
|
}
|
|
|
|
export function deleteChannelFromDb(id: string): void {
|
|
db.query("DELETE FROM channels WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function updateChannelName(id: string, name: string): void {
|
|
db.query("UPDATE channels SET name = ? WHERE id = ?").run(name, id);
|
|
}
|
|
|
|
// Queue persistence functions
|
|
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
|
db.query("BEGIN").run();
|
|
try {
|
|
db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId);
|
|
|
|
const insert = db.query(
|
|
"INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)"
|
|
);
|
|
for (let i = 0; i < trackIds.length; i++) {
|
|
insert.run(channelId, trackIds[i], i);
|
|
}
|
|
db.query("COMMIT").run();
|
|
} catch (e) {
|
|
db.query("ROLLBACK").run();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function loadChannelQueue(channelId: string): string[] {
|
|
const rows = db.query(
|
|
"SELECT track_id FROM channel_queue WHERE channel_id = ? ORDER BY position"
|
|
).all(channelId) as { track_id: string }[];
|
|
return rows.map(r => r.track_id);
|
|
}
|
|
|
|
export function removeTrackFromQueues(trackId: string): void {
|
|
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
|
}
|
|
|
|
// Playlist tables
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS playlists (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
owner_id INTEGER NOT NULL,
|
|
is_public INTEGER DEFAULT 0,
|
|
share_token TEXT,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch()),
|
|
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
playlist_id TEXT NOT NULL,
|
|
track_id TEXT NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
|
)
|
|
`);
|
|
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id)`);
|
|
|
|
// Playlist types
|
|
export interface PlaylistRow {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
owner_id: number;
|
|
is_public: number;
|
|
share_token: string | null;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
export interface Playlist {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
ownerId: number;
|
|
isPublic: boolean;
|
|
shareToken: string | null;
|
|
trackIds: string[];
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
// Playlist CRUD functions
|
|
export function createPlaylist(name: string, ownerId: number, description: string = ""): Playlist {
|
|
const id = crypto.randomUUID();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
db.query(`
|
|
INSERT INTO playlists (id, name, description, owner_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(id, name, description, ownerId, now, now);
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
description,
|
|
ownerId,
|
|
isPublic: false,
|
|
shareToken: null,
|
|
trackIds: [],
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
}
|
|
|
|
export function getPlaylist(id: string): Playlist | null {
|
|
const row = db.query("SELECT * FROM playlists WHERE id = ?").get(id) as PlaylistRow | null;
|
|
if (!row) return null;
|
|
|
|
const tracks = db.query(
|
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
|
).all(id) as { track_id: string }[];
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
ownerId: row.owner_id,
|
|
isPublic: !!row.is_public,
|
|
shareToken: row.share_token,
|
|
trackIds: tracks.map(t => t.track_id),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
}
|
|
|
|
export function getPlaylistsByUser(userId: number): Playlist[] {
|
|
const rows = db.query(
|
|
"SELECT * FROM playlists WHERE owner_id = ? ORDER BY name"
|
|
).all(userId) as PlaylistRow[];
|
|
|
|
return rows.map(row => {
|
|
const tracks = db.query(
|
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
|
).all(row.id) as { track_id: string }[];
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
ownerId: row.owner_id,
|
|
isPublic: !!row.is_public,
|
|
shareToken: row.share_token,
|
|
trackIds: tracks.map(t => t.track_id),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getPublicPlaylists(excludeUserId?: number): Playlist[] {
|
|
const query = excludeUserId
|
|
? "SELECT * FROM playlists WHERE is_public = 1 AND owner_id != ? ORDER BY name"
|
|
: "SELECT * FROM playlists WHERE is_public = 1 ORDER BY name";
|
|
|
|
const rows = excludeUserId
|
|
? db.query(query).all(excludeUserId) as PlaylistRow[]
|
|
: db.query(query).all() as PlaylistRow[];
|
|
|
|
return rows.map(row => {
|
|
const tracks = db.query(
|
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
|
).all(row.id) as { track_id: string }[];
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
ownerId: row.owner_id,
|
|
isPublic: !!row.is_public,
|
|
shareToken: row.share_token,
|
|
trackIds: tracks.map(t => t.track_id),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getPlaylistByShareToken(token: string): Playlist | null {
|
|
const row = db.query(
|
|
"SELECT * FROM playlists WHERE share_token = ?"
|
|
).get(token) as PlaylistRow | null;
|
|
|
|
if (!row) return null;
|
|
|
|
const tracks = db.query(
|
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
|
).all(row.id) as { track_id: string }[];
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
ownerId: row.owner_id,
|
|
isPublic: !!row.is_public,
|
|
shareToken: row.share_token,
|
|
trackIds: tracks.map(t => t.track_id),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
}
|
|
|
|
export function updatePlaylist(id: string, updates: { name?: string; description?: string; isPublic?: boolean }): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const sets: string[] = ["updated_at = ?"];
|
|
const values: any[] = [now];
|
|
|
|
if (updates.name !== undefined) {
|
|
sets.push("name = ?");
|
|
values.push(updates.name);
|
|
}
|
|
if (updates.description !== undefined) {
|
|
sets.push("description = ?");
|
|
values.push(updates.description);
|
|
}
|
|
if (updates.isPublic !== undefined) {
|
|
sets.push("is_public = ?");
|
|
values.push(updates.isPublic ? 1 : 0);
|
|
}
|
|
|
|
values.push(id);
|
|
db.query(`UPDATE playlists SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
export function deletePlaylist(id: string): void {
|
|
db.query("DELETE FROM playlists WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function setPlaylistTracks(playlistId: string, trackIds: string[]): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
db.query("BEGIN").run();
|
|
try {
|
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ?").run(playlistId);
|
|
|
|
const insert = db.query(
|
|
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
|
);
|
|
for (let i = 0; i < trackIds.length; i++) {
|
|
insert.run(playlistId, trackIds[i], i);
|
|
}
|
|
|
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
|
db.query("COMMIT").run();
|
|
} catch (e) {
|
|
db.query("ROLLBACK").run();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function addTracksToPlaylist(playlistId: string, trackIds: string[]): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
db.query("BEGIN").run();
|
|
try {
|
|
// Get current max position
|
|
const max = db.query(
|
|
"SELECT COALESCE(MAX(position), -1) as max_pos FROM playlist_tracks WHERE playlist_id = ?"
|
|
).get(playlistId) as { max_pos: number };
|
|
|
|
let pos = max.max_pos + 1;
|
|
const insert = db.query(
|
|
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
|
);
|
|
for (const trackId of trackIds) {
|
|
insert.run(playlistId, trackId, pos++);
|
|
}
|
|
|
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
|
db.query("COMMIT").run();
|
|
} catch (e) {
|
|
db.query("ROLLBACK").run();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
db.query("BEGIN").run();
|
|
try {
|
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
|
|
|
|
// Reorder remaining tracks
|
|
db.query(`
|
|
UPDATE playlist_tracks
|
|
SET position = position - 1
|
|
WHERE playlist_id = ? AND position > ?
|
|
`).run(playlistId, position);
|
|
|
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
|
db.query("COMMIT").run();
|
|
} catch (e) {
|
|
db.query("ROLLBACK").run();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export function generatePlaylistShareToken(playlistId: string): string {
|
|
const token = crypto.randomUUID().slice(0, 12);
|
|
db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId);
|
|
return token;
|
|
}
|
|
|
|
export function removePlaylistShareToken(playlistId: string): void {
|
|
db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId);
|
|
}
|
|
|
|
// Slow queue table for yt-dlp playlist downloads
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS slow_queue (
|
|
id TEXT PRIMARY KEY,
|
|
url TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
status TEXT DEFAULT 'queued',
|
|
progress REAL DEFAULT 0,
|
|
error TEXT,
|
|
playlist_id TEXT,
|
|
playlist_name TEXT,
|
|
position INTEGER,
|
|
created_at INTEGER,
|
|
completed_at INTEGER,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
|
)
|
|
`);
|
|
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_user ON slow_queue(user_id)`);
|
|
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_status ON slow_queue(status)`);
|
|
|
|
// Slow queue types
|
|
export interface SlowQueueRow {
|
|
id: string;
|
|
url: string;
|
|
title: string;
|
|
user_id: number;
|
|
status: string;
|
|
progress: number;
|
|
error: string | null;
|
|
playlist_id: string | null;
|
|
playlist_name: string | null;
|
|
position: number | null;
|
|
created_at: number;
|
|
completed_at: number | null;
|
|
}
|
|
|
|
// Slow queue CRUD functions
|
|
export function saveSlowQueueItem(item: {
|
|
id: string;
|
|
url: string;
|
|
title: string;
|
|
userId: number;
|
|
status: string;
|
|
progress: number;
|
|
error?: string;
|
|
playlistId?: string;
|
|
playlistName?: string;
|
|
position?: number;
|
|
createdAt: number;
|
|
completedAt?: number;
|
|
}): void {
|
|
db.query(`
|
|
INSERT INTO slow_queue (id, url, title, user_id, status, progress, error, playlist_id, playlist_name, position, created_at, completed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
status = excluded.status,
|
|
progress = excluded.progress,
|
|
error = excluded.error,
|
|
completed_at = excluded.completed_at
|
|
`).run(
|
|
item.id,
|
|
item.url,
|
|
item.title,
|
|
item.userId,
|
|
item.status,
|
|
item.progress,
|
|
item.error ?? null,
|
|
item.playlistId ?? null,
|
|
item.playlistName ?? null,
|
|
item.position ?? null,
|
|
item.createdAt,
|
|
item.completedAt ?? null
|
|
);
|
|
}
|
|
|
|
export function updateSlowQueueItem(id: string, updates: {
|
|
status?: string;
|
|
progress?: number;
|
|
error?: string;
|
|
completedAt?: number;
|
|
}): void {
|
|
const sets: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (updates.status !== undefined) {
|
|
sets.push("status = ?");
|
|
values.push(updates.status);
|
|
}
|
|
if (updates.progress !== undefined) {
|
|
sets.push("progress = ?");
|
|
values.push(updates.progress);
|
|
}
|
|
if (updates.error !== undefined) {
|
|
sets.push("error = ?");
|
|
values.push(updates.error);
|
|
}
|
|
if (updates.completedAt !== undefined) {
|
|
sets.push("completed_at = ?");
|
|
values.push(updates.completedAt);
|
|
}
|
|
|
|
if (sets.length === 0) return;
|
|
|
|
values.push(id);
|
|
db.query(`UPDATE slow_queue SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
export function loadSlowQueue(): SlowQueueRow[] {
|
|
return db.query(
|
|
"SELECT * FROM slow_queue WHERE status IN ('queued', 'downloading') ORDER BY created_at"
|
|
).all() as SlowQueueRow[];
|
|
}
|
|
|
|
export function deleteSlowQueueItem(id: string): void {
|
|
db.query("DELETE FROM slow_queue WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function clearCompletedSlowQueue(maxAge: number = 3600): void {
|
|
const cutoff = Math.floor(Date.now() / 1000) - maxAge;
|
|
db.query(
|
|
"DELETE FROM slow_queue WHERE status IN ('complete', 'error', 'cancelled') AND completed_at < ?"
|
|
).run(cutoff);
|
|
}
|
|
|
|
export function getSlowQueueByUser(userId: number): SlowQueueRow[] {
|
|
return db.query(
|
|
"SELECT * FROM slow_queue WHERE user_id = ? ORDER BY created_at"
|
|
).all(userId) as SlowQueueRow[];
|
|
}
|
|
|
|
export function playlistNameExists(name: string, userId: number): boolean {
|
|
const result = db.query(
|
|
"SELECT 1 FROM playlists WHERE name = ? AND owner_id = ? LIMIT 1"
|
|
).get(name, userId);
|
|
return !!result;
|
|
}
|
|
|
|
export function generateUniquePlaylistName(baseName: string, userId: number): string {
|
|
if (!playlistNameExists(baseName, userId)) {
|
|
return baseName;
|
|
}
|
|
|
|
let counter = 2;
|
|
while (playlistNameExists(`${baseName} (${counter})`, userId)) {
|
|
counter++;
|
|
}
|
|
return `${baseName} (${counter})`;
|
|
}
|