started implementing radarr and sonarr

This commit is contained in:
Chris Kruining 2025-06-05 20:24:17 +02:00
parent 7b363964f7
commit f198d98437
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
23 changed files with 45022 additions and 15 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
import type { paths } from "./radarr.generated";
import { query } from "@solidjs/router";
import createClient from "openapi-fetch";
const getBaseUrl = () => {
"use server";
return process.env.RADARR_BASE_URL;
};
const getClient = () => {
"use server";
return createClient<paths>({
baseUrl: getBaseUrl(),
headers: {
"X-Api-Key": `${process.env.RADARR_API_KEY}`,
"Content-Type": 'application/json;',
},
});
};
export const get = query(async () => {
"use server";
const { data, error } = await getClient().GET('/api/v3/movie');
return data;
}, 'radarr.get');
export const addMovie = query(async (id: string) => {
"use server";
const { data, error } = await getClient().POST('/api/v3/movie', {
body: {
},
});
return data;
}, 'radarr.get');
export const listIds = query(async () => {
"use server";
const { data, error } = await getClient().GET('/api/v3/movie');
return Object.fromEntries(data?.map(({ id, tmdbId }) => ([ `m${tmdbId}`, { radarr: id } ] as const)) ?? []);
}, 'radarr.listIds');

View file

@ -0,0 +1,57 @@
import type { paths as v3Paths } from "./sonarr.v3.generated";
import type { paths as v5Paths } from "./sonarr.v5.generated";
import { query } from "@solidjs/router";
import createClient from "openapi-fetch";
const getBaseUrl = () => {
"use server";
return process.env.SONARR_BASE_URL;
};
const getClient = () => {
"use server";
return createClient<v3Paths&v5Paths>({
baseUrl: getBaseUrl(),
headers: {
"X-Api-Key": `${process.env.SONARR_API_KEY}`,
"Content-Type": 'application/json;',
},
});
};
export const TEST = query(async () => {
"use server";
const { data } = await getClient().GET('/api/v3/series', {
params: {
query: {
}
}
});
return data;
}, 'sonarr.TEST');
export const getByTmdbId = query(async (id: string) => {
"use server";
const { data } = await getClient().GET('/api/v3/series/lookup', {
params: {
query: {
term: `tmdb:${id}`
}
}
});
return data?.[0];
}, 'sonarr.getByTmdbId');
export const listIds = query(async () => {
"use server";
const { data, error } = await getClient().GET('/api/v3/series');
return Object.fromEntries(data?.map(({ id, tmdbId }) => ([ `s${tmdbId}`, { sonarr: id } ] as const)) ?? []);
}, 'sonarr.listIds');

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,902 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post: {
parameters: {
query?: {
returnUrl?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"multipart/form-data": {
username?: string;
password?: string;
rememberMe?: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/log": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
page?: number;
pageSize?: number;
sortKey?: string;
sortDirection?: components["schemas"]["SortDirection"];
level?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LogResourcePagingResource"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ping": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PingResource"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PingResource"];
};
};
};
};
patch?: never;
trace?: never;
};
"/api/v5/series": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
tvdbId?: number;
includeSeasonImages?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeriesResource"][];
};
};
};
};
put?: never;
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SeriesResource"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeriesResource"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/series/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
includeSeasonImages?: boolean;
};
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeriesResource"];
};
};
};
};
put: {
parameters: {
query?: {
moveFiles?: boolean;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["SeriesResource"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SeriesResource"];
};
};
};
};
post?: never;
delete: {
parameters: {
query?: {
deleteFiles?: boolean;
addImportListExclusion?: boolean;
};
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/series/{id}/folder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/series/lookup": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
term?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": components["schemas"]["SeriesResource"][];
"application/json": components["schemas"]["SeriesResource"][];
"text/json": components["schemas"]["SeriesResource"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/content/{path}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
path: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
path: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{path}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
path: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/update": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpdateResource"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/settings/update": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": components["schemas"]["UpdateSettingsResource"];
"application/json": components["schemas"]["UpdateSettingsResource"];
"text/json": components["schemas"]["UpdateSettingsResource"];
};
};
};
};
put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["UpdateSettingsResource"];
"text/json": components["schemas"]["UpdateSettingsResource"];
"application/*+json": components["schemas"]["UpdateSettingsResource"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": components["schemas"]["UpdateSettingsResource"];
"application/json": components["schemas"]["UpdateSettingsResource"];
"text/json": components["schemas"]["UpdateSettingsResource"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v5/settings/update/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpdateSettingsResource"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AddSeriesOptions: {
ignoreEpisodesWithFiles?: boolean;
ignoreEpisodesWithoutFiles?: boolean;
monitor?: components["schemas"]["MonitorTypes"];
searchForMissingEpisodes?: boolean;
searchForCutoffUnmetEpisodes?: boolean;
};
AlternateTitleResource: {
title?: string | null;
/** Format: int32 */
seasonNumber?: number | null;
/** Format: int32 */
sceneSeasonNumber?: number | null;
sceneOrigin?: string | null;
comment?: string | null;
};
Language: {
/** Format: int32 */
id?: number;
name?: string | null;
};
LogResource: {
/** Format: int32 */
id?: number;
/** Format: date-time */
time?: string;
exception?: string | null;
exceptionType?: string | null;
level: string | null;
logger: string | null;
message: string | null;
};
LogResourcePagingResource: {
/** Format: int32 */
page?: number;
/** Format: int32 */
pageSize?: number;
sortKey?: string | null;
sortDirection?: components["schemas"]["SortDirection"];
/** Format: int32 */
totalRecords?: number;
records?: components["schemas"]["LogResource"][] | null;
};
MediaCover: {
coverType?: components["schemas"]["MediaCoverTypes"];
url?: string | null;
remoteUrl?: string | null;
};
/** @enum {string} */
MediaCoverTypes: "unknown" | "poster" | "banner" | "fanart" | "screenshot" | "headshot" | "clearlogo";
/** @enum {string} */
MonitorTypes: "unknown" | "all" | "future" | "missing" | "existing" | "firstSeason" | "lastSeason" | "latestSeason" | "pilot" | "recent" | "monitorSpecials" | "unmonitorSpecials" | "none" | "skip";
/** @enum {string} */
NewItemMonitorTypes: "all" | "none";
PingResource: {
status?: string | null;
};
Ratings: {
/** Format: int32 */
votes?: number;
/** Format: double */
value?: number;
};
SeasonResource: {
/** Format: int32 */
seasonNumber?: number;
monitored?: boolean;
statistics?: components["schemas"]["SeasonStatisticsResource"];
images?: components["schemas"]["MediaCover"][] | null;
};
SeasonStatisticsResource: {
/** Format: date-time */
nextAiring?: string | null;
/** Format: date-time */
previousAiring?: string | null;
/** Format: int32 */
episodeFileCount?: number;
/** Format: int32 */
episodeCount?: number;
/** Format: int32 */
totalEpisodeCount?: number;
/** Format: int64 */
sizeOnDisk?: number;
releaseGroups?: string[] | null;
/** Format: double */
readonly percentOfEpisodes?: number;
};
SeriesResource: {
/** Format: int32 */
id?: number;
title?: string | null;
alternateTitles?: components["schemas"]["AlternateTitleResource"][] | null;
sortTitle?: string | null;
status?: components["schemas"]["SeriesStatusType"];
readonly ended?: boolean;
profileName?: string | null;
overview?: string | null;
/** Format: date-time */
nextAiring?: string | null;
/** Format: date-time */
previousAiring?: string | null;
network?: string | null;
airTime?: string | null;
images?: components["schemas"]["MediaCover"][] | null;
originalLanguage?: components["schemas"]["Language"];
remotePoster?: string | null;
seasons?: components["schemas"]["SeasonResource"][] | null;
/** Format: int32 */
year?: number;
path?: string | null;
/** Format: int32 */
qualityProfileId?: number;
seasonFolder?: boolean;
monitored?: boolean;
monitorNewItems?: components["schemas"]["NewItemMonitorTypes"];
useSceneNumbering?: boolean;
/** Format: int32 */
runtime?: number;
/** Format: int32 */
tvdbId?: number;
/** Format: int32 */
tvRageId?: number;
/** Format: int32 */
tvMazeId?: number;
/** Format: int32 */
tmdbId?: number;
/** Format: date-time */
firstAired?: string | null;
/** Format: date-time */
lastAired?: string | null;
seriesType?: components["schemas"]["SeriesTypes"];
cleanTitle?: string | null;
imdbId?: string | null;
titleSlug?: string | null;
rootFolderPath?: string | null;
folder?: string | null;
certification?: string | null;
genres?: string[] | null;
tags?: number[] | null;
/** Format: date-time */
added?: string;
addOptions?: components["schemas"]["AddSeriesOptions"];
ratings?: components["schemas"]["Ratings"];
statistics?: components["schemas"]["SeriesStatisticsResource"];
episodesChanged?: boolean | null;
};
SeriesStatisticsResource: {
/** Format: int32 */
seasonCount?: number;
/** Format: int32 */
episodeFileCount?: number;
/** Format: int32 */
episodeCount?: number;
/** Format: int32 */
totalEpisodeCount?: number;
/** Format: int64 */
sizeOnDisk?: number;
releaseGroups?: string[] | null;
/** Format: double */
readonly percentOfEpisodes?: number;
};
/** @enum {string} */
SeriesStatusType: "continuing" | "ended" | "upcoming" | "deleted";
/** @enum {string} */
SeriesTypes: "standard" | "daily" | "anime";
/** @enum {string} */
SortDirection: "default" | "ascending" | "descending";
UpdateChanges: {
new?: string[] | null;
fixed?: string[] | null;
};
/** @enum {string} */
UpdateMechanism: "builtIn" | "script" | "external" | "apt" | "docker";
UpdateResource: {
/** Format: int32 */
id?: number;
version: string | null;
branch: string | null;
/** Format: date-time */
releaseDate?: string;
fileName: string | null;
url: string | null;
installed?: boolean;
/** Format: date-time */
installedOn?: string | null;
installable?: boolean;
latest?: boolean;
changes: components["schemas"]["UpdateChanges"];
hash: string | null;
};
UpdateSettingsResource: {
/** Format: int32 */
id?: number;
branch?: string | null;
updateAutomatically?: boolean;
updateMechanism?: components["schemas"]["UpdateMechanism"];
updateScriptPath?: string | null;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import createClient from "openapi-fetch";
import { query } from "@solidjs/router";
import { Entry, SearchResult } from "../types";
import { paths as pathsV3, operations } from "./tmdb.generated";
import { paths as pathsV3 } from "./tmdb.v3.generated";
import { paths as pathsV4 } from "./tmdb.not.generated";
interface TMDBItem {
@ -58,8 +58,6 @@ export const getEntry = query(
tv: { series_id: Number.parseInt(id.slice(1)) },
} as const)[mediaType];
console.log(`going to fetch from '${endpoint}' with id '${id}'`)
const { data } = await clientV3.GET(endpoint, {
params: {
path: params,

View file

@ -9,16 +9,41 @@ import {
getEntry as getTmdbEntry,
searchMulti,
} from "./apis/tmdb";
import { listIds as listSerieIds } from "./apis/sonarr";
import { listIds as listMovieIds } from "./apis/radarr";
import { merge } from "~/utilities";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
const lookupTable = query(async () => listItemIds(), 'content.lookupTable');
const lookupTable = query(async () => {
'use server';
const [items, sonarr, radarr] = await Promise.all([
listItemIds(), listSerieIds(), listMovieIds() ]);
return merge(items, sonarr, radarr);
}, 'content.lookupTable');
export const getHighlights = () => getContinueWatching(jellyfinUserId);
export const getStream = query(async (id: string, range: string) => {
const table = await lookupTable();
const table = await lookupTable();
const ids = table[id];
return getItemStream(table[id].jellyfin, range);
if (ids.jellyfin) {
return getItemStream(ids.jellyfin, range);
}
// - If the lookup table has no entry
// than this means that we do not have the requested entry at all,
// neither in trackers nor in the media server
//
// - If the lookup table contains a jellyfin id,
// than we have the content and can stream straight away
//
// - If we have the radarr or sonarr id,
// than we are tracking the entry,
// but it is not available for use yet
console.log(ids);
}, 'content.stream');
export const listCategories = query(async (): Promise<Category[]> => {
@ -35,7 +60,7 @@ export const listCategories = query(async (): Promise<Category[]> => {
export const getEntryFromSlug = query(
async (slug: string): Promise<Entry | undefined> => {
const { id } = slug.match(/^.+-(?<id>\w+)$/)?.groups ?? {};
const id = slug.match(/\w+$/)![0];
return getTmdbEntry(id);
},

View file

@ -37,6 +37,7 @@
background: linear-gradient(to bottom, black, transparent) top left / 100% 20% no-repeat;
& > header {
z-index: 1;
display: block grid;
place-items: center;
@ -48,6 +49,7 @@
}
& > section {
z-index: 2;
display: block grid;
place-items: center;
@ -59,6 +61,7 @@
}
& > footer {
z-index: 0;
position: relative;
display: block grid;
grid: auto auto / auto auto auto;

View file

@ -8,7 +8,7 @@ import {
RouteDefinition,
useParams,
} from "@solidjs/router";
import { Show } from "solid-js";
import { createEffect, Show } from "solid-js";
import { Details } from "~/components/details";
import {
createSlug,

View file

@ -0,0 +1,23 @@
import { A } from "@solidjs/router";
import { ParentProps } from "solid-js";
export default function Experimental(props: ParentProps) {
return (
<div style={{ overflow: "auto" }}>
<nav
style={{
position: "sticky",
"inset-block-start": 0,
display: "flex",
gap: "var(--size-2)",
}}
>
<A href="/">Home</A>
<A href="/experimental/sonarr">Sonarr</A>
<A href="/experimental/radarr">Radarr</A>
</nav>
<main>{props.children}</main>
</div>
);
}

View file

@ -0,0 +1,3 @@
export default function NotFound() {
return <>NOT FOUND</>;
}

View file

@ -0,0 +1,3 @@
export default function Index() {
return <></>;
}

View file

@ -0,0 +1,13 @@
import { createAsync } from "@solidjs/router";
import { createEffect } from "solid-js";
import { TEST } from "~/features/content/apis/sonarr";
export default function Sonarr() {
const result = createAsync(() => TEST());
createEffect(() => {
console.log("the merged lookup table", result());
});
return <pre>{JSON.stringify(result(), null, 2)}</pre>;
}

View file

@ -44,3 +44,19 @@ export const hash = (
return hash;
};
export const merge = (...objects: Record<string, any>[]): Record<string, any> => {
if (objects.length === 0) {
return {};
}
const target = objects[0];
for (const key of new Set(objects.map(o => Object.keys(o)).flat())) {
const values = objects.filter(o => Object.hasOwn(o, key)).map(o => o[key]);
target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1);
}
return target;
};