diff --git a/backend/src/common/common.dto.ts b/backend/src/common/common.dto.ts index 19af1c3..919fdcc 100644 --- a/backend/src/common/common.dto.ts +++ b/backend/src/common/common.dto.ts @@ -48,4 +48,5 @@ export class SuccessResponseDto { export enum MediaType { Movie = 'Movie', Series = 'Series', + Episode = 'Episode', } diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/source-plugins/source-plugins.controller.ts index b88cd5b..fc3c506 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/source-plugins/source-plugins.controller.ts @@ -240,35 +240,6 @@ export class SourcesController { return { streams, }; - - // const plugins = await this.sourcesService.getPlugins(); - // const streams: VideoStreamListDto['streams'] = []; - - // for (const pluginId in plugins) { - // const plugin = plugins[pluginId]; - - // if (!plugin) continue; - - // const settings = this.userSourcesService.getSourceSettings( - // user, - // pluginId, - // ); - - // if (!settings) continue; - - // const videoStream = await plugin.getMovieStreams(tmdbId, { - // settings, - // token, - // }); - - // if (!videoStream) continue; - - // streams[pluginId] = videoStream; - // } - - // return { - // streams, - // }; } @Get( @@ -460,42 +431,4 @@ export class SourcesController { targetUrl, }); } - - // @All('movies/:tmdbId/sources/:sourceId/stream/proxy/*') - // async getMovieStreamProxy( - // @Param() params: any, - // @Req() req: Request, - // @Res() res: Response, - // @GetAuthUser() user: User, - // ) { - // const sourceId = params.sourceId; - // const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - // if (!settings) throw new UnauthorizedException(); - - // const { url, headers } = this.sourcesService - // .getPlugin(sourceId) - // ?.handleProxy( - // { - // uri: params[0] + '?' + req.url.split('?')[1], - // headers: req.headers, - // }, - // settings, - // ); - - // // console.log('url', url.split('?')[0]); - // const proxyRes = await fetch(url.split('?')[0], { - // method: req.method || 'GET', - // headers: { - // // ...headers, - // // Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`, - // }, - // }).catch((e) => { - // console.error('error fetching proxy response', e); - // throw new InternalServerErrorException(); - // }); - - // Readable.from(proxyRes.body).pipe(res); - // res.status(proxyRes.status); - // } } diff --git a/backend/src/users/play-state/play-state.controller.ts b/backend/src/users/play-state/play-state.controller.ts index fe2dc77..7d9a3d7 100644 --- a/backend/src/users/play-state/play-state.controller.ts +++ b/backend/src/users/play-state/play-state.controller.ts @@ -22,19 +22,18 @@ export class PlayStateController { constructor(private playStateService: PlayStateService) {} @Put('movie/tmdb/:tmdbId') - @ApiQuery({ name: 'mediaType', enum: MediaType, required: false }) + // @ApiQuery({ name: 'mediaType', enum: MediaType, required: false }) async updateMoviePlayStateByTmdbId( @Param('userId') userId: string, @Param('tmdbId') tmdbId: string, @Body() playState: UpdatePlayStateDto, - @Query('mediaType', new ParseEnumPipe(MediaType, { optional: true })) - mediaType?: MediaType, + // @Query('mediaType', new ParseEnumPipe(MediaType, { optional: true })) + // mediaType?: MediaType, ) { return this.playStateService.updateOrCreateMoviePlayState( userId, tmdbId, playState, - mediaType, ); } diff --git a/backend/src/users/play-state/play-state.dto.ts b/backend/src/users/play-state/play-state.dto.ts index 9efa709..1e09f47 100644 --- a/backend/src/users/play-state/play-state.dto.ts +++ b/backend/src/users/play-state/play-state.dto.ts @@ -1,4 +1,4 @@ -import { OmitType } from '@nestjs/swagger'; +import { OmitType, PartialType } from '@nestjs/swagger'; import { PlayState } from './play-state.entity'; export class PlayStateDto extends PlayState {} @@ -21,12 +21,6 @@ export class PlayStateDto extends PlayState {} // } // } -export class UpdatePlayStateDto extends OmitType(PlayState, [ - 'id', - 'tmdbId', - 'episode', - 'season', - 'user', - 'userId', - 'lastPlayedAt', -]) {} +export class UpdatePlayStateDto extends PartialType( + OmitType(PlayStateDto, ['userId']), +) {} diff --git a/backend/src/users/play-state/play-state.entity.ts b/backend/src/users/play-state/play-state.entity.ts index 7db5b41..c5e3d90 100644 --- a/backend/src/users/play-state/play-state.entity.ts +++ b/backend/src/users/play-state/play-state.entity.ts @@ -17,16 +17,16 @@ import { MediaType } from 'src/common/common.dto'; @Entity() @Unique(['tmdbId', 'userId', 'season', 'episode']) export class PlayState { - @ApiProperty({ required: false, type: 'string' }) + @ApiProperty({ type: 'string' }) @PrimaryGeneratedColumn('uuid') id: string; @ApiProperty({ required: true, type: 'number' }) - @Column({ unique: true }) + @Column() tmdbId: string; - @ApiProperty({ required: false, enum: MediaType }) - @Column({ nullable: true }) + @ApiProperty({ enum: MediaType }) + @Column() mediaType: MediaType; @ApiProperty({ required: true, type: 'string' }) @@ -47,7 +47,6 @@ export class PlayState { episode: number = 0; @ApiProperty({ - required: false, type: 'boolean', default: false, description: 'Whether the user has watched this media', @@ -56,7 +55,6 @@ export class PlayState { watched: boolean = false; @ApiProperty({ - required: false, default: false, example: 0.5, description: 'A number between 0 and 1', @@ -67,7 +65,6 @@ export class PlayState { @ApiProperty({ type: 'string', description: 'Last time the user played this media', - required: false, }) @UpdateDateColumn() lastPlayedAt: Date; diff --git a/backend/src/users/play-state/play-state.service.ts b/backend/src/users/play-state/play-state.service.ts index 8464d45..cc0758b 100644 --- a/backend/src/users/play-state/play-state.service.ts +++ b/backend/src/users/play-state/play-state.service.ts @@ -18,12 +18,12 @@ export class PlayStateService { }); } - async findShowPlayState( + async findSeriesPlayStates( userId: string, tmdbId: string, season?: number, episode?: number, - ): Promise { + ): Promise { const playStates = (await this.playStateRepository.find({ where: { @@ -42,14 +42,13 @@ export class PlayStateService { return a.episode - b.episode; }); - return playStates[0]; + return playStates; } async updateOrCreateMoviePlayState( userId: string, tmdbId: string, playState: UpdatePlayStateDto, - mediaType?: MediaType, ) { let state = await this.findMoviePlayState(userId, tmdbId); @@ -57,9 +56,7 @@ export class PlayStateService { state = this.playStateRepository.create(); state.userId = userId; state.tmdbId = tmdbId; - if (mediaType) { - state.mediaType = mediaType; - } + state.mediaType = MediaType.Movie; } state.progress = playState.progress; @@ -75,7 +72,16 @@ export class PlayStateService { episode: number, playState: UpdatePlayStateDto, ) { - let state = await this.findShowPlayState(userId, tmdbId, season, episode); + let state = await this.findSeriesPlayStates( + userId, + tmdbId, + season, + episode, + ).then((states) => + states.find( + (state) => state.season === season && state.episode === episode, + ), + ); if (!state) { state = this.playStateRepository.create(); @@ -83,6 +89,7 @@ export class PlayStateService { state.tmdbId = tmdbId; state.season = season; state.episode = episode; + state.mediaType = MediaType.Episode; } state.progress = playState.progress; @@ -102,7 +109,12 @@ export class PlayStateService { season: number, episode: number, ) { - const state = await this.findShowPlayState(userId, tmdbId, season, episode); + const state = await this.findSeriesPlayStates( + userId, + tmdbId, + season, + episode, + ); return await this.playStateRepository.remove(state); } } diff --git a/backend/src/users/user.dto.ts b/backend/src/users/user.dto.ts index 4b2c695..87ac60f 100644 --- a/backend/src/users/user.dto.ts +++ b/backend/src/users/user.dto.ts @@ -58,7 +58,7 @@ export class UpdateUserDto extends PartialType( export class SignInDto extends PickType(User, ['name', 'password'] as const) {} -export class MediaUserDataDto { +export class MovieUserDataDto { @ApiProperty() tmdbId: string; @@ -68,3 +68,14 @@ export class MediaUserDataDto { @ApiProperty({ type: PlayStateDto, required: false }) playState?: PlayStateDto; } + +export class SeriesUserDataDto { + @ApiProperty() + tmdbId: string; + + @ApiProperty() + inLibrary: boolean; + + @ApiProperty({ type: [PlayStateDto] }) + playStates: PlayStateDto[]; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 83ee35a..d9048bc 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -22,7 +22,8 @@ import { import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { CreateUserDto, - MediaUserDataDto, + MovieUserDataDto, + SeriesUserDataDto, UpdateUserDto, UserDto, } from './user.dto'; @@ -167,12 +168,12 @@ export class UsersController { @Get(':userId/user-data/movie/tmdb/:tmdbId') @ApiOkResponse({ description: 'User movie data found', - type: MediaUserDataDto, + type: MovieUserDataDto, }) async getUserMovieData( @Param('userId') userId: string, @Param('tmdbId') tmdbId: string, - ): Promise { + ): Promise { const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); const playState = await this.playStateService.findMoviePlayState( userId, @@ -187,17 +188,17 @@ export class UsersController { } @UseGuards(UserAccessControl) - @Get(':userId/user-data/show/tmdb/:tmdbId') + @Get(':userId/user-data/series/tmdb/:tmdbId') @ApiOkResponse({ - description: 'User show data found', - type: MediaUserDataDto, + description: 'User series data found', + type: SeriesUserDataDto, }) - async getShowUserData( + async getSeriesUserData( @Param('userId') userId: string, @Param('tmdbId') tmdbId: string, - ): Promise { + ): Promise { const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); - const playState = await this.playStateService.findShowPlayState( + const playState = await this.playStateService.findSeriesPlayStates( userId, tmdbId, ); @@ -205,29 +206,28 @@ export class UsersController { return { tmdbId, inLibrary: !!libraryItem, - playState: playState, + playStates: playState, }; } @UseGuards(UserAccessControl) - @Get(':userId/user-data/show/tmdb/:tmdbId/season/:season/episode/:episode') + @Get(':userId/user-data/series/tmdb/:tmdbId/season/:season/episode/:episode') @ApiOkResponse({ - description: 'User show data found', - type: MediaUserDataDto, + description: 'User series data found', + type: MovieUserDataDto, }) async getEpisodeUserData( @Param('userId') userId: string, @Param('tmdbId') tmdbId: string, @Param('season', ParseIntPipe) season: number, @Param('episode', ParseIntPipe) episode: number, - ): Promise { + ): Promise { const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); - const playState = await this.playStateService.findShowPlayState( - userId, - tmdbId, - season, - episode, - ); + const playState = await this.playStateService + .findSeriesPlayStates(userId, tmdbId, season, episode) + .then((states) => + states.find((s) => s.season === season && s.episode === episode), + ); return { tmdbId, diff --git a/package.json b/package.json index 4db6bff..1f6724e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "format": "prettier --plugin-search-dir . --write .", "openapi:update": "npm run --prefix backend §generate && npm run openapi:codegen", "openapi:codegen": "openapi-typescript \"backend/swagger-spec.json\" -o src/lib/apis/reiverr/reiverr.generated.d.ts", - "openapi:generate": "swagger-typescript-api -p \"backend/swagger-spec.json\" -o src/lib/apis/reiverr -n reiverr.openapi.ts --axios --module-name-first-tag" + "openapi:generate": "swagger-typescript-api -p \"backend/swagger-spec.json\" -o src/lib/apis/reiverr -n reiverr.openapi.ts --axios --module-name-first-tag", + "openapi:generate-tmdb": "swagger-typescript-api -p \"backend/swagger-spec.json\" -o src/lib/apis/reiverr -n reiverr.openapi.ts --axios --module-name-first-tag" }, "devDependencies": { "@jellyfin/sdk": "^0.8.2", diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 498a54a..4b6eaf3 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -57,9 +57,9 @@ export interface MediaSource { } export interface PlayState { - id?: string; + id: string; tmdbId: number; - mediaType?: 'Movie' | 'Series'; + mediaType: 'Movie' | 'Series' | 'Episode'; userId: string; season?: number; episode?: number; @@ -67,21 +67,21 @@ export interface PlayState { * Whether the user has watched this media * @default false */ - watched?: boolean; + watched: boolean; /** * A number between 0 and 1 * @default false * @example 0.5 */ - progress?: number; + progress: number; /** Last time the user played this media */ - lastPlayedAt?: string; + lastPlayedAt: string; } export interface LibraryItem { id?: string; tmdbId: number; - mediaType: 'Movie' | 'Series'; + mediaType: 'Movie' | 'Series' | 'Episode'; userId: string; user?: string; } @@ -116,9 +116,9 @@ export interface UpdateUserDto { } export interface PlayStateDto { - id?: string; + id: string; tmdbId: number; - mediaType?: 'Movie' | 'Series'; + mediaType: 'Movie' | 'Series' | 'Episode'; userId: string; season?: number; episode?: number; @@ -126,23 +126,29 @@ export interface PlayStateDto { * Whether the user has watched this media * @default false */ - watched?: boolean; + watched: boolean; /** * A number between 0 and 1 * @default false * @example 0.5 */ - progress?: number; + progress: number; /** Last time the user played this media */ - lastPlayedAt?: string; + lastPlayedAt: string; } -export interface MediaUserDataDto { +export interface MovieUserDataDto { tmdbId: string; inLibrary: boolean; playState?: PlayStateDto; } +export interface SeriesUserDataDto { + tmdbId: string; + inLibrary: boolean; + playStates: PlayStateDto[]; +} + export interface CreateSourceDto { pluginSettings?: object; /** @default false */ @@ -164,7 +170,7 @@ export interface MovieDto { export interface LibraryItemDto { tmdbId: string; - mediaType: 'Movie' | 'Series'; + mediaType: 'Movie' | 'Series' | 'Episode'; playStates?: PlayStateDto[]; metadata?: MovieDto; } @@ -174,7 +180,11 @@ export interface SuccessResponseDto { } export interface UpdatePlayStateDto { - mediaType?: 'Movie' | 'Series'; + id?: string; + tmdbId?: number; + mediaType?: 'Movie' | 'Series' | 'Episode'; + season?: number; + episode?: number; /** * Whether the user has watched this media * @default false @@ -186,6 +196,8 @@ export interface UpdatePlayStateDto { * @example 0.5 */ progress?: number; + /** Last time the user played this media */ + lastPlayedAt?: string; } export interface SignInDto { @@ -786,7 +798,7 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/api/users/${userId}/user-data/movie/tmdb/${tmdbId}`, method: 'GET', format: 'json', @@ -797,12 +809,12 @@ export class Api extends HttpClient - this.request({ - path: `/api/users/${userId}/user-data/show/tmdb/${tmdbId}`, + getSeriesUserData: (userId: string, tmdbId: string, params: RequestParams = {}) => + this.request({ + path: `/api/users/${userId}/user-data/series/tmdb/${tmdbId}`, method: 'GET', format: 'json', ...params @@ -813,7 +825,7 @@ export class Api extends HttpClient extends HttpClient - this.request({ - path: `/api/users/${userId}/user-data/show/tmdb/${tmdbId}/season/${season}/episode/${episode}`, + this.request({ + path: `/api/users/${userId}/user-data/series/tmdb/${tmdbId}/season/${season}/episode/${episode}`, method: 'GET', format: 'json', ...params @@ -897,7 +909,7 @@ export class Api extends HttpClient @@ -935,15 +947,11 @@ export class Api extends HttpClient this.request({ path: `/api/users/${userId}/play-state/movie/tmdb/${tmdbId}`, method: 'PUT', - query: query, body: data, type: ContentType.Json, ...params diff --git a/src/lib/components/EpisodeCard/EpisodeCard.svelte b/src/lib/components/EpisodeCard/EpisodeCard.svelte index d6d23bb..930728d 100644 --- a/src/lib/components/EpisodeCard/EpisodeCard.svelte +++ b/src/lib/components/EpisodeCard/EpisodeCard.svelte @@ -76,14 +76,14 @@ // !isOnDeck && !$hasFocus })} /> -
- {#if handlePlay} - -
+ + +
diff --git a/src/lib/components/Notifications/notification.store.ts b/src/lib/components/Notifications/notification.store.ts index 5899f96..97f8cf8 100644 --- a/src/lib/components/Notifications/notification.store.ts +++ b/src/lib/components/Notifications/notification.store.ts @@ -25,7 +25,13 @@ function useNotificationStack() { } export const notificationStack = useNotificationStack(); -export const createErrorNotification = (message: string) => { +export const createErrorNotification = (title: string, message?: string) => { console.error(message); - notificationStack.create(Notification, { title: 'Unexpected error occurred', body: message }); + notificationStack.create(Notification, { + title: message ? title : 'Unexpected error occurred', + body: message ?? title + }); +}; +export const createInfoNotification = (title: string, message?: string) => { + notificationStack.create(Notification, { title, body: message }); }; diff --git a/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte b/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte index ee1cc92..7428487 100644 --- a/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte +++ b/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte @@ -65,12 +65,12 @@ if (season !== undefined && episode !== undefined) { reiverrApiNew.users.updateEpisodePlayStateByTmdbId(userId, tmdbId, season, episode, { progress: video.currentTime / video?.duration, - watched: progressTime > 0.9 + ...(video.currentTime / video?.duration > 0.9 && { watched: true }) }); } else { reiverrApiNew.users.updateMoviePlayStateByTmdbId(userId, tmdbId, { progress: video.currentTime / video?.duration, - watched: progressTime > 0.9 + ...(video.currentTime / video?.duration > 0.9 && { watched: true }) }); } } diff --git a/src/lib/components/VideoPlayer/VideoElement.svelte b/src/lib/components/VideoPlayer/VideoElement.svelte index 6a59da3..849c94a 100644 --- a/src/lib/components/VideoPlayer/VideoElement.svelte +++ b/src/lib/components/VideoPlayer/VideoElement.svelte @@ -2,6 +2,10 @@ import Hls from 'hls.js'; import { isTizen } from '../../utils/browser-detection'; import type { PlaybackInfo, SubtitleInfo } from './VideoPlayer'; + import { + createErrorNotification, + createInfoNotification + } from '../Notifications/notification.store'; export let playbackInfo: PlaybackInfo | undefined; export let subtitleInfo: SubtitleInfo | undefined; @@ -101,7 +105,9 @@ bind:duration={totalTime} bind:volume bind:muted - on:timeupdate={() => (progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime)} + on:timeupdate={() => { + progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime; + }} on:progress={handleProgress} on:loadeddata={() => { // console.log('video loaded', video.currentTime, video.duration, playbackInfo?.progress); @@ -113,12 +119,17 @@ } console.log('Video loaded'); + createInfoNotification('Video loaded'); }} on:waiting={() => (buffering = true)} on:playing={() => (buffering = false)} on:dblclick on:click={togglePlay} - on:error={(e) => console.error('Error loading video', e)} + on:error={(e) => { + createErrorNotification('Error loading video', 'Unsupported video format'); + }} + on:loadstart={() => createInfoNotification('Loading video')} + on:loadedmetadata={() => createInfoNotification('Loaded metadata')} autoplay playsinline crossorigin="anonymous" diff --git a/src/lib/components/VideoPlayer/VideoPlayer.ts b/src/lib/components/VideoPlayer/VideoPlayer.ts index 81bcf90..c032e1a 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.ts +++ b/src/lib/components/VideoPlayer/VideoPlayer.ts @@ -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 { MediaUserDataDto } from '../../apis/reiverr/reiverr.openapi'; +import type { MovieUserDataDto } from '../../apis/reiverr/reiverr.openapi'; export type SubtitleInfo = { subtitles?: Subtitles; @@ -49,21 +49,43 @@ export type PlayerStateValue = typeof initialValue; function usePlayerState() { const store = writable(initialValue); + async function streamTmdbItem(options: { + tmdbId: string; + season?: number; + episode?: number; + sourceId: string; + key: string; + progress?: number; + }) { + const { tmdbId, season, episode, sourceId, key, progress } = options; + + store.set({ visible: true, jellyfinId: tmdbId, sourceId }); + modalStack.create(MovieVideoPlayerModal, { + tmdbId, + episode, + season, + sourceId, + key, + progress + }); + } + async function streamTmdbMovie( tmdbId: string, options: { - userData?: MediaUserDataDto; sourceId?: string; key?: string; + progress?: number; } ) { - let { sourceId, key, userData } = options; + let { sourceId, key } = options; + const { progress = 0 } = options; if (!sourceId) { const streams = await Promise.all( get(sources).map((s) => reiverrApiNew.sources - .getMovieStreams(tmdbId, s.source.id) + .getMovieStreams(s.source.id, tmdbId) .then((r) => ({ source: s.source, streams: r.data.streams })) ) ); @@ -81,7 +103,7 @@ function usePlayerState() { tmdbId, sourceId, key, - progress: userData?.playState?.progress ?? 0 + progress }); } @@ -92,10 +114,12 @@ function usePlayerState() { options: { sourceId?: string; key?: string; - userData?: MediaUserDataDto; + progress?: number; } = {} ) { - let { sourceId, key, userData } = options; + let { sourceId, key } = options; + const { progress = 0 } = options; + if (!sourceId) { const streams = await Promise.all( get(sources).map((s) => @@ -121,7 +145,7 @@ function usePlayerState() { season, sourceId, key, - progress: userData?.playState?.progress ?? 0 + progress }); } @@ -129,6 +153,7 @@ function usePlayerState() { ...store, streamMovie: streamTmdbMovie, streamEpisode: streamTmdbEpisode, + streamTmdbItem, streamJellyfinId: (id: string) => { store.set({ visible: true, jellyfinId: id, sourceId: '' }); modalStack.create(JellyfinVideoPlayerModal, { id }); diff --git a/src/lib/pages/TitlePages/EpisodePage.svelte b/src/lib/pages/TitlePages/EpisodePage.svelte index 2a4b688..259a0bd 100644 --- a/src/lib/pages/TitlePages/EpisodePage.svelte +++ b/src/lib/pages/TitlePages/EpisodePage.svelte @@ -1,33 +1,28 @@ @@ -261,17 +257,22 @@ + + {#await Promise.all([jellyfinEpisode, sonarrEpisode])} Play Manage Media @@ -285,14 +286,6 @@ Play --> - {:else} + + {#if !$inLibrary}
{#if !$inLibrary} @@ -364,7 +375,7 @@ // 'opacity-0': hideInterface })} > - --> + {#await $tmdbSeries then series} diff --git a/src/lib/stores/library.store.ts b/src/lib/stores/library.store.ts index 6ccd16b..6adaf95 100644 --- a/src/lib/stores/library.store.ts +++ b/src/lib/stores/library.store.ts @@ -1,19 +1,103 @@ +import type { TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api'; +import { createModal } from '$lib/components/Modal/modal.store'; +import { createErrorNotification } from '$lib/components/Notifications/notification.store'; +import { playerState } from '$lib/components/VideoPlayer/VideoPlayer'; +import StreamSelectorModal from '$lib/pages/TitlePages/StreamSelectorModal.svelte'; import { get, writable } from 'svelte/store'; -import { reiverrApiNew, user } from './user.store'; -import type { MediaUserDataDto } from '../apis/reiverr/reiverr.openapi'; +import type { + MediaSource, + MovieUserDataDto, + SeriesUserDataDto, + VideoStreamCandidateDto +} from '../apis/reiverr/reiverr.openapi'; import type { MediaType } from '../types'; +import { reiverrApiNew, sources, user } from './user.store'; -export function useUserData( +export type EpisodeData = { + season: number; + episode: number; + watched: boolean; + progress: number; +}; + +async function getStreams( + tmdbId: string, + season?: number, + episode?: number +): Promise<{ source: MediaSource; streams: VideoStreamCandidateDto[] }[]> { + return Promise.all( + get(sources).map(async (source) => { + return { + source: source.source, + streams: await( + season !== undefined && episode !== undefined + ? reiverrApiNew.sources + .getEpisodeStreams(source.source.id, tmdbId, season, episode) + .then((r) => r.data?.streams ?? []) + : reiverrApiNew.sources + .getMovieStreams(source.source.id, tmdbId) + .then((r) => r.data?.streams ?? []) + ) + }; + }) + ); +} + +async function handleAutoplay(options: { + tmdbId: string; + season?: number; + episode?: number; + progress?: number; +}) { + const { tmdbId, season, episode, progress } = options; + + const awaitedStreams = await getStreams(tmdbId, season, episode); + + const firstSource = awaitedStreams.find((p) => p.streams.length > 0); + const sourceId = firstSource?.source.id; + const key = firstSource?.streams[0]?.key; + + if (season !== undefined && episode !== undefined) { + playerState.streamEpisode(tmdbId, season, episode, { + progress, + sourceId, + key + }); + } +} + +async function handleStreamSelector(options: { + tmdbId: string; + season?: number; + episode?: number; + progress?: number; +}) { + const { tmdbId, season, episode, progress } = options; + + createModal(StreamSelectorModal, { + getStreams: (s) => + getStreams(tmdbId, season, episode).then((r) => r.find((p) => p.source === s)?.streams ?? []), + selectStream: (source, stream) => + playerState.streamTmdbItem({ + tmdbId, + season, + episode, + progress, + key: stream.key, + sourceId: source.id + }) + }); +} + +function useUserLibrary( mediaType: MediaType, tmdbId: string, - userDataP: Promise + userDataP: Promise ) { const inLibrary = writable(undefined); - const progress = writable(0); userDataP.then((d) => { inLibrary.set(d?.inLibrary ?? false); - progress.set(d?.playState?.progress ?? 0); }); async function handleAddToLibrary() { @@ -46,8 +130,170 @@ export function useUserData( return { inLibrary, - progress, handleAddToLibrary, handleRemoveFromLibrary }; } + +function useIsWatched( + userData: Promise, + toggleFn: (userId: string, watched: boolean) => Promise +) { + const isWatched = writable(undefined); + + userData.then((d) => { + isWatched.set(d?.playState?.watched ?? false); + }); + + async function toggleIsWatched() { + const watched = get(isWatched); + const userId = get(user)?.id; + + if (!userId) { + return; + } + + return toggleFn(userId, !watched).finally(() => { + isWatched.set(!watched); + }); + } + + return { + isWatched, + toggleIsWatched + }; +} + +function useCanStream() { + const canStream = writable(true); + + return { + canStream + }; +} + +export function useSeriesUserData( + tmdbId: string, + userData: Promise, + tmdbSeries: Promise +) { + const libraryStore = useUserLibrary('Series', tmdbId, userData); + const canStreamStore = useCanStream(); + const episodesUserData = writable([]); + const nextEpisode = writable({ + season: 1, + episode: 1, + progress: 0, + watched: false + }); + + Promise.all([userData, tmdbSeries]).then(([userData, tmdbSeries]) => { + const episodesData: EpisodeData[] = []; + let foundNext = false; + for (let season = 1; season <= (tmdbSeries.number_of_seasons ?? 0); season++) { + for ( + let episode = 1; + episode <= (tmdbSeries.seasons?.[season - 1]?.episode_count ?? 0); + episode++ + ) { + const ep = userData?.playStates?.find((p) => p.season === season && p.episode === episode); + if (!foundNext && !ep?.watched) { + nextEpisode.set({ + season, + episode, + progress: ep?.progress ?? 0, + watched: ep?.watched ?? false + }); + foundNext = true; + } + episodesData.push({ + season, + episode, + watched: ep?.watched ?? false, + progress: ep?.progress ?? 0 + }); + } + } + episodesUserData.set(episodesData); + }); + + return { + ...libraryStore, + ...canStreamStore, + nextEpisode, + episodesUserData, + handleAutoplay: async () => { + const { season, episode, progress } = get(nextEpisode) ?? {}; + + if (season === undefined || episode === undefined) { + createErrorNotification('Could not find next episode'); + return; + } + + return handleAutoplay({ tmdbId, season, episode, progress }); + }, + handleStreamSelector: async () => { + const { season, episode, progress } = get(nextEpisode) ?? {}; + + if (season === undefined || episode === undefined) { + createErrorNotification('Could not find next episode'); + return; + } + + return handleStreamSelector({ tmdbId, season, episode, progress }); + } + }; +} + +export function useMovieUserData(tmdbId: string, userData: Promise) { + const libraryStore = useUserLibrary('Movie', tmdbId, userData); + const canStreamStore = useCanStream(); + const isWatchedStore = useIsWatched(userData, (userId, watched) => + reiverrApiNew.users.updateMoviePlayStateByTmdbId(userId, tmdbId, { + watched + }) + ); + const progress = writable(0); + + userData.then((d) => { + progress.set(d?.playState?.progress ?? 0); + }); + + return { + ...libraryStore, + ...canStreamStore, + ...isWatchedStore, + progress, + handleAutoplay: async () => handleAutoplay({ tmdbId, progress: get(progress) }), + handleStreamSelector: async () => handleStreamSelector({ tmdbId, progress: get(progress) }) + }; +} + +export function useEpisodeUserData( + tmdbId: string, + season: number, + episode: number, + userData: Promise +) { + const canStreamStore = useCanStream(); + const isWatchedStore = useIsWatched(userData, (userId, watched) => + reiverrApiNew.users.updateEpisodePlayStateByTmdbId(userId, tmdbId, season, episode, { + watched + }) + ); + const progress = writable(0); + + userData.then((d) => { + progress.set(d?.playState?.progress ?? 0); + }); + + return { + ...canStreamStore, + ...isWatchedStore, + progress, + handleAutoplay: async () => + handleAutoplay({ tmdbId, season, episode, progress: get(progress) }), + handleStreamSelector: async () => + handleStreamSelector({ tmdbId, season, episode, progress: get(progress) }) + }; +} diff --git a/src/lib/stores/user.store.ts b/src/lib/stores/user.store.ts index b0c5d5c..5c1aac8 100644 --- a/src/lib/stores/user.store.ts +++ b/src/lib/stores/user.store.ts @@ -101,7 +101,7 @@ function useUser() { refreshUser }, sources: { - subscribe: sources.subscribe + subscribe: sources.subscribe, }, isAppInitialized: { subscribe: isAppInitialized.subscribe diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a6c6aa9..b609d03 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -33,6 +33,10 @@ export function formatSize(size: number) { } } +export function formatThousands(num: number) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + export function request(fetcher: (arg: A) => Promise, args: A | undefined = undefined) { const loading = writable(args !== undefined); const error = writable(null);