diff --git a/backend/plugins/jellyfin.plugin/src/index.ts b/backend/plugins/jellyfin.plugin/src/index.ts index e1dc9d2..a194a0d 100644 --- a/backend/plugins/jellyfin.plugin/src/index.ts +++ b/backend/plugins/jellyfin.plugin/src/index.ts @@ -7,6 +7,7 @@ import { } from './jellyfin.openapi'; import { PlaybackConfig, + SourcePluginCapabilities, PluginSettings, PluginSettingsTemplate, SourcePlugin, @@ -14,7 +15,11 @@ import { UserContext, VideoStream, VideoStreamCandidate, -} from 'plugins/plugin-types'; + IndexItem, + PaginatedResponse, + PaginationParams, + SourcePluginError, +} from '../../plugin-types'; import { bitrateQualities, formatSize, @@ -106,7 +111,41 @@ export default class JellyfinPlugin implements SourcePlugin { }; }; - getIndex: () => Promise>; + getCapabilities: (conext: UserContext) => Promise = + async (context) => { + return { + deletion: true, + indexing: true, + playback: true, + requesting: true, + }; + }; + + getMovieIndex: ( + context: UserContext, + pagination: PaginationParams, + ) => Promise> = async ( + userContext: JellyfinUserContext, + pagination, + ) => { + const items = ( + await this.getLibraryItems( + new PluginContext(userContext.settings, userContext.token), + ) + ).filter((i) => i.ProviderIds?.Tmdb && i.Type === 'Movie'); + + const startIndex = (pagination.page - 1) * pagination.itemsPerPage; + const endIndex = startIndex + pagination.itemsPerPage; + + return { + total: items.length, + page: pagination.page, + itemsPerPage: pagination.itemsPerPage, + items: items.slice(startIndex, endIndex).map((item) => ({ + id: item.ProviderIds?.Tmdb, + })), + }; + }; getIsIndexable: () => boolean = () => true; @@ -177,9 +216,13 @@ export default class JellyfinPlugin implements SourcePlugin { deviceProfile: undefined, }, ): Promise { - return this.getMovieStream(tmdbId, '', userContext, config).then( - (stream) => [stream], - ); + return this.getMovieStream(tmdbId, '', userContext, config) + .then((stream) => [stream]) + .catch((e) => { + if (e === SourcePluginError.StreamNotFound) { + return []; + } else throw e; + }); } async getMovieStream( @@ -203,7 +246,7 @@ export default class JellyfinPlugin implements SourcePlugin { // console.log(items.map((item) => item)) if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) { - throw new Error('Movie stream not found'); + throw SourcePluginError.StreamNotFound; } /* diff --git a/backend/plugins/plugin-types.d.ts b/backend/plugins/plugin-types.ts similarity index 94% rename from backend/plugins/plugin-types.d.ts rename to backend/plugins/plugin-types.ts index 933d8fc..db0652b 100644 --- a/backend/plugins/plugin-types.d.ts +++ b/backend/plugins/plugin-types.ts @@ -1,3 +1,7 @@ +export enum SourcePluginError { + StreamNotFound = 'StreamNotFound', +} + export type PluginSettingsLink = { type: 'link'; url: string; @@ -81,22 +85,38 @@ export type PlaybackConfig = { defaultLanguage: string | undefined; }; +export type SourcePluginCapabilities = { + playback: boolean; + indexing: boolean; + requesting: boolean; + deletion: boolean; +}; + +export class IndexItem { + id: string; +} + +export class PaginatedResponse { + total: number; + page: number; + itemsPerPage: number; + items: T[]; +} + +export class PaginationParams { + page: number; + itemsPerPage: number; +} + export interface SourcePlugin { name: string; getIsIndexable: () => boolean; - getIndex: () => Promise< - Record< - number, - any - // | { tmdbId: number; quality: number } - // | { - // tmdbId: number; - // seasons: Record>; - // } - > - >; + getMovieIndex: ( + context: UserContext, + pagination: PaginationParams, + ) => Promise>; getSettingsTemplate: () => PluginSettingsTemplate; @@ -104,6 +124,8 @@ export interface SourcePlugin { settings: Record, ) => Promise; + getCapabilities: (conext: UserContext) => Promise; + getMovieStream: ( tmdbId: string, key: string, @@ -117,13 +139,6 @@ export interface SourcePlugin { config?: PlaybackConfig, ) => Promise; - getMovieStream: ( - tmdbId: string, - context: UserContext, - key: string, - config?: PlaybackConfig, - ) => Promise; - getEpisodeStream: ( tmdbId: string, season: number, diff --git a/backend/src/common/common.decorator.ts b/backend/src/common/common.decorator.ts new file mode 100644 index 0000000..aa511a1 --- /dev/null +++ b/backend/src/common/common.decorator.ts @@ -0,0 +1,45 @@ +import { + applyDecorators, + createParamDecorator, + ExecutionContext, + Type, +} from '@nestjs/common'; +import { PaginatedResponseDto, PaginationParamsDto } from './common.dto'; +import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; + +export const GetPaginationParams = createParamDecorator( + (data: number | undefined, ctx: ExecutionContext): PaginationParamsDto => { + const request = ctx.switchToHttp().getRequest(); + const page = parseInt(request.query.page, 10) || 1; + const itemsPerPage = parseInt(request.query.itemsPerPage, 10) || data || 50; + + return { + itemsPerPage, + page, + }; + }, +); + +export const PaginatedApiOkResponse = >( + data: GenericType, +) => + applyDecorators( + ApiExtraModels(PaginatedResponseDto, data), + ApiOkResponse({ + description: `The paginated result of ${data.name}`, + schema: { + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + items: { + type: 'array', + items: { $ref: getSchemaPath(data) }, + }, + }, + required: ['items'], + }, + ], + }, + }), + ); diff --git a/backend/src/common/common.dto.ts b/backend/src/common/common.dto.ts new file mode 100644 index 0000000..15e6f0e --- /dev/null +++ b/backend/src/common/common.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginatedResponse, PaginationParams } from 'plugins/plugin-types'; + +export class PaginatedResponseDto implements PaginatedResponse { + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + itemsPerPage: number; + + // @ApiProperty() + items: T[]; +} + +export class PaginationParamsDto implements PaginationParams { + @ApiProperty() + page: number; + + @ApiProperty() + itemsPerPage: number; +} diff --git a/backend/src/source-plugins/device-profile.dto.ts b/backend/src/source-plugins/device-profile.dto.ts index d72beb7..b418bcb 100644 --- a/backend/src/source-plugins/device-profile.dto.ts +++ b/backend/src/source-plugins/device-profile.dto.ts @@ -1,6 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; +import { + CodecProfile, + ContainerProfile, + DeviceProfile, + DirectPlayProfile, + ProfileCondition, + SubtitleProfile, + TranscodingProfile, +} from 'plugins/plugin-types'; -export class DirectPlayProfileDto { +export class DirectPlayProfileDto implements DirectPlayProfile { @ApiProperty({ required: false, description: 'Gets or sets the container.', @@ -30,7 +39,7 @@ export class DirectPlayProfileDto { Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; } -export class ProfileConditionDto { +export class ProfileConditionDto implements ProfileCondition { @ApiProperty({ description: 'Gets or sets the condition.', enum: [ @@ -123,7 +132,7 @@ export class ProfileConditionDto { IsRequired?: boolean; } -export class TranscodingProfileDto { +export class TranscodingProfileDto implements TranscodingProfile { @ApiProperty({ description: 'Gets or sets the container.', nullable: true, @@ -250,7 +259,7 @@ export class TranscodingProfileDto { EnableAudioVbrEncoding?: boolean; } -export class ContainerProfileDto { +export class ContainerProfileDto implements ContainerProfile { @ApiProperty({ description: 'Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet.', @@ -285,7 +294,7 @@ export class ContainerProfileDto { SubContainer?: string | null; } -export class CodecProfileDto { +export class CodecProfileDto implements CodecProfile { @ApiProperty({ description: 'Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet.', @@ -335,7 +344,7 @@ export class CodecProfileDto { SubContainer?: string | null; } -export class SubtitleProfileDto { +export class SubtitleProfileDto implements SubtitleProfile { @ApiProperty({ description: 'Gets or sets the format.', nullable: true, @@ -381,7 +390,7 @@ export class SubtitleProfileDto { * the device is able to direct play (without transcoding or remuxing), * as well as which containers/codecs to transcode to in case it isn't. */ -export class DeviceProfileDto { +export class DeviceProfileDto implements DeviceProfile { @ApiProperty({ description: 'Gets or sets the name of this device profile. User profiles must have a unique name.', diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/source-plugins/source-plugins.controller.ts index ff73f7c..ca0bbe7 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/source-plugins/source-plugins.controller.ts @@ -4,9 +4,11 @@ import { Body, Controller, Get, + Injectable, + InternalServerErrorException, NotFoundException, Param, - ParseIntPipe, + PipeTransform, Post, Query, Req, @@ -15,23 +17,49 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { SourcePluginsService } from './source-plugins.service'; -import { AuthGuard, GetAuthToken, GetUser } from 'src/auth/auth.guard'; import { Request, Response } from 'express'; -import { Readable } from 'stream'; -import { User } from 'src/users/user.entity'; -import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; +import { SourcePlugin, SourcePluginError } from 'plugins/plugin-types'; +import { AuthGuard, GetAuthToken, GetUser } from 'src/auth/auth.guard'; import { + GetPaginationParams, + PaginatedApiOkResponse, +} from 'src/common/common.decorator'; +import { + PaginatedResponseDto, + PaginationParamsDto, +} from 'src/common/common.dto'; +import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; +import { User } from 'src/users/user.entity'; +import { Readable } from 'stream'; +import { + IndexItemDto, PlaybackConfigDto, PluginSettingsDto, PluginSettingsTemplateDto, - VideoStreamListDto, - ValidationResponsekDto as ValidationResponseDto, + SourcePluginCapabilitiesDto, + ValidationResponseDto, VideoStreamDto, + VideoStreamListDto, } from './source-plugins.dto'; +import { SourcePluginsService } from './source-plugins.service'; export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; +@Injectable() +export class ValidateSourcePluginPipe implements PipeTransform { + constructor(private readonly sourcesService: SourcePluginsService) {} + + async transform(sourceId: string) { + const plugin = this.sourcesService.getPlugin(sourceId); + + if (!plugin) { + throw new NotFoundException('Plugin not found'); + } + + return plugin; + } +} + @Controller() @UseGuards(AuthGuard) export class SourcesController { @@ -95,6 +123,58 @@ export class SourcesController { return plugin.validateSettings(settings.settings); } + @ApiTags('sources') + @Get('sources/:sourceId/capabilities') + @ApiOkResponse({ + type: SourcePluginCapabilitiesDto, + }) + async getSourceCapabilities( + @GetUser() user: User, + @Param('sourceId', ValidateSourcePluginPipe) plugin: SourcePlugin, + @GetAuthToken() token: string, + ): Promise { + const settings = this.userSourcesService.getSourceSettings( + user, + plugin.name, + ); + + if (!settings) { + throw new BadRequestException('Source configuration not found'); + } + + return plugin.getCapabilities({ + settings: settings.settings, + token, + }); + } + + @ApiTags('sources') + @Get('sources/:sourceId/index/movies') + @PaginatedApiOkResponse(IndexItemDto) + async getSourceMovieIndex( + @GetUser() user: User, + @Param('sourceId', ValidateSourcePluginPipe) plugin: SourcePlugin, + @GetAuthToken() token: string, + @GetPaginationParams() pagination: PaginationParamsDto, + ): Promise> { + const settings = this.userSourcesService.getSourceSettings( + user, + plugin.name, + ); + + if (!settings) { + throw new BadRequestException('Source configuration not found'); + } + + return plugin.getMovieIndex( + { + settings, + token, + }, + pagination, + ); + } + @ApiTags('movies') @Get('movies/:tmdbId/sources/:sourceId/streams') @ApiOkResponse({ @@ -119,10 +199,11 @@ export class SourcesController { throw new BadRequestException('Source configuration not found'); } - const streams = await plugin.getMovieStreams(tmdbId, { - settings, - token, - }); + const streams = await plugin + .getMovieStreams(tmdbId, { + settings, + token, + }) return { streams, @@ -184,15 +265,21 @@ export class SourcesController { throw new BadRequestException('Source configuration not found'); } - return plugin.getMovieStream( - tmdbId, - key || '', - { - settings, - token, - }, - config, - ); + return plugin + .getMovieStream( + tmdbId, + key || '', + { + settings, + token, + }, + config, + ) + .catch((e) => { + if (e === SourcePluginError.StreamNotFound) { + throw new NotFoundException('Stream not found'); + } else throw new InternalServerErrorException(); + }); } @ApiTags('movies') diff --git a/backend/src/source-plugins/source-plugins.dto.ts b/backend/src/source-plugins/source-plugins.dto.ts index 0c637e6..d21bf2c 100644 --- a/backend/src/source-plugins/source-plugins.dto.ts +++ b/backend/src/source-plugins/source-plugins.dto.ts @@ -5,12 +5,14 @@ import { } from '@nestjs/swagger'; import { AudioStream, + IndexItem, PlaybackConfig, PluginSettings, PluginSettingsInput, PluginSettingsLink, PluginSettingsTemplate, Quality, + SourcePluginCapabilities, Subtitles, ValidationResponse, VideoStream, @@ -19,6 +21,11 @@ import { } from 'plugins/plugin-types'; import { DeviceProfileDto } from './device-profile.dto'; +export class IndexItemDto implements IndexItem { + @ApiProperty() + id: string; +} + class PluginSettingsLinkDto implements PluginSettingsLink { @ApiProperty({ example: 'link', enum: ['link'] }) type: 'link'; @@ -61,6 +68,20 @@ export class PluginSettingsTemplateDto { settings: PluginSettingsTemplate; } +export class SourcePluginCapabilitiesDto implements SourcePluginCapabilities { + @ApiProperty() + playback: boolean; + + @ApiProperty() + indexing: boolean; + + @ApiProperty() + requesting: boolean; + + @ApiProperty() + deletion: boolean; +} + export class PluginSettingsDto { @ApiProperty({ type: 'object', @@ -75,7 +96,7 @@ export class PluginSettingsDto { settings: PluginSettings; } -export class ValidationResponsekDto implements ValidationResponse { +export class ValidationResponseDto implements ValidationResponse { @ApiProperty({ example: true }) isValid: boolean; diff --git a/src/App.svelte b/src/App.svelte index a6c0c1f..7d7c6b3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,7 +12,7 @@ import { createModal } from './lib/components/Modal/modal.store'; import UpdateDialog from './lib/components/Dialog/UpdateDialog.svelte'; import { localSettings } from './lib/stores/localstorage.store'; - import { user } from './lib/stores/user.store'; + import { isAppInitialized, user } from './lib/stores/user.store'; import { sessions } from './lib/stores/session.store'; import SplashScreen from './lib/pages/SplashScreen.svelte'; import UsersPage from './lib/pages/UsersPage.svelte'; @@ -62,7 +62,7 @@ -{#if $user === undefined} +{#if !$isAppInitialized || $user === undefined} {:else if $user === null} diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index b8642f5..788b8da 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -111,7 +111,7 @@ export interface PluginSettingsDto { settings: Record; } -export interface ValidationResponsekDto { +export interface ValidationResponseDto { /** @example true */ isValid: boolean; /** @example {"setting1":"error message","setting2":"another error message"} */ @@ -120,6 +120,23 @@ export interface ValidationResponsekDto { replace: Record; } +export interface SourcePluginCapabilitiesDto { + playback: boolean; + indexing: boolean; + requesting: boolean; + deletion: boolean; +} + +export interface PaginatedResponseDto { + total: number; + page: number; + itemsPerPage: number; +} + +export interface IndexItemDto { + id: string; +} + export interface VideoStreamPropertyDto { label: string; value: string | number; @@ -787,13 +804,48 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/api/sources/${sourceId}/settings/validate`, method: 'POST', body: data, type: ContentType.Json, format: 'json', ...params + }), + + /** + * No description + * + * @tags sources + * @name GetSourceCapabilities + * @request GET:/api/sources/{sourceId}/capabilities + */ + getSourceCapabilities: (sourceId: string, params: RequestParams = {}) => + this.request({ + path: `/api/sources/${sourceId}/capabilities`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetSourceMovieIndex + * @request GET:/api/sources/{sourceId}/index/movies + */ + getSourceMovieIndex: (sourceId: string, params: RequestParams = {}) => + this.request< + PaginatedResponseDto & { + items: IndexItemDto[]; + }, + any + >({ + path: `/api/sources/${sourceId}/index/movies`, + method: 'GET', + format: 'json', + ...params }) }; movies = { diff --git a/src/lib/components/VideoPlayer/VideoPlayer.ts b/src/lib/components/VideoPlayer/VideoPlayer.ts index 8781585..afc383d 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.ts +++ b/src/lib/components/VideoPlayer/VideoPlayer.ts @@ -2,10 +2,9 @@ import { get, writable } from 'svelte/store'; import { modalStack } from '../Modal/modal.store'; import { jellyfinItemsStore } from '../../stores/data.store'; import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte'; -import { reiverrApiNew } from '../../stores/user.store'; +import { reiverrApiNew, sources } from '../../stores/user.store'; import { createErrorNotification } from '../Notifications/notification.store'; import VideoPlayerModal from './VideoPlayerModal.svelte'; -import { sources } from '../../stores/sources.store'; import MovieVideoPlayerModal from './MovieVideoPlayerModal.svelte'; export type SubtitleInfo = { @@ -54,8 +53,8 @@ function usePlayerState() { const streams = await Promise.all( get(sources).map((s) => reiverrApiNew.movies - .getMovieStreams(tmdbId, s.id) - .then((r) => ({ source: s, streams: r.data.streams })) + .getMovieStreams(tmdbId, s.source.id) + .then((r) => ({ source: s.source, streams: r.data.streams })) ) ); sourceId = streams?.[0]?.source.id || ''; diff --git a/src/lib/pages/LibraryPage.svelte b/src/lib/pages/LibraryPage.svelte index 3ccd0f2..1199d8f 100644 --- a/src/lib/pages/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage.svelte @@ -1,18 +1,52 @@ @@ -61,7 +99,7 @@ {/if} {/await} -
+ +
+
+
Available for streaming
+
+ + + {#each availableTmdbItems as item} + + {/each} + +
diff --git a/src/lib/pages/MoviePage/MoviePage.svelte b/src/lib/pages/MoviePage/MoviePage.svelte index e2fa774..b13a4bf 100644 --- a/src/lib/pages/MoviePage/MoviePage.svelte +++ b/src/lib/pages/MoviePage/MoviePage.svelte @@ -36,8 +36,7 @@ import { capitalize, formatSize } from '../../utils'; import ConfirmDialog from '../../components/Dialog/ConfirmDialog.svelte'; import { TMDB_BACKDROP_SMALL } from '../../constants.js'; - import { reiverrApiNew } from '../../stores/user.store'; - import { sources } from '../../stores/sources.store'; + import { reiverrApiNew, sources } from '../../stores/user.store'; import { get } from 'svelte/store'; import type { VideoStreamCandidateDto, MediaSource } from '../../apis/reiverr/reiverr.openapi'; import MovieStreams from './MovieStreams.MoviePage.svelte'; @@ -60,11 +59,12 @@ for (const source of get(sources)) { out.set( - source, - reiverrApiNew.movies.getMovieStreams(id, source.id).then((r) => r.data?.streams ?? []) + source.source, + reiverrApiNew.movies + .getMovieStreams(id, source.source.id) + .then((r) => r.data?.streams ?? []) ); } - console.log(out); return out; } diff --git a/src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte b/src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte index 848dba6..5e79d81 100644 --- a/src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte +++ b/src/lib/pages/MoviePage/MovieStreams.MoviePage.svelte @@ -8,7 +8,10 @@ import { modalStack } from '../../components/Modal/modal.store'; export let streams: Map>; - export let createStreamDetailsDialog: (source: MediaSource, stream: VideoStreamCandidateDto) => void; + export let createStreamDetailsDialog: ( + source: MediaSource, + stream: VideoStreamCandidateDto + ) => void; {#each [...streams.keys()] as source} -

- {capitalize(source.id)} -

{#await streams.get(source)} Loading... {:then streams} - = 2 - })} - > - {#each streams || [] as stream} - createStreamDetailsDialog(source, stream)} - on:enter={scrollIntoView({ vertical: 128 })} - focusOnClick - > -
-

- {stream.title} -

-
- {#each stream.properties.slice(0, 2) as property} -
- {property.formatted ?? property.value} + {#if streams?.length} +

+ {capitalize(source.id)} +

+ = 2 + })} + > + {#each streams || [] as stream} + createStreamDetailsDialog(source, stream)} + on:enter={scrollIntoView({ vertical: 128 })} + focusOnClick + > +
+

+ {stream.title} +

- {/each} - -
- {/each} -
+ + {/each} + + {/if} {/await} {/each} diff --git a/src/lib/stores/sources.store.ts b/src/lib/stores/sources.store.ts index 745546c..f0d27cb 100644 --- a/src/lib/stores/sources.store.ts +++ b/src/lib/stores/sources.store.ts @@ -1,18 +1,41 @@ -import { derived, get, writable } from 'svelte/store'; -import { getReiverrApiNew, reiverrApi, type ReiverrUser } from '../apis/reiverr/reiverr-api'; -import axios from 'axios'; -import type { operations } from '../apis/reiverr/reiverr.generated'; -import { type Session, sessions } from './session.store'; -import { user } from './user.store'; +import { derived, writable } from 'svelte/store'; +import type { MediaSource, SourcePluginCapabilitiesDto } from '../apis/reiverr/reiverr.openapi'; +import { reiverrApiNew, user } from './user.store'; function useSources() { - const availableSources = derived( - user, - (user) => user?.mediaSources?.filter((s) => s.enabled)?.map((s) => ({ ...s })) ?? [] + const sources = writable<{ source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[]>( + [] ); + user.subscribe(async (user) => { + if (!user) { + sources.set([]); + return; + } + + const out: { source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[] = []; + + user?.mediaSources + ?.filter((s) => s.enabled) + ?.forEach(async (s) => { + out.push({ + source: s, + capabilities: await reiverrApiNew.sources + .getSourceCapabilities(s.id, s.pluginSettings ?? ({} as any)) + .then((r) => r.data) + }); + }); + + sources.set(out); + }); + + // const availableSources = derived( + // user, + // (user) => user?.mediaSources?.filter((s) => s.enabled)?.map((s) => ({ ...s })) ?? [] + // ); + return { - subscribe: availableSources.subscribe + subscribe: sources.subscribe }; } diff --git a/src/lib/stores/user.store.ts b/src/lib/stores/user.store.ts index aca47f6..4b9a210 100644 --- a/src/lib/stores/user.store.ts +++ b/src/lib/stores/user.store.ts @@ -3,18 +3,50 @@ import { getReiverrApiNew, reiverrApi, type ReiverrUser } from '../apis/reiverr/ import axios from 'axios'; import type { operations } from '../apis/reiverr/reiverr.generated'; import { type Session, sessions } from './session.store'; +import type { SourcePluginCapabilitiesDto, MediaSource } from '../apis/reiverr/reiverr.openapi'; export let reiverrApiNew: ReturnType; function useUser() { const activeSession = derived(sessions, (sessions) => sessions.activeSession); + const initializedStores = writable({ user: false, sources: false }); const userStore = writable(undefined); + const sources = writable<{ source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[]>( + [] + ); + const isAppInitialized = derived(initializedStores, ({ user, sources }) => user && sources); let lastActiveSession: Session | undefined; activeSession.subscribe(async (activeSession) => { - refreshUser(activeSession); - reiverrApiNew = getReiverrApiNew(); + initializedStores.set({ user: false, sources: false }); + await refreshUser(activeSession); + }); + + userStore.subscribe(async (user) => { + if (!user) { + sources.set([]); + initializedStores.update((i) => ({ ...i, sources: i.user })); + return; + } + + const out: { source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[] = []; + + await Promise.all( + user?.mediaSources + ?.filter((s) => s.enabled) + ?.map(async (s) => { + out.push({ + source: s, + capabilities: await reiverrApiNew.sources + .getSourceCapabilities(s.id, s.pluginSettings ?? ({} as any)) + .then((r) => r.data) + }); + }) ?? [] + ); + + sources.set(out); + initializedStores.update((i) => ({ ...i, sources: i.user })); }); async function updateUser(updateFn: (user: ReiverrUser) => ReiverrUser) { @@ -26,6 +58,7 @@ function useUser() { const { user: update, error } = await reiverrApi.updateUser(updated.id, updated); if (update) { + initializedStores.update((i) => ({ ...i, user: true })); userStore.set(update); } @@ -34,6 +67,7 @@ function useUser() { async function refreshUser(activeSession = get(sessions)?.activeSession) { if (!activeSession) { + initializedStores.update((i) => ({ ...i, user: true })); userStore.set(null); return; } @@ -51,14 +85,30 @@ function useUser() { .then((r) => r.data) .catch(() => null); - if (lastActiveSession === activeSession) userStore.set(user); + if (lastActiveSession === activeSession) { + initializedStores.update((i) => ({ ...i, user: true })); + reiverrApiNew = getReiverrApiNew(); + userStore.set(user); + } } + // initializedStores.subscribe((i) => console.log('initializedStores', i)); + return { - subscribe: userStore.subscribe, - updateUser, - refreshUser + user: { + subscribe: userStore.subscribe, + updateUser, + refreshUser + }, + sources: { + subscribe: sources.subscribe + }, + isAppInitialized: { + subscribe: isAppInitialized.subscribe + } }; } -export const user = useUser(); +export const { user, sources, isAppInitialized } = useUser(); +// isAppInitialized.subscribe((i) => console.log('isAppInitialized', i)); +sources.subscribe((s) => console.log('sources', s, s.length));