Inital implementation

This commit is contained in:
olaven 2024-04-07 11:03:59 +02:00
parent b632ea4f95
commit bfadcae817
12 changed files with 254 additions and 222 deletions

View File

@ -1,5 +1,5 @@
import { SearchResult } from "../lib/nrk.ts";
import CopyButton from "../islands/CopyButton.tsx"; import CopyButton from "../islands/CopyButton.tsx";
import { SearchResult } from "../lib/nrk/nrk.ts";
export default function SeriesCard(props: { serie: SearchResult; origin: string }) { export default function SeriesCard(props: { serie: SearchResult; origin: string }) {
const feedUrl = new URL(`/api/feeds/${props.serie.seriesId}`, props.origin); const feedUrl = new URL(`/api/feeds/${props.serie.seriesId}`, props.origin);

View File

@ -1,28 +1,83 @@
import * as datetime from "datetime" import { nrkRadio } from "./nrk/nrk.ts";
import { Series } from "./storage.ts" import { parse } from "./parse.ts";
import { Series, storage } from "./storage.ts";
import * as datetime from "datetime";
const UPDATE_INTERVAL_HOURS = 1; const SYNC_INTERVAL_HOURS = 1;
function getAction(options: {
existingSeries: Series | null,
}) {
const { existingSeries } = options;
if (existingSeries === null) {
return "INITIAL_FETCH";
}
const timeSinceLastFetch = datetime.difference( async function initialFetch(options: { id: string }) {
new Date(), const fromNrk = await nrkRadio.getSerieData(options.id);
existingSeries.lastFetched, const parsed = parse.series(fromNrk);
{
units: ["hours"],
}
)
if (timeSinceLastFetch.hours && const stored = storage.write(parsed);
timeSinceLastFetch.hours > UPDATE_INTERVAL_HOURS) { if (!stored) {
return "UPDATE"; throw new Error(`Failed to store series ${options.id}`);
} }
return "RETURN_AS_IS"; return parsed;
} }
async function updateFetch(existingSeries: Series) {
const fromNrk = await nrkRadio.getSerieData(existingSeries.id);
const parsed = parse.series(fromNrk);
const newEpisodes = parsed.episodes.filter((episode) => {
return !existingSeries.episodes.find((serieEpisode) => serieEpisode.id === episode.id);
});
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),
};
const updateSuccessful = await storage.write(updated);
if (!updateSuccessful) {
throw new Error(`Failed to update series ${existingSeries.id}`);
}
return updated;
}
async function getSeries(options: { id: string }): Promise<Series> {
const seriesFromStorage = await storage.read(options);
/**
* We don't have the feed in storage,
* and we need to fetch it for the first time.
*/
if (seriesFromStorage === null) {
// TODO: schedule a job to fetch the entire backlog https://github.com/olaven/NRSS/issues/24
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
) {
return seriesFromStorage;
}
/**
* We have the feed in storage, but it's too old
* and needs to be refreshed.
*/
const updatedSeries = await updateFetch(seriesFromStorage);
return updatedSeries;
}
export const caching = {
getSeries,
};

View File

@ -1,31 +1,30 @@
import { nrkRadio } from "./nrk/nrk.ts"; import { nrkRadio } from "./nrk/nrk.ts";
import { Series } from "./storage.ts"; import { Series } from "./storage.ts";
type NrkSerieData = Awaited<ReturnType<typeof nrkRadio.getSerieData>> type NrkSerieData = Awaited<ReturnType<typeof nrkRadio.getSerieData>>;
function series(nrkSeries: NrkSerieData): Series { function series(nrkSeries: NrkSerieData): Series {
const imageUrl = nrkSeries.squareImage?.at(-1)?.url ?? "";
const imageUrl = nrkSeries.squareImage?.at(-1)?.url ?? ""; return {
id: nrkSeries.id,
return { title: nrkSeries.titles.title,
id: nrkSeries.id, subtitle: nrkSeries.titles.subtitle ?? null,
title: nrkSeries.titles.title, link: `https://radio.nrk.no/podkast/${nrkSeries.id}`,
subtitle: nrkSeries.titles.subtitle ?? null, imageUrl: imageUrl,
link: `https://radio.nrk.no/podkast/${nrkSeries.id}`, lastFetchedAt: new Date(),
imageUrl: imageUrl, episodes: nrkSeries.episodes.map((episode) => {
lastFetched: new Date(), return {
episodes: nrkSeries.episodes.map(episode => { id: episode.id,
return { title: episode.titles.title,
id: episode.id, subtitle: episode.titles.subtitle ?? null,
title: episode.titles.title, link: episode._links.share?.href ?? "",
subtitle: episode.titles.subtitle ?? null, date: new Date(episode.date),
link: episode._links.share?.href ?? "", durationInSeconds: episode.durationInSeconds,
date: new Date(episode.date), };
durationInSeconds: episode.durationInSeconds, }),
} };
}),
}
} }
export const parse = { export const parse = {
series, series,
} };

View File

@ -2,22 +2,18 @@ import { assertEquals, assertExists } from "asserts";
import { testUtils } from "./test-utils.ts"; import { testUtils } from "./test-utils.ts";
import { rss } from "./rss.ts"; import { rss } from "./rss.ts";
Deno.test("generated rss contains the series title", () => { Deno.test("generated rss contains the series title", () => {
const series = testUtils.generateSeries();
const series = testUtils.generateSeries(); const feed = rss.assembleFeed(series);
const feed = rss.assembleFeed(series); assertExists(feed);
assertExists(feed) assertEquals(feed.includes(series.title), true);
assertEquals(feed.includes(series.title), true)
}); });
Deno.test("generated rss contains all episode titles", () => { Deno.test("generated rss contains all episode titles", () => {
const series = testUtils.generateSeries();
const series = testUtils.generateSeries(); const feed = rss.assembleFeed(series);
const feed = rss.assembleFeed(series); assertExists(feed);
assertExists(feed) series.episodes.forEach((episode) => {
series.episodes.forEach(episode => { assertEquals(feed.includes(episode.title), true);
assertEquals(feed.includes(episode.title), true) });
}) });
})

View File

@ -3,91 +3,88 @@ 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) {
// 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([ ["version", "1.0"],
["version", "1.0"], ["encoding", "UTF-8"],
["encoding", "UTF-8"], ]),
tag(
"rss",
[
tag("channel", [
tag("title", series.title),
tag("link", series.link),
tag("itunes:author", "NRK"),
/**
* serie.category.id does not overlap with Apple's supported categories..
* These podcast feeds are not going to be indexed in itunes anyways, so
* a static, valid category is fine. The point is simply to pass third party
* podcast feed validation.
*/
tag("itunes:category", "", [["text", "Government"]]),
tag("itunes:owner", [
tag("itunes:name", "NRK"),
tag("itunes:email", "nrkpodcast@nrk.no"),
]),
tag(
"description",
series.subtitle || "",
),
tag("ttl", "60"), //60 minutes
...(series.imageUrl
? [
tag("itunes:image", "", [
["href", series.imageUrl],
]),
tag("image", [
tag("url", series.imageUrl),
tag("title", series.title),
tag("link", series.link),
]),
]
: []),
...series.episodes.map((episode) => assembleEpisode({ series, episode })),
]), ]),
tag( ],
"rss", [
[ ["version", "2.0"],
tag("channel", [ ["xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"],
tag("title", series.title), ["xmlns:content", "http://purl.org/rss/1.0/modules/content/"],
tag("link", series.link), ["xmlns:podcast", "https://podcastindex.org/namespace/1.0"],
tag("itunes:author", "NRK"), ],
/** ),
* serie.category.id does not overlap with Apple's supported categories.. );
* These podcast feeds are not going to be indexed in itunes anyways, so
* a static, valid category is fine. The point is simply to pass third party
* podcast feed validation.
*/
tag("itunes:category", "", [["text", "Government"]]),
tag("itunes:owner", [
tag("itunes:name", "NRK"),
tag("itunes:email", "nrkpodcast@nrk.no"),
]),
tag(
"description",
series.subtitle || "",
),
tag("ttl", "60"), //60 minutes
...(series.imageUrl
? [
tag("itunes:image", "", [
["href", series.imageUrl],
]),
tag("image", [
tag("url", series.imageUrl),
tag("title", series.title),
tag("link", series.link),
]),
]
: []),
...series.episodes.map((episode) => assembleEpisode({ series, episode })),
]),
],
[
["version", "2.0"],
["xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"],
["xmlns:content", "http://purl.org/rss/1.0/modules/content/"],
["xmlns:podcast", "https://podcastindex.org/namespace/1.0"],
],
),
);
} }
function assembleEpisode(options: { series: Series, episode: Episode }) { function assembleEpisode(options: { series: Series; episode: Episode }) {
const { series, episode } = options;
const { series, episode } = options; const description = episode.subtitle || "";
const description = episode.subtitle || ""; return tag("item", [
tag("title", episode.title),
return tag("item", [ tag("link", episode.link),
tag("title", episode.title), tag("description", description),
tag("link", episode.link), tag("itunes:summary", description),
tag("description", description), tag("guid", episode.id, [["isPermaLink", "false"]]),
tag("itunes:summary", description), tag("pubDate", new Date(episode.date).toUTCString()),
tag("guid", episode.id, [["isPermaLink", "false"]]), tag("itunes:duration", episode.durationInSeconds.toString()),
tag("pubDate", new Date(episode.date).toUTCString()), tag("podcast:chapters", "", [
tag("itunes:duration", episode.durationInSeconds.toString()), [
tag("podcast:chapters", "", [ "url",
[ `${getHostName()}/api/feeds/${series.id}/${episode.id}/chapters`,
"url", ],
`${getHostName()}/api/feeds/${series.id}/${episode.id}/chapters`, ["type", "application/json+chapters"],
], ]),
["type", "application/json+chapters"], tag("enclosure", "", [
]), ["url", episode.link],
tag("enclosure", "", [ ["length", episode.durationInSeconds.toString()],
["url", episode.link], ["type", "audio/mpeg3"],
["length", episode.durationInSeconds.toString()], ]),
["type", "audio/mpeg3"], ]);
]),
]);
} }
export const rss = { export const rss = {
assembleFeed assembleFeed,
} };

View File

@ -2,22 +2,20 @@ import { assertEquals } from "asserts";
import { storage } from "./storage.ts"; import { storage } from "./storage.ts";
import { testUtils } from "./test-utils.ts"; import { testUtils } from "./test-utils.ts";
Deno.test("can store a series", async () => { Deno.test("can store a series", async () => {
const series = testUtils.generateSeries();
await storage.write(series);
const series = testUtils.generateSeries(); const readSeries = await storage.read(series);
await storage.write(series);
const readSeries = await storage.read(series); assertEquals(readSeries, series);
assertEquals(readSeries, series);
}); });
Deno.test("can retrieve a series", async () => { Deno.test("can retrieve a series", async () => {
const series = testUtils.generateSeries(); const series = testUtils.generateSeries();
await storage.write(series); await storage.write(series);
const readSeries = await storage.read(series); const readSeries = await storage.read(series);
assertEquals(readSeries, series); assertEquals(readSeries, series);
}); });

View File

@ -1,42 +1,42 @@
export type Episode = { export type Episode = {
id: string, id: string;
title: string, title: string;
subtitle: string | null; subtitle: string | null;
link: string, link: string;
date: Date, date: Date;
durationInSeconds: number, durationInSeconds: number;
} };
export type Series = { export type Series = {
id: string, id: string;
title: string, title: string;
subtitle: string | null; subtitle: string | null;
link: string, link: string;
imageUrl: string, imageUrl: string;
lastFetched: Date, lastFetchedAt: Date;
episodes: Episode[], episodes: Episode[];
} };
const kv = await Deno.openKv(); const kv = await Deno.openKv();
function seriesKey(series: { id: string }) { function seriesKey(series: { id: string }) {
return ["series", series.id]; return ["series", series.id];
} }
async function read(options: { id: string }): Promise<Series | null> { async function read(options: { id: string }): Promise<Series | null> {
const { id } = options; const { id } = options;
const key = seriesKey({ id }); const key = seriesKey({ id });
const read = await kv.get<Series>(key); const read = await kv.get<Series>(key);
return read.value; return read.value;
} }
async function write(series: Series): Promise<boolean> { async function write(series: Series): Promise<boolean> {
const key = seriesKey(series); const key = seriesKey(series);
const stored = await kv.set(key, series); const stored = await kv.set(key, series);
return stored.ok; return stored.ok;
} }
export const storage = { export const storage = {
read, read,
write, write,
} };

View File

@ -1,30 +1,31 @@
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 { Series } from "./storage.ts";
function generateSeries(overrides: Partial<Series> = {}): Series { function generateSeries(overrides: Partial<Series> = {}): Series {
return { return {
id: faker.word.words(1), id: faker.word.words(1),
title: faker.word.words(3),
subtitle: faker.word.words(3),
link: faker.internet.url(),
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), title: faker.word.words(3),
subtitle: faker.word.words(3), subtitle: faker.word.words(3),
link: faker.internet.url(), link: faker.internet.url(),
imageUrl: faker.image.url(), description: faker.lorem.paragraphs(2),
lastFetched: faker.date.recent(), id: faker.string.uuid(),
episodes: new Array(faker.number.int({ min: 0, max: 100 })) date: faker.date.recent(),
.fill(null).map(() => ({ durationInSeconds: faker.number.int({
title: faker.word.words(3), min: 0,
subtitle: faker.word.words(3), max: 3600,
link: faker.internet.url(), }),
description: faker.lorem.paragraphs(2), ...overrides,
id: faker.string.uuid(), })),
date: faker.date.recent(), };
durationInSeconds: faker.number.int({
min: 0, max: 3600
}),
...overrides,
}))
}
} }
export const testUtils = { export const testUtils = {
generateSeries, generateSeries,
} };

View File

@ -1,30 +1,16 @@
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { storage } from "../../../lib/storage.ts"; import { caching } from "../../../lib/caching.ts";
import { rss } from "../../../lib/rss.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 storedSeries = await storage.read({ id: seriesId }); const series = await caching.getSeries({ id: seriesId });
if (storedSeries === null) { const feed = rss.assembleFeed(series);
return new Response(feed, {
// TODO: fetch for the first time
// const convert and store
}
if (datetime)
const feedContent = await buildFeed(seriesId);
return new Response(feedContent, {
headers: { headers: {
"Content-Type": "application/xml", "Content-Type": "application/xml",
}, },
}) });
}; };

View File

@ -1,6 +1,6 @@
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } 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 { nrkRadio, PodcastEpisode } from "../../../../../lib/nrk.ts"; import { nrkRadio, PodcastEpisode } from "../../../../../lib/nrk/nrk.ts";
type Chapter = { type Chapter = {
title: string | undefined; title: string | undefined;

View File

@ -4,8 +4,8 @@ import Footer from "../components/Footer.tsx";
import Header from "../components/Header.tsx"; import Header from "../components/Header.tsx";
import Search from "../components/Search.tsx"; import Search from "../components/Search.tsx";
import SeriesCard from "../components/SeriesCard.tsx"; import SeriesCard from "../components/SeriesCard.tsx";
import { nrkRadio, SearchResultList } from "../lib/nrk.ts";
import { CSS, render } from "$gfm"; import { CSS, render } from "$gfm";
import { nrkRadio, SearchResultList } from "../lib/nrk/nrk.ts";
interface HandlerData { interface HandlerData {
query: string; query: string;