Feature/types #4

Merged
augustskare merged 2 commits from feature/types into main 2023-03-11 09:46:56 +00:00
8 changed files with 2350 additions and 88 deletions

View File

@ -1,17 +1,15 @@
import { Serie } from "../lib/nrk.ts" import { SearchResult } from "../lib/nrk.ts"
import CopyButton from "../islands/CopyButton.tsx"; import CopyButton from "../islands/CopyButton.tsx";
export function SerieCard(props: { serie: Serie, origin: string }) { export function SerieCard(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);
const image = props.serie.images[0];
console.log(feedUrl.toString())
return ( return (
<div className="border-2 my-2 p-2"> <div className="border-2 my-2 p-2">
<div className="mx-auto h-full w-full"> <div className="mx-auto h-full w-full">
<h2 className="text-xl">{props.serie.title}</h2> <h2 className="text-xl">{props.serie.title}</h2>
<p className="text-md">{props.serie.description}</p> <p className="text-md">{props.serie.description}</p>
<img src={props.serie.images[0].uri} /> <img src={image.uri} width={image.width} />
<CopyButton text={feedUrl.toString()}> <CopyButton text={feedUrl.toString()}>
Kopier URL Kopier URL
</CopyButton> </CopyButton>

View File

@ -4,15 +4,13 @@
import config from "./deno.json" assert { type: "json" }; import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/api/feeds/[seriesId].ts"; import * as $0 from "./routes/api/feeds/[seriesId].ts";
import * as $1 from "./routes/api/search.ts"; import * as $1 from "./routes/index.tsx";
import * as $2 from "./routes/index.tsx";
import * as $$0 from "./islands/CopyButton.tsx"; import * as $$0 from "./islands/CopyButton.tsx";
const manifest = { const manifest = {
routes: { routes: {
"./routes/api/feeds/[seriesId].ts": $0, "./routes/api/feeds/[seriesId].ts": $0,
"./routes/api/search.ts": $1, "./routes/index.tsx": $1,
"./routes/index.tsx": $2,
}, },
islands: { islands: {
"./islands/CopyButton.tsx": $$0, "./islands/CopyButton.tsx": $$0,

1781
lib/nrk-catalog.ts Normal file

File diff suppressed because it is too large Load Diff

504
lib/nrk-search.ts Normal file
View File

@ -0,0 +1,504 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/radio/search/title": {
/** Gives results for titles */
get: operations["TitleSearch"];
};
"/radio/search/title/suggest": {
/** Gives suggestion for misspelling on titles only */
get: operations["TitleSuggestSearch"];
};
"/radio/search/suggest": {
/** Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling */
get: operations["TitleSuggestSearchDeprecated"];
};
"/radio/search/search": {
/** Search endpoint for all content */
get: operations["Search"];
};
"/radio/search/search/suggest": {
/** Gives suggestion for misspelling */
get: operations["RadioSuggestSearch"];
};
"/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. */
get: operations["RadioListAllForCategory"];
};
"/radio/search/int/search": {
/** Search endpoint for `internal use only`. Searches series and episodes. */
get: operations["InternalSearch"];
};
}
export interface components {
schemas: {
/** Series search result */
seriessearchintresult: {
id: string;
/** @description If type is `customSeason` */
seasonId?: string;
/** @enum {string} */
type: "series" | "podcast" | "customSeason";
title: string;
description?: string;
trimmedDescription?: string;
images?: components["schemas"]["image"][];
} & {
seriesId: unknown;
};
/** Episode search result */
episodesearchintresult: {
id: string;
seriesId: string;
/** @description If type is `customSeasonEpisode` */
seasonId?: string;
/** @enum {string} */
type:
| "seriesEpisode"
| "podcastEpisode"
| "singleProgram"
| "customSeasonEpisode";
title: string;
subtitle?: string;
trimmedSubtitle?: string;
description?: string;
trimmedDescription?: string;
images?: components["schemas"]["image"][];
/**
* Format: date
* @description Date in UTC
*/
date?: string;
};
searchresult: {
/** @description Gives the total count of hits */
count: number;
takeCount: components["schemas"]["searchTakeCount"];
totalCount: components["schemas"]["searchTotalCount"];
results: {
channels?: components["schemas"]["channelResult"];
categories?: components["schemas"]["categoryResult"];
series?: components["schemas"]["seriesResult"];
episodes?: components["schemas"]["episodeResult"];
isSuggestResult?: boolean;
/** @description if `isSuggestResult` is true, `suggestQuery` displays the suggested value which is used in the current result set */
suggestQuery?: string;
};
} & {
isSuggestResult: unknown;
};
/**
* Channel result
* @description Radio channels
*/
channelResult: {
links?: components["schemas"]["nextLink"];
results?: {
/** @description the radio channel id */
id: string;
/** @enum {string} */
type: "channel";
title: string;
images: components["schemas"]["image"][];
images_1_1?: components["schemas"]["image"][];
highlights?: components["schemas"]["titleHighlight"];
}[];
};
/**
* Category results
* @description Categories that should redirect to a category page
*/
categoryResult: {
links?: components["schemas"]["nextLink"];
results?: {
/** @description the category id */
id: string;
/** @enum {string} */
type: "category";
title: string;
images: components["schemas"]["image"][];
images_1_1?: components["schemas"]["image"][];
highlights?: components["schemas"]["titleHighlight"];
}[];
};
/**
* Series results
* @description ODM series, podcast series, custom podcast seasons
*/
seriesResult: {
links?: components["schemas"]["nextLink"];
results?: {
/** @description Unique id */
id: string;
seriesId: string;
/** @description Only included in type `customSeason` */
seasonId?: string;
/** @enum {string} */
type: "series" | "podcast" | "customSeason";
title: string;
/** @description Only included in type `customSeason` */
mainTitle?: string;
images: components["schemas"]["image"][];
images_1_1?: components["schemas"]["image"][];
highlights?: components["schemas"]["seriesHighlight"];
}[];
};
/**
* Episode results
* @description ODM series episodes, podcast series episodes, single programs, custom podcast seasons episodes
*/
episodeResult: {
links?: components["schemas"]["nextLink"];
results?: {
/** @description Unique id */
id: string;
episodeId: string;
/** @description Not included in type `singleProgram` */
seriesId?: string;
/** @description Only included in type `customSeasonEpisode` */
seasonId?: string;
/** @enum {string} */
type:
| "seriesEpisode"
| "podcastEpisode"
| "customSeasonEpisode"
| "singleProgram";
title: string;
/** @description Not included in type `singleProgram` */
seriesTitle?: string;
images: components["schemas"]["image"][];
images_1_1?: components["schemas"]["image"][];
/** Format: date */
date: string;
highlights?: components["schemas"]["episodeHighlight"];
}[];
};
nextLink: {
/**
* Format: url
* @description If available, the `next` links retrieves more results for the given section
*/
next?: string;
};
episodeHighlight: {
/** @enum {string} */
field?: "title";
/** @description The highlighted text. `{` marks the start of the highlight, `}` marks the end of the highlight */
text?: string;
};
seriesHighlight: {
/** @enum {string} */
field?: "title" | "contributor";
/** @description The highlighted text. `{` marks the start of the highlight, `}` marks the end of the highlight */
text?: string;
};
titleHighlight: {
/** @enum {string} */
field?: "title";
/** @description The highlighted text. `{` marks the start of the highlight, `}` marks the end of the highlight */
text?: string;
};
/** @description Gives the count for the current result scope */
searchTakeCount: {
all: number;
series: number;
episodes: number;
categories: number;
channels: number;
};
/** @description Gives the count for the total matching set of results in the index. */
searchTotalCount: {
all: number;
series: number;
episodes: number;
categories: number;
channels: number;
};
titlesearchresult: {
/** @description Gives the total count of hits for pagination */
count?: number;
takeCount?: components["schemas"]["takeCount"];
totalCount?: components["schemas"]["totalCount"];
results?: components["schemas"]["resultobject"][];
};
resultobject: {
/** @description A hashed unique value for the result item */
id?: string;
/** @description Id of the series */
seriesId?: string;
/** @description Id of the episode */
episodeId?: string;
/**
* @description `customSeason` and `customSeasonEpisode` are included in `V2` for this endpoint
* @enum {string}
*/
type?:
| "podcast"
| "series"
| "podcastEpisode"
| "seriesEpisode"
| "singleProgram"
| "customSeason"
| "customSeasonEpisode";
/** @description series title */
seriesTitle?: string;
/** @description Episode title */
episodeTitle?: string;
/** @description The series description */
seriesDescription?: string;
/** @description The episode description */
episodeDescription?: string;
images?: components["schemas"]["image"][];
/** @description First transmission date for episodes */
firstTransmissionDate?: string;
/** @description Latest transmission date for episodes */
lastTransmissionDate?: string;
/** @description Indicates if the episode is available on CDN and playable. */
playbackAvailable?: boolean;
};
/** @description Gives the count for the current result scope */
takeCount: {
all?: number;
series?: number;
episodes?: number;
};
/** @description Gives the count for the total matching set of results in the index. */
totalCount: {
all?: number;
series?: number;
episodes?: number;
};
image: {
/**
* @description The image uri
* @example https://gfx.nrk.no/bGUIqql3v_BzrJBHxGnQHwxeHk-T5QHVJpOLdq5zsUFQ.jpg
*/
uri?: string;
/**
* @description The image width
* @example 300
*/
width?: number;
};
/** @description Describe link to other resource */
halLink: {
/** @description the link */
href: string;
/** @description Is href a template string. */
templated?: boolean;
};
/** @description Only the link corresponding to the seriesListItem type is set */
seriesListItemLinks: {
series?: components["schemas"]["halLink"];
podcast?: components["schemas"]["halLink"];
customSeason?: components["schemas"]["halLink"];
singleProgram?: components["schemas"]["halLink"];
};
serieslistitem: {
_links: components["schemas"]["seriesListItemLinks"];
id: string;
/** @enum {string} */
type: "series" | "podcast" | "singleProgram" | "customSeason";
/** @description Id for series or podcast. Not set for singleProgram */
seriesId?: string;
/** @description Id for season. Only set for customSeason */
seasonId?: string;
title: string;
/** @description First character in title. # is used for titles starting on numerals */
initialCharacter: string;
images: components["schemas"]["image"][];
};
letterlistitem: {
/** @description List of all initialCharacters for series/podcasts/seasons in category. Current page might not include all letters listed here */
letter: string;
/** @description Number of items for letter */
count: number;
/** @description Link to paginated page starting at letter */
link: string;
};
categoriesLinks: {
nextPage?: components["schemas"]["halLink"];
prevPage?: components["schemas"]["halLink"];
nextLetter?: components["schemas"]["halLink"];
prevLetter?: components["schemas"]["halLink"];
};
categoriesResponse: {
_links: components["schemas"]["categoriesLinks"];
letters: components["schemas"]["letterlistitem"][];
/** @description Title of category */
title: string;
series: components["schemas"]["serieslistitem"][];
/** @description Total number of items for category and letter if set */
totalCount: number;
};
};
}
export interface operations {
/** Gives results for titles */
TitleSearch: {
parameters: {
header: {
accept?: "application/json;api-version=2";
};
query: {
/** The query text */
q: string;
/** Number of results to take */
take?: number;
/** Number of results to skip */
skip?: number;
/** Filters only playable content */
filterNotPlayable?: boolean;
/** Returns only the specified type */
type?: "series" | "episode";
};
};
responses: {
/** OK */
200: {
content: {
"application/json": components["schemas"]["titlesearchresult"];
};
};
};
};
/** Gives suggestion for misspelling on titles only */
TitleSuggestSearch: {
parameters: {
query: {
/** The query text */
q: string;
};
};
responses: {
/** OK */
200: {
content: {
"application/json": string[];
};
};
};
};
/** Deprecated. Use '/radio/search/title/suggest'. Gives suggestion for misspelling */
TitleSuggestSearchDeprecated: {
parameters: {
query: {
/** The query text */
q: string;
/** Defaults to 10 if not set */
take?: number;
};
};
responses: {
/** OK */
200: {
content: {
"application/json": string[];
};
};
};
};
/** Search endpoint for all content */
Search: {
parameters: {
query: {
/** The query text */
q: string;
/** Number of results to take per type in the result set (see type parameter) */
take?: number;
/** Number of results to skip */
skip?: number;
/** Which page of results to get */
page?: number;
/** Filters only playable content, applies to `episodes` and `series` */
filterNotPlayable?: boolean;
/** Returns only the specified type */
type?: "series" | "episode" | "channel" | "category";
};
};
responses: {
/** OK */
200: {
content: {
"application/json": components["schemas"]["searchresult"];
};
};
};
};
/** Gives suggestion for misspelling */
RadioSuggestSearch: {
parameters: {
query: {
/** The query text */
q: string;
};
};
responses: {
/** OK */
200: {
content: {
"application/json": string[];
};
};
};
};
/** 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: {
parameters: {
path: {
category: string;
};
query: {
take?: number;
skip?: number;
};
};
responses: {
/** OK */
200: {
content: {
"application/json": components["schemas"]["categoriesResponse"];
};
};
/** Category not found */
404: {
content: {
"text/plain": unknown;
};
};
};
};
/** Search endpoint for `internal use only`. Searches series and episodes. */
InternalSearch: {
parameters: {
query: {
/** The query text */
q: string;
/** Defaults to 10 if not set */
take?: number;
/** Defaults to `desc` if not set */
sort?: string;
/** options `series` or `episode` */
type?: string;
};
};
responses: {
/** OK */
200: {
content: {
"application/json": (
| components["schemas"]["episodesearchintresult"]
| components["schemas"]["seriessearchintresult"]
)[];
};
};
};
};
}
export interface external {}

View File

@ -1,50 +1,46 @@
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 catalogComponents} from './nrk-catalog.ts';
// incomplete type of Series
export type Serie = { type ArrayElement<A> = A extends readonly (infer T)[] ? T : never
id: string, //random id export type Serie = catalogComponents["schemas"]["SeriesViewModel"]
seriesId: string, // readable id type OriginalEpisode = catalogComponents["schemas"]["EpisodeHalResource"];
title: string, export type Episode = OriginalEpisode & { url: string; }
description: string, export type SearchResultList = searchComponents["schemas"]["seriesResult"]["results"];
images: any, export type SearchResult = ArrayElement<SearchResultList> & { description?: string }
/* Incomplete manifest types */
interface Manifest {
id: string;
playable: {
endSequenceStartTime: null,
duration: string;
assets: {
url: string;
format: string;
mimeType: string;
encrypted: boolean;
}[];
};
} }
// incomplete type of Episode async function withDownloadLink(episode: OriginalEpisode): Promise<Episode> {
export type Episode = {
id: string,
episodeId: string,
url: string,
titles: {
title: string,
description: string,
subtitle: string,
},
durationInSeconds: number,
date: string,
}
async function withDownloadLink(withoutUrl: Omit<Episode, "url">) {
// getting stream link // getting stream link
const [playbackStatus, playbackResponse] = await get<any>(`https://psapi.nrk.no/playback/manifest/podcast/${withoutUrl.episodeId}`) const [playbackStatus, playbackResponse] = await get<Manifest>(`https://psapi.nrk.no/playback/manifest/podcast/${episode.episodeId}`)
if (playbackStatus === OK && playbackResponse) {
if (playbackStatus === OK) { return {...episode, url: playbackResponse.playable.assets[0].url};
const url = playbackResponse.playable.assets[0].url;
return {
...withoutUrl, url
}
} else { } else {
throw `Error getting downloadLink for ${withoutUrl.episodeId}` throw `Error getting downloadLink for ${episode.episodeId}`
} }
} }
export const nrkRadio = { export const nrkRadio = {
search: async (query: string): Promise<Serie[]> => { search: async (query: string): Promise<SearchResultList> => {
const [status, response] = await get<any>(`https://psapi.nrk.no/radio/search/search?q=${query}`);
if (status === OK) { const [status, response] = await get<searchComponents["schemas"]["searchresult"]>(`https://psapi.nrk.no/radio/search/search?q=${query}`);
const series = response.results.series.results if (status === OK && response) {
return series as Serie[]; return response.results.series?.results;
} else { } else {
throw `Something went wrong with ${query} - got status ${status}` throw `Something went wrong with ${query} - got status ${status}`
} }
@ -55,23 +51,17 @@ export const nrkRadio = {
[episodeStatus, episodeResponse], [episodeStatus, episodeResponse],
[seriesStatus, serieResponse], [seriesStatus, serieResponse],
] = await Promise.all([ ] = await Promise.all([
get<any>(`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}/episodes`), get<catalogComponents["schemas"]["EpisodesHalResource"]>(`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}/episodes`),
get<any>(`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}`) get<catalogComponents["schemas"]["SeriesHalResource"]>(`https://psapi.nrk.no/radio/catalog/podcast/${seriesId}`)
]); ]);
if (episodeStatus === OK && seriesStatus === OK) { if (episodeStatus === OK && seriesStatus === OK && serieResponse?.series && episodeResponse?._embedded.episodes?.length) {
const episodes = await Promise.all(episodeResponse._embedded.episodes.map(episode => withDownloadLink(episode)));
const episodes: Episode[] = await Promise.all(episodeResponse._embedded.episodes.map(withDownloadLink));
return { return {
id: serieResponse.series.id, ...serieResponse.series,
title: serieResponse.series.titles.title,
subtitle: serieResponse.series.titles.subtitle,
image: serieResponse.series.image[1],
episodes, episodes,
} }
} else { } else {
throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}` throw `Error getting episodes for ${seriesId}: EpisodeStatus: ${episodeStatus}. SerieStatus: ${seriesStatus}`
} }

View File

@ -1,17 +1,18 @@
import { nrkRadio, Serie, Episode } from "../../../lib/nrk.ts" import { nrkRadio, Episode } from "../../../lib/nrk.ts"
import { HandlerContext } from "$fresh/server.ts"; import { HandlerContext } from "$fresh/server.ts";
import { serialize, tag, declaration } from "https://deno.land/x/serializexml@v0.3.2/mod.ts"; import { serialize, tag, declaration } from "https://deno.land/x/serializexml@v0.3.2/mod.ts";
function toItemTag(episode: Episode) { function toItemTag(episode: Episode) {
const description = episode.titles.description || episode.titles.subtitle; const description = episode.titles.subtitle || "";
return tag("item", [ return tag("item", [
tag("title", episode.titles.title), tag("title", episode.titles.title),
tag("link", episode.url), tag("link", episode.url),
tag("description", description), tag("description", description),
tag("itunes:summary", description), tag("itunes:summary", description),
tag("guid", episode.id, [["isPermaLink", "false"]]), tag("guid", episode.id, [["isPermaLink", "false"]]),
tag("pubDate", episode.date), tag("pubDate", new Date(episode.date).toUTCString()),
tag("itunes:duration", episode.durationInSeconds.toString()),
tag("enclosure", "", [ tag("enclosure", "", [
["url", episode.url], ["url", episode.url],
["length", episode.durationInSeconds.toString()], ["length", episode.durationInSeconds.toString()],
@ -20,8 +21,9 @@ function toItemTag(episode: Episode) {
]) ])
} }
async function buildFeed(seriesId: any) { async function buildFeed(seriesId: string) {
const serie = await nrkRadio.getSerieData(seriesId) const serie = await nrkRadio.getSerieData(seriesId)
const imageUrl = serie.squareImage.at(-1)?.url;
// Quickly adapted from https://raw.githubusercontent.com/olaven/paperpod/1cde9abd3174b26e126aa74fc5a3b63fd078c0fd/packages/converter/src/rss.ts // Quickly adapted from https://raw.githubusercontent.com/olaven/paperpod/1cde9abd3174b26e126aa74fc5a3b63fd078c0fd/packages/converter/src/rss.ts
return serialize( return serialize(
@ -33,20 +35,23 @@ async function buildFeed(seriesId: any) {
"rss", "rss",
[ [
tag("channel", [ tag("channel", [
tag("title", serie.title), tag("title", serie.titles.title),
tag("link", `https://radio.nrk.no/podkast/${serie.id}`), tag("link", `https://radio.nrk.no/podkast/${serie.id}`),
tag("itunes:author", "NRK"),
tag( tag(
"description", "description",
serie.subtitle serie.titles.subtitle || ""
), ),
tag("ttl", "60"), //60 minutes tag("ttl", "60"), //60 minutes
tag("itunes:image", "", [ ...(imageUrl ? [
["href", serie.image.url], tag("itunes:image", "", [
]), ["href", imageUrl],
tag("image", [ ]),
tag("url", serie.image.url), tag("image", [
tag("title", serie.title), tag("url", imageUrl),
]), tag("title", serie.titles.title),
])
] : []),
...serie.episodes.map(toItemTag), ...serie.episodes.map(toItemTag),
]), ]),
], ],

View File

@ -1,14 +0,0 @@
import { nrkRadio } from "../../lib/nrk.ts"
import { HandlerContext } from "$fresh/server.ts";
export const handler = async (req: Request, _ctx: HandlerContext): Promise<Response> => {
const { query } = await req.json();
const series = await nrkRadio.search(query);
return new Response(JSON.stringify({ series }), {
headers: { "Content-Type": "application/json" }
});
};

View File

@ -2,11 +2,11 @@ import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import Search from "../components/Search.tsx" import Search from "../components/Search.tsx"
import { SerieCard } from "../components/SeriesCard.tsx"; import { SerieCard } from "../components/SeriesCard.tsx";
import { nrkRadio, Serie } from "../lib/nrk.ts"; import { nrkRadio, SearchResult, SearchResultList, Serie } from "../lib/nrk.ts";
interface HandlerData { interface HandlerData {
query: string; query: string;
result?: Serie[]; result?: SearchResultList;
origin: string; origin: string;
} }
@ -14,7 +14,7 @@ export const handler: Handlers<HandlerData> = {
async GET(request, ctx) { async GET(request, ctx) {
const url = new URL(request.url); const url = new URL(request.url);
const query = url.searchParams.get("query"); const query = url.searchParams.get("query");
let result: Serie[] | undefined; let result: SearchResultList | undefined;
if (query) { if (query) {
result = await nrkRadio.search(query); result = await nrkRadio.search(query);
} }