Files
iptvnator/libs/shared/database/src/lib/schema.ts
T
4grayandClaude Opus 4.7 9ecf4d44d3 perf(database): add composite indexes for hot ORDER BY paths
The existing single-column indexes force a sort step for the most common
list queries. These three composite indexes let SQLite scan in index
order and skip the sort entirely:

- recently_viewed(playlist_id, viewed_at DESC):
  getRecentItems(): WHERE playlist_id = ? ORDER BY viewed_at DESC LIMIT 100
- favorites(playlist_id, position, added_at DESC):
  getFavorites/getGlobalFavorites/getAllGlobalFavorites():
  WHERE playlist_id = ? ORDER BY position ASC, added_at DESC
- playback_positions(playlist_id, updated_at DESC):
  getRecentPlaybackPositions(): WHERE playlist_id = ? ORDER BY updated_at DESC LIMIT 20

CREATE INDEX IF NOT EXISTS so existing DBs pick up the new indexes on
next startup with no migration required. Mirrored in the Drizzle schema
using sql\`col DESC\` so the ORM stays the source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:11:44 +02:00

333 lines
12 KiB
TypeScript

/**
* Drizzle ORM schema for IPTVnator database
* This schema defines the structure for Xtream Codes API data storage
*
* Single source of truth for database schema - used by:
* - electron-backend (full read-write access)
* - agent-backend (read-only access for AI queries)
*/
import { sql } from 'drizzle-orm';
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
// Playlists table
export const playlists = sqliteTable('playlists', {
id: text('id').primaryKey(),
name: text('name').notNull(),
serverUrl: text('serverUrl'),
username: text('username'),
password: text('password'),
dateCreated: text('date_created').default(sql`CURRENT_TIMESTAMP`),
lastUpdated: text('last_updated'),
type: text('type', {
enum: ['xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url'],
}).notNull(),
userAgent: text('userAgent'),
origin: text('origin'),
referrer: text('referrer'),
filePath: text('filePath'),
autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false),
macAddress: text('macAddress'),
url: text('url'),
portalUrl: text('portal_url'),
count: integer('count'),
importDate: text('import_date'),
updateDate: integer('update_date'),
position: integer('position'),
favorites: text('favorites'),
recentlyViewed: text('recently_viewed'),
payload: text('payload'),
lastUsage: text('last_usage'),
});
// App key-value state table (e.g. one-time migration flags)
export const appState = sqliteTable('app_state', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
});
// Categories table
export const categories = sqliteTable(
'categories',
{
id: integer('id').primaryKey({ autoIncrement: true }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type', { enum: ['live', 'movies', 'series'] }).notNull(),
xtreamId: integer('xtream_id').notNull(),
hidden: integer('hidden', { mode: 'boolean' }).default(false),
},
(table) => ({
playlistIdx: index('idx_categories_playlist').on(table.playlistId),
typeIdx: index('idx_categories_type').on(table.type),
playlistTypeXtreamUnique: uniqueIndex(
'categories_playlist_type_xtream_unique'
).on(table.playlistId, table.type, table.xtreamId),
})
);
// Content table (streams/VODs/series)
export const content = sqliteTable(
'content',
{
id: integer('id').primaryKey({ autoIncrement: true }),
categoryId: integer('category_id')
.notNull()
.references(() => categories.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
rating: text('rating'),
added: text('added'),
posterUrl: text('poster_url'),
backdropUrl: text('backdrop_url'),
epgChannelId: text('epg_channel_id'),
tvArchive: integer('tv_archive'),
tvArchiveDuration: integer('tv_archive_duration'),
directSource: text('direct_source'),
xtreamId: integer('xtream_id').notNull(),
type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(),
},
(table) => ({
typeIdx: index('idx_content_type').on(table.type),
categoryIdx: index('idx_content_category').on(table.categoryId),
titleIdx: index('idx_content_title').on(table.title),
xtreamIdx: index('idx_content_xtream').on(table.xtreamId),
categoryTypeXtreamUnique: uniqueIndex(
'content_category_type_xtream_unique'
).on(table.categoryId, table.type, table.xtreamId),
typeAddedIdx: index('idx_content_type_added').on(
table.type,
table.added
),
})
);
// Recently viewed table
export const recentlyViewed = sqliteTable(
'recently_viewed',
{
id: integer('id').primaryKey({ autoIncrement: true }),
contentId: integer('content_id')
.notNull()
.references(() => content.id, { onDelete: 'cascade' }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
viewedAt: text('viewed_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
contentPlaylistUnique: uniqueIndex(
'recently_viewed_content_playlist_unique'
).on(table.contentId, table.playlistId),
playlistIdx: index('recently_viewed_playlist_idx').on(table.playlistId),
viewedAtIdx: index('recently_viewed_viewed_at_idx').on(table.viewedAt),
playlistViewedIdx: index('recently_viewed_playlist_viewed_idx').on(
table.playlistId,
sql`${table.viewedAt} DESC`
),
})
);
// Favorites table
export const favorites = sqliteTable(
'favorites',
{
id: integer('id').primaryKey({ autoIncrement: true }),
contentId: integer('content_id')
.notNull()
.references(() => content.id, { onDelete: 'cascade' }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
/** Display order position in the global favorites list (lower = first) */
position: integer('position').default(0),
},
(table) => ({
contentPlaylistUnique: uniqueIndex(
'favorites_content_playlist_unique'
).on(table.contentId, table.playlistId),
playlistIdx: index('favorites_playlist_idx').on(table.playlistId),
contentIdx: index('favorites_content_idx').on(table.contentId),
playlistPositionIdx: index('favorites_playlist_position_idx').on(
table.playlistId,
table.position,
sql`${table.addedAt} DESC`
),
})
);
// EPG Channels table
export const epgChannels = sqliteTable(
'epg_channels',
{
id: text('id').primaryKey(), // Channel ID from EPG source
displayName: text('display_name').notNull(),
iconUrl: text('icon_url'),
url: text('url'),
sourceUrl: text('source_url').notNull(), // Which EPG URL this came from
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
sourceIdx: index('idx_epg_channels_source').on(table.sourceUrl),
nameIdx: index('idx_epg_channels_name').on(table.displayName),
})
);
// EPG Programs table
export const epgPrograms = sqliteTable(
'epg_programs',
{
id: integer('id').primaryKey({ autoIncrement: true }),
channelId: text('channel_id')
.notNull()
.references(() => epgChannels.id, { onDelete: 'cascade' }),
start: text('start').notNull(), // ISO datetime
stop: text('stop').notNull(), // ISO datetime
title: text('title').notNull(),
description: text('description'),
category: text('category'),
iconUrl: text('icon_url'),
rating: text('rating'),
episodeNum: text('episode_num'),
},
(table) => ({
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
startIdx: index('idx_epg_programs_start').on(table.start),
stopIdx: index('idx_epg_programs_stop').on(table.stop),
timeRangeIdx: index('idx_epg_programs_time_range').on(
table.channelId,
table.start,
table.stop
),
})
);
// Playback Positions table
export const playbackPositions = sqliteTable(
'playback_positions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
// For VOD: store xtream_id of the movie
// For Series: store episode ID (from XtreamSerieEpisode.id)
contentXtreamId: integer('content_xtream_id').notNull(),
// 'vod' | 'episode'
contentType: text('content_type', {
enum: ['vod', 'episode'],
}).notNull(),
// For episodes: store series xtream_id for grouping
seriesXtreamId: integer('series_xtream_id'),
// For episodes: store season and episode numbers for display
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
// Playback position in seconds
positionSeconds: integer('position_seconds').notNull().default(0),
// Total duration in seconds (for percentage calculation)
durationSeconds: integer('duration_seconds'),
// Last updated timestamp
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
// Unique constraint: one position per content per playlist
contentPlaylistUnique: uniqueIndex(
'playback_positions_content_playlist_unique'
).on(table.contentXtreamId, table.playlistId, table.contentType),
playlistIdx: index('playback_positions_playlist_idx').on(
table.playlistId
),
seriesIdx: index('playback_positions_series_idx').on(
table.seriesXtreamId
),
updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt),
playlistUpdatedIdx: index(
'playback_positions_playlist_updated_idx'
).on(table.playlistId, sql`${table.updatedAt} DESC`),
})
);
// Type exports for TypeScript
export type Playlist = typeof playlists.$inferSelect;
export type NewPlaylist = typeof playlists.$inferInsert;
export type AppState = typeof appState.$inferSelect;
export type NewAppState = typeof appState.$inferInsert;
export type Category = typeof categories.$inferSelect;
export type NewCategory = typeof categories.$inferInsert;
export type Content = typeof content.$inferSelect;
export type NewContent = typeof content.$inferInsert;
export type RecentlyViewed = typeof recentlyViewed.$inferSelect;
export type NewRecentlyViewed = typeof recentlyViewed.$inferInsert;
export type Favorite = typeof favorites.$inferSelect;
export type NewFavorite = typeof favorites.$inferInsert;
export type EpgChannel = typeof epgChannels.$inferSelect;
export type NewEpgChannel = typeof epgChannels.$inferInsert;
export type EpgProgramDb = typeof epgPrograms.$inferSelect;
export type NewEpgProgramDb = typeof epgPrograms.$inferInsert;
export type PlaybackPosition = typeof playbackPositions.$inferSelect;
export type NewPlaybackPosition = typeof playbackPositions.$inferInsert;
// Downloads table
export const downloads = sqliteTable(
'downloads',
{
id: integer('id').primaryKey({ autoIncrement: true }),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
// Content identifiers
xtreamId: integer('xtream_id').notNull(),
contentType: text('content_type', {
enum: ['vod', 'episode'],
}).notNull(),
// For episodes: store series info
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
// Download metadata
title: text('title').notNull(),
url: text('url').notNull(),
fileName: text('file_name'),
filePath: text('file_path'),
posterUrl: text('poster_url'),
// Download progress
status: text('status', {
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
})
.notNull()
.default('queued'),
bytesDownloaded: integer('bytes_downloaded').default(0),
totalBytes: integer('total_bytes'),
errorMessage: text('error_message'),
// Timestamps
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
playlistIdx: index('downloads_playlist_idx').on(table.playlistId),
statusIdx: index('downloads_status_idx').on(table.status),
xtreamPlaylistUnique: uniqueIndex(
'downloads_xtream_playlist_unique'
).on(table.xtreamId, table.playlistId, table.contentType),
})
);
export type Download = typeof downloads.$inferSelect;
export type NewDownload = typeof downloads.$inferInsert;