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"]
|
||||
}
|
||||
Reference in New Issue
Block a user