Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Database
Shared database library for IPTVnator providing Drizzle ORM schema and connection utilities.
## Usage
```typescript
// Full access (electron-backend)
import { getDatabase, initDatabase } from 'database';
// Read-only access (agent-backend)
import { getReadOnlyDatabase } from 'database';
// Schema and types
import { content, categories, playlists, type Content } from 'database';
```
## Exports
### Schema (`schema.ts`)
- **Tables**: `playlists`, `categories`, `content`, `recentlyViewed`, `favorites`
- **Types**: `Playlist`, `Category`, `Content`, `RecentlyViewed`, `Favorite` (and `New*` variants)
### Connection (`connection.ts`)
- `getDatabase(options?)` - Full read-write access
- `getReadOnlyDatabase()` - Read-only access for agent queries
- `initDatabase(options?)` - Initialize with custom options
- `closeDatabase()` - Close connection
- `getDatabasePath()` - Get database file path
## Database Location
The SQLite database is stored at: `~/.iptvnator/databases/iptvnator.db`
+23
View File
@@ -0,0 +1,23 @@
{
"name": "database",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/database/src",
"projectType": "library",
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/database",
"main": "libs/shared/database/src/index.ts",
"tsConfig": "libs/shared/database/tsconfig.lib.json",
"assets": ["libs/shared/database/*.md"],
"format": ["cjs"]
}
},
"lint": {
"command": "eslint libs/shared/database/**/*.ts"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Shared Database module for IPTVnator
* Provides Drizzle ORM schema and connection utilities for SQLite database
* Used by both electron-backend (read-write) and agent-backend (read-only)
*/
export * from './lib/schema';
export * from './lib/connection';
export * from './lib/path-utils';
+581
View File
@@ -0,0 +1,581 @@
/**
* Database connection and initialization for IPTVnator
* Uses Drizzle ORM with better-sqlite3
* Stores database file under ~/.iptvnator/databases/ by default.
* E2E tests can override the root with IPTVNATOR_E2E_DATA_DIR.
*
* Provides two connection modes:
* - Full access (for electron-backend): creates tables, read-write
* - Read-only access (for agent-backend): no table creation, read-only queries
*/
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import { getIptvnatorDatabasePath } from './path-utils';
export type DatabaseInstance = BetterSQLite3Database<typeof schema>;
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
let db: DatabaseInstance | null = null;
let sqlite: Database.Database | null = null;
let initPromise: Promise<DatabaseInstance> | null = null;
function readTraceFlag(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return value ? TRACE_ENV_TRUE_VALUES.has(value) : false;
}
function isSqlTraceEnabled(): boolean {
return (
readTraceFlag('IPTVNATOR_TRACE_STARTUP') ||
readTraceFlag('IPTVNATOR_TRACE_DB') ||
readTraceFlag('IPTVNATOR_TRACE_SQL')
);
}
function compactSqlForTrace(sql: string): string {
const compactSql = sql.replace(/\s+/g, ' ').trim();
return compactSql.length <= 180
? compactSql
: `${compactSql.slice(0, 177)}...`;
}
function traceSql(scope: string, message: string, payload?: unknown): void {
if (payload === undefined) {
console.log(`[IPTVnator Trace][${scope}] ${message}`);
return;
}
console.log(
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}`
);
}
/**
* Get the database file path
*/
export function getDatabasePath(): string {
return getIptvnatorDatabasePath();
}
/**
* SQL statements for creating all tables
*/
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
serverUrl TEXT,
username TEXT,
password TEXT,
date_created TEXT DEFAULT (datetime('now')),
last_updated TEXT,
type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')),
userAgent TEXT,
origin TEXT,
referrer TEXT,
filePath TEXT,
autoRefresh INTEGER DEFAULT 0,
macAddress TEXT,
url TEXT,
portal_url TEXT,
count INTEGER,
import_date TEXT,
update_date INTEGER,
position INTEGER,
favorites TEXT,
recently_viewed TEXT,
payload TEXT,
last_usage TEXT
)`,
`CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
xtream_id INTEGER NOT NULL,
hidden INTEGER DEFAULT 0,
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS content (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
title TEXT NOT NULL,
rating TEXT,
added TEXT,
poster_url TEXT,
backdrop_url TEXT,
epg_channel_id TEXT,
tv_archive INTEGER,
tv_archive_duration INTEGER,
direct_source TEXT,
xtream_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS recently_viewed (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER NOT NULL,
playlist_id TEXT NOT NULL,
viewed_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER NOT NULL,
playlist_id TEXT NOT NULL,
added_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`,
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`,
`CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`,
`CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`,
`CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`,
`CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`,
// EPG tables
`CREATE TABLE IF NOT EXISTS epg_channels (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
icon_url TEXT,
url TEXT,
source_url TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS epg_programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL,
start TEXT NOT NULL,
stop TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
category TEXT,
icon_url TEXT,
rating TEXT,
episode_num TEXT,
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
)`,
// EPG indexes
`CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`,
`CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_stop ON epg_programs(stop)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`,
// FTS5 virtual table for full-text search on EPG programs
`CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
title,
description,
category,
content='epg_programs',
content_rowid='id'
)`,
// Triggers to keep FTS index in sync with epg_programs table
`CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN
INSERT INTO epg_programs_fts(rowid, title, description, category)
VALUES (new.id, new.title, new.description, new.category);
END`,
`CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
VALUES ('delete', old.id, old.title, old.description, old.category);
END`,
`CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
VALUES ('delete', old.id, old.title, old.description, old.category);
INSERT INTO epg_programs_fts(rowid, title, description, category)
VALUES (new.id, new.title, new.description, new.category);
END`,
// Playback Positions table
`CREATE TABLE IF NOT EXISTS playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
content_xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
position_seconds INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id)`,
`CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id)`,
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
// Downloads table
`CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
/**
* Migration statements that may fail if already applied
* These are run with try-catch to handle existing columns
*/
const COLUMN_MIGRATION_STATEMENTS = [
// v1.0.0 -> v1.1.0: Add hidden column to categories for category management
`ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`,
// v1.1.0 -> v1.2.0: Add playlist metadata/payload columns for M3U + unified playlist persistence
`ALTER TABLE playlists ADD COLUMN portal_url TEXT`,
`ALTER TABLE playlists ADD COLUMN count INTEGER`,
`ALTER TABLE playlists ADD COLUMN import_date TEXT`,
`ALTER TABLE playlists ADD COLUMN update_date INTEGER`,
`ALTER TABLE playlists ADD COLUMN position INTEGER`,
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
// v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up
`ALTER TABLE content ADD COLUMN epg_channel_id TEXT`,
`ALTER TABLE content ADD COLUMN tv_archive INTEGER`,
`ALTER TABLE content ADD COLUMN tv_archive_duration INTEGER`,
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
];
/**
* Create tables if they don't exist
*/
function createTables(sqliteDb: Database.Database): void {
for (const stmt of CREATE_TABLE_STATEMENTS) {
sqliteDb.exec(stmt);
}
}
function isDuplicateColumnError(error: unknown): boolean {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? '')
: '';
return message.toLowerCase().includes('duplicate column name');
}
type XtreamCategoryDuplicateGroup = {
playlistId: string;
type: 'live' | 'movies' | 'series';
xtreamId: number;
};
type XtreamCategoryCandidate = {
id: number;
hidden: number;
contentCount: number;
};
type XtreamContentDuplicateGroup = {
categoryId: number;
type: 'live' | 'movie' | 'series';
xtreamId: number;
};
type XtreamContentCandidate = {
id: number;
};
function deduplicateXtreamCache(sqliteDb: Database.Database): void {
const executeCleanup = sqliteDb.transaction(() => {
const duplicateCategoryGroups = sqliteDb
.prepare(
`SELECT
playlist_id AS playlistId,
type,
xtream_id AS xtreamId
FROM categories
GROUP BY playlist_id, type, xtream_id
HAVING COUNT(*) > 1`
)
.all() as XtreamCategoryDuplicateGroup[];
const selectCategoryCandidates = sqliteDb.prepare(
`SELECT
categories.id AS id,
COALESCE(categories.hidden, 0) AS hidden,
COUNT(content.id) AS contentCount
FROM categories
LEFT JOIN content ON content.category_id = categories.id
WHERE categories.playlist_id = ?
AND categories.type = ?
AND categories.xtream_id = ?
GROUP BY categories.id, categories.hidden
ORDER BY COUNT(content.id) DESC, COALESCE(categories.hidden, 0) ASC, categories.id ASC`
);
const updateContentCategory = sqliteDb.prepare(
`UPDATE content SET category_id = ? WHERE category_id = ?`
);
const deleteCategory = sqliteDb.prepare(
`DELETE FROM categories WHERE id = ?`
);
for (const group of duplicateCategoryGroups) {
const candidates = selectCategoryCandidates.all(
group.playlistId,
group.type,
group.xtreamId
) as XtreamCategoryCandidate[];
const canonicalCategoryId = candidates[0]?.id;
if (!canonicalCategoryId) {
continue;
}
for (const candidate of candidates.slice(1)) {
updateContentCategory.run(canonicalCategoryId, candidate.id);
deleteCategory.run(candidate.id);
}
}
const duplicateContentGroups = sqliteDb
.prepare(
`SELECT
category_id AS categoryId,
type,
xtream_id AS xtreamId
FROM content
GROUP BY category_id, type, xtream_id
HAVING COUNT(*) > 1`
)
.all() as XtreamContentDuplicateGroup[];
const selectContentCandidates = sqliteDb.prepare(
`SELECT id
FROM content
WHERE category_id = ?
AND type = ?
AND xtream_id = ?
ORDER BY id ASC`
);
const moveFavorites = sqliteDb.prepare(
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
FROM favorites
WHERE content_id = ?
ON CONFLICT(content_id, playlist_id) DO NOTHING`
);
const deleteFavorites = sqliteDb.prepare(
`DELETE FROM favorites WHERE content_id = ?`
);
const moveRecentlyViewed = sqliteDb.prepare(
`INSERT INTO recently_viewed (content_id, playlist_id, viewed_at)
SELECT ?, playlist_id, viewed_at
FROM recently_viewed
WHERE content_id = ?
ON CONFLICT(content_id, playlist_id) DO UPDATE SET
viewed_at = CASE
WHEN excluded.viewed_at > recently_viewed.viewed_at
THEN excluded.viewed_at
ELSE recently_viewed.viewed_at
END`
);
const deleteRecentlyViewed = sqliteDb.prepare(
`DELETE FROM recently_viewed WHERE content_id = ?`
);
const deleteContent = sqliteDb.prepare(`DELETE FROM content WHERE id = ?`);
for (const group of duplicateContentGroups) {
const candidates = selectContentCandidates.all(
group.categoryId,
group.type,
group.xtreamId
) as XtreamContentCandidate[];
const canonicalContentId = candidates[0]?.id;
if (!canonicalContentId) {
continue;
}
for (const candidate of candidates.slice(1)) {
moveFavorites.run(canonicalContentId, candidate.id);
deleteFavorites.run(candidate.id);
moveRecentlyViewed.run(canonicalContentId, candidate.id);
deleteRecentlyViewed.run(candidate.id);
deleteContent.run(candidate.id);
}
}
});
executeCleanup();
}
function runMigrationStatements(
sqliteDb: Database.Database,
statements: string[]
): void {
for (const stmt of statements) {
try {
sqliteDb.exec(stmt);
} catch (error) {
// Ignore idempotent ALTER TABLE errors on existing columns.
if (isDuplicateColumnError(error)) {
continue;
}
const compactStmt = stmt.replace(/\s+/g, ' ').trim();
const message =
typeof error === 'object' &&
error !== null &&
'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Migration failed (continuing): ${compactStmt} :: ${message}`
);
}
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
deduplicateXtreamCache(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
}
export interface DatabaseOptions {
/** Open database in read-only mode (for agent-backend) */
readonly?: boolean;
/** Skip table creation (useful for read-only connections) */
skipTableCreation?: boolean;
}
/**
* Initialize the database connection with full access (read-write)
* Creates tables if they don't exist
* Used by electron-backend
*/
export async function initDatabase(
options: DatabaseOptions = {}
): Promise<DatabaseInstance> {
const { readonly = false, skipTableCreation = false } = options;
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const filePath = getDatabasePath();
sqlite = new Database(filePath, {
readonly,
verbose: isSqlTraceEnabled()
? (sql: string) => {
traceSql('sql-main', 'query', {
sql: compactSqlForTrace(sql),
});
}
: undefined,
});
if (isSqlTraceEnabled()) {
traceSql('sql-main', 'open', {
filePath,
readonly,
});
}
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
if (!readonly) {
sqlite.pragma('journal_mode = WAL');
}
// Create tables only for read-write connections
if (!readonly && !skipTableCreation) {
createTables(sqlite);
runMigrations(sqlite);
}
const database = drizzle(sqlite, { schema });
db = database;
return database;
})();
return initPromise;
}
/**
* Get the database instance
* Initializes the database if not already initialized (with defaults for full access)
* Used by electron-backend
*/
export async function getDatabase(
options?: DatabaseOptions
): Promise<DatabaseInstance> {
if (db) return db;
return initDatabase(options);
}
/**
* Get a read-only database connection
* Used by agent-backend for safe read-only queries
*/
export async function getReadOnlyDatabase(): Promise<DatabaseInstance> {
return getDatabase({ readonly: true, skipTableCreation: true });
}
/**
* Close the database connection
*/
export function closeDatabase(): void {
if (sqlite) {
sqlite.close();
if (isSqlTraceEnabled()) {
traceSql('sql-main', 'close');
}
sqlite = null;
db = null;
initPromise = null;
}
}
@@ -0,0 +1,51 @@
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
export const IPTVNATOR_E2E_DATA_DIR_ENV = 'IPTVNATOR_E2E_DATA_DIR';
function ensureDirectory(dirPath: string): string {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
return dirPath;
}
export function getIptvnatorDataRoot(): string {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (e2eDataDir) {
return ensureDirectory(e2eDataDir);
}
return ensureDirectory(join(homedir(), '.iptvnator'));
}
export function getIptvnatorDatabaseDirectory(): string {
return ensureDirectory(join(getIptvnatorDataRoot(), 'databases'));
}
export function getIptvnatorDatabasePath(): string {
return join(getIptvnatorDatabaseDirectory(), 'iptvnator.db');
}
export function getElectronUserDataPath(): string | null {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (!e2eDataDir) {
return null;
}
return ensureDirectory(join(e2eDataDir, 'user-data'));
}
export function getElectronConfigDirectory(): string | null {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (!e2eDataDir) {
return null;
}
return ensureDirectory(join(e2eDataDir, 'config'));
}
+320
View File
@@ -0,0 +1,320 @@
/**
* 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),
})
);
// 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),
})
);
// 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),
})
);
// 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;
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"esModuleInterop": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'shared-interfaces',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/interfaces',
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "shared-interfaces",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "shared-interfaces",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/interfaces/src",
"projectType": "library",
"tags": ["scope:shared", "domain:shared-contracts", "type:util"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/interfaces",
"main": "libs/shared/interfaces/src/index.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.lib.json",
"assets": []
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/interfaces/jest.config.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
export * from './lib/epg-channel.model';
export * from './lib/epg-item.interface';
export * from './lib/epg-program.model';
export * from './lib/external-player-session.interface';
export * from './lib/indexed-db.config';
export * from './lib/ipc-command.class';
export * from './lib/ipc-commands';
export * from './lib/language.enum';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-backup.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist-refresh.interface';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/portal-playback.interface';
export * from './lib/settings.interface';
export * from './lib/stalker-portal-actions.enum';
export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/theme.enum';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
export * from './lib/xtream-code-actions';
export * from './lib/xtream-item.interface';
export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-serie-details.interface';
export * from './lib/xtream-serie-item.interface';
export * from './lib/xtream-vod-details.interface';
export * from './lib/xtream-vod-stream.interface';
export * from './lib/workspace-product-branding.interface';
// Stalker interfaces
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-portal-item.interface';
export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
@@ -0,0 +1,32 @@
/**
* Represents channel object
* TODO: define channel interface in iptv-parser library
*/
export interface Channel {
id: string;
url: string;
name: string;
group: {
title: string;
};
tvg: {
id: string;
name: string;
url: string;
logo: string;
rec: string;
};
epgParams?: string;
timeshift?: string;
catchup?: {
type?: string;
source?: string;
days?: string;
};
http: {
referrer: string;
'user-agent': string;
origin: string;
};
radio: string;
}
+22
View File
@@ -0,0 +1,22 @@
import { Channel } from './channel.interface';
/**
* Creates new channel object based on the given fields
* @param params partial channel object
*/
export function createChannel(params: Partial<Channel>): Channel {
return {
id: params?.id || params.url,
name: params.name,
group: params.group,
url: params.url,
tvg: params.tvg,
timeshift: params.timeshift,
catchup: params.catchup,
http: params.http,
radio: params.radio,
} as Channel;
}
/** Possible sidebar view options */
export type SidebarView = 'CHANNELS' | 'PLAYLISTS';
@@ -0,0 +1,9 @@
/**
* Flattened EPG channel metadata returned to the renderer for lookup-based
* enrichment of playlist channels.
*/
export interface EpgChannelMetadata {
id: string;
displayName: string;
iconUrl: string | null;
}
@@ -0,0 +1,17 @@
import { EpgChannel } from './epg-channel.model';
import { EpgProgram } from './epg-program.model';
/**
* EPG Channel with associated programs
*/
export interface EpgChannelWithPrograms extends EpgChannel {
programs: EpgProgram[];
}
/**
* EPG Data structure
*/
export interface EpgData {
channels: EpgChannel[];
programs: EpgProgram[];
}
@@ -0,0 +1,9 @@
/**
* EPG Channel interface compatible with epg-parser v0.4.0
*/
export interface EpgChannel {
id: string;
displayName: { lang: string; value: string }[];
icon: { src: string; width?: number; height?: number }[];
url: string[];
}
@@ -0,0 +1,13 @@
export interface EpgItem {
id: string;
epg_id: string;
title: string;
lang: string;
start: string;
end: string;
stop: string;
description: string;
channel_id: string;
start_timestamp: string;
stop_timestamp: string;
}
@@ -0,0 +1,17 @@
/**
* EPG Program interface - flat structure matching database storage
*/
export interface EpgProgram {
start: string; // ISO string
stop: string; // ISO string
channel: string;
title: string;
desc: string | null;
category: string | null;
startTimestamp?: number | null;
stopTimestamp?: number | null;
date?: string;
episodeNum?: string | null;
iconUrl?: string | null;
rating?: string | null;
}
@@ -0,0 +1,24 @@
import { PlayerContentInfo } from './portal-playback.interface';
export type ExternalPlayerName = 'mpv' | 'vlc';
export type ExternalPlayerSessionStatus =
| 'launching'
| 'opened'
| 'playing'
| 'error'
| 'closed';
export interface ExternalPlayerSession {
id: string;
player: ExternalPlayerName;
status: ExternalPlayerSessionStatus;
title: string;
thumbnail?: string | null;
streamUrl: string;
contentInfo?: PlayerContentInfo;
startedAt: string;
updatedAt: string;
error?: string;
canClose: boolean;
}
@@ -0,0 +1,74 @@
import { DBConfig } from 'ngx-indexed-db';
/**
* Contains names of the database stores
*/
export enum DbStores {
Playlists = 'playlists',
}
/** Defines db tables and schema */
export const dbConfig: DBConfig = {
name: 'iptvnator',
version: 1,
objectStoresMeta: [
{
store: DbStores.Playlists,
storeConfig: { keyPath: '_id', autoIncrement: false },
storeSchema: [
{
name: '_id',
keypath: '_id',
options: { unique: false },
},
{
name: 'filename',
keypath: 'filename',
options: { unique: false },
},
{ name: 'title', keypath: 'title', options: { unique: false } },
{ name: 'count', keypath: 'count', options: { unique: false } },
{
name: 'playlist',
keypath: 'playlist',
options: { unique: false },
},
{
name: 'importDate',
keypath: 'importDate',
options: { unique: false },
},
{
name: 'lastUsage',
keypath: 'lastUsage',
options: { unique: false },
},
{
name: 'favorites',
keypath: 'favorites',
options: { unique: false },
},
{
name: 'recentlyViewed',
keypath: 'recentlyViewed',
options: { unique: false },
},
{
name: 'autoRefresh',
keypath: 'autoRefresh',
options: { unique: false },
},
{
name: 'url',
keypath: 'url',
options: { unique: false },
},
{
name: 'filePath',
keypath: 'filePath',
options: { unique: false },
},
],
},
],
};
@@ -0,0 +1,9 @@
export class IpcCommand {
constructor(
public id: string,
public callback: (payload: any) => void
) {
this.id = id;
this.callback = callback;
}
}
@@ -0,0 +1,56 @@
// EPG related commands
export const EPG_FETCH = 'EPG:FETCH';
export const EPG_FETCH_DONE = 'EPG:FETCH_DONE';
export const EPG_ERROR = 'EPG:ERROR';
export const EPG_GET_CHANNELS = 'EPG:GET_CHANNELS';
export const EPG_GET_CHANNELS_DONE = 'EPG:GET_CHANNELS_DONE';
export const EPG_GET_CHANNELS_BY_RANGE = 'EPG:EPG_GET_CHANNELS_BY_RANGE';
export const EPG_GET_CHANNELS_BY_RANGE_RESPONSE =
'EPG:EPG_GET_CHANNELS_BY_RANGE_RESPONSE';
export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';
// Playlist related commands
export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL';
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
export const PLAYLIST_REFRESH = 'PLAYLIST:REFRESH';
export const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
export const PLAYLIST_CANCEL_REFRESH = 'PLAYLIST:CANCEL_REFRESH';
// General
export const ERROR = 'ERROR';
export const OPEN_FILE = 'OPEN_FILE';
// Views
export const VIEW_SETTINGS = 'VIEW:SETTINGS';
export const VIEW_ADD_PLAYLIST = 'VIEW:PLAYLISTS';
// Auto-update
export const AUTO_UPDATE_PLAYLISTS = 'AUTO_UPDATE';
export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE';
// Experimental
export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER';
export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH';
export const SET_MPV_REUSE_INSTANCE = 'SET_MPV_REUSE_INSTANCE';
export const OPEN_VLC_PLAYER = 'OPEN_VLC_PLAYER';
export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH';
export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION';
export const EXTERNAL_PLAYER_SESSION_UPDATE =
'EXTERNAL_PLAYER_SESSION_UPDATE';
// Xtream
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
export const XTREAM_RESPONSE = 'XTREAM_RESPONSE';
export const XTREAM_CANCEL_SESSION = 'XTREAM_CANCEL_SESSION';
// Stalker
export const STALKER_REQUEST = 'STALKER_REQUEST';
export const STALKER_RESPONSE = 'STALKER_RESPONSE';
export const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
// Settings
export const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
// Remote Control
export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
@@ -0,0 +1,23 @@
/**
* Enum with all languages that are available in the application
*/
export enum Language {
ARABIC = 'ar',
MOROCCAN_ARABIC = 'ary',
ENGLISH = 'en',
KOREAN = 'ko',
RUSSIAN = 'ru',
GERMAN = 'de',
SPANISH = 'es',
CHINESE = 'zh',
TRADITIONAL_CHINESE = 'zhtw',
FRENCH = 'fr',
ITALIAN = 'it',
TURKISH = 'tr',
JAPANESE = 'ja',
DUTCH = 'nl',
BELARUSIAN = 'by',
POLISH = 'pl',
PORTUGUESE = 'pt',
GREEK = 'el',
}
@@ -0,0 +1,36 @@
export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
};
raw: string;
};
items: ParsedPlaylistItem[];
}
export interface ParsedPlaylistItem {
name: string;
tvg: {
id: string;
name: string;
url: string;
logo: string;
rec: string;
};
group: {
title: string;
};
http: {
referrer: string;
'user-agent': string;
};
url: string;
raw: string;
catchup?: {
type?: string;
source?: string;
days?: string;
};
timeshift?: string;
radio?: string;
}
@@ -0,0 +1,15 @@
/**
* Playback position data for resume functionality
*/
export interface PlaybackPositionData {
contentXtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
positionSeconds: number;
durationSeconds?: number;
playlistId?: string;
updatedAt?: string;
sessionId?: string;
}
@@ -0,0 +1,113 @@
import { PlaybackPositionData } from './playback-position.interface';
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
export const PLAYLIST_BACKUP_KIND = 'iptvnator-playlist-backup';
export const PLAYLIST_BACKUP_VERSION = 1;
export type PlaylistBackupPortalType = 'm3u' | 'xtream' | 'stalker';
export type M3uBackupSourceKind = 'url' | 'file' | 'text';
export type XtreamBackupContentType = 'live' | 'movie' | 'series';
export type XtreamBackupCategoryType = 'live' | 'movies' | 'series';
export interface PlaylistBackupSettings {
epgUrls: string[];
}
export interface PlaylistBackupBaseEntry {
portalType: PlaylistBackupPortalType;
exportedId: string;
title: string;
autoRefresh: boolean;
position?: number;
}
export interface M3uPlaylistBackupEntry extends PlaylistBackupBaseEntry {
portalType: 'm3u';
source: {
kind: M3uBackupSourceKind;
rawM3u: string;
url?: string;
userAgent?: string;
referrer?: string;
origin?: string;
filePathHint?: string;
};
userState: {
favorites: string[];
recentlyViewed: M3uRecentlyViewedItem[];
hiddenGroupTitles: string[];
};
}
export interface XtreamBackupHiddenCategory {
categoryType: XtreamBackupCategoryType;
xtreamId: number;
}
export interface XtreamBackupFavoriteItem {
contentType: XtreamBackupContentType;
xtreamId: number;
addedAt?: string;
position?: number | null;
}
export interface XtreamBackupRecentlyViewedItem {
contentType: XtreamBackupContentType;
xtreamId: number;
viewedAt: string;
}
export interface XtreamBackupUserState {
hiddenCategories: XtreamBackupHiddenCategory[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
playbackPositions: PlaybackPositionData[];
}
export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry {
portalType: 'xtream';
connection: {
serverUrl: string;
username: string;
password?: string;
};
userState: XtreamBackupUserState;
}
export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry {
portalType: 'stalker';
connection: {
portalUrl: string;
macAddress: string;
isFullStalkerPortal?: boolean;
username?: string;
password?: string;
userAgent?: string;
referrer?: string;
origin?: string;
stalkerSerialNumber?: string;
stalkerDeviceId1?: string;
stalkerDeviceId2?: string;
stalkerSignature1?: string;
stalkerSignature2?: string;
};
userState: {
favorites: StalkerPortalItem[];
recentlyViewed: StalkerPortalItem[];
};
}
export type PlaylistBackupEntry =
| M3uPlaylistBackupEntry
| XtreamPlaylistBackupEntry
| StalkerPlaylistBackupEntry;
export interface PlaylistBackupManifestV1 {
kind: typeof PLAYLIST_BACKUP_KIND;
version: typeof PLAYLIST_BACKUP_VERSION;
exportedAt: string;
includeSecrets: boolean;
settings?: PlaylistBackupSettings;
playlists: PlaylistBackupEntry[];
}
@@ -0,0 +1,33 @@
import { Playlist } from './playlist.interface';
export type PlaylistMeta = Pick<
Playlist,
| 'count'
| 'title'
| 'filename'
| '_id'
| 'url'
| 'importDate'
| 'userAgent'
| 'referrer'
| 'origin'
| 'filePath'
| 'updateDate'
| 'updateState'
| 'position'
| 'autoRefresh'
| 'favorites'
| 'serverUrl'
| 'username'
| 'password'
| 'macAddress'
| 'hiddenGroupTitles'
| 'portalUrl'
| 'recentlyViewed'
| 'isFullStalkerPortal'
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
| 'stalkerDeviceId2'
| 'stalkerSignature1'
| 'stalkerSignature2'
>;
@@ -0,0 +1,34 @@
import { StalkerPortalItem } from './stalker-portal-item.interface';
export interface M3uRecentlyViewedItem {
source: 'm3u';
id: string;
url: string;
title: string;
channel_id?: string;
poster_url?: string;
tvg_id?: string;
tvg_name?: string;
group_title?: string;
category_id: 'live';
added_at: string | number;
}
export type PlaylistRecentlyViewedItem =
| M3uRecentlyViewedItem
| StalkerPortalItem;
export function isM3uRecentlyViewedItem(
item: unknown
): item is M3uRecentlyViewedItem {
if (!item || typeof item !== 'object') {
return false;
}
const candidate = item as Record<string, unknown>;
return (
candidate['source'] === 'm3u' &&
typeof candidate['url'] === 'string' &&
candidate['url'].trim().length > 0
);
}
@@ -0,0 +1,117 @@
import { PlaylistMeta } from './playlist-meta.type';
import { PortalRecentItem } from './portal-activity-item.interface';
import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
PlaylistRecentlyViewedItem,
isM3uRecentlyViewedItem,
} from './playlist-recently-viewed.interface';
interface PlaylistRecentLabels {
stalker: string;
m3u: string;
}
function getPlaylistRecentItems(
playlist: PlaylistMeta
): PlaylistRecentlyViewedItem[] {
return Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
}
function mapStalkerPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem, index) => {
if (isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
acc.push({
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
source: 'stalker',
stalker_item: rawItem,
});
return acc;
},
[]
);
}
function mapM3uPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem) => {
if (!isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const channelUrl = rawItem.url.trim();
if (!channelUrl) {
return acc;
}
acc.push({
id: rawItem.id || channelUrl,
title:
rawItem.title?.trim() ||
rawItem.tvg_name?.trim() ||
rawItem.channel_id ||
channelUrl,
type: 'live',
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(rawItem.added_at),
category_id: rawItem.category_id || 'live',
xtream_id: channelUrl,
poster_url: rawItem.poster_url || undefined,
source: 'm3u',
});
return acc;
},
[]
);
}
export function buildPlaylistRecentItems(
playlists: PlaylistMeta[],
labels: PlaylistRecentLabels
): PortalRecentItem[] {
return playlists.reduce<PortalRecentItem[]>((acc, playlist) => {
if (playlist.macAddress) {
acc.push(
...mapStalkerPlaylistRecentItems(playlist, labels.stalker)
);
return acc;
}
if (!playlist.serverUrl) {
acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u));
}
return acc;
}, []);
}
@@ -0,0 +1,28 @@
export type PlaylistRefreshPhase =
| 'fetching'
| 'reading-file'
| 'parsing'
| 'saving';
export type PlaylistRefreshStatus =
| 'started'
| 'progress'
| 'completed'
| 'cancelled'
| 'error';
export interface PlaylistRefreshEvent {
operationId: string;
playlistId: string;
phase?: PlaylistRefreshPhase;
status: PlaylistRefreshStatus;
error?: string;
}
export interface PlaylistRefreshPayload {
operationId: string;
playlistId: string;
title: string;
filePath?: string;
url?: string;
}
@@ -0,0 +1,70 @@
import { PlaylistRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
/**
* An interface that describe the possible states of the playlist update/refresh process
*/
export enum PlaylistUpdateState {
UPDATED,
IN_PROGRESS,
NOT_UPDATED,
}
/**
* Describes playlist interface
*/
export interface Playlist {
_id: string;
title: string;
filename?: string;
playlist?: any;
importDate: string;
lastUsage: string;
/**
* M3U playlists store channel URL strings (`string[]`).
* Stalker portals store full item objects (`StalkerPortalItem[]`).
*/
favorites?: (string | StalkerPortalItem)[];
items?: unknown[];
header?: unknown;
count: number;
url?: string;
userAgent?: string;
referrer?: string;
origin?: string;
filePath?: string;
autoRefresh: boolean;
updateDate?: number;
updateState?: PlaylistUpdateState;
position?: number;
isTemporary?: boolean;
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
portalUrl?: string;
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/** Serial number for stalker portal - generated once and stored for consistency */
stalkerSerialNumber?: string;
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
stalkerDeviceId1?: string;
/** Optional device ID 2 for stalker portal - if not provided, auto-generated from MAC */
stalkerDeviceId2?: string;
/** Optional signature 1 for stalker portal - required by some portals for device verification */
stalkerSignature1?: string;
/** Optional signature 2 for stalker portal - required by some portals for device verification */
stalkerSignature2?: string;
/** Account info from get_profile call */
stalkerAccountInfo?: {
login?: string;
expireDate?: number;
tariffPlanName?: string;
status?: number;
};
/** Hidden M3U group titles for the groups view */
hiddenGroupTitles?: string[];
}
@@ -0,0 +1,44 @@
import {
PortalActivityType,
StalkerPortalItem,
} from './stalker-portal-item.interface';
/**
* Base interface for cross-provider activity items (recently viewed,
* favorites) displayed on the dashboard and in global lists.
*/
export interface PortalActivityItem {
id: string | number;
title: string;
type: PortalActivityType;
playlist_id: string;
playlist_name?: string;
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
/**
* Wide backdrop URL (typically 16:9), persisted when the user first views
* the detail page. Absent for stalker and for items that have never had
* their detail page opened. Dashboards should fall back to a blurred
* `poster_url` when undefined.
*/
backdrop_url?: string;
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
}
/** A recently-viewed item with a `viewed_at` timestamp. */
export interface PortalRecentItem extends PortalActivityItem {
viewed_at: string;
}
/** A favorite item with an `added_at` timestamp. */
export interface PortalFavoriteItem extends PortalActivityItem {
added_at: string;
}
/** A recently-added catalog item with an `added_at` timestamp. */
export interface PortalAddedItem extends PortalActivityItem {
added_at: string;
}
@@ -0,0 +1,21 @@
export type PortalDebugProvider = 'xtream' | 'stalker';
export type PortalDebugTransport =
| 'electron-main'
| 'electron-renderer'
| 'pwa-http';
export type PortalDebugStatus = 'success' | 'error';
export interface PortalDebugEvent {
requestId: string;
provider: PortalDebugProvider;
operation: string;
transport: PortalDebugTransport;
startedAt: string;
durationMs: number;
status: PortalDebugStatus;
request: unknown;
response?: unknown;
error?: unknown;
}
@@ -0,0 +1,21 @@
import { PlaybackPositionData } from './playback-position.interface';
export interface PlayerContentInfo
extends Omit<
PlaybackPositionData,
'positionSeconds' | 'durationSeconds' | 'updatedAt'
> {
playlistId: string;
}
export interface ResolvedPortalPlayback {
streamUrl: string;
title: string;
thumbnail?: string | null;
startTime?: number;
contentInfo?: PlayerContentInfo;
headers?: Record<string, string>;
userAgent?: string;
referer?: string;
origin?: string;
}
@@ -0,0 +1,42 @@
import { Language } from './language.enum';
import { StreamFormat } from './stream-format.enum';
import { Theme } from './theme.enum';
/**
* Contains all types of supported video players
*/
export enum VideoPlayer {
VideoJs = 'videojs',
Html5Player = 'html5',
MPV = 'mpv',
VLC = 'vlc',
ArtPlayer = 'artplayer',
}
export enum StartupBehavior {
FirstView = 'first-view',
RestoreLastView = 'restore-last-view',
}
/**
* Describes all available settings options of the application
*/
export interface Settings {
player: VideoPlayer;
epgUrl: string[];
streamFormat: StreamFormat;
language: Language;
showCaptions: boolean;
showDashboard: boolean;
startupBehavior: StartupBehavior;
/** Show the desktop footer bar for external playback status */
showExternalPlaybackBar?: boolean;
theme: Theme;
mpvPlayerPath: string;
mpvReuseInstance: boolean;
vlcPlayerPath: string;
remoteControl: boolean;
remoteControlPort: number;
/** Custom download folder path (uses system Downloads folder if not set) */
downloadFolder?: string;
}
@@ -0,0 +1,27 @@
import { normalizeStalkerDate } from './stalker-item.normalizer';
describe('normalizeStalkerDate', () => {
it('normalizes SQLite UTC timestamps without relying on engine-specific parsing', () => {
const originalParse = Date.parse;
const parseSpy = jest
.spyOn(Date, 'parse')
.mockImplementation((value: string) => {
if (value === '2026-04-21 22:49:02') {
return Number.NaN;
}
return originalParse(value);
});
expect(normalizeStalkerDate('2026-04-21 22:49:02')).toBe(
'2026-04-21T22:49:02.000Z'
);
expect(parseSpy).not.toHaveBeenCalledWith('2026-04-21 22:49:02');
});
it('preserves fractional seconds in SQLite UTC timestamps', () => {
expect(normalizeStalkerDate('2026-04-21 22:49:02.7')).toBe(
'2026-04-21T22:49:02.700Z'
);
});
});
@@ -0,0 +1,157 @@
import {
PortalActivityType,
StalkerPortalItem,
} from './stalker-portal-item.interface';
const SQLITE_UTC_TIMESTAMP_PATTERN =
/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/;
/**
* Extract a stable ID from an untyped Stalker portal item.
*
* Stalker portals use different id fields depending on the content type
* (`id`, `stream_id`, `series_id`, `movie_id`). This function returns
* the first non-empty one, or a generated fallback based on the playlist
* id and index.
*/
export function extractStalkerItemId(
item: StalkerPortalItem | Record<string, unknown>,
fallbackPrefix = '',
fallbackIndex = 0
): string {
const raw = item as Record<string, unknown>;
const id =
raw['id'] ?? raw['stream_id'] ?? raw['series_id'] ?? raw['movie_id'];
const idStr = String(id ?? '').trim();
return idStr || `${fallbackPrefix}-${fallbackIndex}`;
}
/**
* Extract a display title from a Stalker portal item.
*/
export function extractStalkerItemTitle(
item: StalkerPortalItem | Record<string, unknown>
): string {
const raw = item as Record<string, unknown>;
return String(raw['title'] ?? raw['o_name'] ?? raw['name'] ?? 'Unknown');
}
/**
* Extract a poster/cover URL from a Stalker portal item.
*/
export function extractStalkerItemPoster(
item: StalkerPortalItem | Record<string, unknown>
): string {
const raw = item as Record<string, unknown>;
return String(raw['cover'] ?? raw['logo'] ?? raw['poster_url'] ?? '');
}
/**
* Determine the normalised activity type of a Stalker item.
*
* - `itv` / `live` → `'live'`
* - `series` or `is_series` truthy → `'series'`
* - everything else → `'movie'`
*/
export function extractStalkerItemType(
item: StalkerPortalItem | Record<string, unknown>
): PortalActivityType {
const raw = item as Record<string, unknown>;
const categoryId = String(raw['category_id'] ?? '').toLowerCase();
const streamType = String(raw['stream_type'] ?? '').toLowerCase();
if (categoryId === 'itv' || streamType === 'live') {
return 'live';
}
if (
categoryId === 'series' ||
raw['is_series'] === true ||
raw['is_series'] === 1 ||
raw['is_series'] === '1'
) {
return 'series';
}
return 'movie';
}
/**
* Normalise a raw date value (epoch number, numeric string, or ISO string)
* into a consistent ISO 8601 string.
*/
export function normalizeStalkerDate(value: unknown): string {
if (typeof value === 'number') {
if (!Number.isFinite(value)) return '';
const ms = value > 10_000_000_000 ? value : value * 1000;
return new Date(ms).toISOString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
}
const sqliteUtcIso = normalizeSqliteUtcTimestamp(trimmed);
if (sqliteUtcIso) {
return sqliteUtcIso;
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
return '';
}
/**
* Check whether a raw item from `Playlist.favorites` matches the given itemId.
*
* Used to filter stalker favorites/recently-viewed arrays when removing items.
*/
export function stalkerItemMatchesId(
raw: unknown,
targetId: string,
fallbackPrefix = '',
fallbackIndex = 0
): boolean {
const item = (raw ?? {}) as Record<string, unknown>;
const rawId = extractStalkerItemId(item, fallbackPrefix, fallbackIndex);
return rawId === targetId;
}
function normalizeSqliteUtcTimestamp(value: string): string | null {
const match = SQLITE_UTC_TIMESTAMP_PATTERN.exec(value);
if (!match) {
return null;
}
const [
,
year,
month,
day,
hours,
minutes,
seconds,
milliseconds = '0',
] = match;
const normalizedMs = milliseconds.padEnd(3, '0').slice(0, 3);
return new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds),
Number(normalizedMs)
)
).toISOString();
}
@@ -0,0 +1,11 @@
export enum StalkerPortalActions {
GetCategories = 'get_categories',
GetGenres = 'get_genres',
CreateLink = 'create_link',
GetOrderedList = 'get_ordered_list',
Favorites = 'favorites',
Handshake = 'handshake',
DoAuth = 'do_auth',
GetShortEpg = 'get_short_epg',
GetEpgInfo = 'get_epg_info',
}
@@ -0,0 +1,31 @@
/**
* Represents a Stalker portal item stored in `Playlist.favorites` or
* `Playlist.recentlyViewed` arrays.
*
* Stalker portals store favourites / recently-viewed as full objects
* (unlike M3U playlists, which use a simple `string[]` of channel URLs).
*/
export interface StalkerPortalItem {
id?: string | number;
stream_id?: string | number;
series_id?: string | number;
movie_id?: string | number;
title?: string;
/** Original name */
o_name?: string;
name?: string;
cover?: string;
logo?: string;
poster_url?: string;
/** 'itv' | 'vod' | 'series' — Stalker content type */
category_id?: string | number;
stream_type?: string;
is_series?: boolean | number | string;
/** Embedded VOD-series episode numbers preserved for VOD favorites. */
series?: unknown[];
added_at?: string | number;
cmd?: string;
}
/** Activity type normalised across all providers. */
export type PortalActivityType = 'live' | 'movie' | 'series';
@@ -0,0 +1,28 @@
/**
* Stalker portal series/serial details response structure.
* Contains series metadata from Stalker/Ministra portals.
*/
export interface StalkerSerialDetails {
/** Series title */
name: string;
/** Plot description */
description: string;
/** Director name */
director: string;
/** Comma-separated list of actors */
actors: string;
/** Release year */
year: string;
/** Comma-separated genres */
genres_str: string;
/** Age rating */
age: string;
/** IMDB rating as string */
rating_imdb: string;
/** Kinopoisk rating as string */
rating_kinopoisk: string;
/** Screenshot/poster URL */
screenshot_uri: string;
/** Date added to portal */
added: string;
}
@@ -0,0 +1,35 @@
/**
* Stalker portal VOD details response structure.
* Contains movie metadata from Stalker/Ministra portals.
*/
export interface StalkerVodDetails {
/** Unique identifier for the VOD item */
id: string;
/** Command string used to play the stream (e.g., "/media/file_123.mpg") */
cmd: string;
/** VOD metadata */
info: StalkerVodInfo;
}
export interface StalkerVodInfo {
/** Poster/cover image URL */
movie_image: string;
/** Plot description */
description: string;
/** Formatted/system title (often generic like "video_name_format") */
name: string;
/** Original title (preferred, more descriptive) */
o_name?: string;
/** Comma-separated list of actors */
actors: string;
/** Director name */
director: string;
/** Release date (format varies) */
releasedate: string;
/** Genre(s) */
genre: string;
/** IMDB rating as string */
rating_imdb: string;
/** Kinopoisk rating as string */
rating_kinopoisk: string;
}
@@ -0,0 +1,5 @@
/** Labels of properties in local storage */
export enum STORE_KEY {
Settings = 'settings',
Version = 'version'
}
@@ -0,0 +1,7 @@
/**
* Enum with all the formats that are allowed in the application
*/
export enum StreamFormat {
TsStreamFormat = 'ts',
M3u8StreamFormat = 'm3u8',
}
@@ -0,0 +1,5 @@
export enum Theme {
DarkTheme = 'DARK_THEME',
LightTheme = 'LIGHT_THEME',
SystemTheme = 'SYSTEM_THEME',
}
@@ -0,0 +1,159 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import {
NormalizedVodMeta,
VodDetailsItem,
XtreamVodItem,
StalkerVodItem,
} from './vod-details-item.interface';
/**
* Normalizes Xtream VOD details into a display-friendly format.
* Handles the various field name variations in Xtream API responses.
*/
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
const info = getXtreamVodInfo(item);
const movieData = item?.movie_data;
return {
title: info?.name || movieData?.name || 'Unknown',
description: info?.description || info?.plot,
posterUrl: info?.movie_image || info?.cover_big,
backdropUrl: info?.backdrop_path?.[0],
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: info?.duration || formatDuration(info?.duration_secs),
country: info?.country,
director: info?.director,
actors: info?.actors || info?.cast,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: info?.youtube_trailer,
};
}
/**
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
backdropUrl: undefined, // Stalker doesn't provide backdrop
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: undefined, // Stalker doesn't provide duration
country: undefined, // Stalker doesn't provide country
director: info?.director,
actors: info?.actors,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: undefined, // Stalker doesn't provide trailers
};
}
/**
* Normalizes any VodDetailsItem to display-friendly format.
* Uses discriminated union for type-safe handling.
*/
export function normalizeVodDetails(item: VodDetailsItem): NormalizedVodMeta {
if (item.type === 'xtream') {
return normalizeXtreamVod(item.data);
}
return normalizeStalkerVod(item.data);
}
/**
* Creates an XtreamVodItem from raw data and context.
*/
export function createXtreamVodItem(
data: XtreamVodDetails,
playlistId: string,
vodId?: number
): XtreamVodItem {
return {
type: 'xtream',
data,
playlistId,
vodId: vodId ?? data.movie_data?.stream_id ?? 0,
};
}
/**
* Creates a StalkerVodItem from raw data and context.
*/
export function createStalkerVodItem(
data: StalkerVodDetails,
playlistId: string
): StalkerVodItem {
return {
type: 'stalker',
data,
playlistId,
cmd: data.cmd,
};
}
/**
* Gets the unique identifier for a VOD item.
* Returns vodId for Xtream, id for Stalker.
*/
export function getVodId(item: VodDetailsItem): number | string {
if (item.type === 'xtream') {
return item.vodId;
}
return item.data.id;
}
/**
* Gets the numeric ID for download/favorite tracking.
*/
export function getVodNumericId(item: VodDetailsItem): number {
if (item.type === 'xtream') {
return item.vodId;
}
return Number(item.data.id) || 0;
}
// ============ Helper Functions ============
/**
* Extracts 4-digit year from various date formats.
*/
function extractYear(dateString?: string): string | undefined {
if (!dateString) return undefined;
// Try to extract 4-digit year from beginning
const yearMatch = dateString.match(/^(\d{4})/);
if (yearMatch) {
return yearMatch[1];
}
// Try to parse as date and extract year
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.getFullYear().toString();
}
return dateString.slice(0, 4);
}
/**
* Formats duration from seconds to human-readable string.
*/
function formatDuration(seconds?: number): string | undefined {
if (!seconds || seconds <= 0) return undefined;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
@@ -0,0 +1,96 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
/**
* Discriminated union for VOD details across portal types.
* Enables type-safe handling without runtime type checks.
*
* @example
* ```typescript
* function handleVod(item: VodDetailsItem) {
* if (item.type === 'xtream') {
* // TypeScript knows item.data is XtreamVodDetails
* console.log(item.data.movie_data.stream_id);
* } else {
* // TypeScript knows item.data is StalkerVodDetails
* console.log(item.data.cmd);
* }
* }
* ```
*/
export type VodDetailsItem = XtreamVodItem | StalkerVodItem;
/**
* Xtream Codes VOD item wrapper with context.
*/
export interface XtreamVodItem {
/** Discriminator for Xtream Codes portals */
readonly type: 'xtream';
/** Full VOD details from Xtream API */
data: XtreamVodDetails;
/** Playlist/portal ID for favorites and downloads */
playlistId: string;
/** Stream ID from Xtream API */
vodId: number;
}
/**
* Stalker portal VOD item wrapper with context.
*/
export interface StalkerVodItem {
/** Discriminator for Stalker portals */
readonly type: 'stalker';
/** Full VOD details from Stalker API */
data: StalkerVodDetails;
/** Playlist/portal ID for favorites and downloads */
playlistId: string;
/** Command string for stream playback */
cmd: string;
}
/**
* Normalized metadata for display in templates.
* Provides a consistent interface regardless of portal type.
*/
export interface NormalizedVodMeta {
/** Movie title */
title: string;
/** Plot description */
description?: string;
/** Poster/cover image URL */
posterUrl?: string;
/** Backdrop image URL (Xtream only) */
backdropUrl?: string;
/** Release year (4 digits) */
year?: string;
/** Genre(s) */
genre?: string;
/** Duration string (e.g., "1h 30m") */
duration?: string;
/** Country of origin (Xtream only) */
country?: string;
/** Director name */
director?: string;
/** Actors/cast (comma-separated) */
actors?: string;
/** IMDB rating */
ratingImdb?: string;
/** Kinopoisk rating */
ratingKinopoisk?: string;
/** YouTube trailer ID (Xtream only) */
youtubeTrailer?: string;
}
/**
* Type guard to check if item is Xtream VOD.
*/
export function isXtreamVod(item: VodDetailsItem): item is XtreamVodItem {
return item.type === 'xtream';
}
/**
* Type guard to check if item is Stalker VOD.
*/
export function isStalkerVod(item: VodDetailsItem): item is StalkerVodItem {
return item.type === 'stalker';
}
@@ -0,0 +1,17 @@
import type { Language } from './language.enum';
import type { StreamFormat } from './stream-format.enum';
import type { VideoPlayer } from './settings.interface';
/**
* Optional product branding (e.g. KiraTV). When null, the app behaves as generic IPTVnator.
*/
export interface WorkspaceProductBranding {
readonly appDisplayName: string;
readonly xtreamOnlyUi: boolean;
readonly xtreamDefaultServerUrl: string;
readonly defaultPlaylistTitle: string;
readonly defaultLanguage: Language;
readonly defaultVideoPlayer: VideoPlayer;
readonly defaultStreamFormat: StreamFormat;
readonly indexHtmlLang: string;
}
@@ -0,0 +1,15 @@
export interface XtreamAccountInfoDialogPlaylist {
id: string;
name?: string;
title?: string;
serverUrl: string;
username: string;
password: string;
}
export interface XtreamAccountInfoDialogData {
vodStreamsCount?: number;
liveStreamsCount?: number;
seriesCount?: number;
playlist?: XtreamAccountInfoDialogPlaylist;
}
@@ -0,0 +1,7 @@
export interface XtreamCategory {
id?: number;
category_id: string;
category_name: string;
parent_id: number;
count?: number;
}
@@ -0,0 +1,14 @@
export enum XtreamCodeActions {
GetAccountInfo = 'get_account_info',
GetLiveStreams = 'get_live_streams',
GetLiveCategories = 'get_live_categories',
GetSeries = 'get_series',
GetSeriesCategories = 'get_series_categories',
GetSeriesInfo = 'get_series_info',
GetVodStreams = 'get_vod_streams',
GetVodCategories = 'get_vod_categories',
GetVodInfo = 'get_vod_info',
GetShortEpg = 'get_short_epg',
GetSimpleDataTable = 'get_simple_data_table',
GetSimpleDateTable = 'get_simple_date_table',
}
@@ -0,0 +1,18 @@
export interface XtreamItem {
num: number;
name: string;
stream_type: 'live' | 'movie';
stream_id: number;
stream_icon: string;
added: string;
category_id: string;
custom_sid: string;
direct_source: string;
epg_channel_id?: string;
tv_archive?: number;
tv_archive_duration?: number;
rating_imdb?: string;
xtream_id?: number;
type?: 'movie' | 'series' | 'live';
added_at?: number;
}
@@ -0,0 +1,8 @@
import { XtreamItem } from './xtream-item.interface';
export interface XtreamLiveStream extends XtreamItem {
stream_type: 'live';
epg_channel_id?: string;
tv_archive: number;
tv_archive_duration: number;
}
@@ -0,0 +1,6 @@
import { XtreamCodeActions } from './xtream-code-actions';
export interface XtreamResponse {
payload: unknown;
action: XtreamCodeActions;
}
@@ -0,0 +1,17 @@
import {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
} from './playlist-backup.interface';
import { PlaybackPositionData } from './playback-position.interface';
export interface XtreamPendingRestoreState {
hiddenCategories: XtreamBackupHiddenCategory[];
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
playbackPositions: PlaybackPositionData[];
}
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
return `xtream-restore-${playlistId}`;
}
@@ -0,0 +1,59 @@
export interface XtreamSerieDetails {
seasons: XtreamSerieSeason[];
info: XtreamSerieInfo;
episodes: Record<string, XtreamSerieEpisode[]>;
}
export interface XtreamSerieInfo {
name: string;
cover: string;
plot: string;
cast: string;
director: string;
genre: string;
releaseDate: string;
last_modified: string;
rating: string;
rating_5based: number;
backdrop_path: string[];
youtube_trailer: string;
episode_run_time: string;
category_id: string;
}
export interface XtreamSerieEpisode {
id: string;
episode_num: number;
title: string;
container_extension: string;
info: XtreamSerieEpisodeInfo | []; // Can be empty array when no metadata available
custom_sid: string;
added: string;
season: number;
direct_source: string;
}
export interface XtreamSerieEpisodeInfo {
tmdb_id?: number;
releasedate?: string;
plot?: string;
duration_secs?: number;
duration?: string;
movie_image?: string;
video?: Record<string, string>; // TODO
audio?: Record<string, string>; // TODO
bitrate?: number;
rating?: number;
season?: string;
}
export interface XtreamSerieSeason {
air_date: string;
episode_count: number;
id: number;
name: string;
overview: string;
season_number: number;
cover: string;
cover_big: string;
}
@@ -0,0 +1,18 @@
export interface XtreamSerieItem {
num: number;
name: string;
series_id: number;
cover: string;
plot: string;
cast: string;
director: string;
genre: string;
releaseDate: string;
last_modified: string;
rating: string;
rating_5based: number;
backdrop_path: string[];
youtube_trailer: string;
episode_run_time: string;
category_id: number;
}
@@ -0,0 +1,64 @@
export interface XtreamVodInfo {
kinopoisk_url: string;
tmdb_id: number | string;
name: string;
o_name: string;
cover_big: string;
movie_image: string;
releasedate: string;
episode_run_time: number;
youtube_trailer: string;
director: string;
actors: string;
cast: string;
description: string;
plot: string;
age: string;
mpaa_rating: string;
rating_count_kinopoisk: number;
country: string;
genre: string;
backdrop_path: string[];
duration_secs: number;
duration: string;
video: string[];
audio: string[];
bitrate: number;
rating: number | string;
runtime?: string;
status?: string;
rating_kinopoisk?: string;
rating_imdb?: string;
}
export interface XtreamVodMovieData {
stream_id: number;
name: string;
added: string;
category_id: string;
container_extension: string;
custom_sid: string | null;
direct_source: string;
category_ids?: number[];
}
export interface XtreamVodDetails {
info?: XtreamVodInfo | [] | null;
movie_data?: XtreamVodMovieData;
}
export function getXtreamVodInfo(
details: Pick<XtreamVodDetails, 'info'> | null | undefined
): XtreamVodInfo | null {
const info = details?.info;
if (!info || Array.isArray(info) || typeof info !== 'object') {
return null;
}
return info;
}
export function hasXtreamVodInfo(
details: Pick<XtreamVodDetails, 'info'> | null | undefined
): details is XtreamVodDetails & { info: XtreamVodInfo } {
return getXtreamVodInfo(details) !== null;
}
@@ -0,0 +1,8 @@
import { XtreamItem } from './xtream-item.interface';
export interface XtreamVodStream extends XtreamItem {
stream_type: 'movie';
rating: number;
rating_5based: number;
container_extension: string;
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../../../global.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'm3u-utils',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/m3u-utils',
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "m3u-utils",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "m3u-utils",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/m3u-utils/src",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:util"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/m3u-utils",
"main": "libs/shared/m3u-utils/src/index.ts",
"tsConfig": "libs/shared/m3u-utils/tsconfig.lib.json",
"assets": []
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
@@ -0,0 +1,254 @@
import { Channel, EpgProgram } from 'shared-interfaces';
import {
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from './catchup.utils';
import { createPlaylistObject } from './playlist.utils';
describe('catchup.utils', () => {
const baseChannel: Channel = {
id: 'channel-1',
name: 'News One',
url: 'https://streams.example.com/live/channel-1.m3u8',
group: { title: 'News' },
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
const archivedProgram: EpgProgram = {
start: '2026-04-10T08:00:00.000Z',
stop: '2026-04-10T09:00:00.000Z',
channel: 'news-1',
title: 'Morning News',
desc: null,
category: null,
startTimestamp: 1_775_808_800,
stopTimestamp: 1_775_812_400,
};
it('reports archive days from catchup metadata before legacy fields', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: 'shift', days: '5' },
timeshift: '3',
tvg: { ...baseChannel.tvg, rec: '2' },
})
).toBe(5);
});
it('falls back to tvg-rec when catchup-days and timeshift are blank strings', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: '', days: '' },
timeshift: '',
tvg: { ...baseChannel.tvg, rec: '7' },
})
).toBe(7);
});
it('supports legacy same-stream shift playback when catchup type is shift', () => {
expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true);
expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('supports tvg-rec-only legacy same-stream playback when the channel url is reusable', () => {
const channel: Channel = {
...baseChannel,
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
};
expect(isM3uCatchupPlaybackSupported(channel)).toBe(true);
expect(resolveM3uCatchupUrl(channel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('rewrites utc and lutc query params on catchup-source urls', () => {
const playbackUrl = resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source:
'https://archive.example.com/catchup.m3u8?utc=1&lutc=2&token=abc',
},
},
archivedProgram,
1_775_820_000
);
expect(playbackUrl).toBe(
'https://archive.example.com/catchup.m3u8?utc=1775808800&lutc=1775820000&token=abc'
);
});
it('returns null when the channel metadata declares an unsupported replay scheme', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
},
archivedProgram
)
).toBeNull();
});
it('returns null for tvg-rec-only channels when the stream url is not http', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
},
archivedProgram
)
).toBeNull();
});
it('returns null when archive days are missing', () => {
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
tvg: { ...baseChannel.tvg, rec: '' },
timeshift: '',
catchup: {
type: 'shift',
days: '',
},
},
archivedProgram
)
).toBeNull();
});
it('falls back to parsing the program start string when the unix timestamp is unavailable', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('preserves catchup metadata when storing parsed playlist items', () => {
const playlist = createPlaylistObject('Catchup playlist', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [
{
name: 'News One',
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
group: { title: 'News' },
http: {
referrer: '',
'user-agent': '',
},
url: 'https://streams.example.com/live/channel-1.m3u8',
raw: '#EXTINF:-1,News One',
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
},
],
});
expect(playlist.playlist?.items[0]).toEqual(
expect.objectContaining({
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
})
);
});
});
@@ -0,0 +1,155 @@
import { Channel, EpgProgram } from 'shared-interfaces';
const XMLTV_TIMESTAMP_PATTERN =
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/;
type CatchupSupportMode = 'none' | 'source' | 'shift';
export function getM3uArchiveDays(
channel: Pick<Channel, 'tvg' | 'timeshift' | 'catchup'> | null | undefined
): number {
const value = getFirstNonBlankValue(
channel?.catchup?.days,
channel?.timeshift,
channel?.tvg?.rec
);
return Math.max(0, Number(value ?? 0) || 0);
}
export function isM3uCatchupPlaybackSupported(
channel: Channel | null | undefined
): boolean {
return getM3uCatchupSupportMode(channel) !== 'none';
}
export function resolveM3uCatchupUrl(
channel: Channel | null | undefined,
program: Pick<EpgProgram, 'start' | 'startTimestamp'>,
nowTimestampSeconds = Math.floor(Date.now() / 1000)
): string | null {
const supportMode = getM3uCatchupSupportMode(channel);
if (supportMode === 'none') {
return null;
}
const startTimestamp = getEpgProgramTimestampSeconds(
program.start,
program.startTimestamp
);
if (startTimestamp === null) {
return null;
}
const playbackBaseUrl =
supportMode === 'source'
? channel?.catchup?.source
: channel?.url;
if (!playbackBaseUrl?.trim()) {
return null;
}
const normalizedNow =
Number.isFinite(nowTimestampSeconds) && nowTimestampSeconds > 0
? Math.floor(nowTimestampSeconds)
: Math.floor(Date.now() / 1000);
return setCatchupQueryParams(playbackBaseUrl, {
utc: startTimestamp,
lutc: normalizedNow,
});
}
function getM3uCatchupSupportMode(
channel: Channel | null | undefined
): CatchupSupportMode {
if (getM3uArchiveDays(channel) <= 0) {
return 'none';
}
const catchupSource = channel?.catchup?.source?.trim() ?? '';
if (isHttpUrl(catchupSource)) {
return 'source';
}
const streamUrl = channel?.url?.trim() ?? '';
const catchupType = channel?.catchup?.type?.trim().toLowerCase() ?? '';
if (catchupType === 'shift' && isHttpUrl(streamUrl)) {
return 'shift';
}
if (!catchupType && isHttpUrl(streamUrl)) {
return 'shift';
}
return 'none';
}
function getEpgProgramTimestampSeconds(
dateValue: string,
unixTimestampValue?: number | string | null
): number | null {
const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10);
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
return unixTimestamp;
}
const parsed = Date.parse(dateValue);
if (Number.isFinite(parsed)) {
return Math.floor(parsed / 1000);
}
const match = dateValue.match(XMLTV_TIMESTAMP_PATTERN);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
match;
const utcMillis = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute)
);
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
}
function setCatchupQueryParams(
rawUrl: string,
params: Record<'utc' | 'lutc', number>
): string | null {
try {
const url = new URL(rawUrl.trim());
url.searchParams.set('utc', String(params.utc));
url.searchParams.set('lutc', String(params.lutc));
return url.toString();
} catch {
return null;
}
}
function isHttpUrl(value: string): boolean {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function getFirstNonBlankValue(
...values: Array<string | null | undefined>
): string | undefined {
return values.find(
(value): value is string => value != null && value.trim() !== ''
);
}
@@ -0,0 +1,98 @@
import {
Channel,
ParsedPlaylist,
ParsedPlaylistItem,
Playlist,
} from 'shared-interfaces';
import { v4 as uuidv4 } from 'uuid';
/**
* Aggregates favorite channels as objects from all available playlists
* @param playlists all available playlists
* @returns an array with favorite channels from all playlists
*/
export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] {
const favorites: Channel[] = [];
playlists.forEach((playlist) => {
if (playlist.favorites?.length && playlist.favorites.length > 0) {
playlist?.playlist?.items.forEach((channel: Channel) => {
if (
playlist.favorites &&
(playlist.favorites.includes(channel.id) ||
playlist.favorites.includes(channel.url))
) {
favorites.push(channel);
}
});
}
});
return favorites;
}
/**
* Creates a simplified playlist object which is used for global favorites
* @param channels channels list
* @returns simplified playlist object
*/
export function createFavoritesPlaylist(
channels: Channel[]
): Partial<Playlist> {
return {
_id: 'global-favorites',
count: channels.length,
playlist: {
items: channels,
},
favorites: channels.map((channel) => channel.url),
filename: 'Global favorites',
};
}
/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
*/
export const getFilenameFromUrl = (value: string): string => {
if (value && value.length > 1) {
return value.substring(value.lastIndexOf('/') + 1);
}
return 'Untitled playlist';
};
/**
* Creates a playlist object
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
* @param uploadType upload type - by file or via an url
*/
export const createPlaylistObject = (
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
return {
_id: uuidv4(),
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item: ParsedPlaylistItem) => ({
...item,
id: uuidv4(),
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
};
export const getExtensionFromUrl = (url: string) => {
return url.split(/[#?]/)[0].split('.').pop()?.trim();
};
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}