mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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: {
|
||||
|
||||
+32
-10
@@ -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 () => {
|
||||
|
||||
+2
-1
@@ -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'
|
||||
|
||||
+2
-2
@@ -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 };
|
||||
|
||||
+573
@@ -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);
|
||||
});
|
||||
});
|
||||
+203
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -201,7 +201,7 @@
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(episodeDownloadRequested)="downloadEpisode($event)"
|
||||
(playbackToggleRequested)="
|
||||
handlePlaybackToggleRequested($event)
|
||||
handlePlaybackToggleRequestedFromUi($event)
|
||||
"
|
||||
#seasonContainer
|
||||
/>
|
||||
|
||||
+457
-37
@@ -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(
|
||||
|
||||
+1199
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user