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 (
|
||||
<form className="flex flex-col w-full lg:w-2/3 mx-auto my-4">
|
||||
<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"
|
||||
name="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">
|
||||
Søk
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"tasks": {
|
||||
"start": "deno run -A --watch=static/,routes/ dev.ts",
|
||||
"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": [
|
||||
"kv"
|
||||
|
|
@ -20,9 +21,8 @@
|
|||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
|
||||
"$gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
|
||||
"openapi-typescript": "npm:openapi-typescript@6.7.5",
|
||||
"datetime": "https://deno.land/std@0.221.0/datetime/mod.ts",
|
||||
"asserts": "https://deno.land/std@0.221.0/assert/mod.ts",
|
||||
"serialize-xml": "https://raw.githubusercontent.com/olaven/serialize-xml/v0.4.0/mod.ts"
|
||||
"serialize-xml": "https://raw.githubusercontent.com/olaven/serialize-xml/v0.4.0/mod.ts",
|
||||
"$std/": "https://deno.land/std@0.221.0/"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ import openapiTS from "openapi-typescript";
|
|||
|
||||
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"),
|
||||
},
|
||||
{
|
||||
filename: "./lib/nrk-catalog.ts",
|
||||
filename: "./lib/nrk/nrk-catalog.ts",
|
||||
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"),
|
||||
},
|
||||
] 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 { 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;
|
||||
|
||||
async function initialFetch(options: { id: string }) {
|
||||
async function initialFetch(options: { id: string }): Promise<Series | null> {
|
||||
const series = await nrkRadio.getSeries(options.id);
|
||||
if (!series) {
|
||||
return null;
|
||||
}
|
||||
const stored = storage.write(series);
|
||||
if (!stored) {
|
||||
throw new Error(`Failed to store series ${options.id}`);
|
||||
console.error(`Failed to store series ${options.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!series) {
|
||||
return null;
|
||||
}
|
||||
const newEpisodes = series.episodes.filter((episode) => {
|
||||
return !existingSeries.episodes.find((serieEpisode) => serieEpisode.id === episode.id);
|
||||
});
|
||||
|
|
@ -24,27 +36,46 @@ async function updateFetch(existingSeries: Series) {
|
|||
return existingSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we don't control the API,
|
||||
* we should not make assumptions about the order,
|
||||
* but rather sort the episode to what we want.
|
||||
*/
|
||||
const episodesSortedDescending = [...newEpisodes, ...existingSeries.episodes]
|
||||
.sort((a, b) => a.date.getTime() > b.date.getTime() ? -1 : 1);
|
||||
|
||||
const updated = {
|
||||
...existingSeries,
|
||||
lastFetch: new Date(),
|
||||
/**
|
||||
* Since we don't control the API,
|
||||
* we should not make assumptions about the order,
|
||||
* but rather sort the episode to what we want.
|
||||
*/
|
||||
episodes: [...newEpisodes, ...existingSeries.episodes]
|
||||
.sort((a, b) => a.date.getTime() > b.date.getTime() ? -1 : 1),
|
||||
episodes: episodesSortedDescending,
|
||||
};
|
||||
|
||||
const updateSuccessful = await storage.write(updated);
|
||||
if (!updateSuccessful) {
|
||||
throw new Error(`Failed to update series ${existingSeries.id}`);
|
||||
console.log(`Failed to update series ${existingSeries.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
|
|
@ -56,16 +87,8 @@ async function getSeries(options: { id: string }): Promise<Series> {
|
|||
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
|
||||
if (
|
||||
seriesFromStorage !== null &&
|
||||
timeSinceLastFetch !== null &&
|
||||
timeSinceLastFetch !== undefined &&
|
||||
timeSinceLastFetch <= SYNC_INTERVAL_HOURS
|
||||
) {
|
||||
if (isSeriesFromStorageNew(seriesFromStorage)) {
|
||||
return seriesFromStorage;
|
||||
}
|
||||
|
||||
|
|
@ -80,3 +103,8 @@ async function getSeries(options: { id: string }): Promise<Series> {
|
|||
export const caching = {
|
||||
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);
|
||||
});
|
||||
192
lib/nrk/nrk.ts
192
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 catalogComponents } from "./nrk-catalog.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 Podcast = catalogComponents["schemas"]["SeriesHalResource"];
|
||||
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
||||
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
|
||||
|
||||
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
||||
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
||||
|
|
@ -16,21 +18,19 @@ export type NrkSearchResultList = searchComponents["schemas"]["seriesResult"]["r
|
|||
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
||||
description?: string;
|
||||
};
|
||||
export type SeriesData = { episodes: NrkOriginalEpisode[] } & (RadioSeries["series"] | Podcast["series"]);
|
||||
|
||||
type Manifest = playbackComponents["schemas/playback-channel.json"]["components"]["schemas"]["PlayableManifest"];
|
||||
type NrkSerieData = Awaited<ReturnType<typeof nrkRadio.getSeries>>;
|
||||
|
||||
function parseSeries(nrkSeries: NrkSerieData): Series {
|
||||
const imageUrl = nrkSeries.squareImage?.at(-1)?.url ?? "";
|
||||
function parseSeries(nrkSeriesData: SeriesData): Series {
|
||||
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
|
||||
|
||||
return {
|
||||
id: nrkSeries.id,
|
||||
title: nrkSeries.titles.title,
|
||||
subtitle: nrkSeries.titles.subtitle ?? null,
|
||||
link: `https://radio.nrk.no/podkast/${nrkSeries.id}`,
|
||||
id: nrkSeriesData.id,
|
||||
title: nrkSeriesData.titles.title,
|
||||
subtitle: nrkSeriesData.titles.subtitle ?? null,
|
||||
link: `https://radio.nrk.no/podkast/${nrkSeriesData.id}`,
|
||||
imageUrl: imageUrl,
|
||||
lastFetchedAt: new Date(),
|
||||
episodes: nrkSeries.episodes.map((episode) => {
|
||||
episodes: nrkSeriesData.episodes.map((episode) => {
|
||||
return {
|
||||
id: episode.id,
|
||||
title: episode.titles.title,
|
||||
|
|
@ -46,89 +46,121 @@ function parseSeries(nrkSeries: NrkSerieData): Series {
|
|||
|
||||
const nrkAPI = `https://psapi.nrk.no`;
|
||||
|
||||
async function withDownloadLink(
|
||||
async function search(query: string): Promise<NrkSearchResultList | null> {
|
||||
if (query === "") {
|
||||
console.error("Empty search query.");
|
||||
return null;
|
||||
}
|
||||
const { status, body } = await get<
|
||||
searchComponents["schemas"]["searchresult"]
|
||||
>(`${nrkAPI}/radio/search/search?q=${query}`);
|
||||
if (status === STATUS_CODE.OK && body) {
|
||||
return body.results.series?.results;
|
||||
}
|
||||
|
||||
console.error(`Something went wrong with ${query} - got status ${status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||
let [
|
||||
{ status: episodeStatus, body: episodeResponse },
|
||||
{ status: seriesStatus, body: serieResponse },
|
||||
] = await Promise.all([
|
||||
get<PodcastEpisodes>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
||||
),
|
||||
get<Podcast>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}`,
|
||||
),
|
||||
]);
|
||||
|
||||
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
|
||||
[
|
||||
{ status: episodeStatus, body: episodeResponse },
|
||||
{ status: seriesStatus, body: serieResponse },
|
||||
] = await Promise.all([
|
||||
get<RadioSeriesEpisode>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
||||
),
|
||||
get<RadioSeries>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
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 seriesData = {
|
||||
...serieResponse.series,
|
||||
episodes,
|
||||
};
|
||||
return seriesData;
|
||||
}
|
||||
console.error(
|
||||
`Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSeries(seriesId: string): Promise<Series | null> {
|
||||
const seriesData = await getSeriesData(seriesId);
|
||||
if (!seriesData) {
|
||||
return null;
|
||||
}
|
||||
const parsedSeries = parseSeries(seriesData);
|
||||
return parsedSeries;
|
||||
}
|
||||
|
||||
async function getEpisode(seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> {
|
||||
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
||||
const { status, body: episode } = await get<NrkPodcastEpisode>(url);
|
||||
if (status === STATUS_CODE.OK && episode) {
|
||||
return episode;
|
||||
}
|
||||
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 [playbackStatus, playbackResponse] = await get<Manifest>(
|
||||
let { status: playbackStatus, body: playbackResponse } = await get<Manifest>(
|
||||
`${nrkAPI}/playback/manifest/podcast/${episode.episodeId}`,
|
||||
);
|
||||
|
||||
if (type === "series") {
|
||||
[playbackStatus, playbackResponse] = await get<Manifest>(
|
||||
const { status, body } = await get<Manifest>(
|
||||
`${nrkAPI}/playback/manifest/program/${episode.episodeId}`,
|
||||
);
|
||||
playbackStatus = status;
|
||||
playbackResponse = body;
|
||||
}
|
||||
|
||||
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}`;
|
||||
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: async (query: string): Promise<NrkSearchResultList> => {
|
||||
const [status, response] = await get<
|
||||
searchComponents["schemas"]["searchresult"]
|
||||
>(`${nrkAPI}/radio/search/search?q=${query}`);
|
||||
if (status === OK && response) {
|
||||
return response.results.series?.results;
|
||||
}
|
||||
throw `Something went wrong with ${query} - got status ${status}`;
|
||||
},
|
||||
getSeries: async (seriesId: string) => {
|
||||
let [
|
||||
[episodeStatus, episodeResponse],
|
||||
[seriesStatus, serieResponse],
|
||||
] = await Promise.all([
|
||||
get<PodcastEpisodes>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
||||
),
|
||||
get<Podcast>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}`,
|
||||
),
|
||||
]);
|
||||
|
||||
if (episodeStatus !== OK || seriesStatus !== OK) {
|
||||
[
|
||||
[episodeStatus, episodeResponse],
|
||||
[seriesStatus, serieResponse],
|
||||
] = await Promise.all([
|
||||
get<catalogComponents["schemas"]["EpisodesHalResource"]>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
||||
),
|
||||
get<catalogComponents["schemas"]["SeriesHalResource"]>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
episodeStatus === OK && seriesStatus === OK &&
|
||||
serieResponse?.series && episodeResponse?._embedded.episodes?.length
|
||||
) {
|
||||
const episodes = await Promise.all(
|
||||
episodeResponse._embedded.episodes.map((episode) => withDownloadLink(episode, serieResponse.type)),
|
||||
);
|
||||
|
||||
const serieData = {
|
||||
...serieResponse.series,
|
||||
episodes,
|
||||
};
|
||||
|
||||
const parsedSeries = parseSeries(serieData);
|
||||
return parsedSeries;
|
||||
}
|
||||
throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`;
|
||||
},
|
||||
getEpisode: async (seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> => {
|
||||
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
||||
const [status, episode] = await get<NrkPodcastEpisode>(url);
|
||||
if (status === OK && episode) {
|
||||
return episode;
|
||||
}
|
||||
throw `Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`;
|
||||
},
|
||||
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 { 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", () => {
|
||||
const series = testUtils.generateSeries();
|
||||
|
|
|
|||
18
lib/rss.ts
18
lib/rss.ts
|
|
@ -1,8 +1,8 @@
|
|||
import { declaration, serialize, tag } from "serialize-xml";
|
||||
import { getHostName } from "../utils.ts";
|
||||
import { declaration, serialize, Tag, tag } from "serialize-xml";
|
||||
import { getHostName } from "./utils.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
|
||||
return serialize(
|
||||
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 }) {
|
||||
const { series, episode } = options;
|
||||
|
||||
function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
|
||||
const description = episode.subtitle || "";
|
||||
|
||||
return tag("item", [
|
||||
|
|
@ -73,7 +71,7 @@ function assembleEpisode(options: { series: Series; episode: Episode }) {
|
|||
tag("podcast:chapters", "", [
|
||||
[
|
||||
"url",
|
||||
`${getHostName()}/api/feeds/${series.id}/${episode.id}/chapters`,
|
||||
`${getHostName()}/api/feeds/${seriesId}/${episode.id}/chapters`,
|
||||
],
|
||||
["type", "application/json+chapters"],
|
||||
]),
|
||||
|
|
@ -88,3 +86,7 @@ function assembleEpisode(options: { series: Series; episode: Episode }) {
|
|||
export const rss = {
|
||||
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 { testUtils } from "./test-utils.ts";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
return {
|
||||
|
|
@ -10,22 +10,29 @@ function generateSeries(overrides: Partial<Series> = {}): Series {
|
|||
imageUrl: faker.image.url(),
|
||||
lastFetchedAt: faker.date.recent(),
|
||||
episodes: new Array(faker.number.int({ min: 0, max: 100 }))
|
||||
.fill(null).map(() => ({
|
||||
title: faker.word.words(3),
|
||||
subtitle: faker.word.words(3),
|
||||
link: faker.internet.url(),
|
||||
description: faker.lorem.paragraphs(2),
|
||||
id: faker.string.uuid(),
|
||||
date: faker.date.recent(),
|
||||
durationInSeconds: faker.number.int({
|
||||
min: 0,
|
||||
max: 3600,
|
||||
}),
|
||||
...overrides,
|
||||
})),
|
||||
.fill(null).map(() => (generateEpisode(overrides))),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function generateEpisode(overrides: Partial<Series> = {}): Episode {
|
||||
return {
|
||||
title: faker.word.words(3),
|
||||
subtitle: faker.word.words(3),
|
||||
link: faker.internet.url(),
|
||||
url: faker.internet.url(),
|
||||
shareLink: faker.internet.url(),
|
||||
id: faker.string.uuid(),
|
||||
date: faker.date.recent(),
|
||||
durationInSeconds: faker.number.int({
|
||||
min: 0,
|
||||
max: 3600,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const testUtils = {
|
||||
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 { rss } from "../../../lib/rss.ts";
|
||||
import { responseJSON, responseXML } from "../../../lib/utils.ts";
|
||||
|
||||
export const handler = async (_req: Request, ctx: FreshContext): Promise<Response> => {
|
||||
const seriesId = ctx.params.seriesId;
|
||||
|
||||
const series = await caching.getSeries({ id: seriesId });
|
||||
if (!series) {
|
||||
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
|
||||
}
|
||||
const feed = rss.assembleFeed(series);
|
||||
|
||||
return new Response(feed, {
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
},
|
||||
});
|
||||
return responseXML(feed, STATUS_CODE.OK);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { NrkPodcastEpisode, nrkRadio } from "../../../../../lib/nrk/nrk.ts";
|
||||
import { responseJSON } from "../../../../../lib/utils.ts";
|
||||
|
||||
type Chapter = {
|
||||
title: string | undefined;
|
||||
|
|
@ -25,12 +26,7 @@ export const handler = async (_req: Request, ctx: FreshContext): Promise<Respons
|
|||
const episode = await nrkRadio.getEpisode(seriesId, episodeId);
|
||||
|
||||
if (!episode) {
|
||||
return new Response(JSON.stringify({ message: `Episode ${episodeId} is missing` }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
status: 500,
|
||||
});
|
||||
return responseJSON({ message: `Episode ${episodeId} is missing` }, STATUS_CODE.NotFound);
|
||||
}
|
||||
const chapters = toChapters(episode);
|
||||
const body = {
|
||||
|
|
@ -38,9 +34,5 @@ export const handler = async (_req: Request, ctx: FreshContext): Promise<Respons
|
|||
chapters,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return responseJSON(body, STATUS_CODE.OK);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,27 +7,26 @@ import SeriesCard from "../components/SeriesCard.tsx";
|
|||
import { CSS, render } from "$gfm";
|
||||
import { nrkRadio, NrkSearchResultList } from "../lib/nrk/nrk.ts";
|
||||
|
||||
interface HandlerData {
|
||||
query: string;
|
||||
origin: string;
|
||||
type Props = {
|
||||
query: string | null;
|
||||
rawMarkdown: string;
|
||||
result?: NrkSearchResultList;
|
||||
}
|
||||
result?: NrkSearchResultList | null;
|
||||
};
|
||||
|
||||
export const handler: Handlers<HandlerData> = {
|
||||
export const handler: Handlers<Props> = {
|
||||
async GET(request, ctx) {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query");
|
||||
let result: NrkSearchResultList | undefined;
|
||||
let result: Props["result"];
|
||||
if (query) {
|
||||
result = await nrkRadio.search(query);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
|
|
@ -46,7 +45,7 @@ export default function Home({ data }: PageProps<HandlerData>) {
|
|||
<div className="p-4 mx-auto max-w-screen-md">
|
||||
<Header />
|
||||
<Search defaultValue={data.query} />
|
||||
<SearchResult result={data.result} origin={data.origin} />
|
||||
<SearchResult result={data.result} origin={url.origin} />
|
||||
<div
|
||||
class="markdown-body"
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue