Proyecto App Windows
This commit is contained in:
@@ -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`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user