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>
<a class={css.cta} href={`/watch/${slug()}`}>
<a class={css.cta} href={`/play/${slug()}`}>
Continue
</a>

View file

@ -213,15 +213,13 @@ export const getItemStream = query(
const userData = await getItemUserData(userId, itemId);
console.log(userData);
const { response } = await getClient().GET("/Videos/{itemId}/stream", {
params: {
path: {
itemId,
},
query: {
// startTimeTicks: userData?.playbackPositionTicks,
startTimeTicks: userData?.playbackPositionTicks,
},
},
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";
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;
}, 'radarr.get');

View file

@ -10,7 +10,7 @@ import {
searchMulti,
} from "./apis/tmdb";
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";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
@ -34,8 +34,14 @@ export const getStream = query(async (id: string, range: string) => {
return getItemStream(jellyfinUserId, ids.jellyfin, range);
}
if (ids?.[manager]) {
console.log('id is known, but jellyfin does not (yet) have the file');
if (ids?.radarr) {
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;
}

View file

@ -8,13 +8,12 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
return (
<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>
<strong>{props.entry.title}</strong>
<a href={`/watch/${slug()}`}>Watch now</a>
<a href={`/play/${slug()}`}>Watch now</a>
</figcaption>
</figure>
);

View file

@ -3,7 +3,7 @@ import {
ContextProviderProps,
createContextProvider,
} 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 { createEventListenerMap } from "@solid-primitives/event-listener";
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) {
return api;
}
createEffect(() => {
video[store.state === "playing" ? "play" : "pause"]();
});
createEffect(
on(
() => store.state,
(state) => {
video[state === "playing" ? "play" : "pause"]();
}
)
);
createEffect(() => {
video.muted = store.volume.muted;
@ -174,9 +181,11 @@ export const [VideoProvider, useVideo] = createContextProvider<
);
},
canplay() {
console.log("can play!");
// setStore("loading", false);
},
canplaythrough() {
console.log("can play through!");
setStore("loading", false);
},
waiting() {

View file

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

View file

@ -7,7 +7,7 @@ import {
RouteDefinition,
useParams,
} from "@solidjs/router";
import { createEffect, createMemo, Show } from "solid-js";
import { Show } from "solid-js";
import { createSlug, getEntryFromSlug } from "~/features/content";
import { Player } from "~/features/player";
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
throw redirect(`/watch/${actualSlug}`, { status: 308 });
}, "watch.heal");
throw redirect(`/play/${actualSlug}`, { status: 308 });
}, "play.heal");
interface ItemParams extends Params {
slug: string;
@ -51,19 +51,16 @@ export const route = {
export default function Item() {
const { slug } = useParams<ItemParams>();
const entry = createAsync(() => getEntryFromSlug(slug));
const title = createMemo(() => entry()?.title);
console.log(entry());
createEffect(() => {
console.log(entry());
});
return (
<div class={css.page}>
<Title>{title()}</Title>
<Title>{entry()?.title}</Title>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">
{(entry) => <Player entry={entry()} />}
{(entry) => (
<>
<Player entry={entry()} />
</>
)}
</Show>
</div>
);