Files
iptvnator/libs/shared/database/src/lib/schema.ts
T

478 lines
18 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,
primaryKey,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
// Manual EPG-to-channel mappings live in their own schema module; re-export
// them so `import * as schema from './schema'` keeps a complete namespace.
export * from './epg-mapping.schema';
export * from './vod-source-pins.schema';
// 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'),
epgUrls: text('epg_urls'),
detectedEpgUrls: text('detected_epg_urls'),
manualEpgUrls: text('manual_epg_urls'),
disabledEpgUrls: text('disabled_epg_urls'),
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),
// Partial covering index for visible categories — see connection.ts
// for the full rationale. SQLite can satisfy joins + hidden filter
// directly from this index for the dashboard / search queries.
visibleIdx: index('idx_categories_visible')
.on(table.id, table.playlistId, table.type)
.where(sql`hidden = 0`),
})
);
// 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'),
// Identity a detail view resolved for this item, so anything reading
// it later can repeat that lookup instead of re-deriving one from the
// display title. See ContentMetadataPatch in @iptvnator/shared/interfaces.
tmdbId: integer('tmdb_id'),
releaseYear: integer('release_year'),
originalTitle: text('original_title'),
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),
epgChannelIdx: index('idx_content_epg_channel').on(table.epgChannelId),
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),
})
);
// A global XMLTV ID can be shared by sources with different channel metadata.
export const epgChannelSources = sqliteTable(
'epg_channel_sources',
{
channelId: text('channel_id')
.notNull()
.references(() => epgChannels.id, { onDelete: 'cascade' }),
sourceUrl: text('source_url').notNull(),
displayName: text('display_name').notNull(),
iconUrl: text('icon_url'),
url: text('url'),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
primaryKey({ columns: [table.channelId, table.sourceUrl] }),
index('idx_epg_channel_sources_source').on(table.sourceUrl),
]
);
// 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'),
sourceUrl: text('source_url'),
},
(table) => ({
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
sourceIdx: index('idx_epg_programs_source').on(table.sourceUrl),
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
),
sourceTimeRangeIdx: index('idx_epg_programs_source_time_range').on(
table.sourceUrl,
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(),
// 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'),
episodeIdentityScope: text('episode_identity_scope'),
// Download metadata
title: text('title').notNull(),
url: text('url').notNull(),
fileName: text('file_name'),
filePath: text('file_path'),
posterUrl: text('poster_url'),
requestHeaders: text('request_headers'),
resumeValidator: text('resume_validator'),
metadataSnapshot: text('metadata_snapshot'),
// Download progress
status: text('status', {
enum: [
'queued',
'downloading',
'paused',
'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;
// Live-TV recordings table. Rows are created by the embedded-MPV recording
// tracker, not by the download queue: a recording has no source URL to
// re-fetch, no byte totals, and no retry/resume semantics, so it lives beside
// `downloads` instead of inside it. playlist_id has no FK on purpose —
// recordings outlive the deletion of their source playlist.
export const recordings = sqliteTable(
'recordings',
{
id: integer('id').primaryKey({ autoIncrement: true }),
// Transient embedded-MPV session id, correlation/debugging only.
sessionId: text('session_id'),
// PID of the process that opened the recording. Startup recovery uses
// it to leave rows owned by another live instance alone
// (IPTVNATOR_ALLOW_MULTIPLE_INSTANCES).
ownerPid: integer('owner_pid'),
status: text('status', {
enum: ['recording', 'completed', 'interrupted', 'failed'],
})
.notNull()
.default('recording'),
filePath: text('file_path').notNull(),
fileSizeBytes: integer('file_size_bytes'),
// Metadata snapshot captured at recording start (EPG is
// time-sensitive; it cannot be reconstructed after the fact).
channelName: text('channel_name').notNull(),
channelLogoUrl: text('channel_logo_url'),
playlistId: text('playlist_id'),
playlistName: text('playlist_name'),
sourceType: text('source_type', {
enum: ['m3u', 'xtream', 'stalker'],
}),
epgChannelId: text('epg_channel_id'),
programTitle: text('program_title'),
programDescription: text('program_description'),
programStart: text('program_start'),
programStop: text('program_stop'),
// Stop-time enrichment: JSON array of program snapshots overlapping
// [started_at, ended_at] (a recording can span program boundaries).
programsJson: text('programs_json'),
errorMessage: text('error_message'),
startedAt: text('started_at').notNull(),
endedAt: text('ended_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
statusIdx: index('recordings_status_idx').on(table.status),
filePathIdx: index('recordings_file_path_idx').on(table.filePath),
playlistIdx: index('recordings_playlist_idx').on(table.playlistId),
})
);
export type Recording = typeof recordings.$inferSelect;
export type NewRecording = typeof recordings.$inferInsert;
// TMDB metadata cache table.
// Two row kinds share the table, discriminated by the lookup_key prefix:
// - 'id:<tmdbId>' → full TMDB details payload (JSON)
// - 'title:<normalized>|year:<y>' → search resolution; tmdb_id NULL means
// "no confident match" (negative cache)
export const tmdbMetadata = sqliteTable(
'tmdb_metadata',
{
id: integer('id').primaryKey({ autoIncrement: true }),
mediaType: text('media_type', {
enum: ['movie', 'tv', 'person'],
}).notNull(),
lookupKey: text('lookup_key').notNull(),
language: text('language').notNull(),
tmdbId: integer('tmdb_id'),
payload: text('payload'),
fetchedAt: text('fetched_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
lookupUnique: uniqueIndex('tmdb_metadata_lookup_unique').on(
table.mediaType,
table.lookupKey,
table.language
),
})
);
export type TmdbMetadata = typeof tmdbMetadata.$inferSelect;
export type NewTmdbMetadata = typeof tmdbMetadata.$inferInsert;