mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat: implement detailed tracing for database operations, window events, and renderer API interactions
This commit is contained in:
1 parent
d366672506
commit
04f9686483
12 files changed
+709
-49
No files matched your search
@@ -5,6 +5,12 @@ import type {
|
||||
PlaylistRefreshPayload,
|
||||
XtreamCategory,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
DEBUG_TRACE_EVENT_CHANNEL,
|
||||
isRendererApiTraceEnabled,
|
||||
roundTraceDuration,
|
||||
summarizeForTrace,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
|
||||
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
@@ -40,7 +46,118 @@ const dbSaveContentProgressListeners = new Set<
|
||||
(event: Electron.IpcRendererEvent, data: DbOperationEvent) => void
|
||||
>();
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
const shouldTraceRendererApi = isRendererApiTraceEnabled();
|
||||
|
||||
function emitRendererTrace(payload: {
|
||||
method: string;
|
||||
phase: 'start' | 'success' | 'error';
|
||||
args?: unknown;
|
||||
durationMs?: number;
|
||||
error?: unknown;
|
||||
result?: unknown;
|
||||
}): void {
|
||||
if (!shouldTraceRendererApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload);
|
||||
}
|
||||
|
||||
function wrapElectronApi<T extends Record<string, unknown>>(api: T): T {
|
||||
if (!shouldTraceRendererApi) {
|
||||
return api;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(api).map(([name, value]) => {
|
||||
if (
|
||||
typeof value !== 'function' ||
|
||||
name.startsWith('on') ||
|
||||
name.startsWith('remove')
|
||||
) {
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
const original = value as (...args: unknown[]) => unknown;
|
||||
|
||||
return [
|
||||
name,
|
||||
(...args: unknown[]) => {
|
||||
const startedAt =
|
||||
globalThis.performance?.now?.() ?? Date.now();
|
||||
emitRendererTrace({
|
||||
args: summarizeForTrace(args),
|
||||
method: name,
|
||||
phase: 'start',
|
||||
});
|
||||
|
||||
try {
|
||||
const result = original(...args);
|
||||
|
||||
if (
|
||||
result &&
|
||||
typeof (result as PromiseLike<unknown>).then ===
|
||||
'function'
|
||||
) {
|
||||
return (result as Promise<unknown>)
|
||||
.then((resolvedValue) => {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
method: name,
|
||||
phase: 'success',
|
||||
result: summarizeForTrace(
|
||||
resolvedValue
|
||||
),
|
||||
});
|
||||
return resolvedValue;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
error: summarizeForTrace(error),
|
||||
method: name,
|
||||
phase: 'error',
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
method: name,
|
||||
phase: 'success',
|
||||
result: summarizeForTrace(result),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
error: summarizeForTrace(error),
|
||||
method: name,
|
||||
phase: 'error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
];
|
||||
})
|
||||
) as T;
|
||||
}
|
||||
|
||||
const electronApi = {
|
||||
// Remote control channel change listener
|
||||
onChannelChange: (
|
||||
callback: (data: { direction: 'up' | 'down' }) => void
|
||||
@@ -495,4 +612,6 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler);
|
||||
return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
|
||||
@@ -1,8 +1,95 @@
|
||||
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { rendererAppName, rendererAppPort } from './constants';
|
||||
import {
|
||||
isRendererConsoleTraceEnabled,
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
} from './services/debug-trace';
|
||||
import { store, WINDOW_BOUNDS } from './services/store.service';
|
||||
|
||||
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
|
||||
if (!isWindowTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webContents = mainWindow.webContents;
|
||||
|
||||
trace('window', 'created', {
|
||||
id: mainWindow.id,
|
||||
});
|
||||
|
||||
mainWindow.on('unresponsive', () => {
|
||||
trace('window', 'unresponsive', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
mainWindow.on('responsive', () => {
|
||||
trace('window', 'responsive', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
webContents.on('did-start-loading', () => {
|
||||
trace('window', 'did-start-loading', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on('dom-ready', () => {
|
||||
trace('window', 'dom-ready', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on('did-finish-load', () => {
|
||||
trace('window', 'did-finish-load', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on(
|
||||
'did-fail-load',
|
||||
(_event, errorCode, errorDescription, validatedURL) => {
|
||||
trace('window', 'did-fail-load', {
|
||||
errorCode,
|
||||
errorDescription,
|
||||
id: mainWindow.id,
|
||||
validatedURL,
|
||||
});
|
||||
}
|
||||
);
|
||||
webContents.on('did-navigate', (_event, url) => {
|
||||
trace('window', 'did-navigate', {
|
||||
id: mainWindow.id,
|
||||
url,
|
||||
});
|
||||
});
|
||||
webContents.on('render-process-gone', (_event, details) => {
|
||||
trace('window', 'render-process-gone', {
|
||||
details,
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
if (isRendererConsoleTraceEnabled()) {
|
||||
webContents.on(
|
||||
'console-message',
|
||||
(_event, level, message, line, sourceId) => {
|
||||
trace('renderer-console', 'message', {
|
||||
level,
|
||||
line,
|
||||
message,
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class App {
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
@@ -94,6 +181,7 @@ export default class App {
|
||||
: {}),
|
||||
});
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
import { app, ipcMain } from 'electron';
|
||||
import * as os from 'os';
|
||||
import { environment } from '../../environments/environment';
|
||||
import {
|
||||
DEBUG_TRACE_EVENT_CHANNEL,
|
||||
isRendererApiTraceEnabled,
|
||||
trace,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
export default class ElectronEvents {
|
||||
static bootstrapElectronEvents(): Electron.IpcMain {
|
||||
@@ -25,6 +30,18 @@ ipcMain.on('quit', (event, code) => {
|
||||
app.exit(code);
|
||||
});
|
||||
|
||||
ipcMain.on(DEBUG_TRACE_EVENT_CHANNEL, (event, payload) => {
|
||||
if (!isRendererApiTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
trace('renderer-api', 'event', {
|
||||
payload,
|
||||
senderId: event.sender.id,
|
||||
url: event.sender.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
// Get local IP addresses for remote control URL display
|
||||
ipcMain.handle('get-local-ip-addresses', () => {
|
||||
const interfaces = os.networkInterfaces();
|
||||
|
||||
@@ -8,12 +8,20 @@ import type {
|
||||
DbWorkerMessage,
|
||||
DbWorkerOperation,
|
||||
} from '../workers/database-worker.types';
|
||||
import {
|
||||
isDbTraceEnabled,
|
||||
roundTraceDuration,
|
||||
summarizeForTrace,
|
||||
trace,
|
||||
} from './debug-trace';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
onEvent?: (event: DbOperationEvent) => void;
|
||||
operation: DbWorkerOperation;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -47,12 +55,23 @@ export class DatabaseWorkerClient {
|
||||
await this.ensureWorker();
|
||||
|
||||
const requestId = randomUUID();
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-request', 'dispatch', {
|
||||
operation,
|
||||
payload: summarizeForTrace(payload),
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
onEvent: options?.onEvent,
|
||||
operation,
|
||||
startedAt,
|
||||
});
|
||||
|
||||
this.worker?.postMessage({
|
||||
@@ -126,6 +145,15 @@ export class DatabaseWorkerClient {
|
||||
|
||||
try {
|
||||
const workerURL = pathToFileURL(bootstrap.workerPath);
|
||||
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-worker', 'create', {
|
||||
nativeModuleSearchPaths:
|
||||
bootstrap.nativeModuleSearchPaths?.length ?? 0,
|
||||
workerPath: bootstrap.workerPath,
|
||||
});
|
||||
}
|
||||
|
||||
this.worker = new Worker(workerURL, {
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
@@ -150,6 +178,9 @@ export class DatabaseWorkerClient {
|
||||
|
||||
private handleMessage(message: DbWorkerMessage): void {
|
||||
if (message.type === 'ready') {
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-worker', 'ready');
|
||||
}
|
||||
this.readyResolve?.();
|
||||
this.readyResolve = null;
|
||||
this.readyReject = null;
|
||||
@@ -157,6 +188,12 @@ export class DatabaseWorkerClient {
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-event', 'worker-event', {
|
||||
event: message.event,
|
||||
requestId: message.requestId,
|
||||
});
|
||||
}
|
||||
this.pendingRequests.get(message.requestId)?.onEvent?.(message.event);
|
||||
return;
|
||||
}
|
||||
@@ -169,10 +206,31 @@ export class DatabaseWorkerClient {
|
||||
this.pendingRequests.delete(message.requestId);
|
||||
|
||||
if (message.success) {
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-request', 'resolved', {
|
||||
durationMs: roundTraceDuration(
|
||||
Date.now() - pendingRequest.startedAt
|
||||
),
|
||||
operation: pendingRequest.operation,
|
||||
requestId: message.requestId,
|
||||
result: summarizeForTrace(message.result),
|
||||
});
|
||||
}
|
||||
pendingRequest.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-request', 'failed', {
|
||||
durationMs: roundTraceDuration(
|
||||
Date.now() - pendingRequest.startedAt
|
||||
),
|
||||
error: message.error,
|
||||
operation: pendingRequest.operation,
|
||||
requestId: message.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
pendingRequest.reject(
|
||||
createWorkerError(
|
||||
message.error ?? { message: 'Database worker request failed' }
|
||||
@@ -181,6 +239,10 @@ export class DatabaseWorkerClient {
|
||||
}
|
||||
|
||||
private handleWorkerFailure(error: Error): void {
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-worker', 'error', error);
|
||||
}
|
||||
|
||||
if (this.readyReject) {
|
||||
this.readyReject(error);
|
||||
}
|
||||
@@ -194,6 +256,10 @@ export class DatabaseWorkerClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDbTraceEnabled()) {
|
||||
trace('db-worker', 'exit', { code });
|
||||
}
|
||||
|
||||
const error =
|
||||
code === 0
|
||||
? new Error('Database worker exited unexpectedly')
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const TRACE_PREFIX = '[IPTVnator Trace]';
|
||||
const MAX_TRACE_ARRAY_ITEMS = 5;
|
||||
const MAX_TRACE_OBJECT_KEYS = 8;
|
||||
const MAX_TRACE_STRING_LENGTH = 180;
|
||||
const MAX_TRACE_DEPTH = 2;
|
||||
|
||||
export const DEBUG_TRACE_EVENT_CHANNEL = 'IPTVNATOR_DEBUG_TRACE_EVENT';
|
||||
|
||||
function readFlag(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
return value ? TRACE_ENV_TRUE_VALUES.has(value) : false;
|
||||
}
|
||||
|
||||
function truncateString(value: string): string {
|
||||
if (value.length <= MAX_TRACE_STRING_LENGTH) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, MAX_TRACE_STRING_LENGTH - 3)}...`;
|
||||
}
|
||||
|
||||
function summarizeObject(
|
||||
value: Record<string, unknown>,
|
||||
depth: number
|
||||
): Record<string, unknown> {
|
||||
const entries = Object.entries(value);
|
||||
const summary: Record<string, unknown> = {};
|
||||
|
||||
if (
|
||||
value.constructor?.name &&
|
||||
value.constructor.name !== 'Object'
|
||||
) {
|
||||
summary.__type = value.constructor.name;
|
||||
}
|
||||
|
||||
entries.slice(0, MAX_TRACE_OBJECT_KEYS).forEach(([key, entryValue]) => {
|
||||
summary[key] = summarizeForTrace(entryValue, depth + 1);
|
||||
});
|
||||
|
||||
if (entries.length > MAX_TRACE_OBJECT_KEYS) {
|
||||
summary.__moreKeys = entries.length - MAX_TRACE_OBJECT_KEYS;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function isStartupTraceEnabled(): boolean {
|
||||
return readFlag('IPTVNATOR_TRACE_STARTUP');
|
||||
}
|
||||
|
||||
export function isRendererApiTraceEnabled(): boolean {
|
||||
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC');
|
||||
}
|
||||
|
||||
export function isDbTraceEnabled(): boolean {
|
||||
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB');
|
||||
}
|
||||
|
||||
export function isSqlTraceEnabled(): boolean {
|
||||
return (
|
||||
isStartupTraceEnabled() ||
|
||||
readFlag('IPTVNATOR_TRACE_DB') ||
|
||||
readFlag('IPTVNATOR_TRACE_SQL')
|
||||
);
|
||||
}
|
||||
|
||||
export function isWindowTraceEnabled(): boolean {
|
||||
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_WINDOW');
|
||||
}
|
||||
|
||||
export function isRendererConsoleTraceEnabled(): boolean {
|
||||
return readFlag('IPTVNATOR_TRACE_RENDERER_CONSOLE');
|
||||
}
|
||||
|
||||
export function roundTraceDuration(durationMs: number): number {
|
||||
return Math.round(durationMs * 10) / 10;
|
||||
}
|
||||
|
||||
export function compactSqlForTrace(sql: string): string {
|
||||
return truncateString(sql.replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
export function summarizeForTrace(
|
||||
value: unknown,
|
||||
depth = 0
|
||||
): unknown {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateString(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return '[Function]';
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: truncateString(value.message),
|
||||
};
|
||||
}
|
||||
|
||||
if (depth >= MAX_TRACE_DEPTH) {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
type: 'array',
|
||||
length: value.length,
|
||||
};
|
||||
}
|
||||
|
||||
return typeof value === 'object'
|
||||
? {
|
||||
type:
|
||||
(value as { constructor?: { name?: string } }).constructor
|
||||
?.name ?? 'object',
|
||||
}
|
||||
: String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
type: 'array',
|
||||
length: value.length,
|
||||
items: value
|
||||
.slice(0, MAX_TRACE_ARRAY_ITEMS)
|
||||
.map((entry) => summarizeForTrace(entry, depth + 1)),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return summarizeObject(value as Record<string, unknown>, depth);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function safeStringifyForTrace(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
fallback: summarizeForTrace(payload),
|
||||
stringifyError:
|
||||
error instanceof Error
|
||||
? truncateString(error.message)
|
||||
: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function trace(
|
||||
scope: string,
|
||||
message: string,
|
||||
payload?: unknown
|
||||
): void {
|
||||
if (payload === undefined) {
|
||||
console.log(`${TRACE_PREFIX}[${scope}] ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace(
|
||||
summarizeForTrace(payload)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
loadNativeModuleFromSearchPaths,
|
||||
registerNativeModuleSearchPaths,
|
||||
} from './worker-runtime-paths';
|
||||
import {
|
||||
compactSqlForTrace,
|
||||
isSqlTraceEnabled,
|
||||
trace,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
let Database: typeof BetterSqlite3;
|
||||
let drizzleFactory:
|
||||
@@ -63,11 +68,25 @@ export async function getWorkerDatabase(): Promise<AppDatabase> {
|
||||
}
|
||||
|
||||
const filePath = getIptvnatorDatabasePath();
|
||||
sqlite = new Database(filePath);
|
||||
sqlite = new Database(filePath, {
|
||||
verbose: isSqlTraceEnabled()
|
||||
? (sql: string) => {
|
||||
trace('sql-worker', 'query', {
|
||||
sql: compactSqlForTrace(sql),
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('busy_timeout = 5000');
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
trace('sql-worker', 'open', {
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
db = getDrizzleFactory()(sqlite, { schema });
|
||||
return db;
|
||||
}
|
||||
@@ -78,6 +97,11 @@ export function closeWorkerDatabase(): void {
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
trace('sql-worker', 'close');
|
||||
}
|
||||
|
||||
sqlite = null;
|
||||
db = null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import SettingsEvents from './app/events/settings.events';
|
||||
import SharedEvents from './app/events/shared.events';
|
||||
import SquirrelEvents from './app/events/squirrel.events';
|
||||
import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { databaseWorkerClient } from './app/services/database-worker-client';
|
||||
import XtreamEvents from './app/events/xtream.events';
|
||||
|
||||
@@ -37,13 +38,24 @@ export default class Main {
|
||||
}
|
||||
|
||||
static bootstrapApp() {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-app');
|
||||
}
|
||||
App.main(app, BrowserWindow);
|
||||
}
|
||||
|
||||
static async bootstrapAppEvents() {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-events:start');
|
||||
}
|
||||
|
||||
// Initialize database before other events
|
||||
await initDatabase();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'init-database:done');
|
||||
}
|
||||
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
PlaylistEvents.bootstrapPlaylistEvents();
|
||||
SharedEvents.bootstrapSharedEvents();
|
||||
@@ -61,10 +73,18 @@ export default class Main {
|
||||
}
|
||||
await resetStaleDownloads();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'reset-stale-downloads:done');
|
||||
}
|
||||
|
||||
// initialize auto updater service
|
||||
if (!App.isDevelopmentMode()) {
|
||||
// UpdateEvents.initAutoUpdateService();
|
||||
}
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-events:done');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +98,9 @@ Main.bootstrapApp();
|
||||
|
||||
// Bootstrap app events after Electron app is ready
|
||||
app.whenReady().then(async () => {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'app.whenReady');
|
||||
}
|
||||
await Main.bootstrapAppEvents();
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
@if (!isWorkspaceLayout) {
|
||||
<!-- @if (!isWorkspaceLayout) {
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
@@ -74,3 +74,4 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
+6
-8
@@ -1,12 +1,10 @@
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
@@ -59,9 +57,9 @@ export interface FavoriteLayoutItem {
|
||||
],
|
||||
})
|
||||
export class FavoritesLayoutComponent {
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(
|
||||
/* readonly isWorkspaceLayout = isWorkspaceLayoutRoute(
|
||||
inject(ActivatedRoute)
|
||||
);
|
||||
); */
|
||||
|
||||
readonly categories = input<XtreamCategory[]>([]);
|
||||
readonly favorites = input<FavoriteLayoutItem[]>([]);
|
||||
@@ -80,7 +78,7 @@ export class FavoritesLayoutComponent {
|
||||
readonly openItem = output<FavoriteLayoutItem>();
|
||||
readonly headerActionClicked = output<void>();
|
||||
|
||||
setCategoryId(categoryId: string | number) {
|
||||
/* setCategoryId(categoryId: string | number) {
|
||||
this.categoryClicked.emit({ category_id: categoryId });
|
||||
}
|
||||
|
||||
@@ -94,5 +92,5 @@ export class FavoritesLayoutComponent {
|
||||
|
||||
onHeaderActionClick() {
|
||||
this.headerActionClicked.emit();
|
||||
}
|
||||
} */
|
||||
}
|
||||
+72
-17
@@ -59,6 +59,15 @@ class StubUnifiedGridTabComponent {
|
||||
|
||||
describe('UnifiedCollectionPageComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedCollectionPageComponent>;
|
||||
let route: ActivatedRoute & {
|
||||
snapshot: {
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParams: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
parent: null;
|
||||
};
|
||||
};
|
||||
const playlistsLoaded = signal(false);
|
||||
const playlists = signal<PlaylistMeta[]>([]);
|
||||
const favoritesData = {
|
||||
@@ -77,6 +86,32 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
playlists.set([]);
|
||||
jest.clearAllMocks();
|
||||
|
||||
route = {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
queryParams: {},
|
||||
params: {},
|
||||
data: {},
|
||||
parent: null,
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
pathFromRoot: [
|
||||
{
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as ActivatedRoute & {
|
||||
snapshot: {
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParams: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
parent: null;
|
||||
};
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
UnifiedCollectionPageComponent,
|
||||
@@ -85,23 +120,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
queryParams: {},
|
||||
params: {},
|
||||
data: {},
|
||||
parent: null,
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
pathFromRoot: [
|
||||
{
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
useValue: route,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
@@ -184,4 +203,40 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reload when local item state changes on empty playlist favorites', async () => {
|
||||
route.snapshot.params = { id: 'playlist-1' };
|
||||
route.snapshot.queryParams = { scope: 'playlist' };
|
||||
route.snapshot.queryParamMap = convertToParamMap({ scope: 'playlist' });
|
||||
playlistsLoaded.set(true);
|
||||
playlists.set([
|
||||
{
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
count: 0,
|
||||
importDate: '2026-04-05T20:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
} as PlaylistMeta,
|
||||
]);
|
||||
|
||||
fixture.componentRef.setInput('portalType', 'm3u');
|
||||
fixture.componentRef.setInput('defaultScope', undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-1',
|
||||
'm3u'
|
||||
);
|
||||
|
||||
fixture.componentInstance.allItems.set([]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+56
-19
@@ -2,12 +2,17 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
takeUntilDestroyed,
|
||||
toObservable,
|
||||
} from '@angular/core/rxjs-interop';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -15,6 +20,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { distinctUntilChanged } from 'rxjs';
|
||||
import {
|
||||
clearNavigationStateKeys,
|
||||
CollectionContentType,
|
||||
@@ -56,6 +62,8 @@ export class UnifiedCollectionPageComponent implements OnInit {
|
||||
readonly defaultScope = input<CollectionScope>();
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly store = inject(Store);
|
||||
private readonly scopeService = inject(ScopeToggleService);
|
||||
private readonly favoritesData = inject(UnifiedFavoritesDataService);
|
||||
@@ -153,20 +161,16 @@ export class UnifiedCollectionPageComponent implements OnInit {
|
||||
)
|
||||
.join('|');
|
||||
});
|
||||
private readonly loadRequest = computed(() => ({
|
||||
mode: this.mode(),
|
||||
portalType: this.portalType(),
|
||||
playlistId: this.playlistId(),
|
||||
scope: this.effectiveScope(),
|
||||
reloadKey: this.favoritesReloadKey(),
|
||||
}));
|
||||
|
||||
private loadRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.mode();
|
||||
this.portalType();
|
||||
this.playlistId();
|
||||
this.effectiveScope();
|
||||
this.favoritesReloadKey();
|
||||
void this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const queryScope = this.route.snapshot.queryParams['scope'] as
|
||||
| CollectionScope
|
||||
@@ -182,6 +186,29 @@ export class UnifiedCollectionPageComponent implements OnInit {
|
||||
const persisted = this.scopeService.getScope(this.scopeKey());
|
||||
this.scope.set(persisted());
|
||||
}
|
||||
|
||||
toObservable(this.loadRequest, {
|
||||
injector: this.injector,
|
||||
})
|
||||
.pipe(
|
||||
distinctUntilChanged(
|
||||
(previous, current) =>
|
||||
previous.mode === current.mode &&
|
||||
previous.portalType === current.portalType &&
|
||||
previous.playlistId === current.playlistId &&
|
||||
previous.scope === current.scope &&
|
||||
previous.reloadKey === current.reloadKey
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(({ mode, portalType, playlistId, scope }) => {
|
||||
void this.loadData({
|
||||
mode,
|
||||
portalType,
|
||||
playlistId,
|
||||
scope,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onScopeChange(value: CollectionScope): void {
|
||||
@@ -243,20 +270,30 @@ export class UnifiedCollectionPageComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
private async loadData(params: {
|
||||
mode: 'favorites' | 'recent';
|
||||
portalType?: string;
|
||||
playlistId?: string;
|
||||
scope: CollectionScope;
|
||||
}): Promise<void> {
|
||||
const requestId = ++this.loadRequestId;
|
||||
if (this.allItems().length === 0) {
|
||||
this.isLoading.set(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const s = this.effectiveScope();
|
||||
const pid = this.playlistId();
|
||||
const pt = this.portalType();
|
||||
const items =
|
||||
this.mode() === 'favorites'
|
||||
? await this.favoritesData.getFavorites(s, pid, pt)
|
||||
: await this.recentData.getRecentItems(s, pid, pt);
|
||||
params.mode === 'favorites'
|
||||
? await this.favoritesData.getFavorites(
|
||||
params.scope,
|
||||
params.playlistId,
|
||||
params.portalType
|
||||
)
|
||||
: await this.recentData.getRecentItems(
|
||||
params.scope,
|
||||
params.playlistId,
|
||||
params.portalType
|
||||
);
|
||||
if (requestId !== this.loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,43 @@ 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
|
||||
*/
|
||||
@@ -467,7 +500,23 @@ export async function initDatabase(
|
||||
|
||||
initPromise = (async () => {
|
||||
const filePath = getDatabasePath();
|
||||
sqlite = new Database(filePath, { readonly });
|
||||
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');
|
||||
@@ -517,6 +566,11 @@ export async function getReadOnlyDatabase(): Promise<DatabaseInstance> {
|
||||
export function closeDatabase(): void {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
traceSql('sql-main', 'close');
|
||||
}
|
||||
|
||||
sqlite = null;
|
||||
db = null;
|
||||
initPromise = null;
|
||||
|
||||
Reference in new issue
Block a user