mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
478 lines
18 KiB
TypeScript
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;
|