From 2276f98447e381977b28e9f57cc3db8c9bd3d109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20Sundf=C3=B8r?= Date: Mon, 6 Oct 2025 14:03:38 +0200 Subject: [PATCH] Support for seasons Closes https://github.com/olaven/nrss/issues/14. --- lib/nrk/nrk.test.ts | 97 +++++++++++++++++++++++++++++++++++++++------ lib/nrk/nrk.ts | 75 +++++++++++++++++++++++++++-------- 2 files changed, 143 insertions(+), 29 deletions(-) diff --git a/lib/nrk/nrk.test.ts b/lib/nrk/nrk.test.ts index 083075c..312a692 100644 --- a/lib/nrk/nrk.test.ts +++ b/lib/nrk/nrk.test.ts @@ -2,12 +2,15 @@ import { assertEquals, assertExists, assertGreaterOrEqual } from "$std/assert/mo import { nrkRadio } from "./nrk.ts"; import { forTestingOnly } from "./nrk.ts"; -Deno.test("Verify search query `trygd` returns one result: 'Trygdekontoret'", async () => { - const result = await nrkRadio.search("trygd"); - assertExists(result); - assertEquals(result.length, 1); - assertEquals(result[0].seriesId, "trygdekontoret"); -}); +Deno.test( + "Verify search query `trygd` returns one result: 'Trygdekontoret'", + async () => { + const result = await nrkRadio.search("trygd"); + assertExists(result); + assertEquals(result.length, 1); + assertEquals(result[0].seriesId, "trygdekontoret"); + }, +); Deno.test("Verify empty search query yields `null`", async () => { const result = await nrkRadio.search(""); @@ -40,13 +43,83 @@ Deno.test("Verify getting series data for 'trygd' does not works", async () => { assertEquals(result, null); }); -Deno.test("Verify getting episodeId 'l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562' for 'trygdekontoret' works", async () => { - const result = await nrkRadio.getEpisode("trygdekontoret", "l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562"); +Deno.test( + "Verify getting episodeId 'l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562' for 'trygdekontoret' works", + async () => { + const result = await nrkRadio.getEpisode( + "trygdekontoret", + "l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562", + ); + assertExists(result); + assertEquals(result.duration.seconds, 4152); + }, +); + +Deno.test( + "Verify getting episodeId 'null' for 'trygdekontoret' yields `null`", + async () => { + const result = await nrkRadio.getEpisode("trygdekontoret", "null"); + assertEquals(result, null); + }, +); + +Deno.test("Can get episodes for seriesnakk", async () => { + const result = await nrkRadio.getSeries("seriesnakk"); assertExists(result); - assertEquals(result.duration.seconds, 4152); }); -Deno.test("Verify getting episodeId 'null' for 'trygdekontoret' yields `null`", async () => { - const result = await nrkRadio.getEpisode("trygdekontoret", "null"); - assertEquals(result, null); +Deno.test("Seriesnakk, an umbrella series yields all seasons", async () => { + const seriesData = await forTestingOnly.getSeriesData("seriesnakk"); + assertExists(seriesData); + assertGreaterOrEqual(seriesData.episodes.length, 1); + const titles = seriesData.episodes.map((e) => e.titles.title); + + const thereseTitle = "Therese-saken: Hva kan et vitne huske? (1:5)"; + assertEquals( + titles.includes(thereseTitle), + true, + `Titles include "${thereseTitle}"`, + ); + + const maktaTitle = "– Vi er ikke idioter og tror at det var elsparkesykler i 1975"; + assertEquals( + titles.includes(maktaTitle), + true, + `Titles include "${maktaTitle}"`, + ); + + const soLongMarianneTitle = "- Se og hør sov på dørmatta hennes! (1:8)"; + assertEquals( + titles.includes(soLongMarianneTitle), + true, + `Titles include "${soLongMarianneTitle}"`, + ); + + const nerdrumTitle = "Seriesnakk: Familien Nerdrum"; + assertEquals( + titles.includes(nerdrumTitle), + true, + `Titles include "${nerdrumTitle}"`, + ); + + const selinaTitle = "– Som å se meg selv (1:6)"; + assertEquals( + titles.includes(selinaTitle), + true, + `Titles include "${selinaTitle}"`, + ); + + const skamTitle = "– William må svare (4:9)"; + assertEquals( + titles.includes(skamTitle), + true, + `Titles include "${skamTitle}"`, + ); + + const natoTitle = "– Jeg ville slite ut Jens (1:2)"; + assertEquals( + titles.includes(natoTitle), + true, + `Titles include "${natoTitle}"`, + ); }); diff --git a/lib/nrk/nrk.ts b/lib/nrk/nrk.ts index 785f959..59b4291 100644 --- a/lib/nrk/nrk.ts +++ b/lib/nrk/nrk.ts @@ -1,8 +1,8 @@ import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.0/mod.ts"; -import { components as searchComponents } from "./nrk-search.ts"; +import { Series } from "../storage.ts"; import { components as catalogComponents } from "./nrk-catalog.ts"; import { external as playbackComponents } from "./nrk-playback.ts"; -import { Series } from "../storage.ts"; +import { components as searchComponents } from "./nrk-search.ts"; type ArrayElement = A extends readonly (infer T)[] ? T : never; type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"]; @@ -10,6 +10,7 @@ type Podcast = catalogComponents["schemas"]["SeriesHalResource"]; type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"]; type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"]; type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"]; +type SeasonEpisodes = catalogComponents["schemas"]["PodcastSeasonHalResource"]; export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"]; export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string }; @@ -18,11 +19,15 @@ export type NrkSearchResultList = searchComponents["schemas"]["seriesResult"]["r export type SearchResult = ArrayElement & { description?: string; }; -export type SeriesData = { episodes: NrkOriginalEpisode[] } & (RadioSeries["series"] | Podcast["series"]); +export type SeriesData = + & { episodes: NrkOriginalEpisode[] } + & ( + | RadioSeries["series"] + | Podcast["series"] + ); function parseSeries(nrkSeriesData: SeriesData): Series { const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? ""; - return { id: nrkSeriesData.id, title: nrkSeriesData.titles.title, @@ -62,6 +67,41 @@ async function search(query: string): Promise { return null; } +async function extractEpisodes( + serieResponse: RadioSeries, + episodeResponse?: PodcastEpisodes, +): Promise { + if (serieResponse.seriesType === "umbrella") { + const seasons = await Promise.all( + serieResponse._links.seasons.map(async (season) => { + const response = await get( + `https://psapi.nrk.no${season.href}`, + ); + return response.body; + }), + ); + + const episodes = await Promise.all( + seasons.flatMap((season) => { + return ( + season?._embedded.episodes?._embedded.episodes?.flatMap((episode) => + getEpisodeWithDownloadLink(episode, serieResponse.type) + ) ?? [] + ); + }), + ); + + return episodes; + } else { + const episodes = await Promise.all( + episodeResponse?._embedded.episodes?.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)) ?? + [], + ); + + return episodes; + } +} + async function getSeriesData(seriesId: string): Promise { let [ { status: episodeStatus, body: episodeResponse }, @@ -70,9 +110,7 @@ async function getSeriesData(seriesId: string): Promise { get( `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`, ), - get( - `${nrkAPI}/radio/catalog/podcast/${seriesId}`, - ), + get(`${nrkAPI}/radio/catalog/podcast/${seriesId}`), ]); if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) { @@ -83,19 +121,17 @@ async function getSeriesData(seriesId: string): Promise { get( `https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`, ), - get( - `https://psapi.nrk.no/radio/catalog/series/${seriesId}`, - ), + get(`https://psapi.nrk.no/radio/catalog/series/${seriesId}`), ]); } if ( - episodeStatus === STATUS_CODE.OK && seriesStatus === STATUS_CODE.OK && - serieResponse?.series && episodeResponse?._embedded.episodes?.length + episodeStatus === STATUS_CODE.OK && + seriesStatus === STATUS_CODE.OK && + serieResponse?.series && + episodeResponse?._embedded.episodes?.length ) { - const episodes = await Promise.all( - episodeResponse._embedded.episodes.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)), - ); + const episodes = await extractEpisodes(serieResponse, episodeResponse); const seriesData = { ...serieResponse.series, episodes, @@ -117,13 +153,18 @@ async function getSeries(seriesId: string): Promise { return parsedSeries; } -async function getEpisode(seriesId: string, episodeId: string): Promise { +async function getEpisode( + seriesId: string, + episodeId: string, +): Promise { const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`; const { status, body: episode } = await get(url); if (status === STATUS_CODE.OK && episode) { return episode; } - console.error(`Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`); + console.error( + `Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`, + ); return null; } -- 2.40.1