mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
Three queries filter on categories.hidden = false: - getGlobalRecentlyAdded — every dashboard load (now sorts by added DESC on the indexed content side after the prior CAST removal) - searchContent — Xtream search with excludeHidden - globalSearch — global Xtream search with excludeHidden All three start from content and join into categories on the category_id PK. The hidden filter is then applied per-row, requiring a row lookup for every result candidate — even before LIMIT. Add a partial covering index on categories: idx_categories_visible(id, playlist_id, type) WHERE hidden = 0. Two wins: - Covering: SQLite serves the join (id) plus filters (playlist_id, type) and the hidden = 0 predicate directly from the index without ever touching the categories row. - Partial: hidden categories are absent from the index, so they're pruned before any work happens — no row lookup, no comparison. Schema mirrored in Drizzle so the ORM stays the single source of truth. CREATE INDEX IF NOT EXISTS so existing DBs pick it up on next startup with no migration needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: f957cd9849e0
339 lines
13 KiB
TypeScript
339 lines
13 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),
|
|
// 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'),
|
|
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;
|