feat: Add unit-tests (#25)

This commit is contained in:
Tim Hårek Andreassen 2024-04-11 21:10:16 +02:00 committed by GitHub
parent 0c2baf356b
commit 33dbdfefcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 384 additions and 173 deletions

18
.github/workflows/test.yaml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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;

51
lib/caching.test.ts Normal file
View File

@ -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);
});

View File

@ -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,
};

52
lib/nrk/nrk.test.ts Normal file
View File

@ -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);
});

View File

@ -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,
}; };

View File

@ -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();

View File

@ -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,
};

View File

@ -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";

View File

@ -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,
}; };

31
lib/utils.ts Normal file
View File

@ -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}`,
},
});
}

View File

@ -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",
},
});
}; };

View File

@ -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",
},
});
}; };

View File

@ -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;
} }

View File

@ -1,9 +0,0 @@
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";
}
}