feat: Add unit-tests (#25)
This commit is contained in:
parent
0c2baf356b
commit
33dbdfefcc
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@master
|
||||||
|
- name: Setup deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v1.x
|
||||||
|
- name: Run checks
|
||||||
|
run: deno task check
|
||||||
|
- name: Run test
|
||||||
|
run: deno task test
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export default function Search(props: { defaultValue: string }) {
|
export default function Search(props: { defaultValue: string | null }) {
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col w-full lg:w-2/3 mx-auto my-4">
|
<form className="flex flex-col w-full lg:w-2/3 mx-auto my-4">
|
||||||
<label className="sr-only" htmlFor="query">Program</label>
|
<label className="sr-only" htmlFor="query">Program</label>
|
||||||
|
|
@ -8,7 +8,7 @@ export default function Search(props: { defaultValue: string }) {
|
||||||
className="border-2 rounded-md px-2"
|
className="border-2 rounded-md px-2"
|
||||||
name="query"
|
name="query"
|
||||||
id="query"
|
id="query"
|
||||||
defaultValue={props.defaultValue}
|
defaultValue={props.defaultValue ?? ""}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="my-2 bg-gray-100 border-2 border-gray-400 rounded-md">
|
<button type="submit" className="my-2 bg-gray-100 border-2 border-gray-400 rounded-md">
|
||||||
Søk
|
Søk
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"start": "deno run -A --watch=static/,routes/ dev.ts",
|
"start": "deno run -A --watch=static/,routes/ dev.ts",
|
||||||
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
||||||
"types:nrk": "deno run -A generate-types.ts && deno fmt ./lib"
|
"types:nrk": "deno run -A generate-types.ts && deno fmt ./lib",
|
||||||
|
"test": "deno test -A"
|
||||||
},
|
},
|
||||||
"unstable": [
|
"unstable": [
|
||||||
"kv"
|
"kv"
|
||||||
|
|
@ -20,9 +21,8 @@
|
||||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||||
"$gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
|
"$gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
|
||||||
"openapi-typescript": "npm:openapi-typescript@6.7.5",
|
"openapi-typescript": "npm:openapi-typescript@6.7.5",
|
||||||
"datetime": "https://deno.land/std@0.221.0/datetime/mod.ts",
|
"serialize-xml": "https://raw.githubusercontent.com/olaven/serialize-xml/v0.4.0/mod.ts",
|
||||||
"asserts": "https://deno.land/std@0.221.0/assert/mod.ts",
|
"$std/": "https://deno.land/std@0.221.0/"
|
||||||
"serialize-xml": "https://raw.githubusercontent.com/olaven/serialize-xml/v0.4.0/mod.ts"
|
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ import openapiTS from "openapi-typescript";
|
||||||
|
|
||||||
const nrkTypes: { filename: string; url: URL }[] = [
|
const nrkTypes: { filename: string; url: URL }[] = [
|
||||||
{
|
{
|
||||||
filename: "./lib/nrk-search.ts",
|
filename: "./lib/nrk/nrk-search.ts",
|
||||||
url: new URL("https://psapi.nrk.no/documentation/openapi/search-radio/v1/openapi.yml"),
|
url: new URL("https://psapi.nrk.no/documentation/openapi/search-radio/v1/openapi.yml"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: "./lib/nrk-catalog.ts",
|
filename: "./lib/nrk/nrk-catalog.ts",
|
||||||
url: new URL("https://psapi.nrk.no/documentation/openapi/programsider-radio/openapi.yml"),
|
url: new URL("https://psapi.nrk.no/documentation/openapi/programsider-radio/openapi.yml"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: "./lib/nrk-playback.ts",
|
filename: "./lib/nrk/nrk-playback.ts",
|
||||||
url: new URL("https://psapi.nrk.no/documentation/openapi/playback/2.0/openapi.json"),
|
url: new URL("https://psapi.nrk.no/documentation/openapi/playback/2.0/openapi.json"),
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { assertEquals } from "$std/assert/mod.ts";
|
||||||
|
import { forTestingOnly } from "./caching.ts";
|
||||||
|
import { testUtils } from "./test-utils.ts";
|
||||||
|
|
||||||
|
Deno.test("Verify getTimeSinceLastFetch for correct difference", () => {
|
||||||
|
const earlyDate = new Date("2024-04-07T20:24:32Z");
|
||||||
|
const date = new Date("2024-04-07T23:24:32Z");
|
||||||
|
const timeSinceLastFetch = forTestingOnly.getTimeSinceLastFetch(date, earlyDate);
|
||||||
|
|
||||||
|
assertEquals(timeSinceLastFetch, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getTimeSinceLastFetch for bad date", () => {
|
||||||
|
const earlyDate = new Date("2024-04-07T20:24:32Z");
|
||||||
|
const date = new Date("bad date");
|
||||||
|
const timeSinceLastFetch = forTestingOnly.getTimeSinceLastFetch(date, earlyDate);
|
||||||
|
|
||||||
|
assertEquals(timeSinceLastFetch, NaN);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getTimeSinceLastFetch for bad dates (plural)", () => {
|
||||||
|
const earlyDate = new Date("badest date");
|
||||||
|
const date = new Date("bad date");
|
||||||
|
const timeSinceLastFetch = forTestingOnly.getTimeSinceLastFetch(date, earlyDate);
|
||||||
|
|
||||||
|
assertEquals(timeSinceLastFetch, NaN);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify seriesFromStorage is old", () => {
|
||||||
|
const earlyDate = new Date("2024-04-07T21:24:32Z");
|
||||||
|
const date = new Date("2024-04-07T23:24:32Z");
|
||||||
|
const timeSinceLastFetch = forTestingOnly.getTimeSinceLastFetch(date, earlyDate);
|
||||||
|
|
||||||
|
const series = testUtils.generateSeries({ lastFetchedAt: date });
|
||||||
|
const syncInterval = 2;
|
||||||
|
const isNew = forTestingOnly.isSeriesFromStorageNew(series, syncInterval, timeSinceLastFetch);
|
||||||
|
|
||||||
|
assertEquals(isNew, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify seriesFromStorage is new", () => {
|
||||||
|
const earlyDate = new Date("2024-04-07T20:24:32Z");
|
||||||
|
const date = new Date("2024-04-07T23:24:32Z");
|
||||||
|
const timeSinceLastFetch = forTestingOnly.getTimeSinceLastFetch(date, earlyDate);
|
||||||
|
|
||||||
|
const series = testUtils.generateSeries({ lastFetchedAt: date });
|
||||||
|
const syncInterval = 2;
|
||||||
|
const isNew = forTestingOnly.isSeriesFromStorageNew(series, syncInterval, timeSinceLastFetch);
|
||||||
|
|
||||||
|
assertEquals(isNew, false);
|
||||||
|
});
|
||||||
|
|
@ -1,21 +1,33 @@
|
||||||
import { nrkRadio } from "./nrk/nrk.ts";
|
import { nrkRadio } from "./nrk/nrk.ts";
|
||||||
import { Series, storage } from "./storage.ts";
|
import { Series, storage } from "./storage.ts";
|
||||||
import * as datetime from "datetime";
|
import * as datetime from "$std/datetime/mod.ts";
|
||||||
|
import { Episode } from "./storage.ts";
|
||||||
|
|
||||||
const SYNC_INTERVAL_HOURS = 1;
|
const SYNC_INTERVAL_HOURS = 1;
|
||||||
|
|
||||||
async function initialFetch(options: { id: string }) {
|
async function initialFetch(options: { id: string }): Promise<Series | null> {
|
||||||
const series = await nrkRadio.getSeries(options.id);
|
const series = await nrkRadio.getSeries(options.id);
|
||||||
|
if (!series) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const stored = storage.write(series);
|
const stored = storage.write(series);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new Error(`Failed to store series ${options.id}`);
|
console.error(`Failed to store series ${options.id}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return series;
|
return series;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateFetch(existingSeries: Series) {
|
type UpdatedSeries = {
|
||||||
|
lastFetch: Date;
|
||||||
|
episodes: Episode[];
|
||||||
|
} & Series;
|
||||||
|
async function updateFetch(existingSeries: Series): Promise<UpdatedSeries | Series | null> {
|
||||||
const series = await nrkRadio.getSeries(existingSeries.id);
|
const series = await nrkRadio.getSeries(existingSeries.id);
|
||||||
|
if (!series) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const newEpisodes = series.episodes.filter((episode) => {
|
const newEpisodes = series.episodes.filter((episode) => {
|
||||||
return !existingSeries.episodes.find((serieEpisode) => serieEpisode.id === episode.id);
|
return !existingSeries.episodes.find((serieEpisode) => serieEpisode.id === episode.id);
|
||||||
});
|
});
|
||||||
|
|
@ -24,27 +36,46 @@ async function updateFetch(existingSeries: Series) {
|
||||||
return existingSeries;
|
return existingSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = {
|
|
||||||
...existingSeries,
|
|
||||||
lastFetch: new Date(),
|
|
||||||
/**
|
/**
|
||||||
* Since we don't control the API,
|
* Since we don't control the API,
|
||||||
* we should not make assumptions about the order,
|
* we should not make assumptions about the order,
|
||||||
* but rather sort the episode to what we want.
|
* but rather sort the episode to what we want.
|
||||||
*/
|
*/
|
||||||
episodes: [...newEpisodes, ...existingSeries.episodes]
|
const episodesSortedDescending = [...newEpisodes, ...existingSeries.episodes]
|
||||||
.sort((a, b) => a.date.getTime() > b.date.getTime() ? -1 : 1),
|
.sort((a, b) => a.date.getTime() > b.date.getTime() ? -1 : 1);
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...existingSeries,
|
||||||
|
lastFetch: new Date(),
|
||||||
|
episodes: episodesSortedDescending,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSuccessful = await storage.write(updated);
|
const updateSuccessful = await storage.write(updated);
|
||||||
if (!updateSuccessful) {
|
if (!updateSuccessful) {
|
||||||
throw new Error(`Failed to update series ${existingSeries.id}`);
|
console.log(`Failed to update series ${existingSeries.id}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSeries(options: { id: string }): Promise<Series> {
|
function getTimeSinceLastFetch(inputDate: Date, againstDate = new Date()): number | null {
|
||||||
|
return datetime.difference(inputDate, againstDate, { units: ["hours"] }).hours ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeriesFromStorageNew(
|
||||||
|
seriesFromStorage: Series,
|
||||||
|
syncInterval = SYNC_INTERVAL_HOURS,
|
||||||
|
timeSinceLastFetch = getTimeSinceLastFetch(seriesFromStorage.lastFetchedAt),
|
||||||
|
) {
|
||||||
|
return !Number.isNaN(timeSinceLastFetch) &&
|
||||||
|
seriesFromStorage !== null &&
|
||||||
|
timeSinceLastFetch !== null &&
|
||||||
|
timeSinceLastFetch !== undefined &&
|
||||||
|
timeSinceLastFetch <= syncInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSeries(options: { id: string }): Promise<Series | null> {
|
||||||
const seriesFromStorage = await storage.read(options);
|
const seriesFromStorage = await storage.read(options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,16 +87,8 @@ async function getSeries(options: { id: string }): Promise<Series> {
|
||||||
return await initialFetch(options);
|
return await initialFetch(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeSinceLastFetch =
|
|
||||||
datetime.difference(seriesFromStorage.lastFetchedAt, new Date(), { units: ["hours"] }).hours;
|
|
||||||
|
|
||||||
// we have the feed in storage and it's not too old
|
// we have the feed in storage and it's not too old
|
||||||
if (
|
if (isSeriesFromStorageNew(seriesFromStorage)) {
|
||||||
seriesFromStorage !== null &&
|
|
||||||
timeSinceLastFetch !== null &&
|
|
||||||
timeSinceLastFetch !== undefined &&
|
|
||||||
timeSinceLastFetch <= SYNC_INTERVAL_HOURS
|
|
||||||
) {
|
|
||||||
return seriesFromStorage;
|
return seriesFromStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,3 +103,8 @@ async function getSeries(options: { id: string }): Promise<Series> {
|
||||||
export const caching = {
|
export const caching = {
|
||||||
getSeries,
|
getSeries,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const forTestingOnly = {
|
||||||
|
getTimeSinceLastFetch,
|
||||||
|
isSeriesFromStorageNew,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { assertEquals, assertExists, assertGreaterOrEqual } from "$std/assert/mod.ts";
|
||||||
|
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 empty search query yields `null`", async () => {
|
||||||
|
const result = await nrkRadio.search("");
|
||||||
|
assertEquals(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getting series data for 'trygdekontoret' works", async () => {
|
||||||
|
const result = await forTestingOnly.getSeriesData("trygdekontoret");
|
||||||
|
assertExists(result);
|
||||||
|
assertGreaterOrEqual(result.episodes.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getting series data for 'trygdekontoret' works", async () => {
|
||||||
|
const seriesData = await forTestingOnly.getSeriesData("trygdekontoret");
|
||||||
|
assertExists(seriesData);
|
||||||
|
const result = nrkRadio.parseSeries(seriesData);
|
||||||
|
assertExists(result);
|
||||||
|
assertGreaterOrEqual(result.episodes.length, 1);
|
||||||
|
assertEquals(result.title, "Trygdekontoret");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getting series for 'trygdekontoret' works", async () => {
|
||||||
|
const result = await nrkRadio.getSeries("trygdekontoret");
|
||||||
|
assertExists(result);
|
||||||
|
assertGreaterOrEqual(result.episodes.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Verify getting series data for 'trygd' does not works", async () => {
|
||||||
|
const result = await nrkRadio.getSeries("trygd");
|
||||||
|
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");
|
||||||
|
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);
|
||||||
|
});
|
||||||
150
lib/nrk/nrk.ts
150
lib/nrk/nrk.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import { get, OK } from "https://deno.land/x/kall@v0.1.0/mod.ts";
|
import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.0/mod.ts";
|
||||||
import { components as searchComponents } from "./nrk-search.ts";
|
import { components as searchComponents } from "./nrk-search.ts";
|
||||||
import { components as catalogComponents } from "./nrk-catalog.ts";
|
import { components as catalogComponents } from "./nrk-catalog.ts";
|
||||||
import { external as playbackComponents } from "./nrk-playback.ts";
|
import { external as playbackComponents } from "./nrk-playback.ts";
|
||||||
|
|
@ -8,6 +8,8 @@ type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
||||||
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
|
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||||
type Podcast = catalogComponents["schemas"]["SeriesHalResource"];
|
type Podcast = catalogComponents["schemas"]["SeriesHalResource"];
|
||||||
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
||||||
|
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||||
|
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
|
||||||
|
|
||||||
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
||||||
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
||||||
|
|
@ -16,21 +18,19 @@ export type NrkSearchResultList = searchComponents["schemas"]["seriesResult"]["r
|
||||||
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
export type SeriesData = { episodes: NrkOriginalEpisode[] } & (RadioSeries["series"] | Podcast["series"]);
|
||||||
|
|
||||||
type Manifest = playbackComponents["schemas/playback-channel.json"]["components"]["schemas"]["PlayableManifest"];
|
function parseSeries(nrkSeriesData: SeriesData): Series {
|
||||||
type NrkSerieData = Awaited<ReturnType<typeof nrkRadio.getSeries>>;
|
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
|
||||||
|
|
||||||
function parseSeries(nrkSeries: NrkSerieData): Series {
|
|
||||||
const imageUrl = nrkSeries.squareImage?.at(-1)?.url ?? "";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nrkSeries.id,
|
id: nrkSeriesData.id,
|
||||||
title: nrkSeries.titles.title,
|
title: nrkSeriesData.titles.title,
|
||||||
subtitle: nrkSeries.titles.subtitle ?? null,
|
subtitle: nrkSeriesData.titles.subtitle ?? null,
|
||||||
link: `https://radio.nrk.no/podkast/${nrkSeries.id}`,
|
link: `https://radio.nrk.no/podkast/${nrkSeriesData.id}`,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
episodes: nrkSeries.episodes.map((episode) => {
|
episodes: nrkSeriesData.episodes.map((episode) => {
|
||||||
return {
|
return {
|
||||||
id: episode.id,
|
id: episode.id,
|
||||||
title: episode.titles.title,
|
title: episode.titles.title,
|
||||||
|
|
@ -46,42 +46,26 @@ function parseSeries(nrkSeries: NrkSerieData): Series {
|
||||||
|
|
||||||
const nrkAPI = `https://psapi.nrk.no`;
|
const nrkAPI = `https://psapi.nrk.no`;
|
||||||
|
|
||||||
async function withDownloadLink(
|
async function search(query: string): Promise<NrkSearchResultList | null> {
|
||||||
episode: PodcastEpisodesSingle,
|
if (query === "") {
|
||||||
type: catalogComponents["schemas"]["Type"],
|
console.error("Empty search query.");
|
||||||
): Promise<NrkOriginalEpisode> {
|
return null;
|
||||||
// getting stream link
|
|
||||||
let [playbackStatus, playbackResponse] = await get<Manifest>(
|
|
||||||
`${nrkAPI}/playback/manifest/podcast/${episode.episodeId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (type === "series") {
|
|
||||||
[playbackStatus, playbackResponse] = await get<Manifest>(
|
|
||||||
`${nrkAPI}/playback/manifest/program/${episode.episodeId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const { status, body } = await get<
|
||||||
if (playbackStatus === OK && playbackResponse) {
|
|
||||||
return { ...episode, url: playbackResponse.playable.assets[0].url };
|
|
||||||
} else {
|
|
||||||
throw `Error getting downloadLink for ${episode.episodeId}, serie: ${episode.originalTitle}. Status: ${playbackStatus}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const nrkRadio = {
|
|
||||||
search: async (query: string): Promise<NrkSearchResultList> => {
|
|
||||||
const [status, response] = await get<
|
|
||||||
searchComponents["schemas"]["searchresult"]
|
searchComponents["schemas"]["searchresult"]
|
||||||
>(`${nrkAPI}/radio/search/search?q=${query}`);
|
>(`${nrkAPI}/radio/search/search?q=${query}`);
|
||||||
if (status === OK && response) {
|
if (status === STATUS_CODE.OK && body) {
|
||||||
return response.results.series?.results;
|
return body.results.series?.results;
|
||||||
}
|
}
|
||||||
throw `Something went wrong with ${query} - got status ${status}`;
|
|
||||||
},
|
console.error(`Something went wrong with ${query} - got status ${status}`);
|
||||||
getSeries: async (seriesId: string) => {
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||||
let [
|
let [
|
||||||
[episodeStatus, episodeResponse],
|
{ status: episodeStatus, body: episodeResponse },
|
||||||
[seriesStatus, serieResponse],
|
{ status: seriesStatus, body: serieResponse },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
get<PodcastEpisodes>(
|
get<PodcastEpisodes>(
|
||||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
||||||
|
|
@ -91,44 +75,92 @@ export const nrkRadio = {
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (episodeStatus !== OK || seriesStatus !== OK) {
|
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
|
||||||
[
|
[
|
||||||
[episodeStatus, episodeResponse],
|
{ status: episodeStatus, body: episodeResponse },
|
||||||
[seriesStatus, serieResponse],
|
{ status: seriesStatus, body: serieResponse },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
get<catalogComponents["schemas"]["EpisodesHalResource"]>(
|
get<RadioSeriesEpisode>(
|
||||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
||||||
),
|
),
|
||||||
get<catalogComponents["schemas"]["SeriesHalResource"]>(
|
get<RadioSeries>(
|
||||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
episodeStatus === OK && seriesStatus === OK &&
|
episodeStatus === STATUS_CODE.OK && seriesStatus === STATUS_CODE.OK &&
|
||||||
serieResponse?.series && episodeResponse?._embedded.episodes?.length
|
serieResponse?.series && episodeResponse?._embedded.episodes?.length
|
||||||
) {
|
) {
|
||||||
const episodes = await Promise.all(
|
const episodes = await Promise.all(
|
||||||
episodeResponse._embedded.episodes.map((episode) => withDownloadLink(episode, serieResponse.type)),
|
episodeResponse._embedded.episodes.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)),
|
||||||
);
|
);
|
||||||
|
const seriesData = {
|
||||||
const serieData = {
|
|
||||||
...serieResponse.series,
|
...serieResponse.series,
|
||||||
episodes,
|
episodes,
|
||||||
};
|
};
|
||||||
|
return seriesData;
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const parsedSeries = parseSeries(serieData);
|
async function getSeries(seriesId: string): Promise<Series | null> {
|
||||||
|
const seriesData = await getSeriesData(seriesId);
|
||||||
|
if (!seriesData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsedSeries = parseSeries(seriesData);
|
||||||
return parsedSeries;
|
return parsedSeries;
|
||||||
}
|
}
|
||||||
throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`;
|
|
||||||
},
|
async function getEpisode(seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> {
|
||||||
getEpisode: async (seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> => {
|
|
||||||
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
||||||
const [status, episode] = await get<NrkPodcastEpisode>(url);
|
const { status, body: episode } = await get<NrkPodcastEpisode>(url);
|
||||||
if (status === OK && episode) {
|
if (status === STATUS_CODE.OK && episode) {
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
throw `Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`;
|
console.error(`Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`);
|
||||||
},
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manifest = playbackComponents["schemas/playback-channel.json"]["components"]["schemas"]["PlayableManifest"];
|
||||||
|
|
||||||
|
async function getEpisodeWithDownloadLink(
|
||||||
|
episode: PodcastEpisodesSingle,
|
||||||
|
type: catalogComponents["schemas"]["Type"],
|
||||||
|
): Promise<NrkOriginalEpisode> {
|
||||||
|
// getting stream link
|
||||||
|
let { status: playbackStatus, body: playbackResponse } = await get<Manifest>(
|
||||||
|
`${nrkAPI}/playback/manifest/podcast/${episode.episodeId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === "series") {
|
||||||
|
const { status, body } = await get<Manifest>(
|
||||||
|
`${nrkAPI}/playback/manifest/program/${episode.episodeId}`,
|
||||||
|
);
|
||||||
|
playbackStatus = status;
|
||||||
|
playbackResponse = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackStatus !== STATUS_CODE.OK && !playbackResponse) {
|
||||||
|
throw new Error(
|
||||||
|
`Error getting downloadLink for ${episode.episodeId}, serie: ${episode.originalTitle}. Status: ${playbackStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...episode, url: playbackResponse.playable.assets[0].url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nrkRadio = {
|
||||||
|
search,
|
||||||
|
getSeries,
|
||||||
|
getEpisode,
|
||||||
|
parseSeries,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forTestingOnly = {
|
||||||
|
getSeriesData,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { assertEquals, assertExists } from "asserts";
|
import { assertEquals, assertExists } from "$std/assert/mod.ts";
|
||||||
import { testUtils } from "./test-utils.ts";
|
import { testUtils } from "./test-utils.ts";
|
||||||
import { rss } from "./rss.ts";
|
import { rss } from "./rss.ts";
|
||||||
|
import { forTestingOnly } from "./rss.ts";
|
||||||
|
|
||||||
|
// NOTE: Could probably be expanded upon.
|
||||||
|
Deno.test("generate tag for episode", () => {
|
||||||
|
const episode = testUtils.generateEpisode();
|
||||||
|
const tag = forTestingOnly.assembleEpisode(episode, "someId");
|
||||||
|
assertExists(tag);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("generated rss contains the series title", () => {
|
Deno.test("generated rss contains the series title", () => {
|
||||||
const series = testUtils.generateSeries();
|
const series = testUtils.generateSeries();
|
||||||
|
|
|
||||||
18
lib/rss.ts
18
lib/rss.ts
|
|
@ -1,8 +1,8 @@
|
||||||
import { declaration, serialize, tag } from "serialize-xml";
|
import { declaration, serialize, Tag, tag } from "serialize-xml";
|
||||||
import { getHostName } from "../utils.ts";
|
import { getHostName } from "./utils.ts";
|
||||||
import { Episode, Series } from "./storage.ts";
|
import { Episode, Series } from "./storage.ts";
|
||||||
|
|
||||||
function assembleFeed(series: Series) {
|
function assembleFeed(series: Series): string {
|
||||||
// Originally adapted from https://raw.githubusercontent.com/olaven/paperpod/1cde9abd3174b26e126aa74fc5a3b63fd078c0fd/packages/converter/src/rss.ts
|
// Originally adapted from https://raw.githubusercontent.com/olaven/paperpod/1cde9abd3174b26e126aa74fc5a3b63fd078c0fd/packages/converter/src/rss.ts
|
||||||
return serialize(
|
return serialize(
|
||||||
declaration([
|
declaration([
|
||||||
|
|
@ -44,7 +44,7 @@ function assembleFeed(series: Series) {
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...series.episodes.map((episode) => assembleEpisode({ series, episode })),
|
...series.episodes.map((episode) => assembleEpisode(episode, series.id)),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -57,9 +57,7 @@ function assembleFeed(series: Series) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assembleEpisode(options: { series: Series; episode: Episode }) {
|
function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
|
||||||
const { series, episode } = options;
|
|
||||||
|
|
||||||
const description = episode.subtitle || "";
|
const description = episode.subtitle || "";
|
||||||
|
|
||||||
return tag("item", [
|
return tag("item", [
|
||||||
|
|
@ -73,7 +71,7 @@ function assembleEpisode(options: { series: Series; episode: Episode }) {
|
||||||
tag("podcast:chapters", "", [
|
tag("podcast:chapters", "", [
|
||||||
[
|
[
|
||||||
"url",
|
"url",
|
||||||
`${getHostName()}/api/feeds/${series.id}/${episode.id}/chapters`,
|
`${getHostName()}/api/feeds/${seriesId}/${episode.id}/chapters`,
|
||||||
],
|
],
|
||||||
["type", "application/json+chapters"],
|
["type", "application/json+chapters"],
|
||||||
]),
|
]),
|
||||||
|
|
@ -88,3 +86,7 @@ function assembleEpisode(options: { series: Series; episode: Episode }) {
|
||||||
export const rss = {
|
export const rss = {
|
||||||
assembleFeed,
|
assembleFeed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const forTestingOnly = {
|
||||||
|
assembleEpisode,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { assertEquals } from "asserts";
|
import { assertEquals } from "$std/assert/mod.ts";
|
||||||
import { storage } from "./storage.ts";
|
import { storage } from "./storage.ts";
|
||||||
import { testUtils } from "./test-utils.ts";
|
import { testUtils } from "./test-utils.ts";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { fakerNB_NO as faker } from "npm:@faker-js/faker";
|
import { fakerNB_NO as faker } from "npm:@faker-js/faker";
|
||||||
import { Series } from "./storage.ts";
|
import { Episode, Series } from "./storage.ts";
|
||||||
|
|
||||||
function generateSeries(overrides: Partial<Series> = {}): Series {
|
function generateSeries(overrides: Partial<Series> = {}): Series {
|
||||||
return {
|
return {
|
||||||
|
|
@ -10,11 +10,18 @@ function generateSeries(overrides: Partial<Series> = {}): Series {
|
||||||
imageUrl: faker.image.url(),
|
imageUrl: faker.image.url(),
|
||||||
lastFetchedAt: faker.date.recent(),
|
lastFetchedAt: faker.date.recent(),
|
||||||
episodes: new Array(faker.number.int({ min: 0, max: 100 }))
|
episodes: new Array(faker.number.int({ min: 0, max: 100 }))
|
||||||
.fill(null).map(() => ({
|
.fill(null).map(() => (generateEpisode(overrides))),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateEpisode(overrides: Partial<Series> = {}): Episode {
|
||||||
|
return {
|
||||||
title: faker.word.words(3),
|
title: faker.word.words(3),
|
||||||
subtitle: faker.word.words(3),
|
subtitle: faker.word.words(3),
|
||||||
link: faker.internet.url(),
|
link: faker.internet.url(),
|
||||||
description: faker.lorem.paragraphs(2),
|
url: faker.internet.url(),
|
||||||
|
shareLink: faker.internet.url(),
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
date: faker.date.recent(),
|
date: faker.date.recent(),
|
||||||
durationInSeconds: faker.number.int({
|
durationInSeconds: faker.number.int({
|
||||||
|
|
@ -22,10 +29,10 @@ function generateSeries(overrides: Partial<Series> = {}): Series {
|
||||||
max: 3600,
|
max: 3600,
|
||||||
}),
|
}),
|
||||||
...overrides,
|
...overrides,
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const testUtils = {
|
export const testUtils = {
|
||||||
generateSeries,
|
generateSeries,
|
||||||
|
generateEpisode,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { STATUS_CODE } from "$fresh/server.ts";
|
||||||
|
|
||||||
|
export function getHostName() {
|
||||||
|
const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID");
|
||||||
|
if (deploymentId) {
|
||||||
|
return `https://nrss-${deploymentId}.deno.dev`;
|
||||||
|
} else {
|
||||||
|
// assume env
|
||||||
|
return "http://localhost:8000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnumValues<T> = T[keyof T];
|
||||||
|
type Status = EnumValues<typeof STATUS_CODE>;
|
||||||
|
|
||||||
|
export function responseJSON(body: unknown | null, status: Status) {
|
||||||
|
return response(body, status, "json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function responseXML(body: unknown | null, status: Status) {
|
||||||
|
return response(body, status, "xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(body: unknown | null, status: number, type: "json" | "xml") {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": `application/${type}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { FreshContext } from "$fresh/server.ts";
|
import { FreshContext, STATUS_CODE } from "$fresh/server.ts";
|
||||||
import { caching } from "../../../lib/caching.ts";
|
import { caching } from "../../../lib/caching.ts";
|
||||||
import { rss } from "../../../lib/rss.ts";
|
import { rss } from "../../../lib/rss.ts";
|
||||||
|
import { responseJSON, responseXML } from "../../../lib/utils.ts";
|
||||||
|
|
||||||
export const handler = async (_req: Request, ctx: FreshContext): Promise<Response> => {
|
export const handler = async (_req: Request, ctx: FreshContext): Promise<Response> => {
|
||||||
const seriesId = ctx.params.seriesId;
|
const seriesId = ctx.params.seriesId;
|
||||||
|
|
||||||
const series = await caching.getSeries({ id: seriesId });
|
const series = await caching.getSeries({ id: seriesId });
|
||||||
|
if (!series) {
|
||||||
|
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
|
||||||
|
}
|
||||||
const feed = rss.assembleFeed(series);
|
const feed = rss.assembleFeed(series);
|
||||||
|
|
||||||
return new Response(feed, {
|
return responseXML(feed, STATUS_CODE.OK);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FreshContext } from "$fresh/server.ts";
|
import { FreshContext, STATUS_CODE } from "$fresh/server.ts";
|
||||||
import { parse, toSeconds } from "https://esm.sh/iso8601-duration@2.1.1";
|
import { parse, toSeconds } from "https://esm.sh/iso8601-duration@2.1.1";
|
||||||
import { NrkPodcastEpisode, nrkRadio } from "../../../../../lib/nrk/nrk.ts";
|
import { NrkPodcastEpisode, nrkRadio } from "../../../../../lib/nrk/nrk.ts";
|
||||||
|
import { responseJSON } from "../../../../../lib/utils.ts";
|
||||||
|
|
||||||
type Chapter = {
|
type Chapter = {
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
|
|
@ -25,12 +26,7 @@ export const handler = async (_req: Request, ctx: FreshContext): Promise<Respons
|
||||||
const episode = await nrkRadio.getEpisode(seriesId, episodeId);
|
const episode = await nrkRadio.getEpisode(seriesId, episodeId);
|
||||||
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
return new Response(JSON.stringify({ message: `Episode ${episodeId} is missing` }), {
|
return responseJSON({ message: `Episode ${episodeId} is missing` }, STATUS_CODE.NotFound);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const chapters = toChapters(episode);
|
const chapters = toChapters(episode);
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -38,9 +34,5 @@ export const handler = async (_req: Request, ctx: FreshContext): Promise<Respons
|
||||||
chapters,
|
chapters,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Response(JSON.stringify(body), {
|
return responseJSON(body, STATUS_CODE.OK);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,27 +7,26 @@ import SeriesCard from "../components/SeriesCard.tsx";
|
||||||
import { CSS, render } from "$gfm";
|
import { CSS, render } from "$gfm";
|
||||||
import { nrkRadio, NrkSearchResultList } from "../lib/nrk/nrk.ts";
|
import { nrkRadio, NrkSearchResultList } from "../lib/nrk/nrk.ts";
|
||||||
|
|
||||||
interface HandlerData {
|
type Props = {
|
||||||
query: string;
|
query: string | null;
|
||||||
origin: string;
|
|
||||||
rawMarkdown: string;
|
rawMarkdown: string;
|
||||||
result?: NrkSearchResultList;
|
result?: NrkSearchResultList | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const handler: Handlers<HandlerData> = {
|
export const handler: Handlers<Props> = {
|
||||||
async GET(request, ctx) {
|
async GET(request, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const query = url.searchParams.get("query");
|
const query = url.searchParams.get("query");
|
||||||
let result: NrkSearchResultList | undefined;
|
let result: Props["result"];
|
||||||
if (query) {
|
if (query) {
|
||||||
result = await nrkRadio.search(query);
|
result = await nrkRadio.search(query);
|
||||||
}
|
}
|
||||||
const rawMarkdown = await Deno.readTextFile(new URL("../docs/what.md", import.meta.url));
|
const rawMarkdown = await Deno.readTextFile(new URL("../docs/what.md", import.meta.url));
|
||||||
return ctx.render({ query: query || "", result, origin: url.origin, rawMarkdown });
|
return ctx.render({ query, result, rawMarkdown });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home({ data }: PageProps<HandlerData>) {
|
export default function Home({ data, url }: PageProps<Props>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
|
@ -46,7 +45,7 @@ export default function Home({ data }: PageProps<HandlerData>) {
|
||||||
<div className="p-4 mx-auto max-w-screen-md">
|
<div className="p-4 mx-auto max-w-screen-md">
|
||||||
<Header />
|
<Header />
|
||||||
<Search defaultValue={data.query} />
|
<Search defaultValue={data.query} />
|
||||||
<SearchResult result={data.result} origin={data.origin} />
|
<SearchResult result={data.result} origin={url.origin} />
|
||||||
<div
|
<div
|
||||||
class="markdown-body"
|
class="markdown-body"
|
||||||
dangerouslySetInnerHTML={{ __html: render(data?.rawMarkdown) }}
|
dangerouslySetInnerHTML={{ __html: render(data?.rawMarkdown) }}
|
||||||
|
|
@ -57,7 +56,7 @@ export default function Home({ data }: PageProps<HandlerData>) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResult({ result, origin }: { result: NrkSearchResultList; origin: string }) {
|
function SearchResult({ result, origin }: { result: Props["result"]; origin: string }) {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue