mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(downloads): preserve unknown episode coordinates
This commit is contained in:
1 parent
b2efcedda1
commit
95cdf83aa2
2 files changed
+431
-81
No files matched your search
+256
-24
@@ -5,8 +5,10 @@ import type {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDownloadOfflineDetail,
|
||||
DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
|
||||
type DeepReadonly,
|
||||
type DownloadOfflineDetail,
|
||||
type DownloadOfflineEpisode,
|
||||
type DownloadOfflineMovieDetail,
|
||||
} from './download-offline-detail.viewmodel';
|
||||
|
||||
const SERIES_ID = 77;
|
||||
@@ -79,7 +81,34 @@ function expectSeries(
|
||||
return detail;
|
||||
}
|
||||
|
||||
function assertReadonlyViewModelTypes(
|
||||
episode: DownloadOfflineEpisode,
|
||||
movie: DownloadOfflineMovieDetail
|
||||
): void {
|
||||
const item: DeepReadonly<DownloadItem> = episode.item;
|
||||
void item;
|
||||
|
||||
// @ts-expect-error View-model source items are recursively readonly.
|
||||
episode.item.title = 'Mutated title';
|
||||
if (episode.item.metadataSnapshot?.genres) {
|
||||
// @ts-expect-error Nested snapshot arrays are recursively readonly.
|
||||
episode.item.metadataSnapshot.genres.push('Mutation');
|
||||
}
|
||||
if (episode.episodeMetadata) {
|
||||
// @ts-expect-error Per-episode metadata is recursively readonly.
|
||||
episode.episodeMetadata.title = 'Mutated episode';
|
||||
}
|
||||
if (movie.snapshot) {
|
||||
// @ts-expect-error Detail snapshots are recursively readonly.
|
||||
movie.snapshot.title = 'Mutated movie';
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildDownloadOfflineDetail', () => {
|
||||
it('exposes source data through recursive readonly view-model types', () => {
|
||||
expect(assertReadonlyViewModelTypes).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves an available movie by its exact positive integer download id', () => {
|
||||
const movieSnapshot = snapshot('movie', 'Stored movie');
|
||||
const selected = download(7, {
|
||||
@@ -298,59 +327,221 @@ describe('buildDownloadOfflineDetail', () => {
|
||||
expect(series.representative).toBe(selected);
|
||||
expect(series.seasons).toEqual([
|
||||
{
|
||||
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
episodes: [
|
||||
{
|
||||
item: selected,
|
||||
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
|
||||
episodeNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
episodeNumber: null,
|
||||
episodeNumberState: 'unknown',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses zero as the stable fallback for sparse or invalid coordinates without dropping files', () => {
|
||||
const sparse = download(23, {
|
||||
it('distinguishes valid S0E0 specials from unknown coordinates', () => {
|
||||
const special = download(23, {
|
||||
seasonNumber: 0,
|
||||
episodeNumber: 0,
|
||||
});
|
||||
const sparse = download(24, {
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: undefined,
|
||||
});
|
||||
const invalid = download(24, {
|
||||
const invalid = download(25, {
|
||||
seasonNumber: -2,
|
||||
episodeNumber: 1.5,
|
||||
});
|
||||
const valid = download(25, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
});
|
||||
const downloads = deepFreeze([valid, sparse, invalid]);
|
||||
const downloads = deepFreeze([invalid, sparse, special]);
|
||||
|
||||
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,
|
||||
})),
|
||||
}))
|
||||
series.seasons.map(
|
||||
({ seasonNumber, seasonNumberState, episodes }) => ({
|
||||
seasonNumber,
|
||||
seasonNumberState,
|
||||
episodes: episodes.map(
|
||||
({
|
||||
item,
|
||||
seasonNumber: episodeSeasonNumber,
|
||||
seasonNumberState: episodeSeasonNumberState,
|
||||
episodeNumber,
|
||||
episodeNumberState,
|
||||
}) => ({
|
||||
id: item.id,
|
||||
seasonNumber: episodeSeasonNumber,
|
||||
seasonNumberState: episodeSeasonNumberState,
|
||||
episodeNumber,
|
||||
episodeNumberState,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
seasonNumber: 0,
|
||||
seasonNumberState: 'known',
|
||||
episodes: [
|
||||
{ id: 23, episodeNumber: 0 },
|
||||
{ id: 24, episodeNumber: 0 },
|
||||
{
|
||||
id: 23,
|
||||
seasonNumber: 0,
|
||||
seasonNumberState: 'known',
|
||||
episodeNumber: 0,
|
||||
episodeNumberState: 'known',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
seasonNumber: 1,
|
||||
episodes: [{ id: 25, episodeNumber: 3 }],
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
episodes: [
|
||||
{
|
||||
id: 24,
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
episodeNumber: null,
|
||||
episodeNumberState: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
episodeNumber: null,
|
||||
episodeNumberState: 'unknown',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps multiple unknown episodes distinct and orders them by createdAt then id', () => {
|
||||
const downloads = deepFreeze([
|
||||
download(63, {
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: undefined,
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
}),
|
||||
download(62, {
|
||||
seasonNumber: Number.NaN,
|
||||
episodeNumber: Number.POSITIVE_INFINITY,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
}),
|
||||
download(61, {
|
||||
seasonNumber: -1,
|
||||
episodeNumber: Number.MAX_SAFE_INTEGER + 1,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
const series = expectSeries(build(63, downloads));
|
||||
|
||||
expect(series.seasons).toHaveLength(1);
|
||||
expect(series.seasons[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
seasonNumber: null,
|
||||
seasonNumberState: 'unknown',
|
||||
})
|
||||
);
|
||||
expect(
|
||||
series.seasons[0].episodes.map(({ item, episodeNumber }) => ({
|
||||
id: item.id,
|
||||
episodeNumber,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 61, episodeNumber: null },
|
||||
{ id: 62, episodeNumber: null },
|
||||
{ id: 63, episodeNumber: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders invalid or missing createdAt after valid timestamps and then by id', () => {
|
||||
const downloads = deepFreeze([
|
||||
download(73, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 4,
|
||||
createdAt: undefined,
|
||||
}),
|
||||
download(72, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 4,
|
||||
createdAt: 'invalid',
|
||||
}),
|
||||
download(71, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 4,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
const series = expectSeries(build(73, downloads));
|
||||
|
||||
expect(series.seasons[0].episodes.map(({ item }) => item.id)).toEqual([
|
||||
71, 72, 73,
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts unknown episodes after known episodes within a known season', () => {
|
||||
const downloads = deepFreeze([
|
||||
download(28, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: undefined,
|
||||
}),
|
||||
download(27, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
}),
|
||||
download(26, {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
const series = expectSeries(build(28, downloads));
|
||||
|
||||
expect(
|
||||
series.seasons[0].episodes.map(
|
||||
({ item, episodeNumber, episodeNumberState }) => ({
|
||||
id: item.id,
|
||||
episodeNumber,
|
||||
episodeNumberState,
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{ id: 26, episodeNumber: 2, episodeNumberState: 'known' },
|
||||
{ id: 27, episodeNumber: 5, episodeNumberState: 'known' },
|
||||
{ id: 28, episodeNumber: null, episodeNumberState: 'unknown' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not collapse known and unknown season groups', () => {
|
||||
const downloads = deepFreeze([
|
||||
download(30, {
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: 2,
|
||||
}),
|
||||
download(29, {
|
||||
seasonNumber: 0,
|
||||
episodeNumber: 3,
|
||||
}),
|
||||
]);
|
||||
|
||||
const series = expectSeries(build(30, downloads));
|
||||
|
||||
expect(
|
||||
series.seasons.map(({ seasonNumber, seasonNumberState }) => ({
|
||||
seasonNumber,
|
||||
seasonNumberState,
|
||||
}))
|
||||
).toEqual([
|
||||
{ seasonNumber: 0, seasonNumberState: 'known' },
|
||||
{ seasonNumber: null, seasonNumberState: 'unknown' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts seasons and episodes numerically, then duplicate episodes by createdAt and id', () => {
|
||||
const downloads = deepFreeze([
|
||||
download(35, {
|
||||
@@ -462,6 +653,43 @@ describe('buildDownloadOfflineDetail', () => {
|
||||
expect(reversed.snapshot).toBe(greaterId);
|
||||
});
|
||||
|
||||
it('selects snapshots with entirely invalid timestamps deterministically by id', () => {
|
||||
const lowerId = snapshot('series', 'Lower id', {
|
||||
enrichedAt: 'invalid',
|
||||
});
|
||||
const middleId = snapshot('series', 'Middle id', {
|
||||
enrichedAt: 'invalid',
|
||||
});
|
||||
const greaterId = snapshot('series', 'Greater id', {
|
||||
enrichedAt: 'invalid',
|
||||
});
|
||||
const downloads = deepFreeze([
|
||||
download(53, {
|
||||
metadataSnapshot: lowerId,
|
||||
updatedAt: 'invalid',
|
||||
createdAt: 'invalid',
|
||||
}),
|
||||
download(54, {
|
||||
metadataSnapshot: middleId,
|
||||
updatedAt: undefined,
|
||||
createdAt: undefined,
|
||||
}),
|
||||
download(55, {
|
||||
metadataSnapshot: greaterId,
|
||||
updatedAt: 'invalid',
|
||||
createdAt: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const forward = expectSeries(build(53, downloads));
|
||||
const reversed = expectSeries(
|
||||
build(53, 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',
|
||||
@@ -500,13 +728,17 @@ describe('buildDownloadOfflineDetail', () => {
|
||||
{
|
||||
item: first,
|
||||
seasonNumber: 1,
|
||||
seasonNumberState: 'known',
|
||||
episodeNumber: 1,
|
||||
episodeNumberState: 'known',
|
||||
episodeMetadata: firstEpisode,
|
||||
},
|
||||
{
|
||||
item: second,
|
||||
seasonNumber: 1,
|
||||
seasonNumberState: 'known',
|
||||
episodeNumber: 2,
|
||||
episodeNumberState: 'known',
|
||||
episodeMetadata: secondEpisode,
|
||||
},
|
||||
]);
|
||||
|
||||
+175
-57
@@ -4,34 +4,59 @@ import type {
|
||||
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;
|
||||
type DeepReadonlyAtomic =
|
||||
bigint | boolean | null | number | string | symbol | undefined;
|
||||
|
||||
export interface DownloadOfflineEpisode {
|
||||
readonly item: DownloadItem;
|
||||
readonly seasonNumber: number;
|
||||
readonly episodeNumber: number;
|
||||
readonly episodeMetadata?: DownloadEpisodeMetadata;
|
||||
}
|
||||
export type DeepReadonly<T> = T extends DeepReadonlyAtomic
|
||||
? T
|
||||
: T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
||||
export interface DownloadOfflineSeason {
|
||||
readonly seasonNumber: number;
|
||||
export type DownloadOfflineSeasonCoordinate =
|
||||
| {
|
||||
readonly seasonNumber: number;
|
||||
readonly seasonNumberState: 'known';
|
||||
}
|
||||
| {
|
||||
readonly seasonNumber: null;
|
||||
readonly seasonNumberState: 'unknown';
|
||||
};
|
||||
|
||||
export type DownloadOfflineEpisodeCoordinate =
|
||||
| {
|
||||
readonly episodeNumber: number;
|
||||
readonly episodeNumberState: 'known';
|
||||
}
|
||||
| {
|
||||
readonly episodeNumber: null;
|
||||
readonly episodeNumberState: 'unknown';
|
||||
};
|
||||
|
||||
export type DownloadOfflineEpisode = DownloadOfflineSeasonCoordinate &
|
||||
DownloadOfflineEpisodeCoordinate & {
|
||||
readonly item: DeepReadonly<DownloadItem>;
|
||||
readonly episodeMetadata?: DeepReadonly<DownloadEpisodeMetadata>;
|
||||
};
|
||||
|
||||
export type DownloadOfflineSeason = DownloadOfflineSeasonCoordinate & {
|
||||
readonly episodes: readonly DownloadOfflineEpisode[];
|
||||
}
|
||||
};
|
||||
|
||||
export interface DownloadOfflineMovieDetail {
|
||||
readonly kind: 'movie';
|
||||
readonly item: DownloadItem;
|
||||
readonly snapshot?: DownloadMetadataSnapshot;
|
||||
readonly item: DeepReadonly<DownloadItem>;
|
||||
readonly snapshot?: DeepReadonly<DownloadMetadataSnapshot>;
|
||||
}
|
||||
|
||||
export interface DownloadOfflineSeriesDetail {
|
||||
readonly kind: 'series';
|
||||
readonly representative: DownloadItem;
|
||||
readonly snapshot?: DownloadMetadataSnapshot;
|
||||
readonly representative: DeepReadonly<DownloadItem>;
|
||||
readonly snapshot?: DeepReadonly<DownloadMetadataSnapshot>;
|
||||
readonly seasons: readonly DownloadOfflineSeason[];
|
||||
}
|
||||
|
||||
@@ -40,14 +65,46 @@ export type DownloadOfflineDetail =
|
||||
|
||||
export interface BuildDownloadOfflineDetailInput {
|
||||
readonly downloadId: number;
|
||||
readonly downloads: readonly DownloadItem[];
|
||||
readonly downloads: readonly DeepReadonly<DownloadItem>[];
|
||||
}
|
||||
|
||||
type ReadonlyDownloadItem = DeepReadonly<DownloadItem>;
|
||||
|
||||
type NormalizedCoordinate =
|
||||
| {
|
||||
readonly state: 'known';
|
||||
readonly value: number;
|
||||
readonly sortBucket: 0;
|
||||
}
|
||||
| {
|
||||
readonly state: 'unknown';
|
||||
readonly value: null;
|
||||
readonly sortBucket: 1;
|
||||
};
|
||||
|
||||
type NormalizedTimestamp =
|
||||
| {
|
||||
readonly state: 'known';
|
||||
readonly value: number;
|
||||
readonly sortBucket: 0;
|
||||
}
|
||||
| {
|
||||
readonly state: 'unknown';
|
||||
readonly sortBucket: 1;
|
||||
};
|
||||
|
||||
interface SortableOfflineEpisode {
|
||||
readonly view: DownloadOfflineEpisode;
|
||||
readonly season: NormalizedCoordinate;
|
||||
readonly episode: NormalizedCoordinate;
|
||||
readonly createdAt: NormalizedTimestamp;
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: number | undefined): value is number {
|
||||
return Number.isSafeInteger(value) && (value ?? 0) > 0;
|
||||
}
|
||||
|
||||
function isLocallyAvailable(item: DownloadItem): boolean {
|
||||
function isLocallyAvailable(item: ReadonlyDownloadItem): boolean {
|
||||
return (
|
||||
item.status === 'completed' &&
|
||||
(item.filePath?.trim().length ?? 0) > 0 &&
|
||||
@@ -60,20 +117,24 @@ function parsedTimestamp(value: string | undefined): number | undefined {
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function normalizedTimestamp(value: string | undefined): number {
|
||||
return parsedTimestamp(value) ?? 0;
|
||||
function normalizedCoordinate(value: number | undefined): NormalizedCoordinate {
|
||||
if (Number.isSafeInteger(value) && (value ?? -1) >= 0) {
|
||||
return { state: 'known', value: value as number, sortBucket: 0 };
|
||||
}
|
||||
return { state: 'unknown', value: null, sortBucket: 1 };
|
||||
}
|
||||
|
||||
function normalizedCoordinate(value: number | undefined): number {
|
||||
return Number.isSafeInteger(value) && (value ?? -1) >= 0
|
||||
? (value as number)
|
||||
: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK;
|
||||
function normalizedTimestamp(value: string | undefined): NormalizedTimestamp {
|
||||
const timestamp = parsedTimestamp(value);
|
||||
return timestamp === undefined
|
||||
? { state: 'unknown', sortBucket: 1 }
|
||||
: { state: 'known', value: timestamp, sortBucket: 0 };
|
||||
}
|
||||
|
||||
function snapshotForKind(
|
||||
item: DownloadItem,
|
||||
item: ReadonlyDownloadItem,
|
||||
mediaKind: DownloadMetadataSnapshot['mediaKind']
|
||||
): DownloadMetadataSnapshot | undefined {
|
||||
): DeepReadonly<DownloadMetadataSnapshot> | undefined {
|
||||
const candidate = item.metadataSnapshot;
|
||||
return candidate?.version === 1 && candidate.mediaKind === mediaKind
|
||||
? candidate
|
||||
@@ -87,7 +148,7 @@ function compareIdsNewestFirst(left: number, right: number): number {
|
||||
return right < left ? -1 : 1;
|
||||
}
|
||||
|
||||
function effectiveSeriesSnapshotTimestamp(item: DownloadItem): number {
|
||||
function effectiveSeriesSnapshotTimestamp(item: ReadonlyDownloadItem): number {
|
||||
const snapshot = snapshotForKind(item, 'series');
|
||||
for (const value of [
|
||||
snapshot?.enrichedAt,
|
||||
@@ -103,8 +164,8 @@ function effectiveSeriesSnapshotTimestamp(item: DownloadItem): number {
|
||||
}
|
||||
|
||||
function selectSeriesSnapshot(
|
||||
members: readonly DownloadItem[]
|
||||
): DownloadMetadataSnapshot | undefined {
|
||||
members: readonly ReadonlyDownloadItem[]
|
||||
): DeepReadonly<DownloadMetadataSnapshot> | undefined {
|
||||
const candidates = members
|
||||
.filter((item) => snapshotForKind(item, 'series') !== undefined)
|
||||
.sort(
|
||||
@@ -119,56 +180,113 @@ function selectSeriesSnapshot(
|
||||
: snapshotForKind(candidates[0], 'series');
|
||||
}
|
||||
|
||||
function toOfflineEpisode(item: DownloadItem): DownloadOfflineEpisode {
|
||||
const seasonNumber = normalizedCoordinate(item.seasonNumber);
|
||||
const episodeNumber = normalizedCoordinate(item.episodeNumber);
|
||||
function seasonCoordinateFields(
|
||||
coordinate: NormalizedCoordinate
|
||||
): DownloadOfflineSeasonCoordinate {
|
||||
return coordinate.state === 'known'
|
||||
? { seasonNumber: coordinate.value, seasonNumberState: 'known' }
|
||||
: { seasonNumber: null, seasonNumberState: 'unknown' };
|
||||
}
|
||||
|
||||
function episodeCoordinateFields(
|
||||
coordinate: NormalizedCoordinate
|
||||
): DownloadOfflineEpisodeCoordinate {
|
||||
return coordinate.state === 'known'
|
||||
? { episodeNumber: coordinate.value, episodeNumberState: 'known' }
|
||||
: { episodeNumber: null, episodeNumberState: 'unknown' };
|
||||
}
|
||||
|
||||
function toSortableOfflineEpisode(
|
||||
item: ReadonlyDownloadItem
|
||||
): SortableOfflineEpisode {
|
||||
const season = normalizedCoordinate(item.seasonNumber);
|
||||
const episode = normalizedCoordinate(item.episodeNumber);
|
||||
const episodeMetadata = snapshotForKind(item, 'series')?.episode;
|
||||
|
||||
return {
|
||||
item,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
...(episodeMetadata === undefined ? {} : { episodeMetadata }),
|
||||
season,
|
||||
episode,
|
||||
createdAt: normalizedTimestamp(item.createdAt),
|
||||
view: {
|
||||
item,
|
||||
...seasonCoordinateFields(season),
|
||||
...episodeCoordinateFields(episode),
|
||||
...(episodeMetadata === undefined ? {} : { episodeMetadata }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compareOfflineEpisodes(
|
||||
left: DownloadOfflineEpisode,
|
||||
right: DownloadOfflineEpisode
|
||||
function compareCoordinates(
|
||||
left: NormalizedCoordinate,
|
||||
right: NormalizedCoordinate
|
||||
): number {
|
||||
return (
|
||||
left.seasonNumber - right.seasonNumber ||
|
||||
left.episodeNumber - right.episodeNumber ||
|
||||
normalizedTimestamp(left.item.createdAt) -
|
||||
normalizedTimestamp(right.item.createdAt) ||
|
||||
left.item.id - right.item.id
|
||||
left.sortBucket - right.sortBucket ||
|
||||
(left.state === 'known' && right.state === 'known'
|
||||
? left.value - right.value
|
||||
: 0)
|
||||
);
|
||||
}
|
||||
|
||||
function compareTimestamps(
|
||||
left: NormalizedTimestamp,
|
||||
right: NormalizedTimestamp
|
||||
): number {
|
||||
return (
|
||||
left.sortBucket - right.sortBucket ||
|
||||
(left.state === 'known' && right.state === 'known'
|
||||
? left.value - right.value
|
||||
: 0)
|
||||
);
|
||||
}
|
||||
|
||||
function compareOfflineEpisodes(
|
||||
left: SortableOfflineEpisode,
|
||||
right: SortableOfflineEpisode
|
||||
): number {
|
||||
return (
|
||||
compareCoordinates(left.season, right.season) ||
|
||||
compareCoordinates(left.episode, right.episode) ||
|
||||
compareTimestamps(left.createdAt, right.createdAt) ||
|
||||
left.view.item.id - right.view.item.id
|
||||
);
|
||||
}
|
||||
|
||||
function buildSeasons(
|
||||
members: readonly DownloadItem[]
|
||||
members: readonly ReadonlyDownloadItem[]
|
||||
): readonly DownloadOfflineSeason[] {
|
||||
const episodes = members.map(toOfflineEpisode).sort(compareOfflineEpisodes);
|
||||
const grouped = new Map<number, DownloadOfflineEpisode[]>();
|
||||
const episodes = members
|
||||
.map(toSortableOfflineEpisode)
|
||||
.sort(compareOfflineEpisodes);
|
||||
const grouped = new Map<
|
||||
number | null,
|
||||
{
|
||||
readonly coordinate: NormalizedCoordinate;
|
||||
readonly episodes: DownloadOfflineEpisode[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const episode of episodes) {
|
||||
const season = grouped.get(episode.seasonNumber);
|
||||
const season = grouped.get(episode.season.value);
|
||||
if (season) {
|
||||
season.push(episode);
|
||||
season.episodes.push(episode.view);
|
||||
} else {
|
||||
grouped.set(episode.seasonNumber, [episode]);
|
||||
grouped.set(episode.season.value, {
|
||||
coordinate: episode.season,
|
||||
episodes: [episode.view],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped].map(([seasonNumber, seasonEpisodes]) => ({
|
||||
seasonNumber,
|
||||
episodes: seasonEpisodes,
|
||||
return [...grouped.values()].map(({ coordinate, episodes }) => ({
|
||||
...seasonCoordinateFields(coordinate),
|
||||
episodes,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSeriesDetail(
|
||||
representative: DownloadItem,
|
||||
downloads: readonly DownloadItem[]
|
||||
representative: ReadonlyDownloadItem,
|
||||
downloads: readonly ReadonlyDownloadItem[]
|
||||
): DownloadOfflineSeriesDetail | undefined {
|
||||
const seriesXtreamId = representative.seriesXtreamId;
|
||||
if (
|
||||
|
||||
Reference in new issue
Block a user