feat(downloads): derive offline detail content

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 103354df71
commit b2efcedda1
2 files changed
+739

No files matched your search

@@ -0,0 +1,514 @@
import type { DownloadItem } from '@iptvnator/services';
import type {
DownloadEpisodeMetadata,
DownloadMetadataSnapshot,
} from '@iptvnator/shared/interfaces';
import {
buildDownloadOfflineDetail,
DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
type DownloadOfflineDetail,
} from './download-offline-detail.viewmodel';
const SERIES_ID = 77;
const CREATED_AT = '2026-01-01T00:00:00.000Z';
function deepFreeze<T>(value: T): T {
Object.freeze(value);
for (const child of Object.values(value as Record<string, unknown>)) {
if (
child !== null &&
typeof child === 'object' &&
!Object.isFrozen(child)
) {
deepFreeze(child);
}
}
return value;
}
function download(
id: number,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
playlistId: 'playlist-a',
xtreamId: 1_000 + id,
contentType: 'episode',
seriesXtreamId: SERIES_ID,
seasonNumber: 1,
episodeNumber: 1,
title: `Download ${id}`,
url: `https://media.example.test/${id}`,
filePath: `/downloads/${id}.mp4`,
fileAvailability: 'available',
status: 'completed',
createdAt: CREATED_AT,
...overrides,
};
}
function snapshot(
mediaKind: DownloadMetadataSnapshot['mediaKind'],
title: string,
overrides: Partial<DownloadMetadataSnapshot> = {}
): DownloadMetadataSnapshot {
return {
version: 1,
language: 'en',
mediaKind,
title,
...overrides,
};
}
function build(
downloadId: number,
downloads: readonly DownloadItem[]
): DownloadOfflineDetail | undefined {
return buildDownloadOfflineDetail({ downloadId, downloads });
}
function expectSeries(
detail: DownloadOfflineDetail | undefined
): Extract<DownloadOfflineDetail, { kind: 'series' }> {
expect(detail?.kind).toBe('series');
if (detail?.kind !== 'series') {
throw new Error('Expected an offline series detail');
}
return detail;
}
describe('buildDownloadOfflineDetail', () => {
it('resolves an available movie by its exact positive integer download id', () => {
const movieSnapshot = snapshot('movie', 'Stored movie');
const selected = download(7, {
contentType: 'vod',
seriesXtreamId: undefined,
metadataSnapshot: movieSnapshot,
});
const downloads = deepFreeze([
download(6, {
contentType: 'vod',
seriesXtreamId: undefined,
}),
selected,
download(8, {
contentType: 'vod',
seriesXtreamId: undefined,
}),
]);
const detail = build(7, downloads);
expect(detail).toEqual({
kind: 'movie',
item: selected,
snapshot: movieSnapshot,
});
expect(detail?.kind === 'movie' && detail.item).toBe(selected);
});
it.each([
Number.NaN,
Number.NEGATIVE_INFINITY,
-1,
0,
1.5,
Number.MAX_SAFE_INTEGER + 1,
Number.POSITIVE_INFINITY,
])('rejects the invalid download id %p', (downloadId) => {
const downloads = deepFreeze([
download(downloadId, {
contentType: 'vod',
seriesXtreamId: undefined,
}),
]);
expect(build(downloadId, downloads)).toBeUndefined();
});
it.each([
['non-completed', { status: 'paused' as const }],
['missing path', { filePath: undefined }],
['empty path', { filePath: ' ' }],
['explicitly missing file', { fileAvailability: 'missing' as const }],
])('does not resolve a movie with %s', (_description, overrides) => {
const unavailable = download(9, {
contentType: 'vod',
seriesXtreamId: undefined,
...overrides,
});
expect(build(9, deepFreeze([unavailable]))).toBeUndefined();
});
it('keeps legacy completed rows available when fileAvailability is absent', () => {
const legacy = download(9, {
contentType: 'vod',
seriesXtreamId: undefined,
fileAvailability: undefined,
});
expect(build(9, deepFreeze([legacy]))).toEqual({
kind: 'movie',
item: legacy,
});
});
it('uses only a movie snapshot for a movie detail', () => {
const wrongKind = snapshot('series', 'Not movie metadata');
const movie = download(9, {
contentType: 'vod',
seriesXtreamId: undefined,
metadataSnapshot: wrongKind,
});
expect(build(9, deepFreeze([movie]))).toEqual({
kind: 'movie',
item: movie,
});
});
it('groups only locally available members with the exact series identity', () => {
const representative = download(10, {
seasonNumber: 1,
episodeNumber: 5,
});
const seasonOneEpisodeTwo = download(11, {
seasonNumber: 1,
episodeNumber: 2,
});
const missing = download(12, {
seasonNumber: 1,
episodeNumber: 3,
fileAvailability: 'missing',
});
const seasonTwoEpisodeOne = download(13, {
seasonNumber: 2,
episodeNumber: 1,
});
const downloads = deepFreeze([
representative,
seasonOneEpisodeTwo,
missing,
seasonTwoEpisodeOne,
download(14, {
playlistId: 'playlist-b',
seasonNumber: 1,
episodeNumber: 1,
}),
download(15, {
seriesXtreamId: SERIES_ID + 1,
seasonNumber: 1,
episodeNumber: 1,
}),
download(16, {
contentType: 'vod',
seasonNumber: 1,
episodeNumber: 1,
}),
download(17, {
status: 'failed',
seasonNumber: 1,
episodeNumber: 4,
}),
download(18, {
filePath: '',
seasonNumber: 2,
episodeNumber: 2,
}),
]);
const before = JSON.stringify(downloads);
const series = expectSeries(build(10, downloads));
expect(series.representative).toBe(representative);
expect(series.seasons.map(({ seasonNumber }) => seasonNumber)).toEqual([
1, 2,
]);
expect(
series.seasons.map(({ episodes }) =>
episodes.map(({ item }) => item.id)
)
).toEqual([[11, 10], [13]]);
expect(JSON.stringify(downloads)).toBe(before);
expect(downloads.map(({ id }) => id)).toEqual([
10, 11, 12, 13, 14, 15, 16, 17, 18,
]);
});
it('allows any available group member to represent the same series', () => {
const first = download(10, {
seasonNumber: 1,
episodeNumber: 5,
});
const second = download(11, {
seasonNumber: 1,
episodeNumber: 2,
});
const downloads = deepFreeze([first, second]);
const series = expectSeries(build(11, downloads));
expect(series.representative).toBe(second);
expect(series.seasons[0].episodes.map(({ item }) => item.id)).toEqual([
11, 10,
]);
});
it('does not resolve an unavailable representative through available group members', () => {
const unavailableRepresentative = download(10, {
fileAvailability: 'missing',
});
const availableMember = download(11);
const downloads = deepFreeze([
unavailableRepresentative,
availableMember,
]);
expect(build(10, downloads)).toBeUndefined();
expect(build(99, downloads)).toBeUndefined();
});
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])(
'rejects an episode with the invalid explicit series id %p',
(seriesXtreamId) => {
const invalid = download(20, { seriesXtreamId });
expect(build(20, deepFreeze([invalid]))).toBeUndefined();
}
);
it('isolates a legacy episode without a series id as a one-episode series', () => {
const selected = download(21, {
seriesXtreamId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
});
const unrelatedLegacyEpisode = download(22, {
seriesXtreamId: undefined,
seasonNumber: 4,
episodeNumber: 8,
});
const downloads = deepFreeze([selected, unrelatedLegacyEpisode]);
const series = expectSeries(build(21, downloads));
expect(series.representative).toBe(selected);
expect(series.seasons).toEqual([
{
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
episodes: [
{
item: selected,
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
episodeNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
},
],
},
]);
});
it('uses zero as the stable fallback for sparse or invalid coordinates without dropping files', () => {
const sparse = download(23, {
seasonNumber: undefined,
episodeNumber: undefined,
});
const invalid = download(24, {
seasonNumber: -2,
episodeNumber: 1.5,
});
const valid = download(25, {
seasonNumber: 1,
episodeNumber: 3,
});
const downloads = deepFreeze([valid, sparse, invalid]);
const series = expectSeries(build(23, downloads));
expect(DOWNLOAD_OFFLINE_COORDINATE_FALLBACK).toBe(0);
expect(
series.seasons.map(({ seasonNumber, episodes }) => ({
seasonNumber,
episodes: episodes.map(({ item, episodeNumber }) => ({
id: item.id,
episodeNumber,
})),
}))
).toEqual([
{
seasonNumber: 0,
episodes: [
{ id: 23, episodeNumber: 0 },
{ id: 24, episodeNumber: 0 },
],
},
{
seasonNumber: 1,
episodes: [{ id: 25, episodeNumber: 3 }],
},
]);
});
it('sorts seasons and episodes numerically, then duplicate episodes by createdAt and id', () => {
const downloads = deepFreeze([
download(35, {
seasonNumber: 2,
episodeNumber: 1,
}),
download(34, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: '2026-01-03T00:00:00.000Z',
}),
download(33, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: '2026-01-02T00:00:00.000Z',
}),
download(32, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: '2026-01-02T00:00:00.000Z',
}),
download(31, {
seasonNumber: 1,
episodeNumber: 2,
}),
]);
const series = expectSeries(build(35, downloads));
expect(
series.seasons.map(({ seasonNumber, episodes }) => ({
seasonNumber,
ids: episodes.map(({ item }) => item.id),
}))
).toEqual([
{ seasonNumber: 1, ids: [31, 32, 33, 34] },
{ seasonNumber: 2, ids: [35] },
]);
});
it('selects snapshots by the newest per-row enrichedAt or timestamp fallback', () => {
const staleEnrichment = snapshot('series', 'Stale enrichment', {
enrichedAt: '2026-02-01T00:00:00.000Z',
});
const newerRow = snapshot('series', 'Newer row');
const wrongKind = snapshot('movie', 'Wrong kind', {
enrichedAt: '2027-01-01T00:00:00.000Z',
});
const downloads = deepFreeze([
download(40, {
metadataSnapshot: staleEnrichment,
updatedAt: '2026-12-01T00:00:00.000Z',
}),
download(41, {
metadataSnapshot: newerRow,
updatedAt: '2026-03-01T00:00:00.000Z',
}),
download(42, {
metadataSnapshot: wrongKind,
updatedAt: '2027-01-01T00:00:00.000Z',
}),
]);
const series = expectSeries(build(40, downloads));
expect(series.snapshot).toBe(newerRow);
});
it('falls through invalid snapshot and row timestamps before breaking ties by id', () => {
const first = snapshot('series', 'First', {
enrichedAt: 'invalid',
});
const updatedFallback = snapshot('series', 'Updated fallback', {
enrichedAt: 'invalid',
});
const createdFallback = snapshot('series', 'Created fallback', {
enrichedAt: 'invalid',
});
const greaterId = snapshot('series', 'Greater id', {
enrichedAt: 'invalid',
});
const downloads = deepFreeze([
download(47, {
metadataSnapshot: first,
updatedAt: '2026-06-01T00:00:00.000Z',
}),
download(48, {
metadataSnapshot: updatedFallback,
updatedAt: '2026-06-05T00:00:00.000Z',
}),
download(49, {
metadataSnapshot: createdFallback,
updatedAt: 'invalid',
createdAt: '2026-06-06T00:00:00.000Z',
}),
download(50, {
metadataSnapshot: greaterId,
updatedAt: 'invalid',
createdAt: '2026-06-06T00:00:00.000Z',
}),
]);
const forward = expectSeries(build(47, downloads));
const reversed = expectSeries(
build(47, deepFreeze([...downloads].reverse()))
);
expect(forward.snapshot).toBe(greaterId);
expect(reversed.snapshot).toBe(greaterId);
});
it('keeps each episode tied to its own snapshot episode metadata', () => {
const firstEpisode: DownloadEpisodeMetadata = {
title: 'Pilot',
plot: 'The first episode.',
stillUrl: 'https://media.example.test/pilot.jpg',
seasonNumber: 1,
episodeNumber: 1,
};
const secondEpisode: DownloadEpisodeMetadata = {
title: 'Second episode',
seasonNumber: 1,
episodeNumber: 2,
};
const firstSnapshot = snapshot('series', 'Series title', {
enrichedAt: '2026-01-01T00:00:00.000Z',
episode: firstEpisode,
});
const secondSnapshot = snapshot('series', 'Series title', {
enrichedAt: '2026-02-01T00:00:00.000Z',
episode: secondEpisode,
});
const first = download(51, {
episodeNumber: 1,
metadataSnapshot: firstSnapshot,
});
const second = download(52, {
episodeNumber: 2,
metadataSnapshot: secondSnapshot,
});
const downloads = deepFreeze([first, second]);
const series = expectSeries(build(51, downloads));
expect(series.snapshot).toBe(secondSnapshot);
expect(series.seasons[0].episodes).toEqual([
{
item: first,
seasonNumber: 1,
episodeNumber: 1,
episodeMetadata: firstEpisode,
},
{
item: second,
seasonNumber: 1,
episodeNumber: 2,
episodeMetadata: secondEpisode,
},
]);
});
});
@@ -0,0 +1,225 @@
import type { DownloadItem } from '@iptvnator/services';
import type {
DownloadEpisodeMetadata,
DownloadMetadataSnapshot,
} from '@iptvnator/shared/interfaces';
/**
* Missing or invalid season/episode coordinates are presented as specials.
* Zero is stable, numeric, and keeps locally available legacy files visible.
*/
export const DOWNLOAD_OFFLINE_COORDINATE_FALLBACK = 0;
export interface DownloadOfflineEpisode {
readonly item: DownloadItem;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly episodeMetadata?: DownloadEpisodeMetadata;
}
export interface DownloadOfflineSeason {
readonly seasonNumber: number;
readonly episodes: readonly DownloadOfflineEpisode[];
}
export interface DownloadOfflineMovieDetail {
readonly kind: 'movie';
readonly item: DownloadItem;
readonly snapshot?: DownloadMetadataSnapshot;
}
export interface DownloadOfflineSeriesDetail {
readonly kind: 'series';
readonly representative: DownloadItem;
readonly snapshot?: DownloadMetadataSnapshot;
readonly seasons: readonly DownloadOfflineSeason[];
}
export type DownloadOfflineDetail =
DownloadOfflineMovieDetail | DownloadOfflineSeriesDetail;
export interface BuildDownloadOfflineDetailInput {
readonly downloadId: number;
readonly downloads: readonly DownloadItem[];
}
function isPositiveSafeInteger(value: number | undefined): value is number {
return Number.isSafeInteger(value) && (value ?? 0) > 0;
}
function isLocallyAvailable(item: DownloadItem): boolean {
return (
item.status === 'completed' &&
(item.filePath?.trim().length ?? 0) > 0 &&
item.fileAvailability !== 'missing'
);
}
function parsedTimestamp(value: string | undefined): number | undefined {
const timestamp = Date.parse(value ?? '');
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function normalizedTimestamp(value: string | undefined): number {
return parsedTimestamp(value) ?? 0;
}
function normalizedCoordinate(value: number | undefined): number {
return Number.isSafeInteger(value) && (value ?? -1) >= 0
? (value as number)
: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK;
}
function snapshotForKind(
item: DownloadItem,
mediaKind: DownloadMetadataSnapshot['mediaKind']
): DownloadMetadataSnapshot | undefined {
const candidate = item.metadataSnapshot;
return candidate?.version === 1 && candidate.mediaKind === mediaKind
? candidate
: undefined;
}
function compareIdsNewestFirst(left: number, right: number): number {
if (left === right) {
return 0;
}
return right < left ? -1 : 1;
}
function effectiveSeriesSnapshotTimestamp(item: DownloadItem): number {
const snapshot = snapshotForKind(item, 'series');
for (const value of [
snapshot?.enrichedAt,
item.updatedAt,
item.createdAt,
]) {
const timestamp = parsedTimestamp(value);
if (timestamp !== undefined) {
return timestamp;
}
}
return 0;
}
function selectSeriesSnapshot(
members: readonly DownloadItem[]
): DownloadMetadataSnapshot | undefined {
const candidates = members
.filter((item) => snapshotForKind(item, 'series') !== undefined)
.sort(
(left, right) =>
effectiveSeriesSnapshotTimestamp(right) -
effectiveSeriesSnapshotTimestamp(left) ||
compareIdsNewestFirst(left.id, right.id)
);
return candidates.length === 0
? undefined
: snapshotForKind(candidates[0], 'series');
}
function toOfflineEpisode(item: DownloadItem): DownloadOfflineEpisode {
const seasonNumber = normalizedCoordinate(item.seasonNumber);
const episodeNumber = normalizedCoordinate(item.episodeNumber);
const episodeMetadata = snapshotForKind(item, 'series')?.episode;
return {
item,
seasonNumber,
episodeNumber,
...(episodeMetadata === undefined ? {} : { episodeMetadata }),
};
}
function compareOfflineEpisodes(
left: DownloadOfflineEpisode,
right: DownloadOfflineEpisode
): number {
return (
left.seasonNumber - right.seasonNumber ||
left.episodeNumber - right.episodeNumber ||
normalizedTimestamp(left.item.createdAt) -
normalizedTimestamp(right.item.createdAt) ||
left.item.id - right.item.id
);
}
function buildSeasons(
members: readonly DownloadItem[]
): readonly DownloadOfflineSeason[] {
const episodes = members.map(toOfflineEpisode).sort(compareOfflineEpisodes);
const grouped = new Map<number, DownloadOfflineEpisode[]>();
for (const episode of episodes) {
const season = grouped.get(episode.seasonNumber);
if (season) {
season.push(episode);
} else {
grouped.set(episode.seasonNumber, [episode]);
}
}
return [...grouped].map(([seasonNumber, seasonEpisodes]) => ({
seasonNumber,
episodes: seasonEpisodes,
}));
}
function buildSeriesDetail(
representative: DownloadItem,
downloads: readonly DownloadItem[]
): DownloadOfflineSeriesDetail | undefined {
const seriesXtreamId = representative.seriesXtreamId;
if (
seriesXtreamId !== undefined &&
!isPositiveSafeInteger(seriesXtreamId)
) {
return undefined;
}
const members =
seriesXtreamId === undefined
? [representative]
: downloads.filter(
(item) =>
isPositiveSafeInteger(item.id) &&
isLocallyAvailable(item) &&
item.contentType === 'episode' &&
item.playlistId === representative.playlistId &&
item.seriesXtreamId === seriesXtreamId
);
const snapshot = selectSeriesSnapshot(members);
return {
kind: 'series',
representative,
...(snapshot === undefined ? {} : { snapshot }),
seasons: buildSeasons(members),
};
}
export function buildDownloadOfflineDetail({
downloadId,
downloads,
}: BuildDownloadOfflineDetailInput): DownloadOfflineDetail | undefined {
if (!isPositiveSafeInteger(downloadId)) {
return undefined;
}
const representative = downloads.find(({ id }) => id === downloadId);
if (!representative || !isLocallyAvailable(representative)) {
return undefined;
}
if (representative.contentType === 'vod') {
const snapshot = snapshotForKind(representative, 'movie');
return {
kind: 'movie',
item: representative,
...(snapshot === undefined ? {} : { snapshot }),
};
}
return buildSeriesDetail(representative, downloads);
}