feat: implement detailed tracing for database operations, window events, and renderer API interactions

This commit is contained in:
4gray committed 2026-04-05 23:26:09 +02:00
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));
+88
View File
@@ -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;
}
+23
View File
@@ -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();
});
@@ -1,4 +1,4 @@
@if (!isWorkspaceLayout) {
<!-- @if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
@@ -74,3 +74,4 @@
}
</div>
</div>
-->
@@ -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();
}
} */
}
@@ -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);
});
});
@@ -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;
}
+55 -1
View File
@@ -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;