alright, got it to the point where it'll automatically download new entries. now to fix the UX of it all...

This commit is contained in:
Chris Kruining 2025-06-16 16:15:45 +02:00
parent 6a209f8698
commit b5306d1d11
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
10 changed files with 195 additions and 61 deletions

View file

@ -0,0 +1,85 @@
# Playing media
This document will describe the application UX flow.
## Types of media & Expected behavior
- movie
- actual movie -> show the video player and start/continue playing the movie
- collection -> Show a picker for which movie to play (also allow play-all)
- audio/music
- artist -> Show top songs, albums, and a play-all button
- album -> Show songs in album and a play-all button
- song -> Show audio player and start playing the song
- show/tv
- series -> Show a picker for the seasons and include a play button that will start/continue the first non-completed episode
- season -> Show a picker for the episodes and include a play button that will start/continue the first non-completed episode
- episode -> Show the video player and start/continue the episode (include the skip to previous&next episode buttons)
- playlist -> play the selected entry according to the above listed definitions
## UX flow
```txt
WHEN type of media IS {
audio -> {
WHEN entry does not exist in lidarr {
add entry to lidarr
}
WHEN queue record status IS {
paused -> ???
downloading -> {
display estimated time remaining
wait for download to complete
}
_ -> ???
}
play audio
}
show/tv -> {
WHEN entry is not an episode {
redirect to earliest non-completed episode
}
WHEN entry does not exist in sonarr {
add entry to sonarr
}
WHEN queue record status IS {
paused -> ???
downloading -> {
display estimated time remaining
wait for download to complete
}
_ -> ???
}
play episode
}
movie -> {
WHEN entry does not exist in radarr {
add entry to radarr
}
WHEN queue record status IS {
paused -> ???
downloading -> {
display estimated time remaining
wait for download to complete
}
_ -> ???
}
play movie
}
}
```

View file

@ -25,7 +25,7 @@ const Page: Component<{ entry: Entry }> = (props) => {
> >
<h2 class={css.title}>{props.entry.title}</h2> <h2 class={css.title}>{props.entry.title}</h2>
<a class={css.cta} href={`/watch/${slug()}`}> <a class={css.cta} href={`/play/${slug()}`}>
Continue Continue
</a> </a>

View file

@ -213,15 +213,13 @@ export const getItemStream = query(
const userData = await getItemUserData(userId, itemId); const userData = await getItemUserData(userId, itemId);
console.log(userData);
const { response } = await getClient().GET("/Videos/{itemId}/stream", { const { response } = await getClient().GET("/Videos/{itemId}/stream", {
params: { params: {
path: { path: {
itemId, itemId,
}, },
query: { query: {
// startTimeTicks: userData?.playbackPositionTicks, startTimeTicks: userData?.playbackPositionTicks,
}, },
}, },
parseAs: 'stream', parseAs: 'stream',

View file

@ -26,10 +26,52 @@ export const getClient = () => {
}); });
}; };
export const get = query(async () => { export const TEST = query(async (id: number) => {
"use server"; "use server";
const { data, error } = await getClient().GET('/api/v3/movie'); const { data, error } = await getClient().GET('/api/v3/queue/details', {
params: {
query: {
movieId: id,
// includeMovie: true,
},
},
});
const item = data?.[0];
if (!item?.status) {
return;
}
switch(item.status) {
case 'paused': {
console.log('not sure what to do now. there\'s a reason it is paused after all. just report it to the client perhaps?');
return;
}
case 'downloading': {
console.log(`download is esitmated to complete at ${item.estimatedCompletionTime}`);
return;
}
default: {
return;
}
}
}, 'radarr.TEST');
export const get = query(async (id: number) => {
"use server";
const { data, error } = await getClient().GET('/api/v3/movie/{id}', {
params: {
path: {
id,
},
},
});
return data; return data;
}, 'radarr.get'); }, 'radarr.get');

View file

@ -10,7 +10,7 @@ import {
searchMulti, searchMulti,
} from "./apis/tmdb"; } from "./apis/tmdb";
import { listIds as listSerieIds, addSeries } from "./apis/sonarr"; import { listIds as listSerieIds, addSeries } from "./apis/sonarr";
import { listIds as listMovieIds, addMovie } from "./apis/radarr"; import { listIds as listMovieIds, addMovie, TEST } from "./apis/radarr";
import { merge } from "~/utilities"; import { merge } from "~/utilities";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763"; const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
@ -34,8 +34,14 @@ export const getStream = query(async (id: string, range: string) => {
return getItemStream(jellyfinUserId, ids.jellyfin, range); return getItemStream(jellyfinUserId, ids.jellyfin, range);
} }
if (ids?.[manager]) { if (ids?.radarr) {
console.log('id is known, but jellyfin does not (yet) have the file'); console.log(`radarr has the entry '${ids.radarr}', but jellyfin does not (yet) have the file`);
console.log(await TEST(ids.radarr))
return;
}
if (ids?.sonarr) {
console.log('sonarr has the entry, but jellyfin does not (yet) have the file');
return; return;
} }

View file

@ -8,13 +8,12 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
return ( return (
<figure class={css.listItem} data-id={props.entry.id}> <figure class={css.listItem} data-id={props.entry.id}>
<img src={props.entry.thumbnail ?? ''} alt={props.entry.title} /> <img src={props.entry.thumbnail ?? ""} alt={props.entry.title} />
<figcaption> <figcaption>
<strong>{props.entry.title}</strong> <strong>{props.entry.title}</strong>
<a href={`/watch/${slug()}`}>Watch now</a> <a href={`/play/${slug()}`}>Watch now</a>
</figcaption> </figcaption>
</figure> </figure>
); );

View file

@ -3,7 +3,7 @@ import {
ContextProviderProps, ContextProviderProps,
createContextProvider, createContextProvider,
} from "@solid-primitives/context"; } from "@solid-primitives/context";
import { Accessor, createEffect, onMount, Setter } from "solid-js"; import { Accessor, createEffect, on, onMount, Setter } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { createEventListenerMap } from "@solid-primitives/event-listener"; import { createEventListenerMap } from "@solid-primitives/event-listener";
import { createFullscreen } from "@solid-primitives/fullscreen"; import { createFullscreen } from "@solid-primitives/fullscreen";
@ -124,13 +124,20 @@ export const [VideoProvider, useVideo] = createContextProvider<
}, },
}; };
console.log(props.root, props.video);
if (isServer || video === undefined) { if (isServer || video === undefined) {
return api; return api;
} }
createEffect(() => { createEffect(
video[store.state === "playing" ? "play" : "pause"](); on(
}); () => store.state,
(state) => {
video[state === "playing" ? "play" : "pause"]();
}
)
);
createEffect(() => { createEffect(() => {
video.muted = store.volume.muted; video.muted = store.volume.muted;
@ -174,9 +181,11 @@ export const [VideoProvider, useVideo] = createContextProvider<
); );
}, },
canplay() { canplay() {
console.log("can play!");
// setStore("loading", false); // setStore("loading", false);
}, },
canplaythrough() { canplaythrough() {
console.log("can play through!");
setStore("loading", false); setStore("loading", false);
}, },
waiting() { waiting() {

View file

@ -33,15 +33,15 @@ const metadata = query(async (id: string) => {
// 3. create sprite from images // 3. create sprite from images
// 4. remove thumbs // 4. remove thumbs
const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`; // const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`;
return json({ // return json({
captions: await Bun.file(`${path}.captions.vtt`).bytes(), // captions: await Bun.file(`${path}.captions.vtt`).bytes(),
thumbnails: { // thumbnails: {
track: await Bun.file(`${path}.thumbnails.vtt`).text(), // track: await Bun.file(`${path}.thumbnails.vtt`).text(),
image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(), // image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(),
}, // },
}); // });
}, "player.metadata"); }, "player.metadata");
interface PlayerProps { interface PlayerProps {
@ -56,32 +56,30 @@ export const Player: Component<PlayerProps> = (props) => {
undefined as unknown as HTMLVideoElement undefined as unknown as HTMLVideoElement
); );
const data = createAsync(() => metadata(props.entry.id), { // const data = createAsync(() => metadata(props.entry.id), {
deferStream: true, // deferStream: true,
initialValue: {} as any, // initialValue: {} as any,
}); // });
const captionUrl = createMemo(() => { // const captionUrl = createMemo(() => {
const { captions } = data(); // const { captions } = data();
return captions !== undefined // return captions !== undefined
? URL.createObjectURL(new Blob([captions], { type: "text/vtt" })) // ? URL.createObjectURL(new Blob([captions], { type: "text/vtt" }))
: ""; // : "";
}); // });
const thumbnails = createMemo(() => { // const thumbnails = createMemo(() => {
const { thumbnails } = data(); // const { thumbnails } = data();
return thumbnails !== undefined // return thumbnails !== undefined
? URL.createObjectURL(new Blob([thumbnails.track], { type: "text/vtt" })) // ? URL.createObjectURL(new Blob([thumbnails.track], { type: "text/vtt" }))
: ""; // : "";
}); // });
createEffect(on(thumbnails, (thumbnails) => {})); // createEffect(on(thumbnails, (thumbnails) => {}));
return ( return (
<> <>
<figure ref={setPlayer} class={css.player}> <figure ref={setPlayer} class={css.player}>
{/* <h1>{props.entry.title}</h1> */}
<video <video
ref={setVideo} ref={setVideo}
src={`/api/content/${props.entry.id}/stream${ src={`/api/content/${props.entry.id}/stream${
@ -91,7 +89,7 @@ export const Player: Component<PlayerProps> = (props) => {
lang="en" lang="en"
autoplay autoplay
> >
<track {/* <track
default default
kind="captions" kind="captions"
label="English" label="English"
@ -99,7 +97,7 @@ export const Player: Component<PlayerProps> = (props) => {
src={captionUrl()} src={captionUrl()}
/> />
<track default kind="chapters" src={thumbnails()} id="thumbnails" /> <track default kind="chapters" src={thumbnails()} id="thumbnails" />
{/* <track kind="captions" /> <track kind="captions" />
<track kind="chapters" /> <track kind="chapters" />
<track kind="descriptions" /> <track kind="descriptions" />
<track kind="metadata" /> <track kind="metadata" />

View file

@ -7,7 +7,7 @@ import {
RouteDefinition, RouteDefinition,
useParams, useParams,
} from "@solidjs/router"; } from "@solidjs/router";
import { createEffect, createMemo, Show } from "solid-js"; import { Show } from "solid-js";
import { createSlug, getEntryFromSlug } from "~/features/content"; import { createSlug, getEntryFromSlug } from "~/features/content";
import { Player } from "~/features/player"; import { Player } from "~/features/player";
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
@ -27,8 +27,8 @@ const healUrl = query(async (slug: string) => {
} }
// Not entirely sure a permanent redirect is what we want in this case // Not entirely sure a permanent redirect is what we want in this case
throw redirect(`/watch/${actualSlug}`, { status: 308 }); throw redirect(`/play/${actualSlug}`, { status: 308 });
}, "watch.heal"); }, "play.heal");
interface ItemParams extends Params { interface ItemParams extends Params {
slug: string; slug: string;
@ -51,19 +51,16 @@ export const route = {
export default function Item() { export default function Item() {
const { slug } = useParams<ItemParams>(); const { slug } = useParams<ItemParams>();
const entry = createAsync(() => getEntryFromSlug(slug)); const entry = createAsync(() => getEntryFromSlug(slug));
const title = createMemo(() => entry()?.title);
console.log(entry());
createEffect(() => {
console.log(entry());
});
return ( return (
<div class={css.page}> <div class={css.page}>
<Title>{title()}</Title> <Title>{entry()?.title}</Title>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess"> <Show when={entry()} fallback="Some kind of pretty 404 page I guess">
{(entry) => <Player entry={entry()} />} {(entry) => (
<>
<Player entry={entry()} />
</>
)}
</Show> </Show>
</div> </div>
); );