fix(skills): align repository guidance with implementation (#1315)

* docs(skills): design implementation synchronization

* docs(skills): plan implementation synchronization

* fix(release): filter internal notes from public body

* docs(release): synchronize release workflow guidance

* fix(stalker): normalize catalog series flags

* fix(stalker): preserve progress with scoped episode IDs

* fix(playback): expose strict position persistence

* docs(stalker): record series position compatibility

* test(skills): validate repository skill contracts

* fix(database): keep SQL trace values private

* docs(skills): refresh Nx and SQLite ownership

* docs(skills): align provider and UI guidance

* docs(skills): tighten validated guidance

* docs(release): require exact release pushes

* style(electron): remove trailing blank line

* fix(ci): classify repository skills coverage
This commit is contained in:
4gray authored and GitHub committed 2026-07-31 08:00:59 +02:00
1 parent 99d167993d
commit 2ac0de752f
68 files changed
+7524 -793

No files matched your search

@@ -6,6 +6,10 @@ export interface PortalPlaybackPositions {
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
savePlaybackPositionOrThrow(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -21,6 +25,11 @@ export interface PortalPlaybackPositions {
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
clearPlaybackPositionOrThrow(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
}
export const PORTAL_PLAYBACK_POSITIONS =
@@ -10,6 +10,7 @@ import { StalkerSelectedVodItem } from './models';
type EpisodeWithMetadata = {
custom_sid?: string;
id?: string;
legacyTrackingId?: number;
originalId?: string;
originalCmd?: string;
};
@@ -68,7 +69,10 @@ describe('stalker-series.adapters', () => {
isExpanded: false,
},
],
'poster.jpg'
{
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
}
);
expect(mapped['1']).toHaveLength(2);
@@ -78,39 +82,152 @@ describe('stalker-series.adapters', () => {
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('derives missing VOD-series season numbers from natural season order', () => {
const mapped = mapVodSeriesEpisodes([
it('generates deterministic VOD-series episode IDs for the same parent and provider episode', () => {
const seasons = [
{
id: 'season-2',
video_id: 'v1',
name: 'Season 2',
season_number: '',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second season pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
season_number: '1',
episodes: [
{
id: 'episode-1',
id: 'provider-episode-1',
series_number: 1,
name: 'Pilot',
name: 'Episode 1',
},
],
isLoading: false,
isExpanded: false,
},
]);
];
const options = {
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
};
const firstMapping = mapVodSeriesEpisodes(seasons, options);
const secondMapping = mapVodSeriesEpisodes(seasons, options);
expect(firstMapping['1'][0].id).toBe('604391373');
expect(firstMapping['1'][0].id).toBe(secondMapping['1'][0].id);
});
it('scopes VOD-series episode IDs by parent while preserving the legacy tracking ID', () => {
const seasons = [
{
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
isLoading: false,
isExpanded: false,
},
];
const firstSeries = mapVodSeriesEpisodes(seasons, {
parentSeriesId: 100,
fallbackPoster: 'poster.jpg',
});
const secondSeries = mapVodSeriesEpisodes(seasons, {
parentSeriesId: 200,
fallbackPoster: 'poster.jpg',
});
const firstEpisode = firstSeries['1'][0] as EpisodeWithMetadata;
const secondEpisode = secondSeries['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.id).not.toBe(secondEpisode.id);
expect(firstEpisode.legacyTrackingId).toBe(624320047);
expect(firstEpisode.legacyTrackingId).toBe(
secondEpisode.legacyTrackingId
);
});
it('scopes VOD-series episode IDs by provider episode identity', () => {
const baseSeason = {
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
isLoading: false,
isExpanded: false,
};
const firstSeries = mapVodSeriesEpisodes(
[
{
...baseSeason,
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
},
],
{ parentSeriesId: 100 }
);
const secondSeries = mapVodSeriesEpisodes(
[
{
...baseSeason,
episodes: [
{
id: 'provider-episode-2',
series_number: 1,
name: 'Episode 1',
},
],
},
],
{ parentSeriesId: 100 }
);
expect(firstSeries['1'][0].id).not.toBe(secondSeries['1'][0].id);
});
it('derives missing VOD-series season numbers from natural season order', () => {
const mapped = mapVodSeriesEpisodes(
[
{
id: 'season-2',
video_id: 'v1',
name: 'Season 2',
season_number: '',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second season pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
],
{ parentSeriesId: 100 }
);
expect(mapped['Season 1'][0].season).toBe(1);
expect(mapped['Season 2'][0].season).toBe(2);
@@ -132,6 +249,7 @@ describe('stalker-series.adapters', () => {
expect(mapped['1']).toHaveLength(2);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('regular-series');
expect(firstEpisode.id).toBe('91189090');
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -29,7 +29,13 @@ export interface StalkerSeriesSeasonVm {
series: number[];
}
export interface MapVodSeriesEpisodesOptions {
parentSeriesId: string | number;
fallbackPoster?: string;
}
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
legacyTrackingId?: number;
originalId?: string;
originalCmd?: string;
}
@@ -44,15 +50,34 @@ function hashString(str: string): number {
return Math.abs(hash);
}
function generateEpisodeId(
seed: string,
function generateLegacyVodEpisodeId(
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
seasonKey: string
): number {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
function generateVodEpisodeId(options: {
parentSeriesId: string | number;
providerEpisodeId: string;
seasonKey: string;
episodeNum: number;
}): number {
return hashString(
JSON.stringify([
'vod',
String(options.parentSeriesId),
options.providerEpisodeId,
options.seasonKey,
options.episodeNum,
])
);
}
function generateRegularEpisodeId(
seed: string,
episodeNum: number
): number {
if (isVodSeries) {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
return hashString(`${seed}_ep_${episodeNum}`);
}
@@ -153,7 +178,7 @@ function createBaseEpisode(
export function mapVodSeriesEpisodes(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
fallbackPoster?: string
options: MapVodSeriesEpisodesOptions
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
@@ -165,12 +190,17 @@ export function mapVodSeriesEpisodes(
const episodeNum =
toEpisodeNumber(episode.series_number) ||
toEpisodeNumber(episode.episode_num);
const trackingId = generateEpisodeId(
String(episode.id ?? ''),
const providerEpisodeId = String(episode.id ?? '');
const legacyTrackingId = generateLegacyVodEpisodeId(
episodeNum,
seasonKey,
true
seasonKey
);
const trackingId = generateVodEpisodeId({
parentSeriesId: options.parentSeriesId,
providerEpisodeId,
seasonKey,
episodeNum,
});
return {
...createBaseEpisode(
@@ -181,14 +211,15 @@ export function mapVodSeriesEpisodes(
'vod-series',
seasonNum,
{
movie_image: episode.cover || fallbackPoster,
movie_image: episode.cover || options.fallbackPoster,
plot: episode.description || '',
duration: episode.duration
? `${episode.duration} min`
: '',
}
),
originalId: String(episode.id ?? ''),
legacyTrackingId,
originalId: providerEpisodeId,
} as StalkerMappedEpisode;
});
});
@@ -205,11 +236,9 @@ export function mapRegularSeriesEpisodes(
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateEpisodeId(
const trackingId = generateRegularEpisodeId(
String(season.cmd ?? ''),
episodeNum,
seasonKey,
false
episodeNum
);
return {
@@ -5,11 +5,31 @@ import {
createStalkerInfo,
createStalkerInlineDetailState,
createStalkerDetailViewState,
isStalkerSeriesFlag,
normalizeStalkerFavoriteItem,
normalizeStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
describe('stalker-vod.utils regressions', () => {
describe('Stalker series flag contract', () => {
it.each([true, 1, '1'])(
'accepts %p and normalizes it to the positive marker',
(value) => {
expect(isStalkerSeriesFlag(value)).toBe(true);
expect(normalizeStalkerSeriesFlag(value)).toBe(true);
}
);
it.each([false, 0, '0', 'true', null, undefined, {}, []])(
'rejects unsupported value %p',
(value) => {
expect(isStalkerSeriesFlag(value)).toBe(false);
expect(normalizeStalkerSeriesFlag(value)).toBeUndefined();
}
);
});
it('routes embedded series[] items to series view state', () => {
const state = createStalkerDetailViewState(
{
@@ -54,10 +54,7 @@ export function isStalkerSeriesFlag(value: unknown): boolean {
export function normalizeStalkerSeriesFlag(
value: unknown
): StalkerSeriesFlag | undefined {
if (value === true || value === 1 || value === '1') {
return true;
}
return undefined;
return isStalkerSeriesFlag(value) ? true : undefined;
}
export function isStalkerSeriesItem(item: {
@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DataService, TmdbEnrichmentService } from '@iptvnator/services';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerSelection } from './with-stalker-selection.feature';
import { withStalkerSeries } from './with-stalker-series.feature';
@@ -187,22 +190,41 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
});
});
it('does not fire a series request for a Ministra VOD-series item', async () => {
it.each([true, 1, '1'] as const)(
'does not fire a series request for a Ministra VOD-series item with is_series=%p',
async (isSeries) => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '11',
name: 'VOD series',
is_series: isSeries,
});
// The legit vod-series season request (type=vod) may fire; the
// wasted regular-series request (type=series) must not.
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(
0
);
}
);
it('does not fetch VOD-series seasons for unsupported truthy is_series', async () => {
store.setSelectedContentType('vod');
store.setSelectedItem({
id: '11',
name: 'VOD series',
is_series: '1',
name: 'Plain VOD',
is_series: 'true',
});
// The legit vod-series season request (type=vod) may fire; the
// wasted regular-series request (type=series) must not.
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
await flushResources();
expect(seriesRequestCalls(dataService.sendIpcEvent)).toHaveLength(0);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('does not refetch seasons when a VOD item is selected after a series', async () => {
@@ -17,6 +17,7 @@ import {
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerSessionService } from '../../stalker-session.service';
import { isStalkerSeriesFlag } from '../../stalker-vod.utils';
import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts';
import {
executeStalkerRequest,
@@ -155,7 +156,7 @@ export function withStalkerSeries() {
!selectedItem ||
selectedItem.id === undefined ||
selectedItem.id === null ||
!selectedItem.is_series
!isStalkerSeriesFlag(selectedItem.is_series)
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
@@ -20,6 +20,7 @@ import {
import {
createPortalFavoritesResource,
createRefreshTrigger,
isStalkerSeriesFlag,
isSelectedStalkerVodFavorite,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
@@ -92,8 +93,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
isStalkerSeriesFlag(item.is_series))
);
});
@@ -18,8 +18,7 @@ describe('StalkerCatalogFacadeService', () => {
};
const unsubscribe = jest.fn();
let playbackUpdateHandler:
| ((data: PlaybackPositionData) => void)
| undefined;
((data: PlaybackPositionData) => void) | undefined;
let playbackPositionBridge: {
onPlaybackPositionUpdate: jest.Mock<
(() => void) | undefined,
@@ -49,6 +48,10 @@ describe('StalkerCatalogFacadeService', () => {
[string, number, 'vod' | 'episode']
>;
};
let stalkerStoreMock: Record<string, unknown> & {
setSearchPhrase: jest.Mock;
setSelectedItem: jest.Mock;
};
beforeEach(() => {
playbackUpdateHandler = undefined;
@@ -68,38 +71,37 @@ describe('StalkerCatalogFacadeService', () => {
}
),
};
stalkerStoreMock = {
selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'),
limit: signal(14),
page: signal(0),
getSelectedCategory: signal(null),
getPaginatedContent: signal([]),
selectedItem: signal(null),
getTotalPages: signal(0),
isPaginatedContentLoading: signal(false),
currentPlaylist: signal(playlist),
getSelectedCategoryName: jest.fn(() => null),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setSearchPhrase: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
setSelectedItem: jest.fn(),
createLinkToPlayVod: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
fetchMovieFileId: jest.fn(),
fetchLinkToPlay: jest.fn(),
resolveVodPlayback: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
StalkerCatalogFacadeService,
{
provide: StalkerStore,
useValue: {
selectedContentType: signal<'vod' | 'series' | 'itv'>(
'vod'
),
limit: signal(14),
page: signal(0),
getSelectedCategory: signal(null),
getPaginatedContent: signal([]),
selectedItem: signal(null),
getTotalPages: signal(0),
isPaginatedContentLoading: signal(false),
currentPlaylist: signal(playlist),
getSelectedCategoryName: jest.fn(() => null),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setSearchPhrase: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
setSelectedItem: jest.fn(),
createLinkToPlayVod: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
fetchMovieFileId: jest.fn(),
fetchLinkToPlay: jest.fn(),
resolveVodPlayback: jest.fn(),
},
useValue: stalkerStoreMock,
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
@@ -115,15 +117,60 @@ describe('StalkerCatalogFacadeService', () => {
it('delegates category search query updates to the Stalker store', () => {
const service = TestBed.inject(StalkerCatalogFacadeService);
const store = TestBed.inject(StalkerStore) as unknown as {
setSearchPhrase: jest.Mock;
};
service.setSearchQuery('matrix');
expect(store.setSearchPhrase).toHaveBeenCalledWith('matrix');
expect(stalkerStoreMock.setSearchPhrase).toHaveBeenCalledWith('matrix');
});
it.each([true, 1, '1'] as const)(
'normalizes supported is_series flag %p when selecting an item',
(isSeries) => {
const service = TestBed.inject(StalkerCatalogFacadeService);
service.selectItem({ id: '42', is_series: isSeries });
expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith(
expect.objectContaining({
id: '42',
is_series: true,
})
);
}
);
it.each([true, 1, '1'] as const)(
'returns empty series progress for supported is_series flag %p',
(isSeries) => {
const service = TestBed.inject(StalkerCatalogFacadeService);
expect(
service.getItemProgress({ id: '42', is_series: isSeries })
).toEqual({ hasSeriesProgress: false });
}
);
it.each([false, 0] as const)(
'keeps non-series flag %p on the ordinary VOD path',
(isSeries) => {
const service = TestBed.inject(StalkerCatalogFacadeService);
const item = { id: '42', is_series: isSeries };
service.selectItem(item);
expect(stalkerStoreMock.setSelectedItem).toHaveBeenCalledWith(
expect.objectContaining({
id: '42',
is_series: undefined,
})
);
expect(service.getItemProgress(item)).toEqual({
progress: 0,
isWatched: false,
});
}
);
it('persists matching external playback updates for the current playlist', async () => {
TestBed.inject(StalkerCatalogFacadeService);
await Promise.resolve();
@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import {
buildStalkerSelectedVodItem,
isStalkerSeriesFlag,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
@@ -185,8 +186,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
selectItem(item: StalkerVodSource): string[] | null {
const needsSeriesFetch =
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1);
this.contentType() === 'vod' && isStalkerSeriesFlag(item.is_series);
this.stalkerStore.setSelectedItem(
buildStalkerSelectedVodItem(item, needsSeriesFetch)
@@ -209,8 +209,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
);
const isSeries =
this.contentType() === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
isStalkerSeriesFlag(item.is_series);
if (hasSeriesProgress) {
return { hasSeriesProgress: true };
@@ -0,0 +1,573 @@
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
const PLAYLIST_ID = 'playlist-1';
const SERIES_ID = 100;
const FOREIGN_SERIES_ID = 200;
const SCOPED_TRACKING_ID = 1001;
const LEGACY_TRACKING_ID = 501;
interface EpisodeOptions {
trackingId?: number;
legacyTrackingId?: number;
seasonNumber?: number;
episodeNumber?: number;
}
interface RejectedOwnershipCase {
name: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}
function createEpisode(options: EpisodeOptions = {}): StalkerMappedEpisode {
const episodeNumber = options.episodeNumber ?? 2;
return {
id: String(options.trackingId ?? SCOPED_TRACKING_ID),
episode_num: episodeNumber,
title: `Episode ${episodeNumber}`,
container_extension: 'mpg',
info: {},
custom_sid: 'vod-series',
added: '',
season: options.seasonNumber ?? 1,
direct_source: '',
legacyTrackingId: options.legacyTrackingId,
originalId: `provider-${episodeNumber}`,
};
}
function createPosition(
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
return {
contentXtreamId: SCOPED_TRACKING_ID,
contentType: 'episode',
seriesXtreamId: SERIES_ID,
seasonNumber: 1,
episodeNumber: 2,
positionSeconds: 45,
durationSeconds: 120,
playlistId: PLAYLIST_ID,
...overrides,
};
}
function reconcile(
episodes: readonly XtreamSerieEpisode[],
seriesPositions: readonly PlaybackPositionData[],
seriesXtreamId = SERIES_ID
) {
return reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason: { '1': episodes },
seriesPositions,
});
}
describe('stalker series position compatibility', () => {
it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition({ positionSeconds: 90 });
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
positionSeconds: 30,
});
const result = reconcile(
[episode],
[legacyPosition, exactPosition]
);
expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toBe(
exactPosition
);
expect(
result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(legacyPosition);
expect(
result.positionsByTrackingId.has(LEGACY_TRACKING_ID)
).toBe(false);
});
it('aliases only the current parent legacy row under the scoped ID without mutating it', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const currentParentLegacy = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: undefined,
episodeNumber: undefined,
});
const foreignParentLegacy = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: FOREIGN_SERIES_ID,
positionSeconds: 99,
});
const result = reconcile(
[episode],
[foreignParentLegacy, currentParentLegacy]
);
const migrated = result.positionsByTrackingId.get(SCOPED_TRACKING_ID);
expect(migrated).toEqual({
...currentParentLegacy,
contentXtreamId: SCOPED_TRACKING_ID,
seriesXtreamId: SERIES_ID,
seasonNumber: 1,
episodeNumber: 2,
});
expect(migrated).not.toBe(currentParentLegacy);
expect(currentParentLegacy.contentXtreamId).toBe(LEGACY_TRACKING_ID);
expect(
result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(currentParentLegacy);
const foreignResult = reconcile(
[episode],
[foreignParentLegacy],
SERIES_ID
);
expect(foreignResult.positionsByTrackingId.size).toBe(0);
expect(foreignResult.legacyPositionByTrackingId.size).toBe(0);
});
it('matches present coordinates numerically and treats nullish coordinates as absent', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const numericStringCoordinates = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: '1' as unknown as number,
episodeNumber: '2' as unknown as number,
});
const absentCoordinates = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: null as unknown as number,
episodeNumber: undefined,
});
expect(
reconcile(
[episode],
[numericStringCoordinates]
).positionsByTrackingId.has(SCOPED_TRACKING_ID)
).toBe(true);
expect(
reconcile(
[episode],
[absentCoordinates]
).positionsByTrackingId.has(SCOPED_TRACKING_ID)
).toBe(true);
});
it('rejects a legacy alias when either present coordinate conflicts', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const wrongSeason = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seasonNumber: 3,
});
const wrongEpisode = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
episodeNumber: 8,
});
for (const position of [wrongSeason, wrongEpisode]) {
const result = reconcile([episode], [position]);
expect(result.positionsByTrackingId.size).toBe(0);
expect(result.legacyPositionByTrackingId.size).toBe(0);
}
});
it('never aliases an episode without legacy tracking metadata', () => {
const episode = createEpisode();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const result = reconcile([episode], [legacyPosition]);
expect(result.positionsByTrackingId.size).toBe(0);
expect(result.legacyPositionByTrackingId.size).toBe(0);
});
it('awaits a scoped save before clearing the confirmed legacy row', async () => {
const order: string[] = [];
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const position = reconcile(
[
createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
}),
],
[legacyPosition]
).positionsByTrackingId.get(SCOPED_TRACKING_ID);
expect(position).toBeDefined();
if (!position) {
throw new Error('Expected a migrated scoped position');
}
const repository = {
savePlaybackPosition: jest.fn(
async (_playlistId: string, saved: PlaybackPositionData) => {
order.push(`save:${saved.contentXtreamId}`);
}
),
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(`clear:${contentXtreamId}`);
}
),
};
await expect(
saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(true);
expect(order).toEqual([
`save:${SCOPED_TRACKING_ID}`,
`clear:${LEGACY_TRACKING_ID}`,
]);
});
it('does not clear legacy when the scoped save rejects', async () => {
const saveError = new Error('save failed');
const repository = {
savePlaybackPosition: jest.fn().mockRejectedValue(saveError),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
};
await expect(
saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
})
).rejects.toBe(saveError);
expect(repository.clearPlaybackPosition).not.toHaveBeenCalled();
});
it('reports a partial save when legacy cleanup rejects after the scoped write', async () => {
const order: string[] = [];
const clearError = new Error('clear failed');
const repository = {
savePlaybackPosition: jest.fn(
async (_playlistId: string, position: PlaybackPositionData) => {
order.push(`save:${position.contentXtreamId}`);
}
),
clearPlaybackPosition: jest.fn(
async (_playlistId: string, contentXtreamId: number) => {
order.push(`clear:${contentXtreamId}`);
throw clearError;
}
),
};
const save = saveStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
});
const rejection = await save.catch(
(error: unknown) => error
);
expect(rejection).toBeInstanceOf(
StalkerSeriesPositionPartialSaveError
);
if (
!(rejection instanceof
StalkerSeriesPositionPartialSaveError)
) {
throw new Error('Expected a partial save error');
}
expect(rejection.cause).toBe(clearError);
expect(rejection.scopedPositionSaved).toBe(true);
expect(order).toEqual([
`save:${SCOPED_TRACKING_ID}`,
`clear:${LEGACY_TRACKING_ID}`,
]);
});
const rejectedOwnershipCases: RejectedOwnershipCase[] = [
{
name: 'different parents',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: FOREIGN_SERIES_ID,
}),
},
{
name: 'missing scoped parent',
position: createPosition({ seriesXtreamId: undefined }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'missing legacy parent',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
seriesXtreamId: undefined,
}),
},
{
name: 'equal scoped and legacy IDs',
position: createPosition(),
legacyPosition: createPosition(),
},
{
name: 'non-episode scoped content',
position: createPosition({ contentType: 'vod' }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'non-episode legacy content',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
contentType: 'vod',
}),
},
{
name: 'conflicting scoped playlist',
position: createPosition({ playlistId: 'playlist-2' }),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
}),
},
{
name: 'conflicting legacy playlist',
position: createPosition(),
legacyPosition: createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
playlistId: 'playlist-2',
}),
},
{
name: 'missing legacy row',
position: createPosition(),
},
];
it.each(rejectedOwnershipCases)(
'does not clean an unowned legacy row for $name',
async ({ position, legacyPosition }) => {
const saveRepository = {
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
clearPlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
};
await expect(
saveStalkerSeriesPosition({
repository: saveRepository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(false);
expect(
saveRepository.savePlaybackPosition
).toHaveBeenCalledWith(PLAYLIST_ID, position);
expect(
saveRepository.clearPlaybackPosition
).not.toHaveBeenCalled();
const clearRepository = {
clearPlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
};
await expect(
clearStalkerSeriesPosition({
repository: clearRepository,
playlistId: PLAYLIST_ID,
position,
legacyPosition,
})
).resolves.toBe(false);
expect(
clearRepository.clearPlaybackPosition
).toHaveBeenCalledTimes(1);
expect(
clearRepository.clearPlaybackPosition
).toHaveBeenCalledWith(
PLAYLIST_ID,
position.contentXtreamId,
position.contentType
);
}
);
it('clears confirmed legacy before scoped state and cannot resurrect it', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
positionSeconds: 20,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const order: string[] = [];
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(`clear:${contentXtreamId}`);
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).resolves.toBe(true);
expect(order).toEqual([
`clear:${LEGACY_TRACKING_ID}`,
`clear:${SCOPED_TRACKING_ID}`,
]);
const refreshed = reconcile([episode], [...rows.values()]);
expect(refreshed.positionsByTrackingId.size).toBe(0);
expect(refreshed.legacyPositionByTrackingId.size).toBe(0);
});
it('leaves exact scoped progress when legacy cleanup rejects', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const clearError = new Error('legacy clear failed');
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
if (contentXtreamId === LEGACY_TRACKING_ID) {
throw clearError;
}
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).rejects.toBe(clearError);
expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition);
expect(
reconcile([episode], [...rows.values()])
.positionsByTrackingId
.get(SCOPED_TRACKING_ID)
).toBe(exactPosition);
});
it('leaves exact scoped progress when scoped clear rejects after legacy cleanup', async () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
});
const exactPosition = createPosition();
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const rows = new Map<number, PlaybackPositionData>([
[SCOPED_TRACKING_ID, exactPosition],
[LEGACY_TRACKING_ID, legacyPosition],
]);
const order: number[] = [];
const clearError = new Error('scoped clear failed');
const repository = {
clearPlaybackPosition: jest.fn(
async (
_playlistId: string,
contentXtreamId: number
) => {
order.push(contentXtreamId);
if (contentXtreamId === SCOPED_TRACKING_ID) {
throw clearError;
}
rows.delete(contentXtreamId);
}
),
};
await expect(
clearStalkerSeriesPosition({
repository,
playlistId: PLAYLIST_ID,
position: exactPosition,
legacyPosition,
})
).rejects.toBe(clearError);
expect(order).toEqual([
LEGACY_TRACKING_ID,
SCOPED_TRACKING_ID,
]);
expect(rows.has(LEGACY_TRACKING_ID)).toBe(false);
expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition);
const refreshed = reconcile([episode], [...rows.values()]);
expect(
refreshed.positionsByTrackingId.get(SCOPED_TRACKING_ID)
).toBe(exactPosition);
expect(refreshed.legacyPositionByTrackingId.size).toBe(0);
});
});
@@ -0,0 +1,203 @@
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util';
import type {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
export interface ReconciledStalkerSeriesPositions {
positionsByTrackingId: Map<number, PlaybackPositionData>;
legacyPositionByTrackingId: Map<number, PlaybackPositionData>;
}
export class StalkerSeriesPositionPartialSaveError extends Error {
readonly cause: unknown;
readonly scopedPositionSaved = true as const;
constructor(cause: unknown) {
super(
'Scoped Stalker series position was saved, but legacy cleanup failed'
);
this.name = 'StalkerSeriesPositionPartialSaveError';
this.cause = cause;
}
}
function matchesMappedCoordinate(
value: number | undefined,
mappedValue: number
): boolean {
return value == null || Number(value) === mappedValue;
}
function isCompatibleLegacyPosition(
position: PlaybackPositionData,
episode: XtreamSerieEpisode
): boolean {
return (
matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) &&
matchesMappedCoordinate(
position.episodeNumber,
Number(episode.episode_num)
)
);
}
export function reconcileStalkerSeriesPositions(options: {
seriesXtreamId: number;
episodesBySeason: Readonly<
Record<string, readonly XtreamSerieEpisode[]>
>;
seriesPositions: readonly PlaybackPositionData[];
}): ReconciledStalkerSeriesPositions {
const positionsByTrackingId = new Map<number, PlaybackPositionData>();
const legacyPositionByTrackingId = new Map<
number,
PlaybackPositionData
>();
const indexedPositions = new Map<number, PlaybackPositionData>();
for (const position of options.seriesPositions) {
if (
position.contentType === 'episode' &&
position.seriesXtreamId === options.seriesXtreamId
) {
indexedPositions.set(position.contentXtreamId, position);
}
}
for (const episodes of Object.values(options.episodesBySeason)) {
for (const episode of episodes) {
const trackingId = Number(episode.id);
if (!Number.isFinite(trackingId)) {
continue;
}
const exactPosition = indexedPositions.get(trackingId);
if (exactPosition) {
positionsByTrackingId.set(trackingId, exactPosition);
}
const legacyTrackingId = (episode as StalkerMappedEpisode)
.legacyTrackingId;
if (
legacyTrackingId == null ||
legacyTrackingId === trackingId
) {
continue;
}
const legacyPosition = indexedPositions.get(legacyTrackingId);
if (
!legacyPosition ||
!isCompatibleLegacyPosition(legacyPosition, episode)
) {
continue;
}
legacyPositionByTrackingId.set(trackingId, legacyPosition);
if (!exactPosition) {
positionsByTrackingId.set(trackingId, {
...legacyPosition,
contentXtreamId: trackingId,
seriesXtreamId: options.seriesXtreamId,
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
});
}
}
}
return {
positionsByTrackingId,
legacyPositionByTrackingId,
};
}
function ownsLegacyPosition(
playlistId: string,
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined
): legacyPosition is PlaybackPositionData {
return (
position.contentType === 'episode' &&
legacyPosition?.contentType === 'episode' &&
position.contentXtreamId !== legacyPosition.contentXtreamId &&
position.seriesXtreamId != null &&
legacyPosition.seriesXtreamId != null &&
position.seriesXtreamId === legacyPosition.seriesXtreamId &&
(!position.playlistId || position.playlistId === playlistId) &&
(!legacyPosition.playlistId ||
legacyPosition.playlistId === playlistId)
);
}
export async function saveStalkerSeriesPosition(options: {
repository: Pick<
PortalPlaybackPositions,
'savePlaybackPosition' | 'clearPlaybackPosition'
>;
playlistId: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}): Promise<boolean> {
await options.repository.savePlaybackPosition(
options.playlistId,
options.position
);
if (
!ownsLegacyPosition(
options.playlistId,
options.position,
options.legacyPosition
)
) {
return false;
}
try {
await options.repository.clearPlaybackPosition(
options.playlistId,
options.legacyPosition.contentXtreamId,
options.legacyPosition.contentType
);
} catch (cause) {
throw new StalkerSeriesPositionPartialSaveError(cause);
}
return true;
}
export async function clearStalkerSeriesPosition(options: {
repository: Pick<PortalPlaybackPositions, 'clearPlaybackPosition'>;
playlistId: string;
position: PlaybackPositionData;
legacyPosition?: PlaybackPositionData;
}): Promise<boolean> {
if (
ownsLegacyPosition(
options.playlistId,
options.position,
options.legacyPosition
)
) {
await options.repository.clearPlaybackPosition(
options.playlistId,
options.legacyPosition.contentXtreamId,
options.legacyPosition.contentType
);
await options.repository.clearPlaybackPosition(
options.playlistId,
options.position.contentXtreamId,
options.position.contentType
);
return true;
}
await options.repository.clearPlaybackPosition(
options.playlistId,
options.position.contentXtreamId,
options.position.contentType
);
return false;
}
@@ -201,7 +201,7 @@
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequested($event)
handlePlaybackToggleRequestedFromUi($event)
"
#seasonContainer
/>
@@ -76,6 +76,19 @@ import {
getStalkerSeriesQuickStartButton,
type StalkerQuickStartButton,
} from './stalker-series-quick-start';
import {
clearStalkerSeriesPosition,
reconcileStalkerSeriesPositions,
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
interface SeriesPositionContext {
readonly generation: number;
readonly playlistId: string;
readonly seriesXtreamId: number;
readonly mutationKey: string;
}
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -105,6 +118,26 @@ import {
export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly migrationPlaybackPositions = {
savePlaybackPosition: (
playlistId: string,
data: PlaybackPositionData
) =>
this.playbackPositions.savePlaybackPositionOrThrow(
playlistId,
data
),
clearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) =>
this.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
contentXtreamId,
contentType
),
};
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
@@ -120,6 +153,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
private readonly rawSeriesPositions = signal<
readonly PlaybackPositionData[]
>([]);
private readonly legacyPositionByTrackingId = signal<
Map<number, PlaybackPositionData>
>(new Map());
private activeSeriesPositionContext: SeriesPositionContext | null = null;
private seriesPositionContextGeneration = 0;
private readonly seriesPositionMutationQueues = new Map<
string,
Promise<void>
>();
private readonly pendingSeriesPositionLoads = new Map<
SeriesPositionContext,
Set<number>
>();
private readonly seriesPositionReloadKeys = new Set<string>();
private seriesPositionsLoadGeneration = 0;
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -276,24 +327,68 @@ export class StalkerSeriesViewComponent implements OnDestroy {
effect(() => {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
const normalizedSeriesId = this.toSeriesId(item?.id ?? 0);
if (item && playlist?._id && normalizedSeriesId > 0) {
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
} else {
this.rawSeriesPositions.set([]);
this.episodePlaybackPositions.set(new Map());
this.legacyPositionByTrackingId.set(new Map());
const context = this.activateSeriesPositionContext(
playlist._id,
normalizedSeriesId
);
void this.loadSeriesPositions(context);
} else {
this.activeSeriesPositionContext = null;
this.seriesPositionContextGeneration++;
this.seriesPositionsLoadGeneration++;
}
});
effect(() => {
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
const rawSeriesPositions = this.rawSeriesPositions();
const episodesBySeason = this.mappedSeasons();
if (!item || !playlistId || seriesXtreamId <= 0) {
if (rawSeriesPositions.length > 0) {
this.rawSeriesPositions.set([]);
}
if (this.episodePlaybackPositions().size > 0) {
this.episodePlaybackPositions.set(new Map());
}
if (this.legacyPositionByTrackingId().size > 0) {
this.legacyPositionByTrackingId.set(new Map());
}
return;
}
const reconciled = reconcileStalkerSeriesPositions({
seriesXtreamId,
episodesBySeason,
seriesPositions: rawSeriesPositions,
});
if (
rawSeriesPositions.length === 0 &&
reconciled.positionsByTrackingId.size === 0 &&
untracked(() => this.episodePlaybackPositions().size) > 0
) {
return;
}
this.episodePlaybackPositions.set(
reconciled.positionsByTrackingId
);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
});
effect(() => {
const session = this.externalPlayback.activeSession();
const item = this.displayItem();
@@ -337,6 +432,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
!playlistId ||
data.contentType !== 'episode' ||
data.playlistId !== playlistId ||
data.seriesXtreamId !== seriesId
@@ -344,7 +440,18 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return;
}
this.updateEpisodePlaybackPosition(data);
// The facade/runtime already saved this row. Repeat the
// idempotent upsert because only this view owns the
// scoped-to-legacy cleanup mapping.
void this.persistSeriesPosition(
playlistId,
data
).catch((error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
});
}
) ?? null;
}
@@ -388,22 +495,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
const displayItem = this.displayItem();
const base = this.isVodSeries()
? mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
)
? mapVodSeriesEpisodes(this.vodSeriesSeasons(), {
parentSeriesId: this.toSeriesId(displayItem?.id ?? 0),
fallbackPoster: displayItem?.info?.movie_image,
})
: mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
displayItem?.info?.movie_image
);
// Overlay lazily fetched TMDB episode data (real names,
// overviews, stills) — a no-op while nothing is fetched
return this.tmdbSeasons.overlay(
base,
this.displayItem()?.info?.tmdb_id
);
return this.tmdbSeasons.overlay(base, displayItem?.info?.tmdb_id);
}
);
@@ -750,11 +855,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
void this.persistSeriesPosition(
playback.contentInfo.playlistId,
position
);
this.updateEpisodePlaybackPosition(position);
).catch((error: unknown) => {
this.logger.error(
'Failed to persist inline series position',
error
);
});
}
showCopyNotification(): void {
@@ -894,20 +1003,30 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
if (request.nextPosition) {
await this.playbackPositions.savePlaybackPosition(
await this.persistSeriesPosition(
playlistId,
request.nextPosition
);
this.updateEpisodePlaybackPosition(request.nextPosition);
return;
}
await this.playbackPositions.clearPlaybackPosition(
await this.clearSeriesPosition(
playlistId,
request.contentXtreamId,
'episode'
request.contentXtreamId
);
}
handlePlaybackToggleRequestedFromUi(
request: SeasonContainerPlaybackToggleRequest
): void {
void this.handlePlaybackToggleRequested(request).catch(
(error: unknown) => {
this.logger.error(
'Failed to update series playback position',
error
);
}
);
this.removeEpisodePlaybackPosition(request.contentXtreamId);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
@@ -975,19 +1094,320 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
private async loadSeriesPositions(
context: SeriesPositionContext
): Promise<void> {
const generation = ++this.seriesPositionsLoadGeneration;
this.trackPendingSeriesPositionLoad(context, generation);
try {
await this.waitForSeriesPositionMutations(
context.mutationKey
);
if (
generation !== this.seriesPositionsLoadGeneration ||
!this.isSeriesPositionContextActive(context)
) {
return;
}
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
context.playlistId,
context.seriesXtreamId
);
if (
generation !== this.seriesPositionsLoadGeneration ||
!this.isSeriesPositionContextActive(context)
) {
return;
}
this.rawSeriesPositions.set(positions);
} finally {
this.untrackPendingSeriesPositionLoad(context, generation);
}
}
private activateSeriesPositionContext(
playlistId: string,
seriesXtreamId: number
): SeriesPositionContext {
const context: SeriesPositionContext = {
generation: ++this.seriesPositionContextGeneration,
playlistId,
seriesXtreamId,
mutationKey: JSON.stringify([playlistId, seriesXtreamId]),
};
this.activeSeriesPositionContext = context;
return context;
}
private isSeriesPositionContextActive(
context: SeriesPositionContext
): boolean {
const activeContext = this.activeSeriesPositionContext;
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.stalkerStore.currentPlaylist()?._id ===
context.playlistId &&
this.toSeriesId(this.displayItem()?.id ?? 0) ===
context.seriesXtreamId
);
}
private waitForSeriesPositionMutations(
mutationKey: string
): Promise<void> {
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
return (
this.seriesPositionMutationQueues.get(mutationKey) ??
Promise.resolve()
);
}
private trackPendingSeriesPositionLoad(
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context) ??
new Set<number>();
generations.add(generation);
this.pendingSeriesPositionLoads.set(context, generations);
}
private untrackPendingSeriesPositionLoad(
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingSeriesPositionLoads.delete(context);
}
}
private hasCurrentPendingSeriesPositionLoad(
context: SeriesPositionContext
): boolean {
return Boolean(
this.pendingSeriesPositionLoads
.get(context)
?.has(this.seriesPositionsLoadGeneration)
);
}
private enqueueSeriesPositionMutation(
context: SeriesPositionContext,
operation: () => Promise<void>
): Promise<void> {
if (this.hasCurrentPendingSeriesPositionLoad(context)) {
this.seriesPositionReloadKeys.add(context.mutationKey);
}
this.seriesPositionsLoadGeneration++;
const previous = this.waitForSeriesPositionMutations(
context.mutationKey
);
const result = previous.then(operation);
const barrier = result.then(
() => undefined,
() => undefined
);
this.seriesPositionMutationQueues.set(
context.mutationKey,
barrier
);
void barrier.then(() => {
if (
this.seriesPositionMutationQueues.get(
context.mutationKey
) === barrier
) {
this.seriesPositionMutationQueues.delete(
context.mutationKey
);
this.reloadSeriesPositionsAfterMutations(
context.mutationKey
);
}
});
this.episodePlaybackPositions.set(positionsMap);
return result;
}
private reloadSeriesPositionsAfterMutations(
mutationKey: string
): void {
if (!this.seriesPositionReloadKeys.delete(mutationKey)) {
return;
}
const context = this.activeSeriesPositionContext;
if (
!context ||
context.mutationKey !== mutationKey ||
!this.isSeriesPositionContextActive(context) ||
this.hasCurrentPendingSeriesPositionLoad(context)
) {
return;
}
void this.loadSeriesPositions(context);
}
private getSeriesPositionMutationContext(
playlistId: string,
seriesXtreamId?: number | null
): SeriesPositionContext | null {
const context = this.activeSeriesPositionContext;
if (
!context ||
context.playlistId !== playlistId ||
(seriesXtreamId != null &&
context.seriesXtreamId !== seriesXtreamId)
) {
return null;
}
return context;
}
private persistSeriesPosition(
playlistId: string,
position: PlaybackPositionData
): Promise<void> {
const context = this.getSeriesPositionMutationContext(
playlistId,
position.seriesXtreamId
);
if (!context) {
return Promise.resolve();
}
const legacyPosition =
this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.enqueueSeriesPositionMutation(context, async () => {
let clearedLegacy: boolean;
try {
clearedLegacy = await saveStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
} catch (error) {
if (
error instanceof
StalkerSeriesPositionPartialSaveError &&
this.isSeriesPositionContextActive(context)
) {
this.publishSavedSeriesPosition(
position,
legacyPosition,
false
);
}
throw error;
}
if (!this.isSeriesPositionContextActive(context)) {
return;
}
this.publishSavedSeriesPosition(
position,
legacyPosition,
clearedLegacy
);
});
}
private publishSavedSeriesPosition(
position: PlaybackPositionData,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([
position.contentXtreamId,
]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
),
position,
]);
this.updateEpisodePlaybackPosition(position);
}
private clearSeriesPosition(
playlistId: string,
contentXtreamId: number
): Promise<void> {
const context = this.getSeriesPositionMutationContext(playlistId);
if (!context) {
return Promise.resolve();
}
const position =
this.episodePlaybackPositions().get(contentXtreamId) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.enqueueSeriesPositionMutation(context, async () => {
const clearedLegacy = await clearStalkerSeriesPosition({
repository: this.migrationPlaybackPositions,
playlistId,
position,
legacyPosition,
});
if (!this.isSeriesPositionContextActive(context)) {
return;
}
this.publishClearedSeriesPosition(
contentXtreamId,
legacyPosition,
clearedLegacy
);
});
}
private publishClearedSeriesPosition(
contentXtreamId: number,
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
}
private async loadAndPlayVodSeriesSeason(
@@ -199,6 +199,130 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
expect(service.onPlaybackPositionUpdate(callback)).toBe(unsubscribe);
expect(onPlaybackPositionUpdate).toHaveBeenCalledWith(callback);
});
describe.each([
{
name: 'save',
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbSavePlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) => target.savePlaybackPosition('playlist-1', createPosition()),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.savePlaybackPositionOrThrow(
'playlist-1',
createPosition()
),
},
{
name: 'clear',
installBridge: (implementation: jest.Mock) => {
window.electron = {
...window.electron,
dbClearPlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPosition(
'playlist-1',
100,
'vod'
),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPositionOrThrow(
'playlist-1',
100,
'vod'
),
},
])('$name persistence', (operation) => {
it('accepts only an explicit success result', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(
jest.fn().mockResolvedValue({ success: true })
);
await expect(operation.invokeStrict(service)).resolves.toBeUndefined();
});
it('propagates rejected IPC', async () => {
const error = new Error('database is locked');
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockRejectedValue(error));
await expect(operation.invokeStrict(service)).rejects.toBe(error);
});
it.each([{ success: false }, {}, undefined])(
'rejects a non-success result %#',
async (result) => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockResolvedValue(result));
await expect(operation.invokeStrict(service)).rejects.toThrow(
'did not succeed'
);
}
);
it('rejects when the storage capability is unavailable', async () => {
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invokeStrict(service)).rejects.toThrow(
'storage is unavailable'
);
expect(bridgeMethod).not.toHaveBeenCalled();
});
it('rejects when the expected bridge method is unavailable', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
await expect(operation.invokeStrict(service)).rejects.toThrow(
'method is unavailable'
);
});
it.each([{ success: false }, {}, undefined])(
'ignores a non-success result through the lenient method %#',
async (result) => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockResolvedValue(result));
await expect(
operation.invokeLenient(service)
).resolves.toBeUndefined();
}
);
it('resolves the lenient method when its bridge method is missing', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
await expect(
operation.invokeLenient(service)
).resolves.toBeUndefined();
});
it('propagates rejected IPC through the lenient method', async () => {
const error = new Error('database is locked');
runtimeCapabilities.supportsPlaybackPositionStorage = true;
operation.installBridge(jest.fn().mockRejectedValue(error));
await expect(operation.invokeLenient(service)).rejects.toBe(error);
});
});
});
function createPosition(
@@ -65,6 +65,27 @@ export class PlaybackPositionRuntimeBridgeService {
await this.bridge?.dbSavePlaybackPosition?.(playlistId, data);
}
async savePlaybackPositionOrThrow(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbSavePlaybackPosition !== 'function') {
throw new Error(
'Playback position save method is unavailable'
);
}
const result = await bridge.dbSavePlaybackPosition(playlistId, data);
if (result?.success !== true) {
throw new Error('Playback position save did not succeed');
}
}
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -150,6 +171,32 @@ export class PlaybackPositionRuntimeBridgeService {
);
}
async clearPlaybackPositionOrThrow(
playlistId: string,
contentXtreamId: number,
contentType: PlaybackPositionContentType
): Promise<void> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbClearPlaybackPosition !== 'function') {
throw new Error(
'Playback position clear method is unavailable'
);
}
const result = await bridge.dbClearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
if (result?.success !== true) {
throw new Error('Playback position clear did not succeed');
}
}
onPlaybackPositionUpdate(
callback: (data: PlaybackPositionData) => void
): (() => void) | undefined {
@@ -18,6 +18,10 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
}
describe('database schema statements', () => {
afterEach(() => {
jest.restoreAllMocks();
});
const {
createTableStatements,
columnMigrationStatements,
@@ -37,6 +41,26 @@ describe('database schema statements', () => {
}
).ensureDownloadsPauseResumeSchema;
it('records only the statement type for expanded main-process SQL', () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const secrets = [
'main-user-secret',
'main-password-secret',
'https://main-user:main-password@example.com/live?token=main-token-secret',
];
__databaseConnectionTestHooks.traceSqlStatement(
`UPDATE playlists SET username = '${secrets[0]}', password = '${secrets[1]}', url = '${secrets[2]}'`
);
const output = (console.log as jest.Mock).mock.calls.flat().join('\n');
expect(output).toContain('"statementType":"UPDATE"');
expect(output).not.toContain('UPDATE playlists');
for (const secret of secrets) {
expect(output).not.toContain(secret);
}
});
function createRebuildSqlite(legacyTableSql: string | undefined) {
const statements: string[] = [];
const transaction = jest.fn((callback: () => void) => callback);
+13 -13
View File
@@ -12,6 +12,10 @@
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import {
redactSensitiveData,
summarizeSqlStatementForTrace,
} from '@iptvnator/shared/logging';
import * as schema from './schema';
import { getIptvnatorDatabasePath } from './path-utils';
@@ -49,13 +53,6 @@ function isSqlTraceEnabled(): boolean {
);
}
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}`);
@@ -63,10 +60,16 @@ function traceSql(scope: string, message: string, payload?: unknown): void {
}
console.log(
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}`
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(
redactSensitiveData(payload)
)}`
);
}
function traceSqlStatement(sql: unknown): void {
traceSql('sql-main', 'query', summarizeSqlStatementForTrace(sql));
}
/**
* Get the database file path
*/
@@ -402,6 +405,7 @@ export const __databaseConnectionTestHooks = {
backfillEpgProgramSourceUrls,
cleanupLegacyTmdbSearchCache,
runMigrations,
traceSqlStatement,
} as const;
/**
@@ -1119,11 +1123,7 @@ export async function initDatabase(
sqlite = new Database(filePath, {
readonly,
verbose: isSqlTraceEnabled()
? (message?: unknown) => {
traceSql('sql-main', 'query', {
sql: compactSqlForTrace(String(message ?? '')),
});
}
? (message?: unknown) => traceSqlStatement(message)
: undefined,
});
@@ -23,6 +23,19 @@ describe('extractStalkerItemType', () => {
})
).toBe('live');
});
it.each([true, 1, '1'] as const)(
'treats is_series=%p as a series item',
(isSeries) => {
expect(
extractStalkerItemType({
id: '50001',
title: 'Portal Series',
is_series: isSeries,
})
).toBe('series');
}
);
});
describe('isStalkerRadioItem', () => {
@@ -50,7 +50,7 @@ export function extractStalkerItemPoster(
* Determine the normalised activity type of a Stalker item.
*
* - `itv` / `live` / radio → `'live'`
* - `series` or `is_series` truthy → `'series'`
* - `series` or `is_series` equal to `true`, `1`, or `'1'` → `'series'`
* - everything else → `'movie'`
*/
export function extractStalkerItemType(
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@iptvnator/shared/logging",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0"
}
}
+10
View File
@@ -5,6 +5,16 @@
"projectType": "library",
"tags": ["scope:shared", "domain:shared-runtime", "type:util"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/logging",
"main": "libs/shared/logging/src/index.ts",
"tsConfig": "libs/shared/logging/tsconfig.lib.json",
"assets": []
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+8
View File
@@ -3,6 +3,14 @@ export {
redactSensitiveData,
} from './lib/redact-sensitive-data';
export type { RedactionOptions } from './lib/redact-sensitive-data';
export {
SQL_TRACE_STATEMENT_TYPE,
summarizeSqlStatementForTrace,
} from './lib/sql-trace-summary';
export type {
SqlTraceStatementType,
SqlTraceSummary,
} from './lib/sql-trace-summary';
export {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
@@ -0,0 +1,58 @@
import { summarizeSqlStatementForTrace } from './sql-trace-summary';
describe('summarizeSqlStatementForTrace', () => {
it.each([
[
'SELECT',
`SELECT * FROM playlists WHERE username = 'trace-user-secret' AND password = 'trace-password-secret' AND token = 'trace-token-secret'`,
],
[
'INSERT',
`INSERT INTO playlists (name) VALUES ('O''Brien-secret')`,
],
[
'UPDATE',
`UPDATE playlists SET url = 'https://url-user-secret:url-password-secret@example.com/live?token=url-token-secret'`,
],
[
'DELETE',
`DELETE FROM content WHERE id = 987654321 AND payload = X'7365637265742D626C6F62'`,
],
[
'SELECT',
` \n\tSeLeCt * FROM content WHERE rating = 12345.6789`,
],
[
'WITH',
`WITH credentials AS (SELECT 'with-secret') SELECT * FROM credentials`,
],
])('returns only the %s statement type', (statementType, sql) => {
const summary = summarizeSqlStatementForTrace(sql);
const serialized = JSON.stringify(summary);
expect(summary).toEqual({ statementType });
expect(serialized).toBe(`{"statementType":"${statementType}"}`);
expect(serialized).not.toContain('secret');
expect(serialized).not.toContain(`O''Brien-secret`);
expect(serialized).not.toContain('987654321');
expect(serialized).not.toContain('7365637265742D626C6F62');
expect(serialized).not.toContain('12345.6789');
expect(serialized).not.toContain('length');
});
it.each([
` \n-- SELECT 'comment-secret'\nSELECT * FROM playlists`,
`\t/* INSERT 'comment-secret' */ SELECT * FROM playlists`,
`VACUUMINTO 'malicious-secret'`,
`SELECTpassword FROM credentials`,
`DO 'unrecognized-secret'`,
`'; DROP TABLE playlists; -- malicious-secret`,
'',
undefined,
null,
])('maps comments and unrecognized input to OTHER', (sql) => {
expect(summarizeSqlStatementForTrace(sql)).toEqual({
statementType: 'OTHER',
});
});
});
@@ -0,0 +1,58 @@
export const SQL_TRACE_STATEMENT_TYPE = {
SELECT: 'SELECT',
INSERT: 'INSERT',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
REPLACE: 'REPLACE',
CREATE: 'CREATE',
ALTER: 'ALTER',
DROP: 'DROP',
PRAGMA: 'PRAGMA',
WITH: 'WITH',
BEGIN: 'BEGIN',
COMMIT: 'COMMIT',
ROLLBACK: 'ROLLBACK',
SAVEPOINT: 'SAVEPOINT',
RELEASE: 'RELEASE',
VACUUM: 'VACUUM',
ANALYZE: 'ANALYZE',
REINDEX: 'REINDEX',
ATTACH: 'ATTACH',
DETACH: 'DETACH',
EXPLAIN: 'EXPLAIN',
OTHER: 'OTHER',
} as const;
export type SqlTraceStatementType =
(typeof SQL_TRACE_STATEMENT_TYPE)[keyof typeof SQL_TRACE_STATEMENT_TYPE];
export interface SqlTraceSummary {
statementType: SqlTraceStatementType;
}
const ALLOWED_STATEMENT_TYPES = new Set<SqlTraceStatementType>(
Object.values(SQL_TRACE_STATEMENT_TYPE).filter(
(statementType) =>
statementType !== SQL_TRACE_STATEMENT_TYPE.OTHER
) as SqlTraceStatementType[]
);
const SQL_STATEMENT_PREFIX = /^\s*([A-Za-z]+)(?=[\s(;]|$)/;
export function summarizeSqlStatementForTrace(sql: unknown): SqlTraceSummary {
if (typeof sql !== 'string') {
return { statementType: SQL_TRACE_STATEMENT_TYPE.OTHER };
}
const match = SQL_STATEMENT_PREFIX.exec(sql);
const candidate = match?.[1]?.toUpperCase() as
| SqlTraceStatementType
| undefined;
return {
statementType:
candidate && ALLOWED_STATEMENT_TYPES.has(candidate)
? candidate
: SQL_TRACE_STATEMENT_TYPE.OTHER,
};
}
+4 -2
View File
@@ -1,7 +1,9 @@
// Shared content grid and card styles
//
// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths):
// @use 'content-grid' as grid;
// Choose a relative @use path from each consuming stylesheet. A current
// portal-shared component uses its local forwarding module as shown below;
// direct consumers of this partial may need a different depth.
// @use '../../styles/content-grid' as grid;
// @include grid.content-grid;
// @include grid.content-card;
+5 -6
View File
@@ -1,13 +1,12 @@
// ─── IPTVnator UI styles library ─────────────────────────────────────────────
// Add libs/ui/styles to stylePreprocessorOptions.includePaths in project.json
// to import these without relative paths:
// Canonical forwarding inventory for shared IPTVnator UI styles.
// The workspace has no global Sass include path for this directory; production
// consumers use a relative path to the partial they need, for example:
//
// @use 'portal-layout' as portal;
// @use 'content-grid' as grid;
// @use 'portal-sidebar';
// @use '../../../../../../ui/styles/portal-layout' as portal;
@forward 'portal-layout';
@forward 'content-grid';
@forward 'portal-sidebar';
@forward 'panel-header';
@forward 'detail-view';
@forward 'detail-view-actions';
+4 -2
View File
@@ -4,8 +4,10 @@
// Include this mixin in a component's SCSS to get the shared flex host+sidebar
// skeleton, then add only the component-specific rules underneath.
//
// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths):
// @use 'portal-layout' as portal;
// Choose the relative @use path from each consuming stylesheet. Current portal
// live layouts use the example below; other consumers may need a different
// depth.
// @use '../../../../../../ui/styles/portal-layout' as portal;
// @include portal.live-layout;
@mixin live-layout {
+4 -2
View File
@@ -1,8 +1,10 @@
// ─── Shared sidebar + sidebar-header styles ───────────────────────────────────
// Plain CSS rules (not a mixin) used for M3U/legacy sidebar layouts.
//
// Usage (after adding libs/ui/styles to stylePreprocessorOptions.includePaths):
// @use 'portal-sidebar'; (no alias needed — no exported members to call)
// Choose the relative @use path from each consuming stylesheet. Current portal
// live layouts use the example below; other consumers may need a different
// depth. No alias is needed because this partial exports no callable members.
// @use '../../../../../../ui/styles/portal-sidebar';
.sidebar {
// Width is controlled by the resizable directive