diff --git a/backend/packages/jellyfin.plugin/src/media-source-provider.ts b/backend/packages/jellyfin.plugin/src/media-source-provider.ts index 034980b..1cb8d15 100644 --- a/backend/packages/jellyfin.plugin/src/media-source-provider.ts +++ b/backend/packages/jellyfin.plugin/src/media-source-provider.ts @@ -1,26 +1,25 @@ import { ActionResponse, - CatalogueItem, - DirectionOption, MediaSourceProvider, - OrderOption, - PaginatedResponse, - PaginationParams, PlaybackConfig, SourceProviderError, SourceProviderSettings, Stream, + StreamBase, StreamCandidate, + StreamResponse, Subtitles, UserContext, } from '@aleksilassila/reiverr-plugin'; +import { + MediaSourceView, + MediaSourceViews, +} from 'packages/reiverr-plugin/dist/src/ui.types'; import { Readable } from 'stream'; import { BaseItemKind, ItemFields, - ItemSortBy, Api as JellyfinApi, - SortOrder, } from './jellyfin.openapi'; import { bitrateQualities, @@ -30,6 +29,11 @@ import { JELLYFIN_DEVICE_ID, } from './utils'; +enum View { + StreamMovie = 'stream-movie', + StreamEpisode = 'stream-episode', +} + export interface JellyfinSettings extends SourceProviderSettings { apiKey: string; baseUrl: string; @@ -56,6 +60,100 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { }); } + getMeidaSourceViews: (options: { + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; + }) => Promise<{ views: MediaSourceViews }> = async ({ + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + if (tmdbMovie) { + return { + views: [ + { + id: View.StreamMovie, + label: 'Stream', + type: 'list-with-details', + }, + ], + }; + } else if (tmdbSeries && tmdbEpisode) { + return { + views: [ + { + id: View.StreamEpisode, + label: 'Stream', + type: 'list-with-details', + }, + ], + }; + } + + return { + views: [], + }; + }; + + getMediaSourceView: (options: { + id: string; + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; + }) => Promise<{ view?: MediaSourceView }> = async ({ + id, + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + let view: MediaSourceView; + + if (id === View.StreamMovie && tmdbMovie) { + const candidates = await this.getTmdbMovieCandidates({ tmdbMovie }); + view = { + type: 'list-with-details', + id, + label: 'Stream', + items: candidates.candidates.map((c) => ({ + ...c, + id: c.streamId, + label: c.title, + actions: c.actions.map((a) => ({ + label: a.label, + type: 'action', + action: a.type, + })), + })), + orderOptions: [], + }; + } else if (id === View.StreamEpisode && tmdbSeries && tmdbEpisode) { + const candidates = await this.getTmdbEpisodeCandidates({ + tmdbSeries, + tmdbEpisode, + }); + + view = { + type: 'list-with-details', + id, + label: 'Stream', + items: candidates.candidates.map((c) => ({ + ...c, + id: c.streamId, + label: c.title, + actions: c.actions.map((a) => ({ + label: a.label, + type: 'action', + action: a.type, + })), + })), + orderOptions: [], + }; + } + + return { view }; + }; + getTmdbMovieCandidates: (options: { tmdbMovie: any; }) => Promise<{ candidates: StreamCandidate[] }> = async ({ tmdbMovie }) => { @@ -74,7 +172,7 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { }); const movie = movies.data.Items.find( - (i) => i.ProviderIds?.Tmdb === tmdbMovie.id, + (i) => i.ProviderIds?.Tmdb === String(tmdbMovie.id), ); if (!movie || !movie.MediaSources || movie.MediaSources.length === 0) { @@ -243,17 +341,40 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { }; }; + getAutoplayStream: (options: { + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; + }) => Promise<{ candidate?: StreamBase }> = async ({ + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + if (tmdbMovie) { + const candidates = await this.getTmdbMovieCandidates({ tmdbMovie }); + return { candidate: candidates.candidates[0] }; + } else if (tmdbSeries && tmdbEpisode) { + const candidates = await this.getTmdbEpisodeCandidates({ + tmdbSeries, + tmdbEpisode, + }); + return { candidate: candidates.candidates[0] }; + } + + return {}; + }; + handleAction: (options: { - streamId: string; + targetId: string; action: string; config?: PlaybackConfig; }) => Promise = async (options) => { - if (options.action === 'stream') { - return this.getStream({ - streamId: options.streamId, - config: options.config, - }).then((stream) => ({ stream })); - } + // if (options.action === 'stream') { + // return this.getStream({ + // streamId: options.streamId, + // config: options.config, + // }).then((stream) => ({ stream })); + // } return { error: { @@ -262,10 +383,10 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { }; }; - getStream?: (options: { + getStream: (options: { streamId: string; config?: PlaybackConfig; - }) => Promise = async (options) => { + }) => Promise = async (options) => { const { progress, audioStreamIndex, deviceProfile } = options.config || {}; const movie = await this.api.items @@ -369,7 +490,7 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { label: s.DisplayTitle, })); - return { + const stream = { streamId: '0', title: movie.Name, properties: [ @@ -417,6 +538,10 @@ export class JellyfinMediaSourceProvider extends MediaSourceProvider { !!mediasSource?.SupportsDirectPlay || !!mediasSource?.SupportsDirectStream, }; + + return { + stream, + }; }; proxyHandler: (options: { diff --git a/backend/packages/reiverr-plugin/src/index.ts b/backend/packages/reiverr-plugin/src/index.ts index 8b5c4ee..164e08c 100644 --- a/backend/packages/reiverr-plugin/src/index.ts +++ b/backend/packages/reiverr-plugin/src/index.ts @@ -1,4 +1,5 @@ export * from './types'; +export * from './ui.types'; export * from './reiverr-plugin'; export * from './meida-source-provider'; export * from './catalogue-provider'; diff --git a/backend/packages/reiverr-plugin/src/meida-source-provider.ts b/backend/packages/reiverr-plugin/src/meida-source-provider.ts index 79cdf7a..884b0d6 100644 --- a/backend/packages/reiverr-plugin/src/meida-source-provider.ts +++ b/backend/packages/reiverr-plugin/src/meida-source-provider.ts @@ -1,7 +1,20 @@ -import { PlaybackConfig, ActionResponse, StreamCandidate } from './types'; +import { + PlaybackConfig, + ActionResponse, + StreamCandidate, + Stream, + StreamBase, + StreamResponse, +} from './types'; import { MediaSourceView, MediaSourceViews } from './ui.types'; import { WithMediaSource } from './with-media-source'; +type PlayableContext = { + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; +}; + /** * MediaSourceProvider is a class that handles all requests for Reiverr users that have configured the plugin as MediaSource. A new MediaSourceProvider is instantiated for each request / function call, and it contains data about the Reiverr user that called the function. */ @@ -22,22 +35,48 @@ export class MediaSourceProvider extends WithMediaSource { this.token = options.token; } - getMeidaSourceViews: (options: { - tmdbMovie?: any; - tmdbSeries?: any; - tmdbEpisode?: any; - }) => Promise = async () => ({ + getMeidaSourceViews: ( + options: PlayableContext, + ) => Promise<{ views: MediaSourceViews }> = async () => ({ views: [], }); - getMediaSourceView: (options: { - id: string; - tmdbMovie?: any; - tmdbSeries?: any; - tmdbEpisode?: any; - }) => Promise = async () => ({}); + getMediaSourceView: ( + options: PlayableContext & { + id: string; + }, + ) => Promise<{ view?: MediaSourceView }> = async () => ({}); + + getAutoplayStream: ( + options: PlayableContext, + ) => Promise<{ candidate?: StreamBase }> = async () => ({}); + + getStream: (options: { + streamId: string; + config?: PlaybackConfig; + }) => Promise = async () => ({}); /** + * Handles stream actions (e.g. stream, download, delete) for a specific stream. + * + * @see Stream + */ + handleAction: (options: { + targetId: string; + action: string; + }) => Promise = async () => ({ + toast: { + title: 'Not supported', + message: 'This action is not supported by this provider.', + type: 'error', + }, + error: { + message: 'Not supported', + }, + }); + + /** + * @deprecated * Returns a list of stream candidates for a movie that the user can choose to stream from. * * @see StreamCandidate @@ -49,6 +88,7 @@ export class MediaSourceProvider extends WithMediaSource { }); /** + * @deprecated * Returns a list of stream candidates for an episode that the user can choose to stream from. * * @see StreamCandidate @@ -60,26 +100,6 @@ export class MediaSourceProvider extends WithMediaSource { candidates: [], }); - /** - * Handles stream actions (e.g. stream, download, delete) for a specific stream. - * - * @see Stream - */ - handleAction: (options: { - streamId: string; - action: string; - config?: PlaybackConfig; - }) => Promise = async () => ({ - toast: { - title: 'Not supported', - message: 'This action is not supported by this provider.', - type: 'error', - }, - error: { - message: 'Not supported', - }, - }); - /** * This method will be called when the client makes a request to the provider's * proxy endpoint (e.g. /api/proxy/:providerName/:path). This can be used to diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index b4e98b9..1867ff1 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -164,8 +164,7 @@ export type Stream = StreamBase & { subtitles: Subtitles[]; }; -export type ActionResponse = { - stream?: Stream; +export type ActionResponseBase = { toast?: { title: string; message: string; @@ -176,6 +175,17 @@ export type ActionResponse = { }; }; +export type StreamResponse = ActionResponseBase & { + stream?: Stream; +}; + +export type ActionResponse = ActionResponseBase & { + result?: { + success: boolean; + message?: string; + }; +}; + export type PlaybackConfig = { bitrate: number | undefined; audioStreamIndex: number | undefined; diff --git a/backend/packages/reiverr-plugin/src/ui.types.ts b/backend/packages/reiverr-plugin/src/ui.types.ts index c97e26e..260e821 100644 --- a/backend/packages/reiverr-plugin/src/ui.types.ts +++ b/backend/packages/reiverr-plugin/src/ui.types.ts @@ -5,32 +5,39 @@ type Icon = { size?: 'lg' | 'md' | 'sm'; }; -type ViewBase = { +export type ViewBase = { id: string; - type: string; + type: 'general' | 'list-with-details'; label: string; priority?: number; }; type GeneralElementBase = { - type: string; + type: + | 'heading' + | 'toggle' + | 'select' + | 'action' + | 'input' + | 'external-link' + | 'open-view'; }; -export type HeadingElement = GeneralElementBase & { +export interface HeadingElement extends GeneralElementBase { type: 'heading'; label: string; description?: string; -}; +} -export type ToggleElement = GeneralElementBase & { +export interface ToggleElement extends GeneralElementBase { type: 'toggle'; label: string; description?: string; value: boolean; style: 'checkbox' | 'switch'; -}; +} -export type SelectElement = GeneralElementBase & { +export interface SelectElement extends GeneralElementBase { type: 'select'; label: string; description?: string; @@ -40,9 +47,9 @@ export type SelectElement = GeneralElementBase & { value: string; }[]; style: 'dropdown' | 'radio'; -}; +} -export type ActionElement = GeneralElementBase & { +export interface ActionElement extends GeneralElementBase { type: 'action'; /** @@ -57,15 +64,35 @@ export type ActionElement = GeneralElementBase & { */ action: string; + disabled?: boolean; + icon?: Icon; // /** // * The parameters to be passed to the action // */ // params: Record; -}; +} -export type InputElement = GeneralElementBase & { +export interface -StreamActionElement extends GeneralElementBase { + type: 'action'; + + /** + * The label of the action + * @example "Stream" + */ + label: 'Stream'; + + /** + * The type of the action + * @example "stream" + */ + action: 'stream'; + + disabled?: boolean; +} + +export interface InputElement extends GeneralElementBase { type: 'input'; label: string; description?: string; @@ -77,34 +104,35 @@ export type InputElement = GeneralElementBase & { maxLength?: number; minLength?: number; disabled?: boolean; -}; +} -export type ExternalLinkElement = GeneralElementBase & { +export interface ExternalLinkElement extends GeneralElementBase { type: 'external-link'; label: string; description?: string; url: string; -}; +} -export type OpenViewElement = GeneralElementBase & { +export interface OpenViewElement extends GeneralElementBase { type: 'open-view'; label: string; description?: string; viewId: string; -}; +} -export type GeneralView = ViewBase & { - type: 'settings'; +export interface GeneralView extends ViewBase { + type: 'general'; elements: ( | HeadingElement | ToggleElement | SelectElement + | StreamActionElement | ActionElement | InputElement | ExternalLinkElement | OpenViewElement )[]; -}; +} export type SortableProperty = { /** @@ -155,20 +183,16 @@ export type ListWithDetailsItem = { /** * A list of actions that the user can perform on the stream. */ - actions: (ActionElement | OpenViewElement)[]; + actions: (StreamActionElement | ActionElement | OpenViewElement)[]; }; export type ListWithDetailsView = ViewBase & { type: 'list-with-details'; items: ListWithDetailsItem[]; - order: OrderOption; + order?: OrderOption; orderOptions: OrderOption[]; }; -export type MediaSourceViews = { - views: ViewBase[]; -}; +export type MediaSourceViews = ViewBase[]; -export type MediaSourceView = { - view?: GeneralView | ListWithDetailsView; -}; +export type MediaSourceView = GeneralView | ListWithDetailsView; diff --git a/backend/packages/torrent-stream.plugin/src/index.ts b/backend/packages/torrent-stream.plugin/src/index.ts index 1938017..336edea 100644 --- a/backend/packages/torrent-stream.plugin/src/index.ts +++ b/backend/packages/torrent-stream.plugin/src/index.ts @@ -1,6 +1,8 @@ import { + CatalogueProvider, MediaSourceProvider, ReiverrPlugin, + SourceProviderSettings, SourceProviderSettingsTemplate, UserContext, ValidationResponse, @@ -8,12 +10,29 @@ import { import { testConnection } from './lib/jackett.api'; import { TorrentMediaSourceProvider } from './media-source-provider'; +class TorrentCatalogueProvider extends CatalogueProvider {} + class TorrentPlugin extends ReiverrPlugin { name: string = 'torrent'; - getMediaSourceProvider: (userContext: UserContext) => MediaSourceProvider = ( - context, - ) => new TorrentMediaSourceProvider(context); + getCatalogueProvider: (options: { + userId: string; + sourceId: string; + settings: SourceProviderSettings; + }) => CatalogueProvider = (options) => new TorrentCatalogueProvider(options); + + getMediaSourceProvider: ( + options: { + userId: string; + sourceId: string; + settings: SourceProviderSettings; + } & { token: string }, + ) => MediaSourceProvider = (options) => + new TorrentMediaSourceProvider(options); + + // getMediaSourceProvider: (userContext: UserContext) => MediaSourceProvider = ( + // context, + // ) => new TorrentMediaSourceProvider(context); getSettingsTemplate: () => SourceProviderSettingsTemplate = () => ({ baseUrl: { diff --git a/backend/packages/torrent-stream.plugin/src/media-source-provider.ts b/backend/packages/torrent-stream.plugin/src/media-source-provider.ts index 7ee7d0e..9c68573 100644 --- a/backend/packages/torrent-stream.plugin/src/media-source-provider.ts +++ b/backend/packages/torrent-stream.plugin/src/media-source-provider.ts @@ -1,14 +1,18 @@ import { - CatalogueItem, + ActionResponse, + ListWithDetailsView, MediaSourceProvider, - PaginatedResponse, - PaginationParams, + MediaSourceView, + MediaSourceViews, PlaybackConfig, - Stream, - StreamActionResponse, + StreamAction, + StreamActionElement, + StreamBase, StreamCandidate, + StreamResponse, Subtitles, UserContext, + ViewBase, } from '@aleksilassila/reiverr-plugin'; import { getEpisodeTorrents, @@ -25,6 +29,26 @@ import { videoExtensions, } from './utils'; +export const movieStreamView = { + id: 'stream-movie', + type: 'list-with-details', + label: 'Stream', + priority: 0, +} satisfies ViewBase; + +export const episodeStreamView = { + id: 'stream-episode', + type: 'list-with-details', + label: 'Stream', + priority: 0, +} satisfies ViewBase; + +export const streamAction = { + action: 'stream', + label: 'Stream', + type: 'action', +} satisfies StreamActionElement; + export class TorrentMediaSourceProvider extends MediaSourceProvider { private proxyUrl: string; @@ -33,11 +57,112 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { this.proxyUrl = `/api/sources/${this.sourceId}/proxy`; } - getTmdbMovieCandidates?: - | ((options: { - tmdbMovie: any; - }) => Promise<{ candidates: StreamCandidate[] }>) - | undefined = async ({ tmdbMovie }) => { + getMeidaSourceViews: (options: { + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; + }) => Promise<{ views: MediaSourceViews }> = async ({ + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + const views: MediaSourceViews = []; + + if (tmdbMovie) { + views.push(movieStreamView); + } else if (tmdbSeries && tmdbEpisode) { + views.push(episodeStreamView); + } + + return { + views, + }; + }; + + getMediaSourceView: ( + options: { tmdbMovie?: any; tmdbSeries?: any; tmdbEpisode?: any } & { + id: string; + }, + ) => Promise<{ view?: MediaSourceView }> = async ({ + id, + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + let view: MediaSourceView | undefined; + + if (id === movieStreamView.id && tmdbMovie) { + const { candidates } = await this.getTmdbMovieCandidates({ + tmdbMovie, + }); + + const view: ListWithDetailsView = { + ...movieStreamView, + items: candidates.map((c) => ({ + id: c.streamId, + label: c.title, + properties: c.properties, + actions: [streamAction], + })), + orderOptions: [], + order: undefined, + }; + + return { view }; + } else if (id === episodeStreamView.id && tmdbSeries && tmdbEpisode) { + const { candidates } = await this.getTmdbEpisodeCandidates({ + tmdbSeries, + tmdbEpisode, + }); + + const view: ListWithDetailsView = { + ...episodeStreamView, + items: candidates.map((c) => ({ + id: c.streamId, + label: c.title, + properties: c.properties, + actions: [streamAction], + })), + orderOptions: [], + order: undefined, + }; + + return { view }; + } + + return {}; + }; + + getAutoplayStream: (options: { + tmdbMovie?: any; + tmdbSeries?: any; + tmdbEpisode?: any; + }) => Promise<{ candidate?: StreamBase }> = async ({ + tmdbMovie, + tmdbSeries, + tmdbEpisode, + }) => { + if (tmdbMovie) { + const { candidates } = await this.getTmdbMovieCandidates({ + tmdbMovie, + }); + + return { candidate: candidates[0] }; + } else if (tmdbSeries && tmdbEpisode) { + const { candidates } = await this.getTmdbEpisodeCandidates({ + tmdbSeries, + tmdbEpisode, + }); + + return { candidate: candidates[0] }; + } + + return {}; + }; + + getTmdbMovieCandidates: (options: { + tmdbMovie: any; + }) => Promise<{ candidates: StreamCandidate[] }> = async ({ tmdbMovie }) => { const settings = this.settings as TorrentSettings; const year = tmdbMovie.release_date @@ -55,12 +180,13 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { return { candidates }; }; - getTmdbEpisodeCandidates?: - | ((options: { - tmdbSeries: any; - tmdbEpisode: any; - }) => Promise<{ candidates: StreamCandidate[] }>) - | undefined = async ({ tmdbSeries, tmdbEpisode }) => { + getTmdbEpisodeCandidates: (options: { + tmdbSeries: any; + tmdbEpisode: any; + }) => Promise<{ candidates: StreamCandidate[] }> = async ({ + tmdbSeries, + tmdbEpisode, + }) => { const settings = this.settings as TorrentSettings; const torrents = getEpisodeTorrents( @@ -109,17 +235,16 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { return { candidates }; }; - handleAction?: (options: { - streamId: string; + handleAction: (options: { + targetId: string; action: string; - config?: PlaybackConfig; - }) => Promise = async (options) => { - if (options.action === 'stream') { - return this.getStream({ - streamId: options.streamId, - config: options.config, - }).then((stream) => ({ stream })); - } + }) => Promise = async (options) => { + // if (options.action === 'stream') { + // return this.getStream({ + // streamId: options.targetId, + // config: options.config, + // }).then((stream) => ({ stream })); + // } return { error: { @@ -131,7 +256,7 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { getStream: (options: { streamId: string; config?: PlaybackConfig; - }) => Promise = async ({ streamId, config }) => { + }) => Promise = async ({ streamId, config }) => { const settings = this.settings as TorrentSettings; const [link, season, episode] = streamId.split(EPISODE_SEPARATOR); @@ -167,7 +292,7 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { lang: 'unknown', })); - return { + const stream = { streamId, src, audioStreamIndex: 0, @@ -181,16 +306,18 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { title: 'Unknown', directPlay: true, }; + + return { + stream, + }; }; - proxyHandler?: - | ((options: { - req: any; - res: any; - uri: string; - targetUrl?: string; - }) => Promise) - | undefined = async ({ req, res, uri, targetUrl }) => { + proxyHandler: (options: { + req: any; + res: any; + uri: string; + targetUrl?: string; + }) => Promise = async ({ req, res, uri, targetUrl }) => { const settings = this.settings as TorrentSettings; const params = new URLSearchParams(uri.split('?').slice(1).join('?')); @@ -297,37 +424,4 @@ export class TorrentMediaSourceProvider extends MediaSourceProvider { res.status(404).send('No file found'); } }; - - getCatalogue?: - | ((options: { - pagination: PaginationParams; - order?: string; - direction?: string; - }) => Promise>) - | undefined; - - getMovieCatalogue?: - | ((options: { - pagination: PaginationParams; - order?: string; - direction?: string; - }) => Promise>) - | undefined; - - getSeriesCatalogue?: - | ((options: { - pagination: PaginationParams; - order?: string; - direction?: string; - }) => Promise>) - | undefined; - - getMissingInCatalogue?: - | ((options: { - pagination: PaginationParams; - order?: string; - direction?: string; - myListItems: Record; - }) => Promise>) - | undefined; } diff --git a/backend/src/source-providers/source-provider.dto.ts b/backend/src/source-providers/source-provider.dto.ts index 99922c7..b764329 100644 --- a/backend/src/source-providers/source-provider.dto.ts +++ b/backend/src/source-providers/source-provider.dto.ts @@ -1,4 +1,6 @@ import { + ActionResponse, + ActionResponseBase, AudioStream, CatalogueItem, PlaybackConfig, @@ -9,10 +11,10 @@ import { SourceProviderSettingsTemplate, Stream, StreamAction, - StreamActionResponse, StreamBase, StreamCandidate, StreamProperty, + StreamResponse, Subtitles, ValidationResponse, } from '@aleksilassila/reiverr-plugin'; @@ -248,17 +250,58 @@ export class StreamDto extends StreamBaseDto implements Stream { subtitles: SubtitlesDto[]; } -export class StreamActionResponseErrorDto { +export class ToastDto { + @ApiProperty() + title: string; + + @ApiProperty() + message: string; + + @ApiProperty({ enum: ['info', 'success', 'error'] }) + type: 'info' | 'success' | 'error'; +} + +export class ActionResponseErrorDto { @ApiProperty({ example: 'Stream not found' }) message: string; } -export class StreamActionResponseDto implements StreamActionResponse { - @ApiProperty({ type: StreamDto, required: false }) - stream?: Stream; +class ActionResponseBaseDto implements ActionResponseBase { + @ApiProperty({ type: ActionResponseErrorDto, required: false }) + error?: ActionResponseErrorDto; - @ApiProperty({ type: StreamCandidateDto, required: false }) - error?: StreamActionResponseErrorDto; + @ApiProperty({ + type: ToastDto, + required: false, + }) + toast?: ToastDto; +} + +export class StreamActionResponseDto + extends ActionResponseBaseDto + implements StreamResponse +{ + @ApiProperty({ type: StreamDto, required: false }) + stream?: StreamDto; +} + +class ActionResponseResultDto { + @ApiProperty() + success: boolean; + + @ApiProperty({ required: false }) + message?: string; +} + +export class ActionResponseDto + extends ActionResponseBaseDto + implements ActionResponse +{ + @ApiProperty({ + type: ActionResponseResultDto, + required: false, + }) + result?: ActionResponseResultDto; } export class PlaybackConfigDto implements PlaybackConfig { @@ -282,6 +325,11 @@ export class PlaybackConfigDto implements PlaybackConfig { defaultLanguage: string | undefined; } +export class MediaSourceActionBodyDto { + @ApiProperty({ type: PlaybackConfigDto, required: false }) + playbackConfig?: PlaybackConfigDto; +} + export class StreamCandidatesDto { @ApiProperty({ type: [StreamCandidateDto], diff --git a/backend/src/source-providers/source-providers.controller.ts b/backend/src/source-providers/source-providers.controller.ts index 7feeeb7..033e9d6 100644 --- a/backend/src/source-providers/source-providers.controller.ts +++ b/backend/src/source-providers/source-providers.controller.ts @@ -97,42 +97,4 @@ export class SourceProvidersController { return provider.validateSettings({ settings: settings.settings }); } - - /** @deprecated in favor of mediaSource capabilities */ - @Get(':providerId/capabilities') - @ApiOkResponse({ - type: SourceProviderCapabilitiesDto, - }) - async getSourceCapabilities( - @GetAuthUser() user: User, - @Param('providerId', GetSourceProviderPipe) provider: ReiverrPlugin, - @GetAuthToken() token: string, - ): Promise { - // const settings = this.mediaSourcesService.getMediaSourceSettings( - // user, - // provider.name, - // ); - - // if (!settings) { - // throw new BadRequestException('Source configuration not found'); - // } - - const mediaSourceProvider = provider.getMediaSourceProvider({ - settings: {}, - sourceId: '', - token: '', - userId: '', - }); - - return { - movieIndexing: !!mediaSourceProvider.getMovieCatalogue, - episodeIndexing: !!mediaSourceProvider.getSeriesCatalogue, - moviePlayback: - !!mediaSourceProvider.getTmdbMovieCandidates && - !!mediaSourceProvider.handleAction, - episodePlayback: - !!mediaSourceProvider.getTmdbEpisodeCandidates && - !!mediaSourceProvider.handleAction, - }; - } } diff --git a/backend/src/source-providers/ui.dto.ts b/backend/src/source-providers/ui.dto.ts new file mode 100644 index 0000000..b5d79ab --- /dev/null +++ b/backend/src/source-providers/ui.dto.ts @@ -0,0 +1,21 @@ +import { type ViewBase } from '@aleksilassila/reiverr-plugin'; +import { ApiProperty } from '@nestjs/swagger'; + +enum ViewType { + GENERAL = 'general', + LIST_WITH_DETAILS = 'list-with-details', +} + +export class ViewBaseDto implements ViewBase { + @ApiProperty() + id: string; + + @ApiProperty({ enum: ViewType, type: 'string' }) + type: 'general' | 'list-with-details'; + + @ApiProperty() + label: string; + + @ApiProperty({ type: 'number', required: false }) + priority?: number; +} diff --git a/backend/src/user-data/library/library.service.ts b/backend/src/user-data/library/library.service.ts index c81dbf4..cdbaa70 100644 --- a/backend/src/user-data/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -235,10 +235,10 @@ export class LibraryService { throw new Error('No connection found'); } - const combined = connection.provider.getCatalogue; - const movies = connection.provider.getMovieCatalogue; - const series = connection.provider.getSeriesCatalogue; - const missing = connection.provider.getMissingInCatalogue; + const combined = connection.catalogueProvider.getCatalogue; + const movies = connection.catalogueProvider.getMovieCatalogue; + const series = connection.catalogueProvider.getSeriesCatalogue; + const missing = connection.catalogueProvider.getMissingInCatalogue; if (type === CatalogueTypeFilter.All && combined) { const response = await combined({ pagination, diff --git a/backend/src/users/media-sources/media-source-responses.dto.ts b/backend/src/users/media-sources/media-source-responses.dto.ts new file mode 100644 index 0000000..63434d0 --- /dev/null +++ b/backend/src/users/media-sources/media-source-responses.dto.ts @@ -0,0 +1,7 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StreamBaseDto } from 'src/source-providers/source-provider.dto'; + +export class AutoplayResponseDto { + @ApiProperty({ type: StreamBaseDto, required: false }) + candidate?: StreamBaseDto; +} diff --git a/backend/src/users/media-sources/media-source.dto.ts b/backend/src/users/media-sources/media-source.dto.ts index baafee6..d0605ea 100644 --- a/backend/src/users/media-sources/media-source.dto.ts +++ b/backend/src/users/media-sources/media-source.dto.ts @@ -1,4 +1,10 @@ -import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; +import { + ApiExtraModels, + ApiProperty, + getSchemaPath, + OmitType, + PartialType, +} from '@nestjs/swagger'; import { PickAndPartial } from 'src/common/common.dto'; import { ValidationResponseDto } from 'src/source-providers/source-provider.dto'; import { MediaSource } from './media-source.entity'; @@ -6,7 +12,22 @@ import { CatalogueCapabilities, DirectionOption, OrderOption, + MediaSourceViews, + MediaSourceView, + GeneralView, + ListWithDetailsView, + ActionElement, + ExternalLinkElement, + HeadingElement, + InputElement, + OpenViewElement, + SelectElement, + ToggleElement, + ListWithDetailsItem, + SortableProperty, + StreamActionElement, } from '@aleksilassila/reiverr-plugin'; +import { ViewBaseDto } from 'src/source-providers/ui.dto'; class CatalogueOrderDirectionOption implements DirectionOption { @ApiProperty() @@ -98,3 +119,317 @@ export class UpdateMediaSourceResponseDto { @ApiProperty({ type: ValidationResponseDto, required: false }) validationResponse: ValidationResponseDto | undefined; } + +export class ViewProviderDto { + @ApiProperty() + view: ViewBaseDto; + + @ApiProperty() + sourceId: string; +} + +export class ViewGroupDto { + @ApiProperty() + label: string; + + @ApiProperty({ type: [ViewProviderDto] }) + viewProviders: ViewProviderDto[]; +} + +export class ViewProvidersResponseDto { + @ApiProperty({ type: [ViewGroupDto] }) + viewGroups: ViewGroupDto[]; +} + +export class HeadingElementDto implements HeadingElement { + @ApiProperty({ type: 'string', enum: ['heading'] }) + type: 'heading'; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + description?: string; +} + +export class ToggleElementDto implements ToggleElement { + @ApiProperty({ type: 'string', enum: ['toggle'] }) + type: 'toggle'; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + description?: string; + + @ApiProperty() + value: boolean; + + @ApiProperty({ enum: ['checkbox', 'switch'] }) + style: 'checkbox' | 'switch'; +} + +class SelectOptionDto { + @ApiProperty() + label: string; + + @ApiProperty() + value: string; +} + +export class SelectElementDto implements SelectElement { + @ApiProperty({ type: 'string', enum: ['select'] }) + type: 'select'; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + description?: string; + + @ApiProperty() + value: string; + + @ApiProperty({ + type: [SelectOptionDto], + }) + options: SelectOptionDto[]; + + @ApiProperty({ enum: ['dropdown', 'radio'] }) + style: 'dropdown' | 'radio'; +} + +class IconDto { + @ApiProperty({ + enum: ['play', 'download', 'delete', 'info', 'external-link'], + }) + type: 'play' | 'download' | 'delete' | 'info' | 'external-link'; + + @ApiProperty({ enum: ['lg', 'md', 'sm'], required: false }) + size?: 'lg' | 'md' | 'sm'; +} + +export class StreamActionElementDto implements StreamActionElement { + @ApiProperty({ type: 'string', enum: ['action'] }) + type: 'action'; + + @ApiProperty({ type: 'string', enum: ['Stream'] }) + label: 'Stream'; + + @ApiProperty({ type: 'string', enum: ['stream'] }) + action: 'stream'; + + @ApiProperty({ required: false }) + disabled?: boolean; +} + +export class ActionElementDto implements ActionElement { + @ApiProperty({ type: 'string', enum: ['action'] }) + type: 'action'; + + @ApiProperty() + label: string; + + @ApiProperty() + action: string; + + @ApiProperty({ required: false }) + disabled?: boolean; + + @ApiProperty({ required: false, type: IconDto }) + icon?: IconDto; +} + +export class InputElementDto implements InputElement { + @ApiProperty({ type: 'string', enum: ['input'] }) + type: 'input'; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + description?: string; + + @ApiProperty({ required: false }) + value: string; + + @ApiProperty({ required: false }) + placeholder?: string; + + @ApiProperty({ + type: 'string', + enum: ['number', 'text', 'email', 'password'], + }) + style: 'number' | 'text' | 'email' | 'password'; + + @ApiProperty({ type: 'number', required: false }) + min?: number; + + @ApiProperty({ type: 'number', required: false }) + max?: number; + + @ApiProperty({ type: 'number', required: false }) + maxLength?: number; + + @ApiProperty({ type: 'number', required: false }) + minLength?: number; + + @ApiProperty({ type: 'boolean', required: false }) + disabled?: boolean; +} + +export class ExternalLinkElementDto implements ExternalLinkElement { + @ApiProperty({ type: 'string', enum: ['external-link'] }) + type: 'external-link'; + + @ApiProperty() + label: string; + + @ApiProperty() + url: string; + + @ApiProperty({ required: false, type: IconDto }) + icon?: IconDto; +} + +export class OpenViewElementDto implements OpenViewElement { + @ApiProperty({ type: 'string', enum: ['open-view'] }) + type: 'open-view'; + + @ApiProperty() + label: string; + + @ApiProperty() + viewId: string; + + @ApiProperty({ required: false, type: IconDto }) + icon?: IconDto; +} + +@ApiExtraModels( + HeadingElementDto, + ToggleElementDto, + SelectElementDto, + StreamActionElementDto, + ActionElementDto, + InputElementDto, + ExternalLinkElementDto, + OpenViewElementDto, +) +export class GeneralViewDto implements GeneralView { + @ApiProperty({ type: 'string', enum: ['general'] }) + type: 'general'; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { $ref: getSchemaPath(HeadingElementDto) }, + { $ref: getSchemaPath(ToggleElementDto) }, + { $ref: getSchemaPath(SelectElementDto) }, + { $ref: getSchemaPath(StreamActionElementDto) }, + { $ref: getSchemaPath(ActionElementDto) }, + { $ref: getSchemaPath(InputElementDto) }, + { $ref: getSchemaPath(ExternalLinkElementDto) }, + { $ref: getSchemaPath(OpenViewElementDto) }, + ], + }, + }) + elements: ( + | HeadingElement + | ToggleElement + | SelectElement + | ActionElement + | InputElement + | ExternalLinkElement + | OpenViewElement + )[]; + id: string; + label: string; + priority?: number; +} + +export class SortablePropertyDto implements SortableProperty { + @ApiProperty() + label: string; + + @ApiProperty({ oneOf: [{ type: 'string ' }, { type: 'number' }] }) + value: string | number; + + @ApiProperty() + formatted: string; + + @ApiProperty({ required: false }) + secondary?: boolean; +} + +@ApiExtraModels(StreamActionElementDto, ActionElementDto, OpenViewElementDto) +export class ListWithDetailsItemDto implements ListWithDetailsItem { + @ApiProperty({ type: 'string' }) + id: string; + + @ApiProperty() + label: string; + + @ApiProperty() + description?: string; + + @ApiProperty({ type: [SortablePropertyDto] }) + properties: SortablePropertyDto[]; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { $ref: getSchemaPath(StreamActionElementDto) }, + { $ref: getSchemaPath(ActionElementDto) }, + { $ref: getSchemaPath(OpenViewElementDto) }, + ], + }, + }) + actions: (ActionElementDto | OpenViewElementDto)[]; +} + +export class ListWithDetailsViewDto implements ListWithDetailsView { + @ApiProperty() + id: string; + + @ApiProperty({ type: 'string', enum: ['list-with-details'] }) + type: 'list-with-details'; + + @ApiProperty() + label: string; + + @ApiProperty({ required: false }) + priority?: number; + + @ApiProperty({ type: [ListWithDetailsItemDto] }) + items: ListWithDetailsItemDto[]; + + @ApiProperty({ required: false, type: OrderOptionDto }) + order?: OrderOption; + + @ApiProperty({ type: [OrderOptionDto] }) + orderOptions: OrderOption[]; +} + +@ApiExtraModels(GeneralViewDto, ListWithDetailsViewDto) +export class MediaSourceViewResponseDto { + @ApiProperty({ + oneOf: [ + { + $ref: getSchemaPath(GeneralViewDto), + }, + { + $ref: getSchemaPath(ListWithDetailsViewDto), + }, + ], + }) + view: GeneralViewDto | ListWithDetailsViewDto; + + // @ApiProperty({ required: false, type: GeneralViewDto }) + // generalView?: GeneralViewDto; + + // @ApiProperty({ required: false, type: ListWithDetailsViewDto }) + // listWithDetailsView?: ListWithDetailsViewDto; +} diff --git a/backend/src/users/media-sources/media-sources.controller.ts b/backend/src/users/media-sources/media-sources.controller.ts index 2d686f0..d91be90 100644 --- a/backend/src/users/media-sources/media-sources.controller.ts +++ b/backend/src/users/media-sources/media-sources.controller.ts @@ -19,7 +19,7 @@ import { UnauthorizedException, UseGuards, } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { GetAuthToken, GetAuthUser, @@ -27,6 +27,8 @@ import { } from 'src/auth/auth.guard'; import { MetadataService } from 'src/metadata/metadata.service'; import { + ActionResponseDto, + MediaSourceActionBodyDto, PlaybackConfigDto, StreamActionResponseDto, StreamCandidatesDto, @@ -35,6 +37,13 @@ import { import { SourceProvidersService } from 'src/source-providers/source-providers.service'; import { User } from 'src/users/user.entity'; import { MediaSourcesService } from './media-sources.service'; +import { + MediaSourceViewResponseDto, + ViewGroupDto, + ViewProvidersResponseDto as ViewGroupsResponseDto, + ViewProviderDto, +} from './media-source.dto'; +import { AutoplayResponseDto } from './media-source-responses.dto'; @Injectable() export class ServiceOwnershipValidator implements CanActivate { @@ -73,6 +82,97 @@ export class MediaSourcesController { private metadataService: MetadataService, ) {} + @Get('views') + @ApiOkResponse({ + description: 'Movie views', + type: ViewGroupsResponseDto, + }) + @ApiQuery({ name: 'tmdbId', type: 'string' }) + @ApiQuery({ name: 'season', type: 'number', required: false }) + @ApiQuery({ name: 'episode', type: 'number', required: false }) + async getMediaSourceViewGroups( + @Query('tmdbId') tmdbId: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + @Query('season', new ParseIntPipe({ optional: true })) season?: number, + @Query('episode', new ParseIntPipe({ optional: true })) episode?: number, + ): Promise { + const context = this.getPlayablePluginContext(tmdbId, season, episode); + + const viewGroups: Record = {}; + + const ps = user.mediaSources.map(async (ms) => { + const connection = await this.getConnection({ + sourceId: ms.id, + userId: user.id, + token, + }); + + const { views } = await connection.provider.getMeidaSourceViews({ + ...(await context), + }); + + views.forEach((view) => { + if (!viewGroups[view.label]) { + viewGroups[view.label] = []; + } + viewGroups[view.label].push({ + view, + sourceId: ms.id, + }); + }); + }); + + await Promise.all(ps); + + return { + viewGroups: Object.entries(viewGroups).map(([label, viewProviders]) => ({ + label, + viewProviders, + })), + }; + } + + @Get(':sourceId/views/:viewId') + @ApiOkResponse({ + description: 'Movie view', + type: MediaSourceViewResponseDto, + }) + @ApiQuery({ name: 'tmdbId', type: 'string' }) + @ApiQuery({ name: 'season', type: 'number', required: false }) + @ApiQuery({ name: 'episode', type: 'number', required: false }) + async getView( + @Param('sourceId') sourceId: string, + @Param('viewId') id: string, + @Query('tmdbId') tmdbId: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + @Query('season', new ParseIntPipe({ optional: true })) season?: number, + @Query('episode', new ParseIntPipe({ optional: true })) episode?: number, + ): Promise { + if (!tmdbId) throw new BadRequestException('tmdbId is required'); + + const context = this.getPlayablePluginContext(tmdbId, season, episode); + + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); + + const { view } = await connection.provider.getMediaSourceView({ + id, + ...(await context), + }); + + return { + view, + // generalView: view.type === 'general' ? view : undefined, + // listWithDetailsView: view.type === 'list-with-details' ? view : undefined, + }; + } + + /** @deprecated */ @Get(':sourceId/candidates/tmdb/:tmdbId') @ApiOkResponse({ description: 'Movie sources', @@ -98,6 +198,7 @@ export class MediaSourcesController { return streams ?? { candidates: [] }; } + /** @deprecated */ @Get(':sourceId/candidates/tmdb/:tmdbId/season/:season/episode/:episode') @ApiOkResponse({ description: 'Episode sources', @@ -131,29 +232,108 @@ export class MediaSourcesController { return streams ?? { candidates: [] }; } - @Post(':sourceId/stream/:streamId/:action') + @Post(':sourceId/autoplay-stream') @ApiOkResponse({ description: 'Movie stream', - type: StreamActionResponseDto, + type: AutoplayResponseDto, }) - async getStreamAction( + @ApiQuery({ name: 'tmdbId', type: 'string' }) + @ApiQuery({ name: 'season', type: 'number', required: false }) + @ApiQuery({ name: 'episode', type: 'number', required: false }) + async getAutoplayStream( @Param('sourceId') sourceId: string, - @Param('streamId') streamId: string, - @Param('action') action: string, @GetAuthUser() user: User, @GetAuthToken() token: string, - @Body() config: PlaybackConfigDto, - ): Promise { + @Query('tmdbId') tmdbId: string, + @Query('season', new ParseIntPipe({ optional: true })) season?: number, + @Query('episode', new ParseIntPipe({ optional: true })) episode?: number, + ): Promise { + if (!tmdbId) throw new BadRequestException('tmdbId is required'); + + const context = this.getPlayablePluginContext(tmdbId, season, episode); + const connection = await this.getConnection({ sourceId, userId: user.id, token, }); - const stream = await connection.provider - .handleAction?.({ + const { candidate } = await connection.provider.getAutoplayStream({ + ...(await context), + }); + + return { + candidate, + }; + } + + @Post(':sourceId/stream/:streamId') + @ApiOkResponse({ + description: 'Movie stream', + type: StreamActionResponseDto, + }) + @ApiBody({ required: false, type: MediaSourceActionBodyDto }) + async getStream( + @Param('sourceId') sourceId: string, + @Param('streamId') streamId: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + @Body() config: MediaSourceActionBodyDto = {}, + ): Promise { + const { playbackConfig } = config; + + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); + + const response = await connection.provider + .getStream({ streamId, - config, + config: playbackConfig, + }) + .catch((e) => { + if (e === SourceProviderError.StreamNotFound) { + throw new NotFoundException('Stream not found'); + } else { + console.error(e); + throw new InternalServerErrorException(); + } + }); + + // if (!response) { + // throw new InternalServerErrorException('No response from provider'); + // } + + return response; + } + + @Post(':sourceId/action/:action/:targetId') + @ApiOkResponse({ + description: 'Movie stream', + type: ActionResponseDto, + }) + // @ApiBody({ required: false, type: MediaSourceActionBodyDto }) + async handleViewAction( + @Param('sourceId') sourceId: string, + @Param('targetId') targetId: string, + @Param('action') action: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + // @Body() config: MediaSourceActionBodyDto = {}, + ): Promise { + // const { playbackConfig } = config; + + const connection = await this.getConnection({ + sourceId, + userId: user.id, + token, + }); + + const response = await connection.provider + .handleAction({ + targetId, action, }) .catch((e) => { @@ -165,11 +345,11 @@ export class MediaSourcesController { } }); - if (!stream) { - throw new NotFoundException('Stream not found'); - } + // if (!response) { + // throw new InternalServerErrorException('No response from provider'); + // } - return stream; + return response; } /** @deprecated */ @@ -226,4 +406,33 @@ export class MediaSourcesController { return connection; } + + async getPlayablePluginContext( + tmdbId: string, + season?: number, + episode?: number, + ) { + const tmdbMovie = + season === undefined && episode === undefined + ? this.metadataService.getMovieByTmdbId(tmdbId).then((m) => m.tmdbMovie) + : undefined; + const tmdbSeries = + season !== undefined && episode !== undefined + ? this.metadataService + .getSeriesByTmdbId(tmdbId) + .then((s) => s.tmdbSeries) + : undefined; + const tmdbEpisode = + season !== undefined && episode !== undefined + ? this.metadataService + .getEpisodeByTmdbId({ tmdbId, season, episode }) + .then((e) => e.tmdbEpisode) + : undefined; + + return { + tmdbMovie: await tmdbMovie, + tmdbSeries: await tmdbSeries, + tmdbEpisode: await tmdbEpisode, + }; + } } diff --git a/backend/src/users/media-sources/media-sources.service.ts b/backend/src/users/media-sources/media-sources.service.ts index 71209cf..5be5577 100644 --- a/backend/src/users/media-sources/media-sources.service.ts +++ b/backend/src/users/media-sources/media-sources.service.ts @@ -1,4 +1,5 @@ import { + CatalogueProvider, MediaSourceProvider, ValidationResponse, } from '@aleksilassila/reiverr-plugin'; @@ -146,6 +147,7 @@ export class MediaSourcesService { }): Promise< | { provider: MediaSourceProvider; + catalogueProvider: CatalogueProvider; mediaSource: MediaSource; } | undefined @@ -163,8 +165,16 @@ export class MediaSourcesService { userId, }); + const catalogueProvider = this.sourceProvidersService + .getPlugin(mediaSource.pluginId) + .getCatalogueProvider({ + userId: mediaSource.userId, + settings: mediaSource.pluginSettings, + sourceId: mediaSource.id, + }); + if (provider && mediaSource) { - return { provider, mediaSource }; + return { provider, mediaSource, catalogueProvider }; } return undefined; diff --git a/package-lock.json b/package-lock.json index b1ab87e..ee51da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "svelte": "^3.59.2", "svelte-check": "^3.6.2", "svelte-i18n": "^4.0.0", - "svelte-navigator": "^3.2.2", "swagger-typescript-api": "^13.0.23", "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss": "^3.4.17", @@ -3734,12 +3733,6 @@ } } }, - "node_modules/dedent-js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", - "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", - "dev": true - }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -4935,15 +4928,6 @@ "get-func-name": "^2.0.0" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5147,16 +5131,6 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5461,16 +5435,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6541,47 +6505,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, - "node_modules/svelte-navigator": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/svelte-navigator/-/svelte-navigator-3.2.2.tgz", - "integrity": "sha512-Xio4ohLUG1nQJ+ENNbLphXXu9L189fnI1WGg+2Q3CIMPe8Jm2ipytKQthdBs8t0mN7p3Eb03SE9hq0xZAqwQNQ==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "svelte2tsx": "^0.1.151" - }, - "peerDependencies": { - "svelte": "3.x" - } - }, - "node_modules/svelte-navigator/node_modules/svelte2tsx": { - "version": "0.1.193", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.1.193.tgz", - "integrity": "sha512-vzy4YQNYDnoqp2iZPnJy7kpPAY6y121L0HKrSBjU/IWW7DQ6T7RMJed2VVHFmVYm0zAGYMDl9urPc6R4DDUyhg==", - "dev": true, - "dependencies": { - "dedent-js": "^1.0.1", - "pascal-case": "^3.1.1" - }, - "peerDependencies": { - "svelte": "^3.24", - "typescript": "^4.1.2" - } - }, - "node_modules/svelte-navigator/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/svelte-preprocess": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", diff --git a/package.json b/package.json index b9e7e94..e059dd9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "svelte": "^3.59.2", "svelte-check": "^3.6.2", "svelte-i18n": "^4.0.0", - "svelte-navigator": "^3.2.2", "swagger-typescript-api": "^13.0.23", "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss": "^3.4.17", @@ -96,4 +95,4 @@ } ] } -} \ No newline at end of file +} diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 5b3d634..02fae0b 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -193,6 +193,146 @@ export interface UpdateUserDto { oldPassword?: string; } +export interface ViewBaseDto { + id: string; + type: 'general' | 'list-with-details'; + label: string; + priority?: number; +} + +export interface ViewProviderDto { + view: ViewBaseDto; + sourceId: string; +} + +export interface ViewGroupDto { + label: string; + viewProviders: ViewProviderDto[]; +} + +export interface ViewProvidersResponseDto { + viewGroups: ViewGroupDto[]; +} + +export interface HeadingElementDto { + type: 'heading'; + label: string; + description?: string; +} + +export interface ToggleElementDto { + type: 'toggle'; + label: string; + description?: string; + value: boolean; + style: 'checkbox' | 'switch'; +} + +export interface SelectOptionDto { + label: string; + value: string; +} + +export interface SelectElementDto { + type: 'select'; + label: string; + description?: string; + value: string; + options: SelectOptionDto[]; + style: 'dropdown' | 'radio'; +} + +export interface StreamActionElementDto { + type: 'action'; + label: 'Stream'; + action: 'stream'; + disabled?: boolean; +} + +export interface IconDto { + type: 'play' | 'download' | 'delete' | 'info' | 'external-link'; + size?: 'lg' | 'md' | 'sm'; +} + +export interface ActionElementDto { + type: 'action'; + label: string; + action: string; + disabled?: boolean; + icon?: IconDto; +} + +export interface InputElementDto { + type: 'input'; + label: string; + description?: string; + value?: string; + placeholder?: string; + style: 'number' | 'text' | 'email' | 'password'; + min?: number; + max?: number; + maxLength?: number; + minLength?: number; + disabled?: boolean; +} + +export interface ExternalLinkElementDto { + type: 'external-link'; + label: string; + url: string; + icon?: IconDto; +} + +export interface OpenViewElementDto { + type: 'open-view'; + label: string; + viewId: string; + icon?: IconDto; +} + +export interface GeneralViewDto { + type: 'general'; + elements: ( + | HeadingElementDto + | ToggleElementDto + | SelectElementDto + | StreamActionElementDto + | ActionElementDto + | InputElementDto + | ExternalLinkElementDto + | OpenViewElementDto + )[]; +} + +export interface SortablePropertyDto { + label: string; + value: string | number; + formatted: string; + secondary?: boolean; +} + +export interface ListWithDetailsItemDto { + id: string; + label: string; + description: string; + properties: SortablePropertyDto[]; + actions: (StreamActionElementDto | ActionElementDto | OpenViewElementDto)[]; +} + +export interface ListWithDetailsViewDto { + id: string; + type: 'list-with-details'; + label: string; + priority?: number; + items: ListWithDetailsItemDto[]; + order?: OrderOptionDto; + orderOptions: OrderOptionDto[]; +} + +export interface MediaSourceViewResponseDto { + view: GeneralViewDto | ListWithDetailsViewDto; +} + export interface VideoStreamPropertyDto { label: string; value: string | number; @@ -215,6 +355,16 @@ export interface StreamCandidatesDto { candidates: StreamCandidateDto[]; } +export interface StreamBaseDto { + streamId: string; + title: string; + properties: VideoStreamPropertyDto[]; +} + +export interface AutoplayResponseDto { + candidate?: StreamBaseDto; +} + export interface DirectPlayProfileDto { /** Gets or sets the container. */ Container?: string | null; @@ -423,6 +573,21 @@ export interface PlaybackConfigDto { defaultLanguage?: string; } +export interface MediaSourceActionBodyDto { + playbackConfig?: PlaybackConfigDto; +} + +export interface ActionResponseErrorDto { + /** @example "Stream not found" */ + message: string; +} + +export interface ToastDto { + title: string; + message: string; + type: 'info' | 'success' | 'error'; +} + export interface AudioStreamDto { index: number; label: string; @@ -465,8 +630,20 @@ export interface StreamDto { } export interface StreamActionResponseDto { + error?: ActionResponseErrorDto; + toast?: ToastDto; stream?: StreamDto; - error?: StreamCandidateDto; +} + +export interface ActionResponseResultDto { + success: boolean; + message?: string; +} + +export interface ActionResponseDto { + error?: ActionResponseErrorDto; + toast?: ToastDto; + result?: ActionResponseResultDto; } export interface UpdateOrCreateMediaSourceDto { @@ -513,13 +690,6 @@ export interface PluginSettingsDto { settings: Record; } -export interface SourceProviderCapabilitiesDto { - moviePlayback: boolean; - episodePlayback: boolean; - movieIndexing: boolean; - episodeIndexing: boolean; -} - export interface MovieUserDataDto { tmdbId: string; inLibrary: boolean; @@ -1072,6 +1242,54 @@ export class Api extends HttpClient + this.request({ + path: `/api/sources/views`, + method: 'GET', + query: query, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetView + * @request GET:/api/sources/{sourceId}/views/{viewId} + */ + getView: ( + sourceId: string, + viewId: string, + query: { + tmdbId: string; + season?: number; + episode?: number; + }, + params: RequestParams = {} + ) => + this.request({ + path: `/api/sources/${sourceId}/views/${viewId}`, + method: 'GET', + query: query, + format: 'json', + ...params + }), + /** * No description * @@ -1112,18 +1330,41 @@ export class Api extends HttpClient + this.request({ + path: `/api/sources/${sourceId}/autoplay-stream`, + method: 'POST', + query: query, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags sources + * @name GetStream + * @request POST:/api/sources/{sourceId}/stream/{streamId} + */ + getStream: ( sourceId: string, streamId: string, - action: string, - data: PlaybackConfigDto, + data?: MediaSourceActionBodyDto, params: RequestParams = {} ) => this.request({ - path: `/api/sources/${sourceId}/stream/${streamId}/${action}`, + path: `/api/sources/${sourceId}/stream/${streamId}`, method: 'POST', body: data, type: ContentType.Json, @@ -1131,6 +1372,26 @@ export class Api extends HttpClient + this.request({ + path: `/api/sources/${sourceId}/action/${action}/${targetId}`, + method: 'POST', + format: 'json', + ...params + }), + /** * No description * @@ -1580,21 +1841,6 @@ export class Api extends HttpClient - this.request({ - path: `/api/providers/${providerId}/capabilities`, - method: 'GET', - format: 'json', - ...params }) }; library = { diff --git a/src/lib/apis/tmdb/tmdb-api.ts b/src/lib/apis/tmdb/tmdb-api.ts index 66afdc9..569e67c 100644 --- a/src/lib/apis/tmdb/tmdb-api.ts +++ b/src/lib/apis/tmdb/tmdb-api.ts @@ -434,6 +434,7 @@ export interface TmdbSeriesFull extends TmdbSeries { images: TvSeriesImagesData; } +/** @deprecated */ export class TmdbApi implements Api { static getClient() { const session = get(sessions).activeSession; @@ -856,8 +857,10 @@ export class TmdbApi implements Api { }; } +/** @deprecated */ export const tmdbApi = new TmdbApi(); +/** @deprecated */ export const TmdbApiOpen = createClient({ baseUrl: 'https://api.themoviedb.org', headers: { @@ -865,6 +868,7 @@ export const TmdbApiOpen = createClient({ } }); +/** @deprecated */ export const getTmdbMovie = async (tmdbId: number) => await TmdbApiOpen.GET('/3/movie/{movie_id}', { params: { @@ -878,6 +882,7 @@ export const getTmdbMovie = async (tmdbId: number) => } }).then((res) => res.data as TmdbMovieFull | undefined); +/** @deprecated */ export const getTmdbSeries = async (tmdbId: number): Promise => await TmdbApiOpen.GET('/3/tv/{series_id}', { params: { @@ -894,6 +899,7 @@ export const getTmdbSeries = async (tmdbId: number): Promise res.data as TmdbSeriesFull | undefined); +/** @deprecated */ export const getTmdbSeriesSeason = async ( tmdbId: number, season: number diff --git a/src/lib/components/ComponentStack/ComponentStack.svelte b/src/lib/components/ComponentStack/ComponentStack.svelte new file mode 100644 index 0000000..b284eba --- /dev/null +++ b/src/lib/components/ComponentStack/ComponentStack.svelte @@ -0,0 +1,42 @@ + + +
+ + + +
diff --git a/src/lib/components/ComponentStack/ComponentStackContainer.svelte b/src/lib/components/ComponentStack/ComponentStackContainer.svelte new file mode 100644 index 0000000..8a096a9 --- /dev/null +++ b/src/lib/components/ComponentStack/ComponentStackContainer.svelte @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/src/lib/components/ComponentStack/ComponentStackItem.svelte b/src/lib/components/ComponentStack/ComponentStackItem.svelte new file mode 100644 index 0000000..c1fce44 --- /dev/null +++ b/src/lib/components/ComponentStack/ComponentStackItem.svelte @@ -0,0 +1,41 @@ + + +{#if component} + +{/if} diff --git a/src/lib/components/DynamicMenu/DynamicMenu.ts b/src/lib/components/DynamicMenu/DynamicMenu.ts new file mode 100644 index 0000000..89fbfaa --- /dev/null +++ b/src/lib/components/DynamicMenu/DynamicMenu.ts @@ -0,0 +1,28 @@ +import { writable } from 'svelte/store'; + +export type MenuStack = ReturnType; + +export function useMenuStack() { + type Page = { + id: symbol; + }; + + const pages = writable([]); + + function addPage(id: symbol) { + const page: Page = { id }; + pages.update((p) => [...p, page]); + } + + function removePage(id: symbol) { + pages.update((p) => { + return p.filter((page) => page.id !== id); + }); + } + + return { + subscribe: pages.subscribe, + addPage, + removePage + }; +} diff --git a/src/lib/components/DynamicMenu/DynamicMenuModal.svelte b/src/lib/components/DynamicMenu/DynamicMenuModal.svelte new file mode 100644 index 0000000..d05157e --- /dev/null +++ b/src/lib/components/DynamicMenu/DynamicMenuModal.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/lib/components/DynamicMenu/DynamicMenuStack.svelte b/src/lib/components/DynamicMenu/DynamicMenuStack.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte b/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte new file mode 100644 index 0000000..af9f4c2 --- /dev/null +++ b/src/lib/components/DynamicMenu/MediaSourceMenuModal.svelte @@ -0,0 +1,117 @@ + + + + { + if (!$componentStack.length) { + close(); + } else { + componentStack.pop(); + detail.stopPropagation(); + } + }} + > + {#if !$componentStack.length} + {#await views then views} + {#each views as view} + + + {capitalize(view.label)} + {#if hasFocus} + + {/if} + + + {/each} + {/await} + {:else} + + {/if} + + diff --git a/src/lib/components/GlobalBackground/BackgroundStack.ts b/src/lib/components/GlobalBackground/BackgroundStack.ts index 53e8948..948c312 100644 --- a/src/lib/components/GlobalBackground/BackgroundStack.ts +++ b/src/lib/components/GlobalBackground/BackgroundStack.ts @@ -1,5 +1,13 @@ import { Selectable, useRegistrar } from '$lib/selectable'; -import { getContext, hasContext, onDestroy, setContext, type ComponentType } from 'svelte'; +import { + getContext, + hasContext, + onDestroy, + setContext, + SvelteComponentTyped, + type ComponentProps, + type ComponentType +} from 'svelte'; import { derived, get, writable } from 'svelte/store'; import YoutubeVideo from '../VideoPlayer/YoutubeVideo.svelte'; @@ -10,14 +18,14 @@ export type Background = { mediaId?: string; }; -export type BackgroundVideo = { +export type BackgroundVideo = { id: symbol; - component: ComponentType; - props: Record; + component: ComponentType; + props: ComponentProps; mediaId?: string; }; -type Page = { +export type BackgroundPage = { id: symbol; isTransparent: boolean; backgrounds: Background[]; @@ -38,7 +46,7 @@ type Page = { export const globalBackground = useRegistrar(); -export const backgroundPagesStack = writable([]); +export const backgroundPagesStack = writable([]); export const visibleBackgrounds = (() => { const store = derived([backgroundPagesStack], ([pages]) => { const topPage = pages[pages.length - 1]; @@ -86,6 +94,7 @@ export const visibleBackgrounds = (() => { let lastFocused: Selectable | undefined = undefined; +export type BackgroundPageStore = ReturnType; function _createBackgroundPage( options: { transparent?: boolean; @@ -102,7 +111,7 @@ function _createBackgroundPage( ); const id = Symbol(); - const page: Page = { + const page: BackgroundPage = { id, backgrounds: reusedPage ? [reusedPage] : [], index: 0, @@ -153,7 +162,7 @@ function _createBackgroundPage( backgroundPagesStack.update((p) => p); } - function setVideo(video: BackgroundVideo) { + function setVideo(video: BackgroundVideo) { if (video.mediaId && video.mediaId === page.video?.mediaId) return; page.video = video; @@ -239,7 +248,7 @@ export const createBackgroundPage: typeof _createBackgroundPage = (...args) => { export function getBackgroundPage() { if (hasContext(BACKGROUND_CONTEXT_KEY)) { - return getContext>(BACKGROUND_CONTEXT_KEY); + return getContext(BACKGROUND_CONTEXT_KEY); } return undefined; diff --git a/src/lib/components/Notifications/notification.store.ts b/src/lib/components/Notifications/notification.store.ts index dfa8a07..d9fee17 100644 --- a/src/lib/components/Notifications/notification.store.ts +++ b/src/lib/components/Notifications/notification.store.ts @@ -1,16 +1,17 @@ -import type { ComponentType } from 'svelte'; +import type { ComponentProps, ComponentType, SvelteComponentTyped } from 'svelte'; import { writable } from 'svelte/store'; import Notification from './Notification.svelte'; -type NotificationItem = { +type NotificationItem = { id: symbol; - component: ComponentType; - props: Record; + component: ComponentType; + props: ComponentProps; }; function useNotificationStack() { const notifications = writable([]); + function create(component: NotificationItem['component'], props: NotificationItem['props'] = {}) { const id = Symbol(); const item = { id, component, props }; diff --git a/src/lib/components/Sidebar/Sidebar.svelte b/src/lib/components/Sidebar/Sidebar.svelte index d3a726f..f6f9420 100644 --- a/src/lib/components/Sidebar/Sidebar.svelte +++ b/src/lib/components/Sidebar/Sidebar.svelte @@ -42,7 +42,6 @@ // manage: 4 // }[$location.pathname.split('/')[1] || '/']; - let isNavBarOpen: Readable; let focusIndex: Writable = writable(0); let selectable: Selectable; @@ -99,7 +98,7 @@ //'max-w-64': $isNavBarOpen } )} - bind:hasFocusWithin={isNavBarOpen} + let:hasFocusWithin bind:focusIndex bind:selectable on:navigate={({ detail }) => { @@ -117,7 +116,7 @@ class={classNames( 'absolute inset-y-0 left-0 min-w-[40rem] w-[25vw] transition-opacity bg-gradient-to-r from-secondary-900 to-transparent', { - 'opacity-0': !$isNavBarOpen, + 'opacity-0': !hasFocusWithin, 'group-hover:opacity-100 pointer-events-none': true } )} @@ -125,7 +124,7 @@
@@ -139,10 +138,10 @@ class={classNames( 'w-full h-full relative flex items-center justify-center transition-opacity', { - 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Users), + 'text-primary-500': hasFocus || (!hasFocusWithin && selectedIndex === Tabs.Users), 'text-stone-300 hover:text-primary-500': - !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Users), - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + !hasFocus && !(!hasFocusWithin && selectedIndex === Tabs.Users), + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -158,7 +157,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20 text-nowrap', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -177,9 +176,9 @@ >
@@ -193,7 +192,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -209,9 +208,9 @@ >
@@ -225,7 +224,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -241,9 +240,9 @@ >
@@ -257,7 +256,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -273,9 +272,9 @@ >
@@ -289,7 +288,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -309,10 +308,10 @@ class={classNames( 'w-full h-full relative flex items-center justify-center transition-opacity', { - 'text-primary-500': hasFocus || (!$isNavBarOpen && selectedIndex === Tabs.Manage), + 'text-primary-500': hasFocus || (!hasFocusWithin && selectedIndex === Tabs.Manage), 'text-stone-300 hover:text-primary-500': - !hasFocus && !(!$isNavBarOpen && selectedIndex === Tabs.Manage), - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + !hasFocus && !(!hasFocusWithin && selectedIndex === Tabs.Manage), + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} @@ -328,7 +327,7 @@ class={classNames( 'text-xl font-medium transition-opacity flex items-center absolute inset-y-0 left-20', { - 'opacity-0 pointer-events-none': $isNavBarOpen === false, + 'opacity-0 pointer-events-none': hasFocusWithin === false, 'group-hover:opacity-100 group-hover:pointer-events-auto': true } )} diff --git a/src/lib/components/Tab/Tab.ts b/src/lib/components/Tab/Tab.ts index ab443ae..9a32f86 100644 --- a/src/lib/components/Tab/Tab.ts +++ b/src/lib/components/Tab/Tab.ts @@ -2,7 +2,8 @@ import type { ComponentProps } from 'svelte'; import { writable } from 'svelte/store'; import type Tab from './Tab.svelte'; -export function useTabs(defaultTab: number, props: Partial> = {}) { +/** TODO: named parameters */ +export function useTabs(defaultTab: number = 0, props: Partial> = {}) { const openTab = writable(defaultTab); const next = () => openTab.update((n) => n + 1); diff --git a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte index 0ceae6c..b213282 100644 --- a/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/TmdbVideoPlayer.svelte @@ -44,8 +44,6 @@ let reportProgressInterval: ReturnType; - let videoStreamP: Promise; - async function reportProgress() { const userId = get(user)?.id; @@ -69,16 +67,21 @@ } const refreshVideoStream = async (audioStreamIndex = 0) => { - videoStreamP = reiverrApi.sources - .getStreamAction(source.id, streamId, 'stream', { - // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, - progress, - audioStreamIndex, - deviceProfile: getDeviceProfile() as any + const stream = await reiverrApi.sources + .getStream(source.id, streamId, { + playbackConfig: { + // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, + progress, + audioStreamIndex, + deviceProfile: getDeviceProfile() as any + } }) - .then((r) => r.data.stream as any); + .then((r) => r.data.stream); - const stream = await videoStreamP; + if (!stream) { + console.error('Stream not found'); + return; + } const mediaLanguagesStore = createLocalStorageStore( 'media-tracks-' + title, diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 5a33a58..5f01f79 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -189,7 +189,11 @@
-
@{source}
+
+ {#if source} + @{source} + {/if} +
Ends at {new Date( diff --git a/src/lib/pages/PageNotFound.svelte b/src/lib/pages/PageNotFound.svelte index ba78b54..efc2e82 100644 --- a/src/lib/pages/PageNotFound.svelte +++ b/src/lib/pages/PageNotFound.svelte @@ -1,5 +1,4 @@ + + + {#await items} + Loading... + {:then items} + {#each items as item} + + + {capitalize(item.label)} + {#if hasFocus} + + {/if} + + + {/each} + {/await} + diff --git a/src/lib/pages/TitlePages/ActionsPage/ActionsMenu.svelte b/src/lib/pages/TitlePages/ActionsPage/ActionsMenu.svelte new file mode 100644 index 0000000..21c21f3 --- /dev/null +++ b/src/lib/pages/TitlePages/ActionsPage/ActionsMenu.svelte @@ -0,0 +1,84 @@ + + + diff --git a/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte b/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte new file mode 100644 index 0000000..9e52bd1 --- /dev/null +++ b/src/lib/pages/TitlePages/ActionsPage/ActionsMenuContainer.svelte @@ -0,0 +1,19 @@ + + + + { + componentStack.pop(); + detail.stopPropagation(); + }} + > + + + diff --git a/src/lib/pages/TitlePages/ActionsPage/ListMenu.svelte b/src/lib/pages/TitlePages/ActionsPage/ListMenu.svelte new file mode 100644 index 0000000..1593b06 --- /dev/null +++ b/src/lib/pages/TitlePages/ActionsPage/ListMenu.svelte @@ -0,0 +1,132 @@ + + + + {#if selectedRow} + + + {#if selectedRow} +
+

+ {capitalize(selectedRow.label)} +

+ +
+ {#each selectedRow.actions as action, index} + {@const selected = index === selectedActionIndex} +
+ {action.label} +
+ {/each} +
+
+ {/if} + {/if} +
+ {#await view} + Loading... + {:then view} + {#each view.items as row} + { + selectedRow = row; + selectedActionIndex = 0; + scrollIntoView({ vertical: 64 })(e); + }} + on:clickOrSelect={() => handleItemAction(row)} + let:hasFocus + class="cursor-pointer" + > + + {capitalize(row.label)} + + + + {row.properties?.map((p) => `${p.label}: ${p.formatted || p.value}`).join(', ')} + + + {:else} +
No streams available
+ {/each} + {/await} +
+
diff --git a/src/lib/pages/TitlePages/ActionsPage/actions-page.ts b/src/lib/pages/TitlePages/ActionsPage/actions-page.ts new file mode 100644 index 0000000..e369201 --- /dev/null +++ b/src/lib/pages/TitlePages/ActionsPage/actions-page.ts @@ -0,0 +1,66 @@ +import type { MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi'; +import { + createErrorNotification, + createInfoNotification +} from '$lib/components/Notifications/notification.store'; +import { useComponentStack } from '$lib/stores/component-stack.store'; +import { + TITLE_USER_DATA_CONTEXT, + type TitleUserData +} from '$lib/stores/user-data/title-user-data.store'; +import { reiverrApi } from '$lib/stores/user.store'; +import { createStoreContext } from '$lib/utils'; +import { getContext, hasContext } from 'svelte'; +import { writable } from 'svelte/store'; + +function usePlayableDataStore(options: { tmdbId: string; season?: number; episode?: number }) { + const { tmdbId, season, episode } = options; + + if (!hasContext(TITLE_USER_DATA_CONTEXT)) throw new Error('TitleUserDataContext not found'); + const titleUserData = getContext(TITLE_USER_DATA_CONTEXT); + + async function handleAction(source: MediaSourceDto, targetId: string, action: string) { + const { toast, result, error } = await reiverrApi.sources + .handleViewAction(source.id, targetId, action) + .then((r) => r.data); + + if (toast && toast.type === 'info') { + createInfoNotification(toast.title, toast.message); + } else if (toast && toast.type === 'error') { + createErrorNotification(toast.title, toast.message); + } + + // if (error) { + + // } + } + + async function handleOpenView(source: MediaSourceDto, viewId: string, callerId: string) {} + + return { + ...options, + ...titleUserData, + handleAction, + handleOpenView + }; +} + +export const playableDataContext = createStoreContext( + 'actions-page-context', + usePlayableDataStore, + { required: true } +); + +export const mediaSourceContext = createStoreContext('media-source', () => + writable(undefined) +); + +export const titlePageContext = createStoreContext( + 'title-page', + () => ({ + componentStack: useComponentStack() + }), + { + required: true + } +); diff --git a/src/lib/pages/TitlePages/EpisodePage.svelte b/src/lib/pages/TitlePages/EpisodePage.svelte index 6f33bb5..3e18b6a 100644 --- a/src/lib/pages/TitlePages/EpisodePage.svelte +++ b/src/lib/pages/TitlePages/EpisodePage.svelte @@ -3,7 +3,7 @@ import { createBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; import HeroCarousel from '$lib/components/HeroShowcase/HeroCarousel.svelte'; import { getStackRouterPage } from '$lib/components/StackRouter/StackRouter'; - import { useEpisodeUserData } from '$lib/stores/media-user-data.store'; + import { useEpisodeUserData } from '$lib/stores/user-data/title-user-data.store'; import { Check, ExternalLink, Play } from 'radix-icons-svelte'; import { onDestroy } from 'svelte'; import Button from '../../components/Button.svelte'; diff --git a/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte b/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte index bce4496..31c67b4 100644 --- a/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte +++ b/src/lib/pages/TitlePages/MoviePage/MoviePage.svelte @@ -1,51 +1,18 @@ -
- - - {#await $tmdbMovie then movie} - {#if movie} - - {/if} - {/await} - - - - {#if trailerId} - - {/if} - - {#if !$inLibrary} - - {:else} - - {/if} - - - - {#if PLATFORM_WEB} - - {/if} - - - -
- - {#await $tmdbMovie then movie} - -
Show Cast
- {#each movie?.credits?.cast?.slice(0, 15) || [] as credit} - - {/each} -
- {/await} - {#await recommendations then recommendations} - -
Recommendations
- {#each recommendations || [] as recommendation} - - {/each} -
- {/await} -
- {#await $tmdbMovie then movie} - -

More Information

-
-
-
-

Directed By

-
- {movie?.credits.crew - ?.filter((c) => c.job === 'Director') - ?.map((c) => c.name) - .join(', ')} -
-
-
-

Written By

-
- {movie?.credits.crew - ?.filter((c) => c.job === 'Writer') - ?.map((c) => c.name) - .join(', ')} -
-
-
-
-
-

Languages

-
- {movie?.spoken_languages?.map((language) => language.name).join(', ')} -
-
-
-

Release Date

-
- {new Date(movie?.release_date || 0).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - })} -
-
-
-
-
- {/await} -
-
+ diff --git a/src/lib/pages/TitlePages/MoviePage/MoviePageDetails.svelte b/src/lib/pages/TitlePages/MoviePage/MoviePageDetails.svelte new file mode 100644 index 0000000..96a5667 --- /dev/null +++ b/src/lib/pages/TitlePages/MoviePage/MoviePageDetails.svelte @@ -0,0 +1,240 @@ + + + +
+ + + {#await $tmdbMovie then movie} + {#if movie} + + {/if} + {/await} + + + + {#if trailerId} + + {/if} + + {#if !$inLibrary} + + {:else} + + {/if} + + + + {#if PLATFORM_WEB} + + {/if} + + + +
+ + {#await $tmdbMovie then movie} + +
Show Cast
+ {#each movie?.credits?.cast?.slice(0, 15) || [] as credit} + + {/each} +
+ {/await} + {#await recommendations then recommendations} + +
Recommendations
+ {#each recommendations || [] as recommendation} + + {/each} +
+ {/await} +
+ {#await $tmdbMovie then movie} + +

More Information

+
+
+
+

Directed By

+
+ {movie?.credits.crew + ?.filter((c) => c.job === 'Director') + ?.map((c) => c.name) + .join(', ')} +
+
+
+

Written By

+
+ {movie?.credits.crew + ?.filter((c) => c.job === 'Writer') + ?.map((c) => c.name) + .join(', ')} +
+
+
+
+
+

Languages

+
+ {movie?.spoken_languages?.map((language) => language.name).join(', ')} +
+
+
+

Release Date

+
+ {new Date(movie?.release_date || 0).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +
+
+
+
+
+ {/await} +
+
+
diff --git a/src/lib/pages/TitlePages/SeriesPage/EpisodeGrid.svelte b/src/lib/pages/TitlePages/SeriesPage/EpisodeGrid.svelte index 17acde4..8cef9e8 100644 --- a/src/lib/pages/TitlePages/SeriesPage/EpisodeGrid.svelte +++ b/src/lib/pages/TitlePages/SeriesPage/EpisodeGrid.svelte @@ -1,6 +1,6 @@ -
- { - if (detail.direction === 'down' && detail.willLeaveContainer) { - $episodeCards?.focus(); - detail.preventNavigation(); - } - }} - > - - {#await $tmdbSeries then series} - {#if series} - - {/if} - {/await} - - - - {#if trailerId} - - {/if} - - {#if !$inLibrary} - - {:else} - - {/if} - - - - {#if PLATFORM_WEB} - - {/if} - - - -
- - - {#await $tmdbSeries then series} - -
Show Cast
- {#each series?.aggregate_credits?.cast?.slice(0, 15) || [] as credit} - - {/each} -
- {/await} - {#await recommendations then recommendations} - -
Recommendations
- {#each recommendations || [] as recommendation} - - {/each} -
- {/await} -
- {#await $tmdbSeries then series} - -

More Information

-
-
-
-

Created By

- {#each series?.created_by || [] as creator} -
{creator.name}
- {/each} -
-
-

Network

-
{series?.networks?.[0]?.name}
-
-
-
-
-

Language

-
{series?.spoken_languages?.[0]?.name}
-
-
-

Last Air Date

-
{series?.last_air_date}
-
-
-
-
- {/await} -
-
+ diff --git a/src/lib/pages/TitlePages/SeriesPage/SeriesPageDetails.svelte b/src/lib/pages/TitlePages/SeriesPage/SeriesPageDetails.svelte new file mode 100644 index 0000000..98c5980 --- /dev/null +++ b/src/lib/pages/TitlePages/SeriesPage/SeriesPageDetails.svelte @@ -0,0 +1,249 @@ + + + +
+ { + if (detail.direction === 'down' && detail.willLeaveContainer) { + $episodeCards?.focus(); + detail.preventNavigation(); + } + }} + > + + {#await $tmdbSeries then series} + {#if series} + + {/if} + {/await} + + + + {#if trailerId} + + {/if} + + {#if !$inLibrary} + + {:else} + + {/if} + + + + {#if PLATFORM_WEB} + + {/if} + + + +
+ + + {#await $tmdbSeries then series} + +
Show Cast
+ {#each series?.aggregate_credits?.cast?.slice(0, 15) || [] as credit} + + {/each} +
+ {/await} + {#await recommendations then recommendations} + +
Recommendations
+ {#each recommendations || [] as recommendation} + + {/each} +
+ {/await} +
+ {#await $tmdbSeries then series} + +

More Information

+
+
+
+

Created By

+ {#each series?.created_by || [] as creator} +
{creator.name}
+ {/each} +
+
+

Network

+
{series?.networks?.[0]?.name}
+
+
+
+
+

Language

+
{series?.spoken_languages?.[0]?.name}
+
+
+

Last Air Date

+
{series?.last_air_date}
+
+
+
+
+ {/await} +
+
+
diff --git a/src/lib/stores/component-stack.store.ts b/src/lib/stores/component-stack.store.ts new file mode 100644 index 0000000..6904a16 --- /dev/null +++ b/src/lib/stores/component-stack.store.ts @@ -0,0 +1,124 @@ +import { createStoreContext } from '$lib/utils'; +import { type ComponentProps, type ComponentType, type SvelteComponentTyped } from 'svelte'; +import { derived, get, writable } from 'svelte/store'; + +export type ComponentPage = { + id: symbol; + group: symbol; + component: ComponentType; + props: ComponentProps; +}; + +export type ComponentStackStore = ReturnType; + +export function useComponentStack

>(initial?: { + component: ComponentType>; + props: P; + group?: symbol | undefined; +}) { + const items = writable[]>([]); + const top = derived(items, ($items) => $items[$items.length - 1]); + + if (initial) { + create(initial.component, initial.props, initial.group); + } + + function close(symbol: symbol) { + items.update((prev) => prev.filter((i) => i.id !== symbol)); + } + + function closeGroup(group: symbol) { + items.update((prev) => prev.filter((i) => i.group !== group)); + } + + function create

>( + component: ComponentType>, + props: P, + group: symbol | undefined = undefined + ) { + const id = Symbol(); + const item = { id, component, props, group: group || id }; + items.update((prev) => [...prev, item]); + return id; + } + + function reset() { + items.set([]); + } + + function closeTopmost() { + const t = get(top); + if (t) { + close(t.id); + } + } + + return { + subscribe: items.subscribe, + top: { + subscribe: top.subscribe + }, + create, + close, + closeGroup, + closeTopmost, + pop: closeTopmost, + reset + }; +} + +export type ComponentStackContext = ReturnType; +type ContextProvider = ReturnType; + +export function useComponentStackContext() { + const contexts: Record = {}; + + function getContextProvider(index: number) { + function setContext(key: string, context: T) { + if (!contexts[key]) { + contexts[key] = []; + } + + const prev = contexts[key].find((ctx) => ctx.index === index); + + if (prev) { + prev.context = context; + } else { + contexts[key].push({ index, context }); + } + } + + function hasContext(key: string) { + return !!contexts[key]; + } + + function getContext(key: string): T; + function getContext(key: string) { + const context = contexts[key] || []; + + for (let i = context.length - 1; i >= 0; i--) { + const ctx = context[i]; + + if (!ctx) continue; + + if (ctx.index <= index) { + return ctx.context; + } + } + } + + return { + setContext, + getContext, + hasContext + }; + } + + return { getContextProvider }; +} + +export const componentStackContextProvider = createStoreContext( + 'component-stack-context', + (context: ComponentStackContext, index: number) => context.getContextProvider(index), + { required: true } +); diff --git a/src/lib/stores/user-data/is-watched.store.ts b/src/lib/stores/user-data/is-watched.store.ts new file mode 100644 index 0000000..1ef65b4 --- /dev/null +++ b/src/lib/stores/user-data/is-watched.store.ts @@ -0,0 +1,35 @@ +import type { MovieUserDataDto } from '$lib/apis/reiverr/reiverr.openapi'; +import { get_store_value as get } from 'svelte/internal'; +import { type Readable, writable } from 'svelte/store'; +import { libraryRefresher } from '../data.store'; +import { user } from '../user.store'; + +export function useIsWatched( + userData: Readable, + toggleFn: (userId: string, watched: boolean) => Promise +) { + const isWatched = writable(undefined); + + userData.subscribe((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); + libraryRefresher.refreshIn(500); + }); + } + + return { + isWatched, + toggleIsWatched + }; +} diff --git a/src/lib/stores/user-data/library.store.ts b/src/lib/stores/user-data/library.store.ts new file mode 100644 index 0000000..c5d75a5 --- /dev/null +++ b/src/lib/stores/user-data/library.store.ts @@ -0,0 +1,57 @@ +import type { MovieUserDataDto, SeriesUserDataDto } from '$lib/apis/reiverr/reiverr.openapi'; +import { get_store_value as get } from 'svelte/internal'; +import { type Readable, writable } from 'svelte/store'; +import { libraryRefresher } from '../data.store'; +import { reiverrApi, user } from '../user.store'; + +export function useUserLibrary( + mediaType: 'movie' | 'series', + tmdbId: string, + userData: Readable +) { + const inLibrary = writable(undefined); + + userData.subscribe((d) => { + inLibrary.set(d?.inLibrary ?? false); + }); + + async function handleAddToLibrary() { + const userId = get(user)?.id; + + if (!userId) { + console.error('Add to library: No user ID'); + return; + } + + const success = await reiverrApi.library + .addLibraryItem(userId, tmdbId, { mediaType }) + .then((r) => r.data.success); + if (success) { + inLibrary.set(true); + libraryRefresher.refreshIn(1500); + } + } + + async function handleRemoveFromLibrary() { + const userId = get(user)?.id; + + if (!userId) { + console.error('Remove from library: No user ID'); + return; + } + + const success = await reiverrApi.library + .removeLibraryItem(userId, tmdbId) + .then((r) => r.data.success); + if (success) { + inLibrary.set(false); + libraryRefresher.refreshIn(500); + } + } + + return { + inLibrary, + handleAddToLibrary, + handleRemoveFromLibrary + }; +} diff --git a/src/lib/stores/user-data/playable-user-data.store.ts b/src/lib/stores/user-data/playable-user-data.store.ts new file mode 100644 index 0000000..f30d3c5 --- /dev/null +++ b/src/lib/stores/user-data/playable-user-data.store.ts @@ -0,0 +1,42 @@ +import type { MediaSourceDto } from '$lib/apis/reiverr/reiverr.openapi'; +import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; +import TmdbVideoPlayer from '$lib/components/VideoPlayer/TmdbVideoPlayer.svelte'; +import { get } from 'svelte/store'; +import { seriesUserDataContext } from './title-user-data.store'; +import { user } from '../user.store'; + +export function useSeriesPlayableUserData(options: { + tmdbId: string; + episode?: number; + season?: number; +}) { + const seriesUserData = seriesUserDataContext.getContext(); + const background = getBackgroundPage(); + + function play( + options: { + source?: MediaSourceDto; + } = {} + ) { + // const source = options.source ? get(user)?.mediaSources + + background?.setVideo({ + id: Symbol(), + component: TmdbVideoPlayer, + props: { + ...videoProps, + streamId: targetId, + source + }, + mediaId: tmdbId + }); + } + + return { + ...seriesUserData, + + unsubscribe: () => {} + }; +} + +export function useMoviePlayableUserData() {} diff --git a/src/lib/stores/media-user-data.store.ts b/src/lib/stores/user-data/title-user-data.store.ts similarity index 61% rename from src/lib/stores/media-user-data.store.ts rename to src/lib/stores/user-data/title-user-data.store.ts index 53abde4..1d95641 100644 --- a/src/lib/stores/media-user-data.store.ts +++ b/src/lib/stores/user-data/title-user-data.store.ts @@ -1,23 +1,30 @@ -import { getBackgroundPage } from '$lib/components/GlobalBackground/BackgroundStack'; +import { + getBackgroundPage, + type BackgroundPage +} from '$lib/components/GlobalBackground/BackgroundStack'; import { createModal } from '$lib/components/Modal/modal.store'; import { createErrorNotification } from '$lib/components/Notifications/notification.store'; import TmdbVideoPlayer from '$lib/components/VideoPlayer/TmdbVideoPlayer.svelte'; import StreamSelectorModal from '$lib/pages/TitlePages/StreamSelectorModal.svelte'; -import { derived, get, writable, type Readable } from 'svelte/store'; +import { createStoreContext } from '$lib/utils'; +import { derived, get, writable } from 'svelte/store'; import type { MediaSourceDto, - MovieUserDataDto, - SeriesUserDataDto, - StreamCandidateDto -} from '../apis/reiverr/reiverr.openapi'; + StreamBaseDto, + StreamCandidateDto, + TmdbItemDto +} from '../../apis/reiverr/reiverr.openapi'; import { episodeUserDataRefresher, libraryRefresher, movieUserDataRefresher, seriesUserDataRefresher, useRequest -} from './data.store'; -import { reiverrApi, tmdbApi, user } from './user.store'; +} from '../data.store'; +import { reiverrApi, tmdbApi, user } from '../user.store'; +import { useUserLibrary } from './library.store'; +import { useIsWatched } from './is-watched.store'; +import type { ComponentProps } from 'svelte'; export type EpisodeData = { season: number; @@ -74,85 +81,90 @@ async function getAutoplayStream(options: { tmdbId: string; season?: number; epi }; } -function useUserLibrary( - mediaType: 'movie' | 'series', - tmdbId: string, - userData: Readable -) { - const inLibrary = writable(undefined); +function usePlayback(options: { + tmdbId: string; + season?: number; + episode?: number; + getVideoProps: () => Promise< + Pick, 'tmdbId' | 'title'> | undefined + >; +}) { + const { tmdbId, season, episode, getVideoProps } = options; + const background = getBackgroundPage(); - userData.subscribe((d) => { - inLibrary.set(d?.inLibrary ?? false); + const autoplayStore = writable<{ + isLoading: boolean; + candidate?: { + stream: StreamBaseDto; + source: MediaSourceDto; + }; + }>({ + isLoading: true }); - async function handleAddToLibrary() { - const userId = get(user)?.id; + fetchAutoplayCandidate(); - if (!userId) { - console.error('Add to library: No user ID'); - return; + async function fetchAutoplayCandidate() { + for (const source of get(user)?.mediaSources ?? []) { + const candidate = await reiverrApi.sources + .getAutoplayStream(source.id, { + tmdbId, + season, + episode + }) + .then((r) => r.data.candidate); + + if (candidate) { + autoplayStore.set({ + isLoading: false, + candidate: { + stream: candidate, + source + } + }); + return candidate; + } } - const success = await reiverrApi.library - .addLibraryItem(userId, tmdbId, { mediaType }) - .then((r) => r.data.success); - if (success) { - inLibrary.set(true); - libraryRefresher.refreshIn(1500); - } + autoplayStore.set({ isLoading: false }); } - async function handleRemoveFromLibrary() { - const userId = get(user)?.id; + async function autoplayStream() { + const { stream, source } = get(autoplayStore)?.candidate ?? {}; - if (!userId) { - console.error('Remove from library: No user ID'); + if (!stream || !source) { + createErrorNotification('Autoplay failed', 'No stream found'); return; } - const success = await reiverrApi.library - .removeLibraryItem(userId, tmdbId) - .then((r) => r.data.success); - if (success) { - inLibrary.set(false); - libraryRefresher.refreshIn(500); - } + return playStream(source, stream.streamId); } - return { - inLibrary, - handleAddToLibrary, - handleRemoveFromLibrary - }; -} + async function playStream(source: MediaSourceDto, streamId: string) { + const props = await getVideoProps(); -function useIsWatched( - userData: Readable, - toggleFn: (userId: string, watched: boolean) => Promise -) { - const isWatched = writable(undefined); - - userData.subscribe((d) => { - isWatched.set(d?.playState?.watched ?? false); - }); - - async function toggleIsWatched() { - const watched = get(isWatched); - const userId = get(user)?.id; - - if (!userId) { + if (!props) { + createErrorNotification('Could not find video props'); return; } - return toggleFn(userId, !watched).finally(() => { - isWatched.set(!watched); - libraryRefresher.refreshIn(500); + background?.setVideo({ + id: Symbol(), + component: TmdbVideoPlayer, + props: { + ...props, + streamId, + source + } }); + + background?.focus(); } return { - isWatched, - toggleIsWatched + autoplayCandidate: { subscribe: autoplayStore.subscribe }, + autoplayStream, + playStream }; } @@ -164,9 +176,10 @@ function useCanStream() { }; } -export function useSeriesUserData(tmdbId: string) { - const background = getBackgroundPage(); +export type TitleUserData = ReturnType & + ReturnType; +export function useSeriesUserData(tmdbId: string) { const userDataRequest = useRequest( () => reiverrApi.users.getSeriesUserData(get(user)?.id as string, tmdbId).then((r) => r.data), { @@ -176,7 +189,6 @@ export function useSeriesUserData(tmdbId: string) { ); const tmdbSeriesRequest = useRequest(() => tmdbApi.getSeriesFull(Number(tmdbId))); const libraryStore = useUserLibrary('series', tmdbId, userDataRequest); - const canStreamStore = useCanStream(); const episodesUserData = writable([]); const nextEpisode = writable({ season: 1, @@ -227,6 +239,13 @@ export function useSeriesUserData(tmdbId: string) { episodesUserData.set(episodesData); }); + const mediaPlayback = usePlayback({ + tmdbId, + season: get(nextEpisode)?.season, + episode: get(nextEpisode)?.episode, + getVideoProps + }); + async function toggleIsWatched() { const watched = get(isWatched); const userId = get(user)?.id; @@ -254,7 +273,7 @@ export function useSeriesUserData(tmdbId: string) { }); } - const getVideoProps = async () => { + async function getVideoProps() { const tmdbSeriesData = get(tmdbSeriesRequest); const { season, episode, progress } = get(nextEpisode) ?? {}; @@ -275,70 +294,91 @@ export function useSeriesUserData(tmdbId: string) { title: tmdbEpisode?.name ?? 'Unknown', subtitle: tmdbSeriesData?.name ?? 'Unknown' }; - }; + } return { + tmdbId, tmdbSeries: tmdbSeriesRequest.promise, ...libraryStore, - ...canStreamStore, + ...mediaPlayback, nextEpisode, episodesUserData, isWatched, toggleIsWatched, - handleAutoplay: async () => { - const videoProps = await getVideoProps(); + // handleAutoplay: async () => { + // const videoProps = await getVideoProps(); - if (!videoProps) return; + // if (!videoProps) return; - const { season, episode } = videoProps; + // const { season, episode } = videoProps; - const { streamId, source } = await getAutoplayStream({ tmdbId, season, episode }); + // const { streamId, source } = await getAutoplayStream({ tmdbId, season, episode }); - if (!streamId || !source) { - createErrorNotification('Autoplay failed', 'No stream found'); - return; - } + // if (!streamId || !source) { + // createErrorNotification('Autoplay failed', 'No stream found'); + // return; + // } - background?.setVideo({ - id: Symbol(), - component: TmdbVideoPlayer, - props: { - ...videoProps, - streamId, - source - }, - mediaId: tmdbId - }); + // background?.setVideo({ + // id: Symbol(), + // component: TmdbVideoPlayer, + // props: { + // ...videoProps, + // streamId, + // source + // }, + // mediaId: tmdbId + // }); - background?.focus(); - }, - handleOpenStreamSelector: async () => { - const videoProps = await getVideoProps(); + // background?.focus(); + // }, + // handleOpenStreamSelector: async () => { + // const videoProps = await getVideoProps(); - if (!videoProps) return; + // if (!videoProps) return; - const { season, episode } = videoProps; + // const { season, episode } = videoProps; - createModal(StreamSelectorModal, { - getStreams: (s) => getStreams(s, tmdbId, season, episode), - selectStream: (source, stream) => { - background?.setVideo({ - id: Symbol(), - component: TmdbVideoPlayer, - props: { - ...videoProps, - streamId: stream.streamId, - source - }, - mediaId: tmdbId - }); + // // createModal(StreamSelectorModal, { + // // getStreams: (s) => getStreams(s, tmdbId, season, episode), + // // selectStream: (source, stream) => { + // // background?.setVideo({ + // // id: Symbol(), + // // component: TmdbVideoPlayer, + // // props: { + // // ...videoProps, + // // streamId: stream.streamId, + // // source + // // }, + // // mediaId: tmdbId + // // }); - background?.focus(); - } - }); + // // background?.focus(); + // // } + // // }); - // return handleOpenStreamSelector({ tmdbId, season, episode, progress }); - }, + // // createModal(MediaSourceMenuModal, { + // // tmdbId, + // // season, + // // episode, + // // playStream: (source, streamId) => { + // // background?.setVideo({ + // // id: Symbol(), + // // component: TmdbVideoPlayer, + // // props: { + // // ...videoProps, + // // streamId, + // // source + // // }, + // // mediaId: tmdbId + // // }); + + // // background?.focus(); + // // } + // // }); + + // // return handleOpenStreamSelector({ tmdbId, season, episode, progress }); + // }, unsubscribe: () => { userDataRequest.unsubscribe(); tmdbSeriesRequest.unsubscribe(); @@ -360,7 +400,6 @@ export function useMovieUserData(tmdbId: string) { const tmdbMovie = useRequest(() => tmdbApi.getMovieFull(Number(tmdbId))); const libraryStore = useUserLibrary('movie', tmdbId, userData); - const canStreamStore = useCanStream(); const isWatchedStore = useIsWatched(userData, (userId, watched) => reiverrApi.users.updateMoviePlayStateByTmdbId(userId, tmdbId, { watched @@ -368,7 +407,7 @@ export function useMovieUserData(tmdbId: string) { ); const progress = derived(userData, ($userData) => $userData?.playState?.progress ?? 0); - const getVideoProps = () => { + const getVideoProps = async () => { const tmdbMovieData = get(tmdbMovie); return { @@ -376,57 +415,64 @@ export function useMovieUserData(tmdbId: string) { progress: get(progress), title: tmdbMovieData?.title ?? 'Unknown', subtitle: tmdbMovieData?.release_date - ? new Date(tmdbMovieData.release_date).getFullYear() + ? String(new Date(tmdbMovieData.release_date).getFullYear()) : undefined }; }; + const mediaPlayback = usePlayback({ + tmdbId, + getVideoProps + }); + return { + tmdbId, ...libraryStore, - ...canStreamStore, ...isWatchedStore, + ...mediaPlayback, tmdbMovie: { subscribe: tmdbMovie.promise.subscribe }, progress, - handleAutoplay: async () => { - const { streamId, source } = await getAutoplayStream({ tmdbId }); + getVideoProps, + // handleAutoplay: async () => { + // const { streamId, source } = await getAutoplayStream({ tmdbId }); - if (!streamId || !source) { - createErrorNotification('Autoplay failed', 'No stream found'); - return; - } + // if (!streamId || !source) { + // createErrorNotification('Autoplay failed', 'No stream found'); + // return; + // } - background?.setVideo({ - id: Symbol(), - component: TmdbVideoPlayer, - props: { - ...getVideoProps(), - streamId, - source - }, - mediaId: tmdbId - }); + // background?.setVideo({ + // id: Symbol(), + // component: TmdbVideoPlayer, + // props: { + // ...getVideoProps(), + // streamId, + // source + // }, + // mediaId: tmdbId + // }); - background?.focus(); - }, - handleOpenStreamSelector: async () => { - createModal(StreamSelectorModal, { - getStreams: (s) => getStreams(s, tmdbId), - selectStream: (source, stream) => { - background?.setVideo({ - id: Symbol(), - component: TmdbVideoPlayer, - props: { - ...getVideoProps(), - streamId: stream.streamId, - source - }, - mediaId: tmdbId - }); + // background?.focus(); + // }, + // handleOpenStreamSelector: async () => { + // createModal(StreamSelectorModal, { + // getStreams: (s) => getStreams(s, tmdbId), + // selectStream: (source, stream) => { + // background?.setVideo({ + // id: Symbol(), + // component: TmdbVideoPlayer, + // props: { + // ...getVideoProps(), + // streamId: stream.streamId, + // source + // }, + // mediaId: tmdbId + // }); - background?.focus(); - } - }); - }, + // background?.focus(); + // } + // }); + // }, unsubscribe: () => { userData.unsubscribe(); tmdbMovie.unsubscribe(); @@ -529,3 +575,17 @@ export function useEpisodeUserData(tmdbId: string, season: number, episode: numb } }; } + +export const TITLE_USER_DATA_CONTEXT = 'title-user-data-context'; + +export const seriesUserDataContext = createStoreContext( + TITLE_USER_DATA_CONTEXT, + useSeriesUserData, + { + required: true + } +); + +export const movieUserDataContext = createStoreContext(TITLE_USER_DATA_CONTEXT, useMovieUserData, { + required: true +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8ee8dad..ba2c14b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { getContext, hasContext, setContext } from 'svelte'; import { get, type Readable, writable } from 'svelte/store'; export function formatSecondsToTime(seconds: number) { @@ -216,3 +217,64 @@ export function useTimeoutStore(duration: number, initialReset = false) { reset }; } + +export const hook = , TReturn>( + fn: (...args: TArgs) => TReturn, + options: { + after?: (v: TReturn) => TReturn; + before?: (v: TArgs) => TArgs; + } = {} +) => { + const hooks: Array<(arg: any) => any> = []; + + const wrappedFn = (...args: TArgs) => { + const a = options.before ? options.before(args) : args; + + const result = fn(...a); + + if (options.after) { + return options.after(result); + } + + return result; + }; + + return wrappedFn; +}; + +export function createStoreContext< + TStore extends object, + TArgs extends Array = Array, + TRequired extends boolean = boolean +>( + key: string, + storeCreator: (...args: TArgs) => TStore, + options: { + required?: TRequired; + } = {} +) { + function createContext(...args: TArgs) { + const store = storeCreator(...args); + setContext(key, store); + return store; + } + + function _getContext(): TRequired extends true ? TStore : Partial; + function _getContext(): Partial | TStore { + if (!hasContext(key)) { + if (options.required === true) { + throw new Error(`Context ${key} not found`); + } else { + return {}; + } + } + + return getContext(key); + } + + return { + createContext, + getContext: _getContext, + useStore: storeCreator + }; +}