chore: tighten validation and runtime logging

* chore: tighten validation and runtime logging

* fix(i18n): localize new settings labels
This commit is contained in:
4gray authored and GitHub committed 2026-05-15 17:43:42 +02:00
1 parent d24c77a143
commit 2d5c4fa4f9
52 files changed
+767 -214

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'epg-data-access',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/epg/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/epg/data-access/jest.config.ts",
"tsConfig": "libs/epg/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -1,119 +1,45 @@
/* import { TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { invoke } from '@tauri-apps/api/core';
import { MockModule, MockProviders } from 'ng-mocks';
import { DataService } from './data.service';
import { firstValueFrom } from 'rxjs';
import { EpgService } from './epg.service';
jest.mock('@tauri-apps/api/core', () => ({
invoke: jest.fn(),
isTauri: () => true,
}));
describe('EpgService', () => {
let service: EpgService;
let snackBar: MatSnackBar;
let store: MockStore;
let translateService: TranslateService;
let dispatchSpy: jest.SpyInstance;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EpgService,
MockProviders(DataService, TranslateService, MatSnackBar),
provideMockStore(),
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
},
},
],
imports: [MockModule(MatSnackBarModule)],
});
service = TestBed.inject(EpgService);
snackBar = TestBed.inject(MatSnackBar);
store = TestBed.inject(MockStore);
translateService = TestBed.inject(TranslateService);
});
jest.spyOn(translateService, 'instant').mockImplementation(
(key) => key
it('returns an empty batch result when the desktop bridge is unavailable', async () => {
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['channel-1'])
);
jest.spyOn(snackBar, 'open');
dispatchSpy = jest.spyOn(store, 'dispatch');
// Suppress console.error during tests
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
expect(result).toEqual(new Map());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a service instance', () => {
expect(service).toBeTruthy();
});
describe('fetchEpg', () => {
it('should show success notification on successful fetch', async () => {
(invoke as jest.Mock).mockResolvedValueOnce({});
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.FETCH_SUCCESS',
null,
expect.any(Object)
);
});
it('should show error notification on fetch failure', async () => {
(invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed'));
service.fetchEpg(['http://example.com/epg.xml']);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.ERROR',
'CLOSE',
expect.any(Object)
);
});
});
describe('getChannelPrograms', () => {
it('should update programs and set EPG flag to true when programs exist', (done) => {
const mockPrograms = [
{
start: new Date().toISOString(),
stop: new Date().toISOString(),
title: 'Test',
},
];
(invoke as jest.Mock).mockResolvedValueOnce(mockPrograms);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(1);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: true })
);
done();
});
});
it('should set EPG flag to false when no programs found', (done) => {
(invoke as jest.Mock).mockResolvedValueOnce([]);
service.getChannelPrograms('test-channel');
service.currentEpgPrograms$.subscribe((programs) => {
expect(programs).toHaveLength(0);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: false })
);
done();
});
});
it('returns null for current program lookup when the desktop bridge is unavailable', async () => {
await expect(
firstValueFrom(service.getCurrentProgramForChannel('channel-1'))
).resolves.toBeNull();
});
});
*/
+8 -2
View File
@@ -3,7 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
import {
createDevLogger,
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
interface CachedProgram {
@@ -17,6 +21,8 @@ type EpgChannelMetadataApi = {
) => Promise<Record<string, EpgChannelMetadata | null>>;
};
const debugEpgService = createDevLogger('EpgService');
@Injectable({
providedIn: 'root',
})
@@ -71,7 +77,7 @@ export class EpgService {
*/
getChannelPrograms(channelId: string): void {
if (!this.isDesktop) return;
console.log('Fetching EPG for channel ID:', channelId);
debugEpgService('Fetching EPG for channel ID:', channelId);
from(window.electron.getChannelPrograms(channelId))
.pipe(
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'database',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js'],
coverageDirectory: '../../../coverage/libs/shared/database',
};
+8
View File
@@ -16,6 +16,14 @@
"format": ["cjs"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/database/jest.config.ts",
"tsConfig": "libs/shared/database/tsconfig.spec.json"
}
},
"lint": {
"command": "eslint libs/shared/database/**/*.ts"
}
@@ -0,0 +1,120 @@
import { __databaseConnectionTestHooks } from './connection';
function compactSql(statement: string): string {
return statement.replace(/\s+/g, ' ').trim();
}
function createdObjectNames(prefix: string, statements: readonly string[]) {
return statements
.map(compactSql)
.filter((statement) => statement.startsWith(prefix))
.map((statement) => {
const match = statement.match(
/^(?:CREATE\s+(?:UNIQUE\s+)?(?:VIRTUAL\s+)?(?:TABLE|INDEX|TRIGGER)\s+IF\s+NOT\s+EXISTS)\s+([^\s(]+)/i
);
return match?.[1] ?? null;
})
.filter((name): name is string => Boolean(name));
}
describe('database schema statements', () => {
const {
createTableStatements,
columnMigrationStatements,
indexMigrationStatements,
} = __databaseConnectionTestHooks;
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
const schemaSql = createTableStatements.map(compactSql).join('\n');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS content');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_channels');
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_programs');
expect(schemaSql).toContain(
'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5'
);
expect(schemaSql).toContain(
'CREATE TABLE IF NOT EXISTS playback_positions'
);
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
);
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique'
);
expect(schemaSql).toContain(
'CREATE TRIGGER IF NOT EXISTS epg_programs_ai'
);
expect(schemaSql).toContain(
'CREATE TRIGGER IF NOT EXISTS epg_programs_ad'
);
expect(schemaSql).toContain(
'CREATE TRIGGER IF NOT EXISTS epg_programs_au'
);
});
it('keeps legacy column migrations idempotent ALTER TABLE statements', () => {
expect(columnMigrationStatements.length).toBeGreaterThan(0);
expect(
columnMigrationStatements
.map(compactSql)
.every((statement) => statement.startsWith('ALTER TABLE '))
).toBe(true);
expect(columnMigrationStatements.map(compactSql)).toEqual(
expect.arrayContaining([
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
'ALTER TABLE playlists ADD COLUMN payload TEXT',
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
])
);
});
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
expect(indexMigrationStatements.length).toBeGreaterThan(0);
expect(
indexMigrationStatements
.map(compactSql)
.every((statement) =>
/^CREATE (?:UNIQUE )?INDEX IF NOT EXISTS /i.test(statement)
)
).toBe(true);
expect(indexMigrationStatements.map(compactSql)).toEqual(
expect.arrayContaining([
'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 INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
])
);
});
it('does not define duplicate fresh-install schema object names', () => {
const objectNames = [
...createdObjectNames(
'CREATE TABLE IF NOT EXISTS',
createTableStatements
),
...createdObjectNames(
'CREATE VIRTUAL TABLE IF NOT EXISTS',
createTableStatements
),
...createdObjectNames(
'CREATE INDEX IF NOT EXISTS',
createTableStatements
),
...createdObjectNames(
'CREATE UNIQUE INDEX IF NOT EXISTS',
createTableStatements
),
...createdObjectNames(
'CREATE TRIGGER IF NOT EXISTS',
createTableStatements
),
];
expect(objectNames).toHaveLength(new Set(objectNames).size);
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+3
View File
@@ -22,6 +22,9 @@
"jestConfig": "libs/shared/interfaces/jest.config.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/dev-logger.util';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
@@ -0,0 +1,55 @@
import { createDevLogger } from './dev-logger.util';
describe('createDevLogger', () => {
const globalWithNgDevMode = globalThis as typeof globalThis & {
ngDevMode?: boolean;
};
const originalNgDevMode = globalWithNgDevMode.ngDevMode;
const originalNodeEnv = process.env['NODE_ENV'];
let debugSpy: jest.SpyInstance;
beforeEach(() => {
globalWithNgDevMode.ngDevMode = true;
process.env['NODE_ENV'] = 'development';
debugSpy = jest.spyOn(console, 'debug').mockImplementation();
});
afterEach(() => {
globalWithNgDevMode.ngDevMode = originalNgDevMode;
if (originalNodeEnv === undefined) {
delete process.env['NODE_ENV'];
} else {
process.env['NODE_ENV'] = originalNodeEnv;
}
debugSpy.mockRestore();
});
it('logs scoped debug output outside production and test mode', () => {
const debug = createDevLogger('Scope');
debug('message', { id: 1 });
expect(debugSpy).toHaveBeenCalledWith('[Scope]', 'message', {
id: 1,
});
});
it('suppresses debug output in Angular production mode', () => {
globalWithNgDevMode.ngDevMode = false;
const debug = createDevLogger('Scope');
debug('message');
expect(debugSpy).not.toHaveBeenCalled();
});
it('suppresses debug output during tests', () => {
process.env['NODE_ENV'] = 'test';
const debug = createDevLogger('Scope');
debug('message');
expect(debugSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
export type DevLogFn = (...args: unknown[]) => void;
function isDevLoggingEnabled(): boolean {
const global = globalThis as typeof globalThis & {
ngDevMode?: boolean;
process?: { env?: { NODE_ENV?: string } };
};
return (
global.ngDevMode !== false && global.process?.env?.NODE_ENV !== 'test'
);
}
export function createDevLogger(scope: string): DevLogFn {
const prefix = `[${scope}]`;
return (...args: unknown[]) => {
if (isDevLoggingEnabled()) {
console.debug(prefix, ...args);
}
};
}
+3
View File
@@ -21,6 +21,9 @@
"options": {
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -26,9 +26,7 @@ import { ChannelActions } from '@iptvnator/m3u-state';
@if (displayIcon() && !logoError()) {
<div
class="backdrop"
[style.backgroundImage]="
'url(' + displayIcon() + ')'
"
[style.backgroundImage]="'url(' + displayIcon() + ')'"
></div>
}
<div class="vignette"></div>
@@ -54,7 +52,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
<h2 class="station-name">
{{ channelName() || 'Radio' }}
</h2>
<span class="station-badge" [class.live]="playState() === 'play'">
<span
class="station-badge"
[class.live]="playState() === 'play'"
>
@if (playState() === 'play') {
<span class="pulse"></span> LIVE
} @else {
@@ -74,14 +75,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
<button
class="play-btn"
mat-fab
(click)="
playState() === 'play' ? stop() : play()
"
(click)="playState() === 'play' ? stop() : play()"
>
<mat-icon>{{
playState() === 'play'
? 'pause'
: 'play_arrow'
playState() === 'play' ? 'pause' : 'play_arrow'
}}</mat-icon>
</button>
@@ -95,19 +92,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
</div>
<div class="volume-row">
<button
mat-icon-button
class="vol-icon"
(click)="mute()"
>
<button mat-icon-button class="vol-icon" (click)="mute()">
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
<mat-slider
class="vol-slider"
min="0"
max="1"
step="0.05"
>
<mat-slider class="vol-slider" min="0" max="1" step="0.05">
<input
matSliderThumb
[ngModel]="volume()"
@@ -209,7 +197,9 @@ export class AudioPlayerComponent {
play() {
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
audio.play().catch((err) => console.log(err));
audio.play().catch((err) => {
console.warn('[AudioPlayer] Audio playback failed:', err);
});
this.playState.set('play');
}
@@ -219,8 +209,7 @@ export class AudioPlayerComponent {
}
setVolume(value: number) {
const clamped =
Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
const clamped = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100;
this.volume.set(clamped);
const audio = this.audioRef()?.nativeElement;
if (audio) audio.volume = clamped;
@@ -14,7 +14,7 @@ import {
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import mpegts from 'mpegts.js';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
@@ -26,6 +26,8 @@ import {
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
/**
* This component contains the implementation of HTML5 based video player
*/
@@ -121,7 +123,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.playChannel(changes['channel'].currentValue);
}
if (changes['volume']?.currentValue !== undefined) {
console.log(
debugHtmlPlayer(
'Setting HTML5 player volume to:',
changes['volume'].currentValue
);
@@ -157,7 +159,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
console.log(
debugHtmlPlayer(
'Using mpegts.js for TS stream:',
channel.name,
url
@@ -193,7 +195,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
Hls &&
Hls.isSupported()
) {
console.log('... switching channel to ', channel.name, url);
debugHtmlPlayer('Switching channel to:', channel.name, url);
this.hls = new Hls();
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
this.handleHlsManifestParsed(url, data);
@@ -205,7 +207,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.log('Using native video player...');
debugHtmlPlayer('Using native video player');
this.addSourceToVideo(
this.videoPlayer.nativeElement,
url,
@@ -315,7 +317,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
*/
onVolumeChange(): void {
const currentVolume = this.videoPlayer.nativeElement.volume;
console.log('Volume changed to:', currentVolume);
debugHtmlPlayer('Volume changed to:', currentVolume);
localStorage.setItem('volume', currentVolume.toString());
}
@@ -12,6 +12,7 @@ import {
} from '@angular/core';
import '@yangkghjh/videojs-aspect-ratio-panel';
import mpegts from 'mpegts.js';
import { createDevLogger } from '@iptvnator/shared/interfaces';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
@@ -89,6 +90,8 @@ type VideoJsPlayer = Omit<
error: () => { code?: number; message?: string } | null;
};
const debugVjsPlayer = createDevLogger('VjsPlayer');
@Component({
selector: 'app-vjs-player',
templateUrl: './vjs-player.component.html',
@@ -145,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
: { ...this.options(), autoplay: true };
this.player = videoJs(this.target().nativeElement, vjsOptions, () => {
console.log(
debugVjsPlayer(
'Setting VideoJS player initial volume to:',
this.volume()
);
@@ -168,7 +171,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
const audioTracks = this.player.audioTracks();
if (audioTracks) {
audioTracks.addEventListener('addtrack', () => {
console.log(
debugVjsPlayer(
'[AudioTrack] addtrack event fired, total tracks:',
this.player.audioTracks().length
);
@@ -176,7 +179,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.setupAudioTrackMenu();
});
audioTracks.addEventListener('removetrack', () => {
console.log(
debugVjsPlayer(
'[AudioTrack] removetrack event fired, total tracks:',
this.player.audioTracks().length
);
@@ -246,7 +249,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
}
if (changes['volume']?.currentValue !== undefined && this.player) {
console.log(
debugVjsPlayer(
'Setting VideoJS player volume to:',
changes['volume'].currentValue
);
@@ -300,7 +303,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
?.el();
if (!videoEl) return;
console.log('Using mpegts.js for TS stream:', url);
debugVjsPlayer('Using mpegts.js for TS stream:', url);
const isLive = this.options().isLive !== false;
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
@@ -455,13 +458,13 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
*/
private logAudioTracks(): void {
const audioTracks = this.player.audioTracks();
console.log(
debugVjsPlayer(
'[AudioTrack] Audio tracks count:',
audioTracks?.length ?? 0
);
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
const t = audioTracks[i];
console.log(
debugVjsPlayer(
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
);
}
@@ -476,12 +479,12 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
if (audioMediaGroups) {
console.log(
debugVjsPlayer(
'[AudioTrack] HLS AUDIO media groups:',
JSON.stringify(Object.keys(audioMediaGroups))
);
} else {
console.log(
debugVjsPlayer(
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
);
}
@@ -494,16 +497,16 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
*/
private setupAudioTrackMenu(): void {
const audioTracks = this.player.audioTracks();
console.log(
debugVjsPlayer(
'[AudioTrack] setupAudioTrackMenu called, tracks:',
audioTracks?.length ?? 0
);
if (!audioTracks || audioTracks.length <= 1) {
console.log(
debugVjsPlayer(
'[AudioTrack] Skipping menu: need >1 tracks, have',
audioTracks?.length ?? 0
);
console.log(
debugVjsPlayer(
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
);
return;
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:util"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/shell/util/jest.config.ts",
"tsConfig": "libs/workspace/shell/util/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}