mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(downloads): derive offline detail content
This commit is contained in:
1 parent
103354df71
commit
b2efcedda1
2 files changed
+739
No files matched your search
+514
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+225
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user