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

27
Caddyfile Normal file
View file

@ -0,0 +1,27 @@
http://localhost
route /sonarr/api/v3/* {
uri strip_prefix /sonarr
reverse_proxy sonarr_v3:4010
}
route /sonarr/api/v5/* {
uri strip_prefix /sonarr
reverse_proxy sonarr_v5:4010
}
route /radarr/* {
uri strip_prefix /radarr
reverse_proxy radarr:4010
}
# route /tmdb/* {
# uri strip_prefix /tmdb
# reverse_proxy tmdb:4010
# }
route /jellifin/* {
uri strip_prefix /jellifin
reverse_proxy jellifin:4010
}

View file

@ -5,6 +5,12 @@
## APIS
### Host mocked api's
```bash
docker compose up
```
### Generate openapi client
- path to source yml or json
@ -12,5 +18,5 @@
example
```bash
bunx openapi-typescript .\src\features\content\apis\api.yml -o .\src\features\content\apis\api.generated.ts
bunx openapi-typescript .\src\features\content\apis\api.json -o .\src\features\content\apis\api.generated.ts
```

54
docker-compose.mocks.yml Normal file
View file

@ -0,0 +1,54 @@
version: '3'
services:
proxy:
image: caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
ports:
- '8080:80'
depends_on:
- sonarr_v3
- sonarr_v5
- radarr
- jellyfin
sonarr_v3:
image: stoplight/prism
volumes:
- './src/features/content/apis:/var/apis'
command: >-
mock
-h 0.0.0.0
-p 4010
/var/apis/sonarr.v3.json
sonarr_v5:
image: stoplight/prism
volumes:
- './src/features/content/apis:/var/apis'
command: >-
mock
-h 0.0.0.0
-p 4010
/var/apis/sonarr.v5.json
radarr:
image: stoplight/prism
volumes:
- './src/features/content/apis:/var/apis'
command: >-
mock
-h 0.0.0.0
-p 4010
/var/apis/radarr.json
jellyfin:
image: stoplight/prism
volumes:
- './src/features/content/apis:/var/apis'
command: >-
mock
-h 0.0.0.0
-p 4010
/var/apis/jellyfin.json
/var/apis/jellyfin.json

View file

@ -1,8 +1,17 @@
version: '3'
services:
jellyfin:
image: stoplight/prism
ports:
- '9003:4010'
sonarr:
image: hotio/sonarr
container_name: sonarr
volumes:
- './src/features/content/apis:/var/apis'
command: 'mock -h 0.0.0.0 /var/apis/jellyfin.json'
- ./media:/media
ports:
- 8989:8989
radarr:
image: hotio/radarr
container_name: radarr
volumes:
- ./media:/media
ports:
- 7878:7878

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];
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);
return getItemStream(table[id].jellyfin, range);
}, '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;
};