diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a9069519f..1a7329072 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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>(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).then === + 'function' + ) { + return (result as Promise) + .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)); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 7be7e80e2..488ea0832 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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(); } diff --git a/apps/electron-backend/src/app/events/electron.events.ts b/apps/electron-backend/src/app/events/electron.events.ts index 47795d0f7..b01b177ca 100644 --- a/apps/electron-backend/src/app/events/electron.events.ts +++ b/apps/electron-backend/src/app/events/electron.events.ts @@ -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(); diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index d09d76a04..a7b8294d5 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -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((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') diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts new file mode 100644 index 000000000..8a24806ff --- /dev/null +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -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, + depth: number +): Record { + const entries = Object.entries(value); + const summary: Record = {}; + + 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, 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) + )}` + ); +} diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index ccf5ae97d..8e6d79d78 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -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 { } 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; } diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 6e5d4cf42..bba772f58 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); }); diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html index 8b2901cc3..50bc83025 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html @@ -1,4 +1,4 @@ -@if (!isWorkspaceLayout) { + diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index 1cdd1afd8..42e818ea8 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -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([]); readonly favorites = input([]); @@ -80,7 +78,7 @@ export class FavoritesLayoutComponent { readonly openItem = output(); readonly headerActionClicked = output(); - 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(); - } + } */ } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index ce0c806fd..68b364243 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -59,6 +59,15 @@ class StubUnifiedGridTabComponent { describe('UnifiedCollectionPageComponent', () => { let fixture: ComponentFixture; + let route: ActivatedRoute & { + snapshot: { + queryParamMap: ReturnType; + queryParams: Record; + params: Record; + data: Record; + parent: null; + }; + }; const playlistsLoaded = signal(false); const playlists = signal([]); 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; + queryParams: Record; + params: Record; + data: Record; + 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); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index e3f8b3cea..7474396cf 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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(); 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 { + private async loadData(params: { + mode: 'favorites' | 'recent'; + portalType?: string; + playlistId?: string; + scope: CollectionScope; + }): Promise { 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; } diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 09a8e255d..0b170007b 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -17,10 +17,43 @@ import { getIptvnatorDatabasePath } from './path-utils'; export type DatabaseInstance = BetterSQLite3Database; +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 | 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 { export function closeDatabase(): void { if (sqlite) { sqlite.close(); + + if (isSqlTraceEnabled()) { + traceSql('sql-main', 'close'); + } + sqlite = null; db = null; initPromise = null;