Compare commits
4 Commits
cache-resp
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a29575e54e | |
|
|
b2c8662861 | |
|
|
a17798fe64 | |
|
|
e81d59f2aa |
|
|
@ -2,12 +2,15 @@ import { assertEquals, assertExists, assertGreaterOrEqual } from "$std/assert/mo
|
|||
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 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("");
|
||||
|
|
@ -40,13 +43,83 @@ Deno.test("Verify getting series data for 'trygd' does not works", async () => {
|
|||
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");
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
Deno.test("Can get episodes for seriesnakk", async () => {
|
||||
const result = await nrkRadio.getSeries("seriesnakk");
|
||||
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);
|
||||
Deno.test("Seriesnakk, an umbrella series yields all seasons", async () => {
|
||||
const seriesData = await forTestingOnly.getSeriesData("seriesnakk");
|
||||
assertExists(seriesData);
|
||||
assertGreaterOrEqual(seriesData.episodes.length, 1);
|
||||
const titles = seriesData.episodes.map((e) => e.titles.title);
|
||||
|
||||
const thereseTitle = "Therese-saken: Hva kan et vitne huske? (1:5)";
|
||||
assertEquals(
|
||||
titles.includes(thereseTitle),
|
||||
true,
|
||||
`Titles include "${thereseTitle}"`,
|
||||
);
|
||||
|
||||
const maktaTitle = "– Vi er ikke idioter og tror at det var elsparkesykler i 1975";
|
||||
assertEquals(
|
||||
titles.includes(maktaTitle),
|
||||
true,
|
||||
`Titles include "${maktaTitle}"`,
|
||||
);
|
||||
|
||||
const soLongMarianneTitle = "- Se og hør sov på dørmatta hennes! (1:8)";
|
||||
assertEquals(
|
||||
titles.includes(soLongMarianneTitle),
|
||||
true,
|
||||
`Titles include "${soLongMarianneTitle}"`,
|
||||
);
|
||||
|
||||
const nerdrumTitle = "Seriesnakk: Familien Nerdrum";
|
||||
assertEquals(
|
||||
titles.includes(nerdrumTitle),
|
||||
true,
|
||||
`Titles include "${nerdrumTitle}"`,
|
||||
);
|
||||
|
||||
const selinaTitle = "– Som å se meg selv (1:6)";
|
||||
assertEquals(
|
||||
titles.includes(selinaTitle),
|
||||
true,
|
||||
`Titles include "${selinaTitle}"`,
|
||||
);
|
||||
|
||||
const skamTitle = "– William må svare (4:9)";
|
||||
assertEquals(
|
||||
titles.includes(skamTitle),
|
||||
true,
|
||||
`Titles include "${skamTitle}"`,
|
||||
);
|
||||
|
||||
const natoTitle = "– Jeg ville slite ut Jens (1:2)";
|
||||
assertEquals(
|
||||
titles.includes(natoTitle),
|
||||
true,
|
||||
`Titles include "${natoTitle}"`,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.0/mod.ts";
|
||||
import { components as searchComponents } from "./nrk-search.ts";
|
||||
import { Series } from "../storage.ts";
|
||||
import { components as catalogComponents } from "./nrk-catalog.ts";
|
||||
import { external as playbackComponents } from "./nrk-playback.ts";
|
||||
import { Series } from "../storage.ts";
|
||||
import { components as searchComponents } from "./nrk-search.ts";
|
||||
|
||||
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
||||
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||
|
|
@ -10,6 +10,7 @@ type Podcast = catalogComponents["schemas"]["SeriesHalResource"];
|
|||
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
||||
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
|
||||
type SeasonEpisodes = catalogComponents["schemas"]["PodcastSeasonHalResource"];
|
||||
|
||||
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
||||
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
||||
|
|
@ -18,11 +19,15 @@ export type NrkSearchResultList = searchComponents["schemas"]["seriesResult"]["r
|
|||
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
||||
description?: string;
|
||||
};
|
||||
export type SeriesData = { episodes: NrkOriginalEpisode[] } & (RadioSeries["series"] | Podcast["series"]);
|
||||
export type SeriesData =
|
||||
& { episodes: NrkOriginalEpisode[] }
|
||||
& (
|
||||
| RadioSeries["series"]
|
||||
| Podcast["series"]
|
||||
);
|
||||
|
||||
function parseSeries(nrkSeriesData: SeriesData): Series {
|
||||
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
|
||||
|
||||
return {
|
||||
id: nrkSeriesData.id,
|
||||
title: nrkSeriesData.titles.title,
|
||||
|
|
@ -62,6 +67,41 @@ async function search(query: string): Promise<NrkSearchResultList | null> {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function extractEpisodes(
|
||||
serieResponse: RadioSeries,
|
||||
episodeResponse?: PodcastEpisodes,
|
||||
): Promise<NrkOriginalEpisode[]> {
|
||||
if (serieResponse.seriesType === "umbrella") {
|
||||
const seasons = await Promise.all(
|
||||
serieResponse._links.seasons.map(async (season) => {
|
||||
const response = await get<SeasonEpisodes>(
|
||||
`https://psapi.nrk.no${season.href}`,
|
||||
);
|
||||
return response.body;
|
||||
}),
|
||||
);
|
||||
|
||||
const episodes = await Promise.all(
|
||||
seasons.flatMap((season) => {
|
||||
return (
|
||||
season?._embedded.episodes?._embedded.episodes?.flatMap((episode) =>
|
||||
getEpisodeWithDownloadLink(episode, serieResponse.type)
|
||||
) ?? []
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return episodes;
|
||||
} else {
|
||||
const episodes = await Promise.all(
|
||||
episodeResponse?._embedded.episodes?.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)) ??
|
||||
[],
|
||||
);
|
||||
|
||||
return episodes;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||
let [
|
||||
{ status: episodeStatus, body: episodeResponse },
|
||||
|
|
@ -70,9 +110,7 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
|||
get<PodcastEpisodes>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
||||
),
|
||||
get<Podcast>(
|
||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}`,
|
||||
),
|
||||
get<Podcast>(`${nrkAPI}/radio/catalog/podcast/${seriesId}`),
|
||||
]);
|
||||
|
||||
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
|
||||
|
|
@ -83,19 +121,17 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
|||
get<RadioSeriesEpisode>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
||||
),
|
||||
get<RadioSeries>(
|
||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
||||
),
|
||||
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
|
||||
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 episodes = await extractEpisodes(serieResponse, episodeResponse);
|
||||
const seriesData = {
|
||||
...serieResponse.series,
|
||||
episodes,
|
||||
|
|
@ -117,13 +153,18 @@ async function getSeries(seriesId: string): Promise<Series | null> {
|
|||
return parsedSeries;
|
||||
}
|
||||
|
||||
async function getEpisode(seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> {
|
||||
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}`);
|
||||
console.error(
|
||||
`Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,3 +47,12 @@ Deno.test("generated rss contains promo with link to donations page", () => {
|
|||
"First promotion should come before the second",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("first promotion is removed", () => {
|
||||
const series = testUtils.generateSeries();
|
||||
const feed = rss.assembleFeed(series);
|
||||
const indexOfFirstPromotion = feed.indexOf(
|
||||
"NRSS er avhengig av din Vipps-støtte",
|
||||
);
|
||||
assertEquals(indexOfFirstPromotion, -1, "First promotion should be removed");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,11 +53,10 @@ function assembleFeed(series: Series): string {
|
|||
}
|
||||
|
||||
function descriptionWithDonationPromotion(description: string): string {
|
||||
const firstPromotion = `⬇️NRSS er avhengig av din Vipps-støtte⬇️`;
|
||||
const secondPromotion =
|
||||
const promotion =
|
||||
`Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med omtrent det samme som prisen på en kaffekopp. Se mer på https://nrss.deno.dev/`;
|
||||
|
||||
return `${firstPromotion}\n\n${description}\n\n${secondPromotion}`;
|
||||
return `${description}\n\n${promotion}`;
|
||||
}
|
||||
|
||||
function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export function responseXML(body: string, status: Status) {
|
|||
export const withExpiry = <T>(response: Response, ttlInSeconds: number) => {
|
||||
const clonedResponse = response.clone();
|
||||
clonedResponse.headers.set("Cache-Control", `max-age=${ttlInSeconds}`);
|
||||
clonedResponse.headers.set("Expires", new Date(Date.now() + ttlInSeconds * 1000).toUTCString());
|
||||
return clonedResponse;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,23 @@ import { responseJSON, responseXML, withExpiry } from "../../../lib/utils.ts";
|
|||
|
||||
const FEED_CACHE_KEY = "feeds-cache";
|
||||
|
||||
// Deno's cache API does not respect these headers.
|
||||
// Until it does, manual expiry handling is needed.
|
||||
// When it's working, this function would not be needed.
|
||||
// See: https://github.com/denoland/deno/issues/25795
|
||||
function isExpired(headers: Headers) {
|
||||
const expiresHeader = headers.get("Expires");
|
||||
if (!expiresHeader) {
|
||||
console.log("No Expires header found, treating as expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiryDate = new Date(expiresHeader);
|
||||
const expired = now > expiryDate;
|
||||
return expired;
|
||||
}
|
||||
|
||||
export const handler = async (
|
||||
request: Request,
|
||||
ctx: FreshContext,
|
||||
|
|
@ -13,11 +30,13 @@ export const handler = async (
|
|||
|
||||
const cache = await caches.open(FEED_CACHE_KEY);
|
||||
const cachedResponse = await cache.match(request);
|
||||
if (cachedResponse) {
|
||||
|
||||
if (cachedResponse && !isExpired(cachedResponse.headers)) {
|
||||
console.log(`Cache hit for feed ${seriesId}`);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
console.log(`Fetching feed for ${seriesId}`);
|
||||
const series = await caching.getSeries({ id: seriesId });
|
||||
if (!series) {
|
||||
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
|
||||
|
|
@ -28,7 +47,7 @@ export const handler = async (
|
|||
const response = withExpiry(
|
||||
responseXML(feed, STATUS_CODE.OK),
|
||||
// 2 hours
|
||||
60 * 60 * 2,
|
||||
2 * 60 * 60,
|
||||
);
|
||||
|
||||
await cache.put(request, response.clone());
|
||||
|
|
|
|||
Loading…
Reference in New Issue