Merge remote-tracking branch 'upstream/main' into style-layout-changes

Signed-off-by: Tim Hårek Andreassen <tim@harek.no>
This commit is contained in:
Tim Hårek Andreassen 2024-04-02 15:29:14 +02:00
commit 25796ef274
No known key found for this signature in database
GPG Key ID: E59C7734F0E10EB5
8 changed files with 4958 additions and 1127 deletions

View File

@ -1,19 +1,21 @@
# NRSS - RSS Feeds for NRK sin podcaster # NRSS - RSS feeds for NRK's podcasts
https://nrss.deno.dev/ Live version: [nrss.deno.dev](https://nrss.deno.dev/)
## Local devlopment A webapp built with Deno's [Fresh](https://fresh.deno.dev/) that generates
public accessible RSS-feeds for their produced podcasts via their
[API](https://psapi.nrk.no/documentation/).
install Deno ## Local development
https://deno.land/manual/getting_started/installation
### Run the application 1. [Install Deno](https://deno.land/manual/getting_started/installation)
1. Run the app: `deno task start`
1. Open [localhost:8000](http://localhost:8000) in your browser
```shell ## What is this?
deno task start
```
Open http://localhost:8000/ to view it in the browser This is made as a reaction that the goverment funded NRK is closing their own
content to their own app instead of building under open standards like RSS.
## Known Problems ## Known Problems

View File

@ -5,7 +5,8 @@
"author": " Olav Sundfør <olav@sundfoer.com>", "author": " Olav Sundfør <olav@sundfoer.com>",
"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"
}, },
"imports": { "imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.8/", "$fresh/": "https://deno.land/x/fresh@1.6.8/",
@ -14,7 +15,8 @@
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@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"
}, },
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
@ -37,4 +39,4 @@
] ]
} }
} }
} }

28
generate-types.ts Normal file
View File

@ -0,0 +1,28 @@
import openapiTS from "openapi-typescript";
const nrkTypes: { filename: string; url: URL }[] = [
{
filename: "./lib/nrk-search.ts",
url: new URL("https://psapi.nrk.no/documentation/openapi/search-radio/v1/openapi.yml"),
},
{
filename: "./lib/nrk-catalog.ts",
url: new URL("https://psapi.nrk.no/documentation/openapi/programsider-radio/openapi.yml"),
},
{
filename: "./lib/nrk-playback.ts",
url: new URL("https://psapi.nrk.no/documentation/openapi/playback/2.0/openapi.json"),
},
] as const;
async function generateTypes() {
for (const type of nrkTypes) {
const contents = await openapiTS(type.url);
await Deno.writeTextFile(new URL(type.filename, import.meta.url), contents);
}
}
if (import.meta.main) {
await generateTypes();
}

File diff suppressed because it is too large Load Diff

3819
lib/nrk-playback.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,35 +5,59 @@
export interface paths { export interface paths {
"/radio/search/title": { "/radio/search/title": {
/** Gives results for titles */ /**
* Title search
* @description Gives results for titles
*/
get: operations["TitleSearch"]; get: operations["TitleSearch"];
}; };
"/radio/search/title/suggest": { "/radio/search/title/suggest": {
/** Gives suggestion for misspelling on titles only */ /**
* Title suggest search
* @description Gives suggestion for misspelling on titles only
*/
get: operations["TitleSuggestSearch"]; get: operations["TitleSuggestSearch"];
}; };
"/radio/search/suggest": { "/radio/search/suggest": {
/** Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling */ /**
* Deprecated
* @deprecated
* @description Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling
*/
get: operations["TitleSuggestSearchDeprecated"]; get: operations["TitleSuggestSearchDeprecated"];
}; };
"/radio/search/search": { "/radio/search/search": {
/** Search endpoint for all content */ /**
* Search
* @description Search endpoint for all content
*/
get: operations["Search"]; get: operations["Search"];
}; };
"/radio/search/search/suggest": { "/radio/search/search/suggest": {
/** Gives suggestion for misspelling */ /**
* Search suggest search
* @description Gives suggestion for misspelling
*/
get: operations["RadioSuggestSearch"]; get: operations["RadioSuggestSearch"];
}; };
"/radio/search/categories/{category}": { "/radio/search/categories/{category}": {
/** Alphabetical listing of all series, podcasts and umbrella seasons that are not excluded from search results in given category. Categories correspond to those in pages-api, including 'alt-innhold'. For the category 'podcast', all podcasts are listed, also those excluded from search results. */ /**
* List all series, podcast and umbrella seasons in category
* @description Alphabetical listing of all series, podcasts and umbrella seasons that are not excluded from search results in given category. Categories correspond to those in pages-api, including 'alt-innhold'. For the category 'podcast', all podcasts are listed, also those excluded from search results.
*/
get: operations["RadioListAllForCategory"]; get: operations["RadioListAllForCategory"];
}; };
"/radio/search/int/search": { "/radio/search/int/search": {
/** Search endpoint for `internal use only`. Searches series and episodes. */ /**
* Internal search
* @description Search endpoint for `internal use only`. Searches series and episodes.
*/
get: operations["InternalSearch"]; get: operations["InternalSearch"];
}; };
} }
export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** Series search result */ /** Series search result */
@ -47,8 +71,6 @@ export interface components {
description?: string; description?: string;
trimmedDescription?: string; trimmedDescription?: string;
images?: components["schemas"]["image"][]; images?: components["schemas"]["image"][];
} & {
seriesId: unknown;
}; };
/** Episode search result */ /** Episode search result */
episodesearchintresult: { episodesearchintresult: {
@ -57,11 +79,7 @@ export interface components {
/** @description If type is `customSeasonEpisode` */ /** @description If type is `customSeasonEpisode` */
seasonId?: string; seasonId?: string;
/** @enum {string} */ /** @enum {string} */
type: type: "seriesEpisode" | "podcastEpisode" | "singleProgram" | "customSeasonEpisode";
| "seriesEpisode"
| "podcastEpisode"
| "singleProgram"
| "customSeasonEpisode";
title: string; title: string;
subtitle?: string; subtitle?: string;
trimmedSubtitle?: string; trimmedSubtitle?: string;
@ -88,8 +106,6 @@ export interface components {
/** @description if `isSuggestResult` is true, `suggestQuery` displays the suggested value which is used in the current result set */ /** @description if `isSuggestResult` is true, `suggestQuery` displays the suggested value which is used in the current result set */
suggestQuery?: string; suggestQuery?: string;
}; };
} & {
isSuggestResult: unknown;
}; };
/** /**
* Channel result * Channel result
@ -131,7 +147,7 @@ export interface components {
*/ */
seriesResult: { seriesResult: {
links?: components["schemas"]["nextLink"]; links?: components["schemas"]["nextLink"];
results?: { results?: ({
/** @description Unique id */ /** @description Unique id */
id: string; id: string;
seriesId: string; seriesId: string;
@ -145,7 +161,7 @@ export interface components {
images: components["schemas"]["image"][]; images: components["schemas"]["image"][];
images_1_1?: components["schemas"]["image"][]; images_1_1?: components["schemas"]["image"][];
highlights?: components["schemas"]["seriesHighlight"]; highlights?: components["schemas"]["seriesHighlight"];
}[]; })[];
}; };
/** /**
* Episode results * Episode results
@ -153,7 +169,7 @@ export interface components {
*/ */
episodeResult: { episodeResult: {
links?: components["schemas"]["nextLink"]; links?: components["schemas"]["nextLink"];
results?: { results?: ({
/** @description Unique id */ /** @description Unique id */
id: string; id: string;
episodeId: string; episodeId: string;
@ -162,11 +178,7 @@ export interface components {
/** @description Only included in type `customSeasonEpisode` */ /** @description Only included in type `customSeasonEpisode` */
seasonId?: string; seasonId?: string;
/** @enum {string} */ /** @enum {string} */
type: type: "seriesEpisode" | "podcastEpisode" | "customSeasonEpisode" | "singleProgram";
| "seriesEpisode"
| "podcastEpisode"
| "customSeasonEpisode"
| "singleProgram";
title: string; title: string;
/** @description Not included in type `singleProgram` */ /** @description Not included in type `singleProgram` */
seriesTitle?: string; seriesTitle?: string;
@ -175,7 +187,7 @@ export interface components {
/** Format: date */ /** Format: date */
date: string; date: string;
highlights?: components["schemas"]["episodeHighlight"]; highlights?: components["schemas"]["episodeHighlight"];
}[]; })[];
}; };
nextLink: { nextLink: {
/** /**
@ -336,30 +348,48 @@ export interface components {
totalCount: number; totalCount: number;
}; };
}; };
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
} }
export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations { export interface operations {
/** Gives results for titles */ /**
* Title search
* @description Gives results for titles
*/
TitleSearch: { TitleSearch: {
parameters: { parameters: {
header: {
accept?: "application/json;api-version=2";
};
query: { query: {
/** The query text */ /**
* @description The query text
* @example stanghelle
*/
q: string; q: string;
/** Number of results to take */ /** @description Number of results to take */
take?: number; take?: number;
/** Number of results to skip */ /**
* @description Number of results to skip
* @example 0
*/
skip?: number; skip?: number;
/** Filters only playable content */ /** @description Filters only playable content */
filterNotPlayable?: boolean; filterNotPlayable?: boolean;
/** Returns only the specified type */ /** @description Returns only the specified type */
type?: "series" | "episode"; type?: "series" | "episode";
}; };
header?: {
accept?: "application/json;api-version=2";
};
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["titlesearchresult"]; "application/json": components["schemas"]["titlesearchresult"];
@ -367,16 +397,22 @@ export interface operations {
}; };
}; };
}; };
/** Gives suggestion for misspelling on titles only */ /**
* Title suggest search
* @description Gives suggestion for misspelling on titles only
*/
TitleSuggestSearch: { TitleSuggestSearch: {
parameters: { parameters: {
query: { query: {
/** The query text */ /**
* @description The query text
* @example smukker
*/
q: string; q: string;
}; };
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": string[]; "application/json": string[];
@ -384,18 +420,28 @@ export interface operations {
}; };
}; };
}; };
/** Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling */ /**
* Deprecated
* @deprecated
* @description Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling
*/
TitleSuggestSearchDeprecated: { TitleSuggestSearchDeprecated: {
parameters: { parameters: {
query: { query: {
/** The query text */ /**
* @description The query text
* @example stanghelle
*/
q: string; q: string;
/** Defaults to 10 if not set */ /**
* @description Defaults to 10 if not set
* @example 10
*/
take?: number; take?: number;
}; };
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": string[]; "application/json": string[];
@ -403,26 +449,38 @@ export interface operations {
}; };
}; };
}; };
/** Search endpoint for all content */ /**
* Search
* @description Search endpoint for all content
*/
Search: { Search: {
parameters: { parameters: {
query: { query: {
/** The query text */ /**
* @description The query text
* @example stanghelle
*/
q: string; q: string;
/** Number of results to take per type in the result set (see type parameter) */ /** @description Number of results to take per type in the result set (see type parameter) */
take?: number; take?: number;
/** Number of results to skip */ /**
* @description Number of results to skip
* @example 0
*/
skip?: number; skip?: number;
/** Which page of results to get */ /**
* @description Which page of results to get
* @example 2
*/
page?: number; page?: number;
/** Filters only playable content, applies to `episodes` and `series` */ /** @description Filters only playable content, applies to `episodes` and `series` */
filterNotPlayable?: boolean; filterNotPlayable?: boolean;
/** Returns only the specified type */ /** @description Returns only the specified type */
type?: "series" | "episode" | "channel" | "category"; type?: "series" | "episode" | "channel" | "category";
}; };
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["searchresult"]; "application/json": components["schemas"]["searchresult"];
@ -430,16 +488,22 @@ export interface operations {
}; };
}; };
}; };
/** Gives suggestion for misspelling */ /**
* Search suggest search
* @description Gives suggestion for misspelling
*/
RadioSuggestSearch: { RadioSuggestSearch: {
parameters: { parameters: {
query: { query: {
/** The query text */ /**
* @description The query text
* @example smukker
*/
q: string; q: string;
}; };
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": string[]; "application/json": string[];
@ -447,25 +511,28 @@ export interface operations {
}; };
}; };
}; };
/** Alphabetical listing of all series, podcasts and umbrella seasons that are not excluded from search results in given category. Categories correspond to those in pages-api, including 'alt-innhold'. For the category 'podcast', all podcasts are listed, also those excluded from search results. */ /**
* List all series, podcast and umbrella seasons in category
* @description Alphabetical listing of all series, podcasts and umbrella seasons that are not excluded from search results in given category. Categories correspond to those in pages-api, including 'alt-innhold'. For the category 'podcast', all podcasts are listed, also those excluded from search results.
*/
RadioListAllForCategory: { RadioListAllForCategory: {
parameters: { parameters: {
path: { query?: {
category: string;
};
query: {
take?: number; take?: number;
skip?: number; skip?: number;
}; };
path: {
category: string;
};
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": components["schemas"]["categoriesResponse"]; "application/json": components["schemas"]["categoriesResponse"];
}; };
}; };
/** Category not found */ /** @description Category not found */
404: { 404: {
content: { content: {
"text/plain": unknown; "text/plain": unknown;
@ -473,28 +540,41 @@ export interface operations {
}; };
}; };
}; };
/** Search endpoint for `internal use only`. Searches series and episodes. */ /**
* Internal search
* @description Search endpoint for `internal use only`. Searches series and episodes.
*/
InternalSearch: { InternalSearch: {
parameters: { parameters: {
query: { query: {
/** The query text */ /**
* @description The query text
* @example stanghelle
*/
q: string; q: string;
/** Defaults to 10 if not set */ /**
* @description Defaults to 10 if not set
* @example 10
*/
take?: number; take?: number;
/** Defaults to `desc` if not set */ /**
* @description Defaults to `desc` if not set
* @example asc
*/
sort?: string; sort?: string;
/** options `series` or `episode` */ /**
* @description options `series` or `episode`
* @example series
*/
type?: string; type?: string;
}; };
}; };
responses: { responses: {
/** OK */ /** @description OK */
200: { 200: {
content: { content: {
"application/json": ( "application/json":
| components["schemas"]["episodesearchintresult"] (components["schemas"]["episodesearchintresult"] | components["schemas"]["seriessearchintresult"])[];
| components["schemas"]["seriessearchintresult"]
)[];
}; };
}; };
}; };

View File

@ -1,37 +1,31 @@
import { get, OK } from "https://deno.land/x/kall@v0.1.0/mod.ts"; import { get, OK } from "https://deno.land/x/kall@v0.1.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";
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 Podcast = catalogComponents["schemas"]["SeriesHalResource"];
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
export type Serie = catalogComponents["schemas"]["SeriesViewModel"]; export type Serie = catalogComponents["schemas"]["SeriesViewModel"];
type _OriginalEpisode = catalogComponents["schemas"]["EpisodeHalResource"]; export type OriginalEpisode = PodcastEpisodesSingle & { url: string };
export type OriginalEpisode = _OriginalEpisode & { url: string }; export type PodcastEpisode = catalogComponents["schemas"]["PodcastEpisodeHalResource"];
export type SearchResultList = searchComponents["schemas"]["seriesResult"]["results"]; export type SearchResultList = searchComponents["schemas"]["seriesResult"]["results"];
export type SearchResult = ArrayElement<SearchResultList> & { export type SearchResult = ArrayElement<SearchResultList> & {
description?: string; description?: string;
}; };
/* Incomplete manifest types */ type Manifest = playbackComponents["schemas/playback-channel.json"]["components"]["schemas"]["PlayableManifest"];
interface Manifest {
id: string; const nrkAPI = `https://psapi.nrk.no`;
playable: {
endSequenceStartTime: null;
duration: string;
assets: {
url: string;
format: string;
mimeType: string;
encrypted: boolean;
}[];
};
}
async function withDownloadLink( async function withDownloadLink(
episode: _OriginalEpisode, episode: PodcastEpisodesSingle,
): Promise<OriginalEpisode> { ): Promise<OriginalEpisode> {
// getting stream link // getting stream link
const [playbackStatus, playbackResponse] = await get<Manifest>( const [playbackStatus, playbackResponse] = await get<Manifest>(
`https://psapi.nrk.no/playback/manifest/podcast/${episode.episodeId}`, `${nrkAPI}/playback/manifest/podcast/${episode.episodeId}`,
); );
if (playbackStatus === OK && playbackResponse) { if (playbackStatus === OK && playbackResponse) {
@ -45,23 +39,22 @@ export const nrkRadio = {
search: async (query: string): Promise<SearchResultList> => { search: async (query: string): Promise<SearchResultList> => {
const [status, response] = await get< const [status, response] = await get<
searchComponents["schemas"]["searchresult"] searchComponents["schemas"]["searchresult"]
>(`https://psapi.nrk.no/radio/search/search?q=${query}`); >(`${nrkAPI}/radio/search/search?q=${query}`);
if (status === OK && response) { if (status === OK && response) {
return response.results.series?.results; return response.results.series?.results;
} else {
throw `Something went wrong with ${query} - got status ${status}`;
} }
throw `Something went wrong with ${query} - got status ${status}`;
}, },
getSerieData: async (seriesId: string) => { getSerieData: async (seriesId: string) => {
const [ const [
[episodeStatus, episodeResponse], [episodeStatus, episodeResponse],
[seriesStatus, serieResponse], [seriesStatus, serieResponse],
] = await Promise.all([ ] = await Promise.all([
get<catalogComponents["schemas"]["EpisodesHalResource"]>( get<PodcastEpisodes>(
`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}/episodes`, `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
), ),
get<catalogComponents["schemas"]["SeriesHalResource"]>( get<Podcast>(
`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}`, `${nrkAPI}/radio/catalog/podcast/${seriesId}`,
), ),
]); ]);
@ -76,17 +69,15 @@ export const nrkRadio = {
...serieResponse.series, ...serieResponse.series,
episodes, episodes,
}; };
} else {
throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`;
} }
throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`;
}, },
getEpisode: async (seriesId: string, episodeId: string): Promise<OriginalEpisode | null> => { getEpisode: async (seriesId: string, episodeId: string): Promise<PodcastEpisode | null> => {
const url = `https://psapi.nrk.no/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`; const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
const [status, episode] = await get<OriginalEpisode>(url); const [status, episode] = await get<PodcastEpisode>(url);
if (status !== OK) { if (status === OK && episode) {
throw `Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`; return episode;
} }
throw `Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`;
return episode;
}, },
}; };

View File

@ -1,12 +1,19 @@
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, OriginalEpisode } from "../../../../../lib/nrk.ts"; import { nrkRadio, PodcastEpisode } from "../../../../../lib/nrk.ts";
// TODO: This type `OriginalEpisode` is missing stuff. type Chapter = {
function toChapters(episode: OriginalEpisode) { title: string | undefined;
startTime: number | undefined;
};
function toChapters(episode: PodcastEpisode): Chapter[] | null {
if (!episode.indexPoints) {
return null;
}
return episode.indexPoints.map((indexPoint) => ({ return episode.indexPoints.map((indexPoint) => ({
title: indexPoint.title, title: indexPoint.title,
startTime: toSeconds(parse(indexPoint.startPoint)), startTime: indexPoint.startPoint ? toSeconds(parse(indexPoint.startPoint)) : undefined,
})); }));
} }