From b262b98df42bbe62e57f94273be7cebf4255d62b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 11:46:15 +0100 Subject: [PATCH] refactor(stalker-vod): implement utility functions and tests for handling VOD details and favorites --- .../src/app/stalker/stalker-vod.utils.spec.ts | 109 ++++++ apps/web/src/app/stalker/stalker-vod.utils.ts | 328 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 apps/web/src/app/stalker/stalker-vod.utils.spec.ts create mode 100644 apps/web/src/app/stalker/stalker-vod.utils.ts diff --git a/apps/web/src/app/stalker/stalker-vod.utils.spec.ts b/apps/web/src/app/stalker/stalker-vod.utils.spec.ts new file mode 100644 index 000000000..09fc0141c --- /dev/null +++ b/apps/web/src/app/stalker/stalker-vod.utils.spec.ts @@ -0,0 +1,109 @@ +import { VodDetailsItem } from 'shared-interfaces'; +import { + createStalkerDetailViewState, + toggleStalkerVodFavorite, +} from './stalker-vod.utils'; + +describe('stalker-vod.utils regressions', () => { + it('routes embedded series[] items to series view state', () => { + const state = createStalkerDetailViewState( + { + id: '101', + cmd: '/media/file_101.mpg', + series: [1, 2, 3], + info: { + name: 'Series in VOD', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + 'playlist-1' + ); + + expect(state.itemDetails?.series?.length).toBe(3); + expect(state.vodDetailsItem).toBeNull(); + }); + + it('routes is_series=1 items to series view state (lazy season flow)', () => { + const state = createStalkerDetailViewState( + { + id: '1507', + cmd: '/media/file_1507.mpg', + is_series: true, + info: { + name: 'Flagged Series', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + 'playlist-1' + ); + + expect(state.itemDetails?.is_series).toBe(true); + expect(state.vodDetailsItem).toBeNull(); + }); + + it('favorite toggle uses completion callback path without delayed state update', () => { + const addToFavorites = jest.fn( + (_item: Record, onDone?: () => void) => { + onDone?.(); + } + ); + const removeFromFavorites = jest.fn( + (_id: string, onDone?: () => void) => { + onDone?.(); + } + ); + const onComplete = jest.fn(); + + const item = { + type: 'stalker', + data: { + id: '42', + cmd: '/media/file_42.mpg', + info: { + name: 'Movie', + movie_image: '', + description: '', + actors: '', + director: '', + releasedate: '', + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }, + playlistId: 'playlist-1', + vodId: 42, + cmd: '/media/file_42.mpg', + } as unknown as VodDetailsItem; + + toggleStalkerVodFavorite( + { item, isFavorite: true }, + { addToFavorites, removeFromFavorites, onComplete } + ); + expect(addToFavorites).toHaveBeenCalledTimes(1); + expect(removeFromFavorites).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + + onComplete.mockClear(); + toggleStalkerVodFavorite( + { item, isFavorite: false }, + { addToFavorites, removeFromFavorites, onComplete } + ); + expect(removeFromFavorites).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/stalker/stalker-vod.utils.ts b/apps/web/src/app/stalker/stalker-vod.utils.ts new file mode 100644 index 000000000..30fd731c1 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-vod.utils.ts @@ -0,0 +1,328 @@ +import { + signal, + WritableSignal, +} from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { PlaylistsService } from 'services'; +import { + createStalkerVodItem, + StalkerVodDetails, + StalkerVodInfo, + VodDetailsItem, +} from 'shared-interfaces'; +import { + StalkerFavoriteItem, + StalkerSelectedVodItem, + StalkerSeriesFlag, + StalkerVodSource, +} from './models/stalker-favorite-item.interface'; + +export interface StalkerDetailViewState { + itemDetails: StalkerSelectedVodItem | null; + vodDetailsItem: VodDetailsItem | null; +} + +export interface StalkerFavoriteToggleEvent { + item: VodDetailsItem; + isFavorite: boolean; +} + +export interface StalkerFavoriteToggleOptions { + addToFavorites: ( + item: Record, + onComplete?: () => void + ) => void; + removeFromFavorites: ( + favoriteId: string, + onComplete?: () => void + ) => void; + onComplete?: () => void; +} + +export function isStalkerSeriesFlag(value: unknown): boolean { + return value === true || value === 1 || value === '1'; +} + +export function normalizeStalkerSeriesFlag( + value: unknown +): StalkerSeriesFlag | undefined { + if (value === true || value === 1 || value === '1') { + return true; + } + return undefined; +} + +export function isStalkerSeriesItem(item: { + is_series?: unknown; + series?: unknown; +}): boolean { + return ( + isStalkerSeriesFlag(item?.is_series) || + (Array.isArray(item?.series) && item.series.length > 0) + ); +} + +export function buildStalkerFavoritePayload( + data: StalkerVodDetails +): Record { + return { + ...data, + category_id: 'vod', + title: data.info?.name, + cover: data.info?.movie_image, + added_at: new Date().toISOString(), + }; +} + +export function matchesFavoriteById( + favorite: Partial, + itemId: string | number +): boolean { + const expectedId = normalizeStalkerEntityId(itemId); + return ( + normalizeStalkerEntityId(favorite?.id) === expectedId || + normalizeStalkerEntityId(favorite?.movie_id) === expectedId || + normalizeStalkerEntityId(favorite?.stream_id) === expectedId || + normalizeStalkerEntityId(favorite?.series_id) === expectedId + ); +} + +export function normalizeStalkerEntityId(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim(); +} + +export function normalizeStalkerEntityIdAsNumber( + value: unknown +): number | null { + const normalized = normalizeStalkerEntityId(value); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function toStringOrFallback(value: unknown, fallback = ''): string { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return fallback; + return String(value); +} + +function toOptionalString(value: unknown): string | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + return String(value); +} + +export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { + const info = item.info ?? {}; + + return { + name: toStringOrFallback( + info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title, + 'Unknown' + ), + o_name: toOptionalString(info.o_name ?? item.o_name), + movie_image: toStringOrFallback( + info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo + ), + description: toStringOrFallback(info.description ?? item.description), + actors: toStringOrFallback(info.actors ?? item.actors), + director: toStringOrFallback(info.director ?? item.director), + releasedate: toStringOrFallback( + info.releasedate ?? item.releasedate ?? item.year + ), + genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str), + rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb), + rating_kinopoisk: toStringOrFallback( + info.rating_kinopoisk ?? item.rating_kinopoisk + ), + }; +} + +export function buildStalkerSelectedVodItem( + item: StalkerVodSource, + forceSeries = false +): StalkerSelectedVodItem { + return { + id: toStringOrFallback(item.id ?? item.stream_id), + cmd: toStringOrFallback(item.cmd), + series: item.series, + has_files: item.has_files, + is_series: + forceSeries || isStalkerSeriesFlag(item?.is_series) + ? true + : undefined, + video_id: item.video_id, + category_id: + typeof item.category_id === 'string' + ? item.category_id + : undefined, + info: createStalkerInfo(item), + }; +} + +export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem { + details: StalkerSelectedVodItem; +} + +export function normalizeStalkerFavoriteItem( + item: StalkerFavoriteItem +): NormalizedStalkerFavoriteItem { + if (item.details && item.details.info) { + return item as NormalizedStalkerFavoriteItem; + } + + const source = (item.details || item) as StalkerVodSource; + const info = createStalkerInfo({ + ...item, + ...source, + }); + const normalizedIsSeries = normalizeStalkerSeriesFlag( + (item as StalkerVodSource).is_series ?? source.is_series + ); + + return { + ...item, + details: { + ...source, + info, + cmd: toStringOrFallback( + (item as StalkerVodSource).cmd ?? source.cmd + ), + id: toStringOrFallback(item.stream_id ?? item.id ?? source.id), + series: (item as StalkerVodSource).series ?? source.series, + is_series: normalizedIsSeries ? true : undefined, + video_id: + (item as StalkerVodSource).video_id ?? source.video_id, + category_id: + typeof (item as StalkerVodSource).category_id === 'string' + ? (item as StalkerVodSource).category_id + : typeof source.category_id === 'string' + ? source.category_id + : undefined, + }, + }; +} + +export function normalizeStalkerVodDetailsItem( + item: StalkerVodSource +): StalkerSelectedVodItem { + return { + ...buildStalkerSelectedVodItem(item), + info: createStalkerInfo(item), + is_series: normalizeStalkerSeriesFlag(item?.is_series) + ? true + : undefined, + }; +} + +export function createStalkerDetailViewState( + normalizedItem: StalkerSelectedVodItem, + playlistId: string +): StalkerDetailViewState { + if (isStalkerSeriesItem(normalizedItem)) { + return { + itemDetails: normalizedItem, + vodDetailsItem: null, + }; + } + + return { + itemDetails: normalizedItem, + vodDetailsItem: createStalkerVodItem( + normalizedItem as StalkerVodDetails, + playlistId + ), + }; +} + +export function clearStalkerDetailViewState(): StalkerDetailViewState { + return { + itemDetails: null, + vodDetailsItem: null, + }; +} + +export function createRefreshTrigger(): { + refreshVersion: WritableSignal; + refresh: () => void; +} { + const refreshVersion = signal(Date.now()); + return { + refreshVersion, + refresh: () => refreshVersion.set(Date.now()), + }; +} + +export function createPortalFavoritesResource( + playlistService: PlaylistsService, + getPortalId: () => string | undefined, + getRefreshVersion: () => number +) { + return createPortalCollectionResource( + playlistService, + getPortalId, + getRefreshVersion, + (service, portalId) => service.getPortalFavorites(portalId) + ); +} + +export function createPortalCollectionResource( + playlistService: PlaylistsService, + getPortalId: () => string | undefined, + getRefreshVersion: () => number, + streamFactory: ( + playlistService: PlaylistsService, + portalId: string + ) => Observable +) { + return rxResource({ + params: () => { + const portalId = getPortalId(); + if (!portalId) return undefined; + return { + portalId, + refreshVersion: getRefreshVersion(), + }; + }, + stream: ({ params }) => + streamFactory(playlistService, params.portalId), + }); +} + +export function isSelectedStalkerVodFavorite( + item: VodDetailsItem | null, + favorites: ReadonlyArray | undefined +): boolean { + if (!item || item.type !== 'stalker') { + return false; + } + + return (favorites ?? []).some((favorite) => + matchesFavoriteById(favorite, item.data.id) + ); +} + +export function toggleStalkerVodFavorite( + event: StalkerFavoriteToggleEvent, + options: StalkerFavoriteToggleOptions +): void { + if (event.item.type !== 'stalker') { + return; + } + + if (event.isFavorite) { + options.addToFavorites( + buildStalkerFavoritePayload(event.item.data), + options.onComplete + ); + } else { + options.removeFromFavorites(event.item.data.id, options.onComplete); + } +}