Inital implementation
This commit is contained in:
parent
b632ea4f95
commit
bfadcae817
|
|
@ -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);
|
||||||
|
|
|
||||||
101
lib/caching.ts
101
lib/caching.ts
|
|
@ -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,
|
async function initialFetch(options: { id: string }) {
|
||||||
}) {
|
const fromNrk = await nrkRadio.getSerieData(options.id);
|
||||||
const { existingSeries } = options;
|
const parsed = parse.series(fromNrk);
|
||||||
if (existingSeries === null) {
|
|
||||||
return "INITIAL_FETCH";
|
const stored = storage.write(parsed);
|
||||||
|
if (!stored) {
|
||||||
|
throw new Error(`Failed to store series ${options.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeSinceLastFetch = datetime.difference(
|
return parsed;
|
||||||
new Date(),
|
|
||||||
existingSeries.lastFetched,
|
|
||||||
{
|
|
||||||
units: ["hours"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (timeSinceLastFetch.hours &&
|
|
||||||
timeSinceLastFetch.hours > UPDATE_INTERVAL_HOURS) {
|
|
||||||
return "UPDATE";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "RETURN_AS_IS";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
13
lib/parse.ts
13
lib/parse.ts
|
|
@ -1,9 +1,8 @@
|
||||||
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 {
|
return {
|
||||||
|
|
@ -12,8 +11,8 @@ function series(nrkSeries: NrkSerieData): Series {
|
||||||
subtitle: nrkSeries.titles.subtitle ?? null,
|
subtitle: nrkSeries.titles.subtitle ?? null,
|
||||||
link: `https://radio.nrk.no/podkast/${nrkSeries.id}`,
|
link: `https://radio.nrk.no/podkast/${nrkSeries.id}`,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
lastFetched: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
episodes: nrkSeries.episodes.map(episode => {
|
episodes: nrkSeries.episodes.map((episode) => {
|
||||||
return {
|
return {
|
||||||
id: episode.id,
|
id: episode.id,
|
||||||
title: episode.titles.title,
|
title: episode.titles.title,
|
||||||
|
|
@ -21,11 +20,11 @@ function series(nrkSeries: NrkSerieData): Series {
|
||||||
link: episode._links.share?.href ?? "",
|
link: episode._links.share?.href ?? "",
|
||||||
date: new Date(episode.date),
|
date: new Date(episode.date),
|
||||||
durationInSeconds: episode.durationInSeconds,
|
durationInSeconds: episode.durationInSeconds,
|
||||||
}
|
};
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parse = {
|
export const parse = {
|
||||||
series,
|
series,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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([
|
||||||
|
|
@ -58,8 +57,7 @@ function assembleFeed(series: Series) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 || "";
|
||||||
|
|
@ -87,7 +85,6 @@ function assembleEpisode(options: { series: Series, episode: Episode }) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const rss = {
|
export const rss = {
|
||||||
assembleFeed
|
assembleFeed,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ 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();
|
const series = testUtils.generateSeries();
|
||||||
await storage.write(series);
|
await storage.write(series);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
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();
|
||||||
|
|
||||||
|
|
@ -39,4 +39,4 @@ async function write(series: Series): Promise<boolean> {
|
||||||
export const storage = {
|
export const storage = {
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
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 {
|
||||||
|
|
@ -8,7 +8,7 @@ function generateSeries(overrides: Partial<Series> = {}): Series {
|
||||||
subtitle: faker.word.words(3),
|
subtitle: faker.word.words(3),
|
||||||
link: faker.internet.url(),
|
link: faker.internet.url(),
|
||||||
imageUrl: faker.image.url(),
|
imageUrl: faker.image.url(),
|
||||||
lastFetched: 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(() => ({
|
||||||
title: faker.word.words(3),
|
title: faker.word.words(3),
|
||||||
|
|
@ -18,13 +18,14 @@ function generateSeries(overrides: Partial<Series> = {}): Series {
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
date: faker.date.recent(),
|
date: faker.date.recent(),
|
||||||
durationInSeconds: faker.number.int({
|
durationInSeconds: faker.number.int({
|
||||||
min: 0, max: 3600
|
min: 0,
|
||||||
|
max: 3600,
|
||||||
}),
|
}),
|
||||||
...overrides,
|
...overrides,
|
||||||
}))
|
})),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const testUtils = {
|
export const testUtils = {
|
||||||
generateSeries,
|
generateSeries,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue