feat: tmdb cache, plugin support changes, series page, episode page, movie page streaming updated

This commit is contained in:
Aleksi Lassila
2025-01-31 18:54:04 +02:00
parent dc295ed203
commit cf289872f7
37 changed files with 3373 additions and 4380 deletions

View File

@@ -22,8 +22,6 @@
export let rating: number | undefined = undefined;
export let progress = 0;
$: console.log('progress', progress);
export let disabled = false;
export let shadow = false;
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';

View File

@@ -6,7 +6,16 @@
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames';
import { Cross1, DotFilled, ExternalLink, Play, Plus, Trash } from 'radix-icons-svelte';
import {
Bookmark,
Cross1,
DotFilled,
ExternalLink,
Minus,
Play,
Plus,
Trash
} from 'radix-icons-svelte';
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
import {
type EpisodeDownload,
@@ -16,7 +25,7 @@
import Button from '../Button.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import { createModal, modalStack } from '../Modal/modal.store';
import { get } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { scrollIntoView, useRegistrar } from '../../selectable';
import ScrollHelper from '../ScrollHelper.svelte';
import Carousel from '../Carousel/Carousel.svelte';
@@ -29,15 +38,34 @@
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
import DownloadDetailsDialog from './DownloadDetailsDialog.svelte';
import { reiverrApiNew, sources, user } from '../../stores/user.store';
import type { VideoStreamCandidateDto } from '../../apis/reiverr/reiverr.openapi';
import type { MediaSource } from '../../apis/reiverr/reiverr.openapi';
import SelectDialog from '../Dialog/SelectDialog.svelte';
import { useUserData } from '../../stores/library.store';
export let id: string;
const tmdbId = Number(id);
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(
tmdbApi.getTmdbSeries,
Number(id)
const showUserData = reiverrApiNew.users
.getShowUserData($user?.id as string, id)
.then((r) => r.data);
const streams = getStreams();
const availableForStreaming = writable(false);
const { inLibrary, progress, handleAddToLibrary, handleRemoveFromLibrary } = useUserData(
'Series',
id,
showUserData
);
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
streams.forEach((p) =>
p.streams.then((s) => availableForStreaming.update((p) => p || s.length > 0))
);
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(tmdbApi.getTmdbSeries, tmdbId);
let sonarrItem = sonarrApi.getSeriesByTmdbId(tmdbId);
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, tmdbId);
$: sonarrDownloads = getDownloads(sonarrItem);
$: sonarrFiles = getFiles(sonarrItem);
@@ -71,6 +99,25 @@
// hideInterface = !!modal;
// });
function getStreams() {
const out: { source: MediaSource; streams: Promise<VideoStreamCandidateDto[]> }[] = [];
for (const source of get(sources)) {
out.push({
source: source.source,
streams: showUserData.then((userData) => {
const { season, episode } = userData.playState ?? {};
return reiverrApiNew.sources
.getEpisodeStreams(source.source.id, id, season ?? 1, episode ?? 1)
.then((r) => r.data?.streams ?? []);
})
});
}
return out;
}
function getJellyfinSeries(id: string) {
return jellyfinApi.getLibraryItemFromTmdbId(id);
}
@@ -78,7 +125,7 @@
const onGrabRelease = () => setTimeout(() => (sonarrDownloads = getDownloads(sonarrItem)), 8000);
function handleAddedToSonarr() {
sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
sonarrItem = sonarrApi.getSeriesByTmdbId(tmdbId);
sonarrItem.then(
(sonarrItem) =>
sonarrItem &&
@@ -141,6 +188,49 @@
.then(() => (sonarrDownloads = getDownloads(sonarrItem)))
});
}
async function handlePlay() {
const awaitedStreams = await Promise.all(
streams.map(async (p) => ({ ...p, streams: await p.streams }))
).then((d) => d.filter((p) => p.streams.length > 0));
if (awaitedStreams.length > 1) {
modalStack.create(SelectDialog, {
title: 'Select Media Source',
subtitle: 'Select the media source you want to use',
options: awaitedStreams.map((p) => p.source.id),
handleSelectOption: (sourceId) => {
const s = awaitedStreams.find((p) => p.source.id === sourceId);
const key = s?.streams[0]?.key;
showUserData.then((userData) =>
playerState.streamEpisode(
id,
userData.playState?.season ?? 1,
userData.playState?.episode ?? 1,
userData,
sourceId,
key
)
);
}
});
} else if (awaitedStreams.length === 1) {
const asd = awaitedStreams.find((p) => p.streams.length > 0);
const sourceId = asd?.source.id;
const key = asd?.streams[0]?.key;
showUserData.then((userData) =>
playerState.streamEpisode(
id,
userData.playState?.season ?? 1,
userData.playState?.episode ?? 1,
userData,
sourceId,
key
)
);
}
}
</script>
<DetachedPage let:handleGoBack let:registrar>
@@ -214,7 +304,20 @@
on:back={handleGoBack}
on:mount={registrar}
>
{#if nextJellyfinEpisode}
<Button class="mr-4" action={handlePlay} disabled={!$availableForStreaming}>
Play
<Play size={19} slot="icon" />
</Button>
{#if !$inLibrary}
<Button class="mr-4" action={handleAddToLibrary} icon={Bookmark}>
Add to Library
</Button>
{:else}
<Button class="mr-4" action={handleRemoveFromLibrary} icon={Minus}>
Remove from Library
</Button>
{/if}
<!-- {#if nextJellyfinEpisode}
<Button
class="mr-4"
on:clickOrSelect={() =>
@@ -223,13 +326,13 @@
Play Season {nextJellyfinEpisode?.ParentIndexNumber} Episode
{nextJellyfinEpisode?.IndexNumber}
<Play size={19} slot="icon" />
</Button>
{:else}
<Button class="mr-4" action={() => handleRequestSeason(1)}>
Request
<Plus size={19} slot="icon" />
</Button>
{/if}
</Button> -->
<!-- {:else} -->
<Button class="mr-4" action={() => handleRequestSeason(1)}>
Request
<Plus size={19} slot="icon" />
</Button>
<!-- {/if} -->
{#if PLATFORM_WEB}
<Button class="mr-4">

View File

@@ -15,6 +15,8 @@
import { modalStackTop } from '../Modal/modal.store';
export let tmdbId: string;
export let season: number | undefined = undefined;
export let episode: number | undefined = undefined;
export let sourceId: string;
export let key: string = '';
export let progress: number = 0;
@@ -46,27 +48,50 @@
let videoStreamP: Promise<VideoStreamDto>;
const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => {
title = r?.title || '';
subtitle = '';
});
// const movieP = tmdbApi.getTmdbMovie(Number(tmdbId)).then((r) => {
// title = r?.title || '';
// subtitle = '';
// });
function reportProgress() {
const userId = get(user)?.id;
if (!userId) {
console.error('Update progress failed: User not logged in');
return;
}
if (video?.readyState === 4 && video?.currentTime > 0 && video?.duration > 0)
reiverrApiNew.users.updateMoviePlayStateByTmdbId($user?.id as string, tmdbId, {
progress: video.currentTime / video?.duration,
watched: progressTime > 0.9
});
if (season !== undefined && episode !== undefined) {
reiverrApiNew.users.updateEpisodePlayStateByTmdbId(userId, tmdbId, season, episode, {
progress: video.currentTime / video?.duration,
watched: progressTime > 0.9
});
} else {
reiverrApiNew.users.updateMoviePlayStateByTmdbId(userId, tmdbId, {
progress: video.currentTime / video?.duration,
watched: progressTime > 0.9
});
}
}
const refreshVideoStream = async (audioStreamIndex = 0) => {
videoStreamP = reiverrApiNew.sources
.getMovieStream(tmdbId, sourceId, key, {
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
progress,
audioStreamIndex,
deviceProfile: getDeviceProfile() as any
})
console.log('refreshVideoStream', season, episode);
videoStreamP = (
season !== undefined && episode !== undefined
? reiverrApiNew.sources.getEpisodeStream(sourceId, tmdbId, season, episode, key, {
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
progress,
audioStreamIndex,
deviceProfile: getDeviceProfile() as any
})
: reiverrApiNew.sources.getMovieStream(tmdbId, sourceId, key, {
// bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000,
progress,
audioStreamIndex,
deviceProfile: getDeviceProfile() as any
})
)
.then((r) => r.data)
.then((d) => ({
...d,

View File

@@ -6,7 +6,7 @@ import { reiverrApiNew, sources } from '../../stores/user.store';
import { createErrorNotification } from '../Notifications/notification.store';
import VideoPlayerModal from './VideoPlayerModal.svelte';
import MovieVideoPlayerModal from './MovieVideoPlayerModal.svelte';
import type { MovieUserDataDto } from '../../apis/reiverr/reiverr.openapi';
import type { MediaUserDataDto } from '../../apis/reiverr/reiverr.openapi';
export type SubtitleInfo = {
subtitles?: Subtitles;
@@ -51,7 +51,7 @@ function usePlayerState() {
async function streamTmdbMovie(
tmdbId: string,
userData: MovieUserDataDto,
userData: MediaUserDataDto,
sourceId: string = '',
key: string = ''
) {
@@ -81,9 +81,47 @@ function usePlayerState() {
});
}
async function streamTmdbEpisode(
tmdbId: string,
season: number,
episode: number,
userData: MediaUserDataDto,
sourceId: string = '',
key: string = ''
) {
if (!sourceId) {
const streams = await Promise.all(
get(sources).map((s) =>
reiverrApiNew.sources
.getEpisodeStreams(s.source.id, tmdbId, season, episode)
.then((r) => ({ source: s.source, streams: r.data.streams }))
)
);
sourceId = streams?.[0]?.source.id || '';
key = streams?.[0]?.streams?.[0]?.key || '';
}
if (!sourceId) {
createErrorNotification('Could not find a suitable source');
return;
}
store.set({ visible: true, jellyfinId: tmdbId, sourceId });
console.log('sourceId', season, episode);
modalStack.create(MovieVideoPlayerModal, {
tmdbId,
episode,
season,
sourceId,
key,
progress: userData.playState?.progress || 0
});
}
return {
...store,
streamMovie: streamTmdbMovie,
streamEpisode: streamTmdbEpisode,
streamJellyfinId: (id: string) => {
store.set({ visible: true, jellyfinId: id, sourceId: '' });
modalStack.create(JellyfinVideoPlayerModal, { id });