Compare commits

..

8 Commits

Author SHA1 Message Date
Olav Sundfør a29575e54e
Support for seasons (#48)
Closes https://github.com/olaven/nrss/issues/14.
2025-10-06 14:11:32 +02:00
Olav Sundfør b2c8662861
Workaround for broken cache expiry handling (#47)
Deno's cache API does not respect expiry dates.
This meant that feeds were cached much longer than intended.

This PR implments a manual cache handling instead of relying on
Deno's implementation. Relevant issue: https://github.com/denoland/deno/issues/25795
2025-10-01 09:51:29 +02:00
Olav Sundfør a17798fe64
Remove promotion at start of description (#46)
It's a bit intense.
2025-09-30 13:54:09 +02:00
Olav Sundfør e81d59f2aa
Add caching to feed endpoint (#45)
In order to reduce load on Deno KV as the costs are relatively high.
2025-09-29 21:57:42 +02:00
Olav Sundfør 4e0ea3b324
Don't crash if email address has sub-address with special characters (#44) 2025-06-18 22:52:14 +02:00
Olav Sundfør 0e77c35fb2
FIX: Existing agreement exists, but is out of sync with db 2025-05-26 14:40:48 +02:00
Olav Sundfør 5cf4e2c152 Redeploy Trigger 2025-05-02 09:14:58 +02:00
Olav Sundfør a88e0cda7f
Add a second promo text to the front. (#40)
* Add a second promo text to the front.

NRSS does not have enough supporters to break even and thus costs me
quite a bit every month. However, it seems to have a stable and high
user base, most of whom presumably could pay. As earlier, I'd like to
avoid shoving the service behind a paywall - that feels totally wrong
for what this project is supposed to be. This PR introduces another,
shorter link to donations _before_ the episode description in the
hopes of attracting more paid users. Let's see if it works :)
2025-04-30 16:21:25 +02:00
9 changed files with 269 additions and 53 deletions

View File

@ -2,12 +2,15 @@ import { assertEquals, assertExists, assertGreaterOrEqual } from "$std/assert/mo
import { nrkRadio } from "./nrk.ts"; import { nrkRadio } from "./nrk.ts";
import { forTestingOnly } from "./nrk.ts"; import { forTestingOnly } from "./nrk.ts";
Deno.test("Verify search query `trygd` returns one result: 'Trygdekontoret'", async () => { Deno.test(
const result = await nrkRadio.search("trygd"); "Verify search query `trygd` returns one result: 'Trygdekontoret'",
assertExists(result); async () => {
assertEquals(result.length, 1); const result = await nrkRadio.search("trygd");
assertEquals(result[0].seriesId, "trygdekontoret"); assertExists(result);
}); assertEquals(result.length, 1);
assertEquals(result[0].seriesId, "trygdekontoret");
},
);
Deno.test("Verify empty search query yields `null`", async () => { Deno.test("Verify empty search query yields `null`", async () => {
const result = await nrkRadio.search(""); const result = await nrkRadio.search("");
@ -40,13 +43,83 @@ Deno.test("Verify getting series data for 'trygd' does not works", async () => {
assertEquals(result, null); assertEquals(result, null);
}); });
Deno.test("Verify getting episodeId 'l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562' for 'trygdekontoret' works", async () => { Deno.test(
const result = await nrkRadio.getEpisode("trygdekontoret", "l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562"); "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); assertExists(result);
assertEquals(result.duration.seconds, 4152);
}); });
Deno.test("Verify getting episodeId 'null' for 'trygdekontoret' yields `null`", async () => { Deno.test("Seriesnakk, an umbrella series yields all seasons", async () => {
const result = await nrkRadio.getEpisode("trygdekontoret", "null"); const seriesData = await forTestingOnly.getSeriesData("seriesnakk");
assertEquals(result, null); 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}"`,
);
}); });

View File

@ -1,8 +1,8 @@
import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.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 { Series } from "../storage.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";
import { Series } from "../storage.ts"; import { components as searchComponents } from "./nrk-search.ts";
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"]; type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
@ -10,6 +10,7 @@ type Podcast = catalogComponents["schemas"]["SeriesHalResource"];
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"]; type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"]; type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"]; type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
type SeasonEpisodes = catalogComponents["schemas"]["PodcastSeasonHalResource"];
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"]; export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string }; export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
@ -18,11 +19,15 @@ 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"]); export type SeriesData =
& { episodes: NrkOriginalEpisode[] }
& (
| RadioSeries["series"]
| Podcast["series"]
);
function parseSeries(nrkSeriesData: SeriesData): Series { function parseSeries(nrkSeriesData: SeriesData): Series {
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? ""; const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
return { return {
id: nrkSeriesData.id, id: nrkSeriesData.id,
title: nrkSeriesData.titles.title, title: nrkSeriesData.titles.title,
@ -62,6 +67,41 @@ async function search(query: string): Promise<NrkSearchResultList | null> {
return 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> { async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
let [ let [
{ status: episodeStatus, body: episodeResponse }, { status: episodeStatus, body: episodeResponse },
@ -70,9 +110,7 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
get<PodcastEpisodes>( get<PodcastEpisodes>(
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`, `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
), ),
get<Podcast>( get<Podcast>(`${nrkAPI}/radio/catalog/podcast/${seriesId}`),
`${nrkAPI}/radio/catalog/podcast/${seriesId}`,
),
]); ]);
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) { if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
@ -83,19 +121,17 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
get<RadioSeriesEpisode>( get<RadioSeriesEpisode>(
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`, `https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
), ),
get<RadioSeries>( get<RadioSeries>(`https://psapi.nrk.no/radio/catalog/series/${seriesId}`),
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
),
]); ]);
} }
if ( if (
episodeStatus === STATUS_CODE.OK && seriesStatus === STATUS_CODE.OK && episodeStatus === STATUS_CODE.OK &&
serieResponse?.series && episodeResponse?._embedded.episodes?.length seriesStatus === STATUS_CODE.OK &&
serieResponse?.series &&
episodeResponse?._embedded.episodes?.length
) { ) {
const episodes = await Promise.all( const episodes = await extractEpisodes(serieResponse, episodeResponse);
episodeResponse._embedded.episodes.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)),
);
const seriesData = { const seriesData = {
...serieResponse.series, ...serieResponse.series,
episodes, episodes,
@ -117,13 +153,18 @@ async function getSeries(seriesId: string): Promise<Series | null> {
return parsedSeries; 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 url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
const { status, body: episode } = await get<NrkPodcastEpisode>(url); const { status, body: episode } = await get<NrkPodcastEpisode>(url);
if (status === STATUS_CODE.OK && episode) { if (status === STATUS_CODE.OK && episode) {
return 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; return null;
} }

View File

@ -1,4 +1,4 @@
import { assertEquals, assertExists } from "$std/assert/mod.ts"; import { assertEquals, assertExists, assertLess } 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"; import { forTestingOnly } from "./rss.ts";
@ -30,6 +30,29 @@ Deno.test("generated rss contains promo with link to donations page", () => {
const series = testUtils.generateSeries(); const series = testUtils.generateSeries();
const feed = rss.assembleFeed(series); const feed = rss.assembleFeed(series);
console.log(feed);
feed.includes("Vurder å støtte utviklingen via Vipps"); feed.includes("Vurder å støtte utviklingen via Vipps");
}); });
Deno.test("generated rss contains promo with link to donations page", () => {
const series = testUtils.generateSeries();
const feed = rss.assembleFeed(series);
const indexOfFirstPromotion = feed.indexOf(
"NRSS er avhengig av din Vipps-støtte",
);
const indexOfSecondPromotion = feed.indexOf("Vurder å støtte utviklingen");
assertLess(
indexOfFirstPromotion,
indexOfSecondPromotion,
"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");
});

View File

@ -27,16 +27,11 @@ function assembleFeed(series: Series): string {
tag("itunes:name", "NRK"), tag("itunes:name", "NRK"),
tag("itunes:email", "nrkpodcast@nrk.no"), tag("itunes:email", "nrkpodcast@nrk.no"),
]), ]),
tag( tag("description", series.subtitle || ""),
"description",
series.subtitle || "",
),
tag("ttl", "60"), //60 minutes tag("ttl", "60"), //60 minutes
...(series.imageUrl ...(series.imageUrl
? [ ? [
tag("itunes:image", "", [ tag("itunes:image", "", [["href", series.imageUrl]]),
["href", series.imageUrl],
]),
tag("image", [ tag("image", [
tag("url", series.imageUrl), tag("url", series.imageUrl),
tag("title", series.title), tag("title", series.title),
@ -76,10 +71,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
tag("pubDate", new Date(episode.date).toUTCString()), tag("pubDate", new Date(episode.date).toUTCString()),
tag("itunes:duration", episode.durationInSeconds.toString()), tag("itunes:duration", episode.durationInSeconds.toString()),
tag("podcast:chapters", "", [ tag("podcast:chapters", "", [
[ ["url", `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`],
"url",
`${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`,
],
["type", "application/json+chapters"], ["type", "application/json+chapters"],
]), ]),
tag("enclosure", "", [ tag("enclosure", "", [

View File

@ -1,5 +1,5 @@
import { assertEquals, assertNotEquals } from "$std/assert/mod.ts"; import { assertEquals, assertNotEquals } from "$std/assert/mod.ts";
import { responseJSON, responseXML } from "./utils.ts"; import { responseJSON, responseXML, withExpiry } from "./utils.ts";
Deno.test("JSON response is stringified", async () => { Deno.test("JSON response is stringified", async () => {
const body = { message: "Hello, World!" }; const body = { message: "Hello, World!" };
@ -14,3 +14,41 @@ Deno.test("XML resopnse is _not_ stringified", async () => {
assertEquals(responseBody, body); assertEquals(responseBody, body);
assertNotEquals(responseBody, JSON.stringify(body)); assertNotEquals(responseBody, JSON.stringify(body));
}); });
Deno.test(
"Response with cache control returns a response with the correct header",
() => {
const response = responseJSON({ message: "Hello, World!" }, 200);
const cachedResponse = withExpiry(response, 3600);
assertEquals(cachedResponse.headers.get("Cache-Control"), "max-age=3600");
},
);
Deno.test(
"Response with cache control returns the same body as the original",
async () => {
const body = { message: "Hello, World!" };
const response = responseJSON(body, 200);
const cachedResponse = withExpiry(response, 3600);
assertEquals(await cachedResponse.text(), await response.text());
},
);
Deno.test("Response with cache control does not modify the original", () => {
const response = responseJSON({ message: "Hello, World!" }, 200);
withExpiry(response, 3600);
assertEquals(response.headers.get("Cache-Control"), null);
});
Deno.test(
"Response with cache control returns the correct number of seconds",
() => {
const response = responseJSON({ message: "Hello, World!" }, 200);
const ttlInSeconds = 1234;
const cachedResponse = withExpiry(response, ttlInSeconds);
assertEquals(
cachedResponse.headers.get("Cache-Control"),
`max-age=${ttlInSeconds}`,
);
},
);

View File

@ -24,6 +24,13 @@ export function responseXML(body: string, status: Status) {
return response(body, status, "xml"); return response(body, status, "xml");
} }
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;
};
function response(body: string, status: number, type: "json" | "xml") { function response(body: string, status: number, type: "json" | "xml") {
return new Response(body, { return new Response(body, {
status, status,

View File

@ -1,8 +1,8 @@
// deno-lint-ignore-file ban-ts-comment // deno-lint-ignore-file ban-ts-comment
import { STATUS_CODE } from "$fresh/server.ts";
import "jsr:@std/dotenv/load"; import "jsr:@std/dotenv/load";
import { encodeHex } from "jsr:@std/encoding/hex"; import { encodeHex } from "jsr:@std/encoding/hex";
import { getHostUrl } from "../utils.ts"; import { getHostUrl } from "../utils.ts";
import { STATUS_CODE } from "$fresh/server.ts";
const config = { const config = {
clientId: Deno.env.get("VIPPS_CLIENT_ID"), clientId: Deno.env.get("VIPPS_CLIENT_ID"),
@ -50,6 +50,11 @@ const getAccessToken = async function () {
export const createAgreement = async function (email: string) { export const createAgreement = async function (email: string) {
const token = await getAccessToken(); const token = await getAccessToken();
const amount = 5000; // 50 NOK const amount = 5000; // 50 NOK
// in case of special characters, like sub-addresses added
// with +, e.g. "name+subscriptions@domain.com"
const urlEncodedEmail = encodeURIComponent(email);
const redirectUrl = `${getHostUrl()}/donations-success?urlEncodedEmail=${urlEncodedEmail}`;
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, { const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, {
method: "POST", method: "POST",
// @ts-ignore // @ts-ignore
@ -74,7 +79,7 @@ export const createAgreement = async function (email: string) {
currency: "NOK", currency: "NOK",
}, },
// email is used to identify the user in the success page // email is used to identify the user in the success page
merchantRedirectUrl: `${getHostUrl()}/donations-success?email=${email}`, merchantRedirectUrl: redirectUrl,
merchantAgreementUrl: `${getHostUrl()}`, merchantAgreementUrl: `${getHostUrl()}`,
productName: "Månedlig støtte til NRSS", productName: "Månedlig støtte til NRSS",
}), }),

View File

@ -1,18 +1,55 @@
import { FreshContext, STATUS_CODE } 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"; import { responseJSON, responseXML, withExpiry } from "../../../lib/utils.ts";
export const handler = async (_req: Request, ctx: FreshContext): Promise<Response> => { 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,
): Promise<Response> => {
const seriesId = ctx.params.seriesId; const seriesId = ctx.params.seriesId;
const cache = await caches.open(FEED_CACHE_KEY);
const cachedResponse = await cache.match(request);
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 }); const series = await caching.getSeries({ id: seriesId });
if (!series) { if (!series) {
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound); return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
} }
const feed = rss.assembleFeed(series); const feed = rss.assembleFeed(series);
const xml = responseXML(feed, STATUS_CODE.OK); const response = withExpiry(
responseXML(feed, STATUS_CODE.OK),
// 2 hours
2 * 60 * 60,
);
return xml; await cache.put(request, response.clone());
return response;
}; };

View File

@ -5,7 +5,9 @@ import { validateEmail } from "../lib/utils.ts";
export const handler: Handlers = { export const handler: Handlers = {
async GET(request, ctx) { async GET(request, ctx) {
const url = new URL(request.url); const url = new URL(request.url);
const email = url.searchParams.get("email"); const urlEncodedEmail = url.searchParams.get("urlEncodedEmail");
const email = urlEncodedEmail ? decodeURIComponent(urlEncodedEmail) : null;
if (!email) { if (!email) {
console.error("Missing email in query params", request.url); console.error("Missing email in query params", request.url);
return Response.redirect("/donations-error"); return Response.redirect("/donations-error");
@ -34,9 +36,7 @@ export const handler: Handlers = {
export default function () { export default function () {
return ( return (
<div className="my-16 text-center flex flex-col items-center min-h-screen "> <div className="my-16 text-center flex flex-col items-center min-h-screen ">
<h1 className="text-6xl mb-8"> <h1 className="text-6xl mb-8">🙏🌟😊💖</h1>
🙏🌟😊💖
</h1>
<p className="text-4xl mb-4"> <p className="text-4xl mb-4">
Tusen takk! Jeg er veldig takknemlig for all støtte. Tusen takk! Jeg er veldig takknemlig for all støtte.
</p> </p>