cool beans yo

This commit is contained in:
Chris Kruining 2025-04-17 14:48:36 +02:00
parent ce62e92370
commit f5b2b7aaba
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
11 changed files with 198 additions and 60 deletions

View file

@ -5,6 +5,7 @@
"name": "streamarr",
"dependencies": {
"@solid-primitives/context": "^0.3.0",
"@solid-primitives/deep": "^0.3.1",
"@solid-primitives/event-listener": "^2.4.0",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
@ -313,12 +314,16 @@
"@solid-primitives/cursor": ["@solid-primitives/cursor@0.0.115", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-8nEmUN/sacXPChwuJOAi6Yi6VnxthW/Jk8VGvvcF38AenjUvOA6FHI6AkJILuFXjQw1PGxia1YbH/Mn77dPiOA=="],
"@solid-primitives/deep": ["@solid-primitives/deep@0.3.1", "", { "dependencies": { "@solid-primitives/memo": "^1.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-DaJ2iuHmYmHyGhA5EACfqVKXNivGlLA+zYborz1pLzKOpyMZq0LvmNoBgwFwN/ncOq5FhGP0Hvr6U7QP+hmLXw=="],
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="],
"@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-0QX9O3eUaQorNNmXZn8a4efSByayIScVq+iGSwheD7m3SL/ACLM5oZlCNpTPLcemnVVfUPAHFiViEj86XpN5qw=="],
"@solid-primitives/media": ["@solid-primitives/media@2.3.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.0", "@solid-primitives/rootless": "^1.5.0", "@solid-primitives/static-store": "^0.1.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-7+C3wfbWnGE/WPoNsqcp/EeOP2aNNB92RCpsWhBth8E5lZo/J+rK6jMb7umVsK0zguT8HBpeXp1pFyFbcsHStA=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.4.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.0", "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MzNCJNpXidQdLOZUsEkwpuq52uwT8zrFrBxEVMEr9N35yIIvGhjqwrI1M6xzPmJGzuVUe8anCk57q+N5gyRk0Q=="],
"@solid-primitives/platform": ["@solid-primitives/platform@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sSxcZfuUrtxcwV0vdjmGnZQcflACzMfLriVeIIWXKp8hzaS3Or3tO6EFQkTd3L8T5dTq+kTtLvPscXIpL0Wzdg=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-QJ3bTSQOlPdHBP2m6llrT13FvVzAwZfx41lTN8lQrRwwcZoWb7kfCAjhaohPnwkAsQ6nJpLjtGfT5GOyuCA4tA=="],

View file

@ -15,6 +15,7 @@
},
"dependencies": {
"@solid-primitives/context": "^0.3.0",
"@solid-primitives/deep": "^0.3.1",
"@solid-primitives/event-listener": "^2.4.0",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",

View file

@ -53,6 +53,10 @@
z-index: calc(var(--sibling-count) - var(--sibling-index));
&:has(> :hover, > :focus-within) {
z-index: calc(var(--sibling-count) + 1);
}
& > * {
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {

View file

@ -1,16 +1,55 @@
import createClient from "openapi-fetch";
import type { paths } from "./jellyfin.generated"; // generated by openapi-typescript
import type { paths, components } from "./jellyfin.generated"; // generated by openapi-typescript
import { query } from "@solidjs/router";
import { Entry } from "../types";
// ===============================
'use server';
// ===============================
type ItemImageType = "Primary" | "Art" | "Backdrop" | "Banner" | "Logo" | "Thumb" | "Disc" | "Box" | "Screenshot" | "Menu" | "Chapter" | "BoxRear" | "Profile";
const baseUrl = process.env.JELLYFIN_BASE_URL;
const client = createClient<paths>({
baseUrl,
headers: {
Authorization: `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`,
'Authorization': `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`,
'Content-Type': 'application/json; profile="CamelCase"',
},
});
export const TEST = query(async () => {
const userId = "a9c51af84bf54578a99ab4dd0ebf0763";
const itemId = "919dfa97e4dad2758a925d056e590a28";
const seriesId = "5230ddbcd9400733dc07e5b8cb7a4f49";
const { data: seriesData } = await client.GET("/UserItems/{itemId}/UserData", {
params: {
path: { itemId: seriesId },
query: { userId }
}
});
const { data: epData } = await client.GET("/UserItems/{itemId}/UserData", {
params: {
path: { itemId },
query: { userId }
}
});
console.log(seriesData, epData)
}, "jellyfin.TEST");
export const getCurrentUser = query(async () => {
const { data, error, response } = await client.GET("/Users/Public", {
params: {},
});
console.log(data, error, response)
return data;
}, "jellyfin.getCurrentUser");
export const listUsers = query(async () => {
const { data, error } = await client.GET("/Users", {
params: {},
@ -19,7 +58,7 @@ export const listUsers = query(async () => {
return data ?? [];
}, "jellyfin.listUsers");
export const getItem = query(async (userId: string, itemId: string) => {
export const getItem = query(async (userId: string, itemId: string): Promise<Entry | undefined> => {
const { data, error } = await client.GET("/Items/{itemId}", {
params: {
path: {
@ -41,30 +80,97 @@ export const getItem = query(async (userId: string, itemId: string) => {
},
});
return data?.Items ?? [];
if (data === undefined) {
return undefined;
}
return {
id: data.Id!,
title: data.Name!,
thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
};
}, "jellyfin.getItem");
export const getContinueWatching = query(
async (userId: string): Promise<Entry[]> => {
const { data, error } = await client.GET("/Users/{userId}/Items/Resume", {
export const getItemImage = query(async (itemId: string, imageType: ItemImageType): Promise<any | undefined> => {
const { data, error } = await client.GET("/Items/{itemId}/Images/{imageType}", {
parseAs: 'blob',
params: {
path: {
userId,
itemId,
imageType
},
query: {
mediaTypes: ["Video"],
fields: ["ProviderIds", "Genres"],
},
},
});
const items = (data?.Items ?? []).map(({ Id, Name }) => ({
id: Id,
title: Name,
thumbnail: `${baseUrl}/Items/${Id}/Images/Primary`,
}));
return data;
}, "jellyfin.getItemImage");
return items;
export const getItemPlaybackInfo = query(async (userId: string, itemId: string): Promise<any | undefined> => {
const { data, error, response } = await client.GET("/Items/{itemId}/PlaybackInfo", {
parseAs: 'text',
params: {
path: {
itemId,
},
query: {
userId,
},
},
});
return undefined;
}, "jellyfin.getItemPlaybackInfo");
export const queryItems = query(async () => {
const { data, error } = await client.GET("/Items", {
params: {
query: {
mediaTypes: ["Video"],
isUnaired: true,
limit: 10,
// fields: ["ProviderIds", "Genres"],
includeItemTypes: ["Series", "Movie"],
recursive: true,
},
},
});
console.log(data);
}, 'jellyfin.queryItems');
export const getContinueWatching = query(
async (userId: string): Promise<Entry[]> => {
const { data, error } = await client.GET("/UserItems/Resume", {
params: {
query: {
userId,
mediaTypes: ["Video"],
// fields: ["ProviderIds", "Genres"],
// includeItemTypes: ["Series", "Movie"]
},
},
});
if (Array.isArray(data?.Items) !== true) {
return [];
}
const uniqueIds = new Set<string>(data.Items.map(item => item.Type === 'Episode' ? item.SeriesId! : 'MOVIE_ID'));
const results = await Promise.allSettled(uniqueIds.values().map(id => getItem(userId, id)).toArray());
assertNoErrors(results);
return results.filter((result): result is PromiseFulfilledResult<Entry> => result.value !== undefined).map(({ value }) => value);
},
"jellyfin.continueWatching",
);
function assertNoErrors<T>(results: PromiseSettledResult<T>[]): asserts results is PromiseFulfilledResult<T>[] {
if (results.some(({ status }) => status !== 'fulfilled')) {
throw new Error('one or more promices failed', { cause: results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason) });
}
}

View file

@ -1,12 +1,15 @@
import type { Category, Entry } from "./types";
import { query } from "@solidjs/router";
import { entries } from "./data";
import { getContinueWatching } from "./apis/jellyfin";
import { getContinueWatching, getItem, TEST } from "./apis/jellyfin";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
export const listCategories = query(async (): Promise<Category[]> => {
"use server";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
// await TEST()
// console.log(await getItemPlaybackInfo(jellyfinUserId, 'a69c0c0ab66177a7adb671f126335d16'));
return [
{ label: "Continue", entries: await getContinueWatching(jellyfinUserId) },
@ -69,9 +72,9 @@ export const getEntry = query(
async (id: Entry["id"]): Promise<Entry | undefined> => {
"use server";
return entries.get(id);
return getItem(jellyfinUserId, id);
},
"series.get",
);
export { listUsers, getItem, getContinueWatching } from "./apis/jellyfin";
export { listUsers, getContinueWatching } from "./apis/jellyfin";

View file

@ -10,7 +10,7 @@ export interface Entry {
summary?: string;
releaseDate?: string;
sources?: Entry.Source[];
thumbnail?: string;
thumbnail?: URL | string;
image?: string;
}

View file

@ -7,8 +7,8 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
const slug = createMemo(() => createSlug(props.entry));
return (
<figure class={css.listItem}>
<img src={props.entry.thumbnail} alt={props.entry.title} />
<figure class={css.listItem} data-id={props.entry.id}>
<img src={props.entry.thumbnail ?? ''} alt={props.entry.title} />
<figcaption>
<strong>{props.entry.title}</strong>

View file

@ -1,17 +1,25 @@
import { Component, createSignal } from "solid-js";
import { Component, createEffect, createSignal, Show } from "solid-js";
import css from "./volume.module.css";
import { createStore, unwrap } from "solid-js/store";
import { trackDeep } from "@solid-primitives/deep";
interface VolumeProps {
value: number;
muted?: boolean;
onInput?: (next: { volume: number, muted: boolean }) => any;
}
export const Volume: Component<VolumeProps> = (props) => {
const [volume, setVolume] = createSignal(props.value);
const [state, setState] = createStore({ volume: props.value, muted: props.muted ?? false });
createEffect(() => {
props.onInput?.(unwrap(trackDeep(state)));
});
return (
<div class={css.container}>
<button>mute</button>
<input type="range" value={volume()} min="0" max="1" step="0.01" />
<button onClick={() => setState('muted', m => !m)}><Show when={state.muted} fallback="mute">unmute</Show></button>
<input type="range" value={state.volume} min="0" max="1" step="0.01" onInput={(e) => setState('volume', e.target.valueAsNumber)} />
</div>
);
};

View file

@ -6,6 +6,7 @@ import { createAsync, json, query } from "@solidjs/router";
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import css from "./player.module.css";
import { Volume } from "./controls/volume";
import { getEntry } from "../content";
const metadata = query(async (id: string) => {
"use server";
@ -42,9 +43,12 @@ export const Player: Component<PlayerProps> = (props) => {
const [video, setVideo] = createSignal<HTMLVideoElement>(
undefined as unknown as HTMLVideoElement,
);
const entry = createAsync(() => getEntry(props.id));
const data = createAsync(() => metadata(props.id), {
deferStream: true,
initialValue: {},
initialValue: {} as any,
});
const captionUrl = createMemo(() => {
const { captions } = data();
@ -168,7 +172,7 @@ export const Player: Component<PlayerProps> = (props) => {
return (
<figure class={css.player}>
<h1>{props.id}</h1>
<h1>{entry()?.title}</h1>
<video
ref={setVideo}
@ -194,7 +198,10 @@ export const Player: Component<PlayerProps> = (props) => {
</video>
<figcaption>
<Volume value={0.5} />
<Volume value={video()?.volume ?? 0} muted={video()?.muted ?? false} onInput={({ volume, muted }) => {
video().volume = volume;
video().muted = muted;
}} />
</figcaption>
<button onclick={toggle}>play/pause</button>

View file

@ -115,63 +115,63 @@
--sibling-index: 15;
}
:has(> :last-child:nth-child(1)) {
--sibbling-count: 1;
:has(> :last-child:nth-child(1)) > * {
--sibling-count: 1;
}
:has(> :last-child:nth-child(2)) {
--sibbling-count: 2;
:has(> :last-child:nth-child(2)) > * {
--sibling-count: 2;
}
:has(> :last-child:nth-child(3)) {
--sibbling-count: 3;
:has(> :last-child:nth-child(3)) > * {
--sibling-count: 3;
}
:has(> :last-child:nth-child(4)) {
--sibbling-count: 4;
:has(> :last-child:nth-child(4)) > * {
--sibling-count: 4;
}
:has(> :last-child:nth-child(5)) {
--sibbling-count: 5;
:has(> :last-child:nth-child(5)) > * {
--sibling-count: 5;
}
:has(> :last-child:nth-child(6)) {
--sibbling-count: 6;
:has(> :last-child:nth-child(6)) > * {
--sibling-count: 6;
}
:has(> :last-child:nth-child(7)) {
--sibbling-count: 7;
:has(> :last-child:nth-child(7)) > * {
--sibling-count: 7;
}
:has(> :last-child:nth-child(8)) {
--sibbling-count: 8;
:has(> :last-child:nth-child(8)) > * {
--sibling-count: 8;
}
:has(> :last-child:nth-child(9)) {
--sibbling-count: 9;
:has(> :last-child:nth-child(9)) > * {
--sibling-count: 9;
}
:has(> :last-child:nth-child(10)) {
--sibbling-count: 10;
:has(> :last-child:nth-child(10)) > * {
--sibling-count: 10;
}
:has(> :last-child:nth-child(11)) {
--sibbling-count: 11;
:has(> :last-child:nth-child(11)) > * {
--sibling-count: 11;
}
:has(> :last-child:nth-child(12)) {
--sibbling-count: 12;
:has(> :last-child:nth-child(12)) > * {
--sibling-count: 12;
}
:has(> :last-child:nth-child(13)) {
--sibbling-count: 13;
:has(> :last-child:nth-child(13)) > * {
--sibling-count: 13;
}
:has(> :last-child:nth-child(14)) {
--sibbling-count: 14;
:has(> :last-child:nth-child(14)) > * {
--sibling-count: 14;
}
:has(> :last-child:nth-child(15)) {
--sibbling-count: 15;
:has(> :last-child:nth-child(15)) > * {
--sibling-count: 15;
}
}

View file

@ -1,4 +1,5 @@
import {
createAsync,
json,
Params,
query,
@ -6,6 +7,7 @@ import {
RouteDefinition,
useParams,
} from "@solidjs/router";
import { createEffect } from "solid-js";
import { createSlug, getEntry } from "~/features/content";
import { Player } from "~/features/player";
import { toSlug } from "~/utilities";
@ -33,6 +35,8 @@ interface ItemParams extends Params {
export const route = {
async preload({ params }) {
await healUrl(params.slug);
return getEntry(params.slug.slice(params.slug.lastIndexOf("-") + 1));
},
} satisfies RouteDefinition;