diff --git a/backend/plugins/jellyfin.plugin/src/index.ts b/backend/plugins/jellyfin.plugin/src/index.ts index 4ae79cd..71c4368 100644 --- a/backend/plugins/jellyfin.plugin/src/index.ts +++ b/backend/plugins/jellyfin.plugin/src/index.ts @@ -39,8 +39,8 @@ export default class JellyfinPluginProvider extends PluginProvider { class JellyfinProvider extends SourceProvider { name: string = 'jellyfin'; - private get proxyUrl() { - return `/api/sources/${this.name}/proxy`; + private getProxyUrl(sourceId: string) { + return `/api/sources/${sourceId}/proxy`; } settingsManager: SettingsManager = new JellyfinSettingsManager(); @@ -177,7 +177,7 @@ class JellyfinProvider extends SourceProvider { const mediasSource = playbackInfo.data?.MediaSources?.[0]; const playbackUri = - this.proxyUrl + + this.getProxyUrl(userContext.sourceId) + (mediasSource?.TranscodingUrl || `/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) + `&reiverr_token=${userContext.token}`; @@ -209,7 +209,8 @@ class JellyfinProvider extends SourceProvider { (s) => s.Type === 'Subtitle' && s.DeliveryUrl, ).map((s, i) => ({ src: - this.proxyUrl + `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, + this.getProxyUrl(userContext.sourceId) + + `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, lang: s.Language, kind: 'subtitles', label: s.DisplayTitle, @@ -335,7 +336,7 @@ class JellyfinProvider extends SourceProvider { const mediasSource = playbackInfo.data?.MediaSources?.[0]; const playbackUri = - this.proxyUrl + + this.getProxyUrl(userContext.sourceId) + (mediasSource?.TranscodingUrl || `/Videos/${mediasSource?.Id}/stream.mp4?Static=true&mediaSourceId=${mediasSource?.Id}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${context.settings.apiKey}&Tag=${mediasSource?.ETag}`) + `&reiverr_token=${userContext.token}`; @@ -367,7 +368,8 @@ class JellyfinProvider extends SourceProvider { (s) => s.Type === 'Subtitle' && s.DeliveryUrl, ).map((s, i) => ({ src: - this.proxyUrl + `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, + this.getProxyUrl(userContext.sourceId) + + `${s.DeliveryUrl}&reiverr_token=${userContext.token}`, lang: s.Language, kind: 'subtitles', label: s.DisplayTitle, diff --git a/backend/plugins/jellyfin.plugin/src/plugin-context.ts b/backend/plugins/jellyfin.plugin/src/plugin-context.ts index 387a7f7..84cb7fa 100644 --- a/backend/plugins/jellyfin.plugin/src/plugin-context.ts +++ b/backend/plugins/jellyfin.plugin/src/plugin-context.ts @@ -4,7 +4,6 @@ import { } from '@aleksilassila/reiverr-plugin'; import { Api as JellyfinApi } from './jellyfin.openapi'; import { JELLYFIN_DEVICE_ID } from './utils'; -import { PluginSettings } from 'plugins/plugin-types'; export interface JellyfinSettings extends SourceProviderSettings { apiKey: string; @@ -21,7 +20,7 @@ export class PluginContext { settings: JellyfinSettings; token: string; - constructor(settings: PluginSettings, token = '') { + constructor(settings: SourceProviderSettings, token = '') { this.token = token; this.settings = settings as JellyfinSettings; this.api = new JellyfinApi({ diff --git a/backend/plugins/plugin-types.ts b/backend/plugins/plugin-types.ts deleted file mode 100644 index bc65e78..0000000 --- a/backend/plugins/plugin-types.ts +++ /dev/null @@ -1,401 +0,0 @@ -export enum SourcePluginError { - StreamNotFound = 'StreamNotFound', -} - -export type PluginSettingsLink = { - type: 'link'; - url: string; - label: string; -}; - -export type PluginSettingsInput = { - type: 'string' | 'number' | 'boolean' | 'password'; - label: string; - placeholder: string; -}; - -export type PluginSettingsTemplate = Record< - string, - PluginSettingsLink | PluginSettingsInput ->; - -export type UserContext = { - token: string; - settings: PluginSettings; -}; - -export type PluginSettings = Record; - -export type ValidationResponse = { - isValid: boolean; - errors: Record; - replace: Record; -}; - -export type AudioStream = { - index: number; - label: string; - codec: string | undefined; - bitrate: number | undefined; -}; - -export type Quality = { - index: number; - bitrate: number; - label: string; - codec: string | undefined; - original: boolean; -}; - -export type Subtitles = { - index: number; - uri: string; - label: string; - codec: string | undefined; -}; - -export type VideoStreamProperty = { - label: string; - value: string | number; - formatted: string | undefined; -}; - -export type VideoStreamCandidate = { - key: string; - title: string; - properties: VideoStreamProperty[]; -}; - -export type VideoStream = VideoStreamCandidate & { - uri: string; - directPlay: boolean; - progress: number; - duration: number; - audioStreams: AudioStream[]; - audioStreamIndex: number; - qualities: Quality[]; - qualityIndex: number; - subtitles: Subtitles[]; -}; - -export type PlaybackConfig = { - bitrate: number | undefined; - audioStreamIndex: number | undefined; - progress: number | undefined; - deviceProfile: DeviceProfile | undefined; - defaultLanguage: string | undefined; -}; - -export type SourcePluginCapabilities = { - playback: boolean; - indexing: boolean; - requesting: boolean; - deletion: boolean; -}; - -export class IndexItem { - id: string; -} - -export class PaginatedResponse { - total: number; - page: number; - itemsPerPage: number; - items: T[]; -} - -export class PaginationParams { - page: number; - itemsPerPage: number; -} - -interface Metadata { - tmdbId?: string; - imdbId?: string; - year?: number; -} - -export interface MovieMetadata extends Metadata { - title: string; - runtime?: number; -} - -export interface EpisodeMetadata extends Metadata { - series: string; - season: number; - episode: number; - episodeRuntime?: number; - seasonEpisodes?: number; -} - -export interface SourcePlugin { - name: string; - - getMovieIndex?: ( - context: UserContext, - pagination: PaginationParams, - ) => Promise>; - - getSettingsTemplate: () => PluginSettingsTemplate; - - validateSettings: ( - settings: Record, - ) => Promise; - - getCapabilities: (conext: UserContext) => Promise; - - getMovieStream: ( - tmdbId: string, - metadata: MovieMetadata, - key: string, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - getMovieStreams: ( - tmdbId: string, - metadata: MovieMetadata, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - getEpisodeStream: ( - tmdbId: string, - metadata: EpisodeMetadata, - key: string, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - getEpisodeStreams: ( - tmdbId: string, - metadata: EpisodeMetadata, - context: UserContext, - config?: PlaybackConfig, - ) => Promise; - - // handleProxy( - // request: { uri: string; headers: any }, - // settings: PluginSettings, - // ): { - // url: string; - // headers: any; - // }; - - proxyHandler?: ( - req: any, - res: any, - options: { context: UserContext; uri: string; targetUrl?: string }, - ) => Promise; -} - -/** - * A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play. - *
- * Specifically, it defines the supported containers and - * codecs (video and/or audio, including codec profiles and levels) - * the device is able to direct play (without transcoding or remuxing), - * as well as which containers/codecs to transcode to in case it isn't. - */ -export interface DeviceProfile { - /** Gets or sets the name of this device profile. User profiles must have a unique name. */ - Name?: string | null; - /** - * Gets or sets the unique internal identifier. - * @format uuid - */ - Id?: string | null; - /** - * Gets or sets the maximum allowed bitrate for all streamed content. - * @format int32 - */ - MaxStreamingBitrate?: number | null; - /** - * Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). - * @format int32 - */ - MaxStaticBitrate?: number | null; - /** - * Gets or sets the maximum allowed bitrate for transcoded music streams. - * @format int32 - */ - MusicStreamingTranscodingBitrate?: number | null; - /** - * Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. - * @format int32 - */ - MaxStaticMusicBitrate?: number | null; - /** Gets or sets the direct play profiles. */ - DirectPlayProfiles?: DirectPlayProfile[]; - /** Gets or sets the transcoding profiles. */ - TranscodingProfiles?: TranscodingProfile[]; - /** Gets or sets the container profiles. Failing to meet these optional conditions causes transcoding to occur. */ - ContainerProfiles?: ContainerProfile[]; - /** Gets or sets the codec profiles. */ - CodecProfiles?: CodecProfile[]; - /** Gets or sets the subtitle profiles. */ - SubtitleProfiles?: SubtitleProfile[]; -} - -/** Defines the MediaBrowser.Model.Dlna.DirectPlayProfile. */ -export interface DirectPlayProfile { - /** Gets or sets the container. */ - Container?: string; - /** Gets or sets the audio codec. */ - AudioCodec?: string | null; - /** Gets or sets the video codec. */ - VideoCodec?: string | null; - /** Gets or sets the Dlna profile type. */ - Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; -} - -/** A class for transcoding profile information. */ -export interface TranscodingProfile { - /** Gets or sets the container. */ - Container?: string; - /** Gets or sets the DLNA profile type. */ - Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; - /** Gets or sets the video codec. */ - VideoCodec?: string; - /** Gets or sets the audio codec. */ - AudioCodec?: string; - /** - * Media streaming protocol. - * Lowercase for backwards compatibility. - */ - Protocol?: 'http' | 'hls'; - /** - * Gets or sets a value indicating whether the content length should be estimated. - * @default false - */ - EstimateContentLength?: boolean; - /** - * Gets or sets a value indicating whether M2TS mode is enabled. - * @default false - */ - EnableMpegtsM2TsMode?: boolean; - /** - * Gets or sets the transcoding seek info mode. - * @default "Auto" - */ - TranscodeSeekInfo?: 'Auto' | 'Bytes'; - /** - * Gets or sets a value indicating whether timestamps should be copied. - * @default false - */ - CopyTimestamps?: boolean; - /** - * Gets or sets the encoding context. - * @default "Streaming" - */ - Context?: 'Streaming' | 'Static'; - /** - * Gets or sets a value indicating whether subtitles are allowed in the manifest. - * @default false - */ - EnableSubtitlesInManifest?: boolean; - /** Gets or sets the maximum audio channels. */ - MaxAudioChannels?: string | null; - /** - * Gets or sets the minimum amount of segments. - * @format int32 - * @default 0 - */ - MinSegments?: number; - /** - * Gets or sets the segment length. - * @format int32 - * @default 0 - */ - SegmentLength?: number; - /** - * Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported. - * @default false - */ - BreakOnNonKeyFrames?: boolean; - /** Gets or sets the profile conditions. */ - Conditions?: ProfileCondition[]; - /** - * Gets or sets a value indicating whether variable bitrate encoding is supported. - * @default true - */ - EnableAudioVbrEncoding?: boolean; -} - -export interface ProfileCondition { - Condition?: - | 'Equals' - | 'NotEquals' - | 'LessThanEqual' - | 'GreaterThanEqual' - | 'EqualsAny'; - Property?: - | 'AudioChannels' - | 'AudioBitrate' - | 'AudioProfile' - | 'Width' - | 'Height' - | 'Has64BitOffsets' - | 'PacketLength' - | 'VideoBitDepth' - | 'VideoBitrate' - | 'VideoFramerate' - | 'VideoLevel' - | 'VideoProfile' - | 'VideoTimestamp' - | 'IsAnamorphic' - | 'RefFrames' - | 'NumAudioStreams' - | 'NumVideoStreams' - | 'IsSecondaryAudio' - | 'VideoCodecTag' - | 'IsAvc' - | 'IsInterlaced' - | 'AudioSampleRate' - | 'AudioBitDepth' - | 'VideoRangeType'; - Value?: string | null; - IsRequired?: boolean; -} - -/** Defines the MediaBrowser.Model.Dlna.ContainerProfile. */ -export interface ContainerProfile { - /** Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet. */ - Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric'; - /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this container will be applied to. */ - Conditions?: ProfileCondition[]; - /** Gets or sets the container(s) which this container must meet. */ - Container?: string | null; - /** Gets or sets the sub container(s) which this container must meet. */ - SubContainer?: string | null; -} - -/** Defines the MediaBrowser.Model.Dlna.CodecProfile. */ -export interface CodecProfile { - /** Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet. */ - Type?: 'Video' | 'VideoAudio' | 'Audio'; - /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition which this profile must meet. */ - Conditions?: ProfileCondition[]; - /** Gets or sets the list of MediaBrowser.Model.Dlna.ProfileCondition to apply if this profile is met. */ - ApplyConditions?: ProfileCondition[]; - /** Gets or sets the codec(s) that this profile applies to. */ - Codec?: string | null; - /** Gets or sets the container(s) which this profile will be applied to. */ - Container?: string | null; - /** Gets or sets the sub-container(s) which this profile will be applied to. */ - SubContainer?: string | null; -} - -/** A class for subtitle profile information. */ -export interface SubtitleProfile { - /** Gets or sets the format. */ - Format?: string | null; - /** Gets or sets the delivery method. */ - Method?: 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop'; - /** Gets or sets the DIDL mode. */ - DidlMode?: string | null; - /** Gets or sets the language. */ - Language?: string | null; - /** Gets or sets the container. */ - Container?: string | null; -} diff --git a/backend/plugins/reiverr-plugin/src/types.ts b/backend/plugins/reiverr-plugin/src/types.ts index 0806954..8640466 100644 --- a/backend/plugins/reiverr-plugin/src/types.ts +++ b/backend/plugins/reiverr-plugin/src/types.ts @@ -24,6 +24,7 @@ export type SourceProviderSettingsTemplate = Record< export type UserContext = { userId: string; token: string; + sourceId: string; settings: SourceProviderSettings; }; diff --git a/backend/plugins/torrent-stream.plugin/src/index.ts b/backend/plugins/torrent-stream.plugin/src/index.ts index cea141e..cca4168 100644 --- a/backend/plugins/torrent-stream.plugin/src/index.ts +++ b/backend/plugins/torrent-stream.plugin/src/index.ts @@ -35,8 +35,8 @@ class TorrentProvider extends SourceProvider { name: string = 'torrent'; settingsManager: SettingsManager = new TorrentSettingsManager(); - get proxyUrl() { - return `/api/sources/${this.name}/proxy`; + getProxyUrl(sourceId: string) { + return `/api/sources/${sourceId}/proxy`; } getMovieStreams = async ( @@ -136,7 +136,7 @@ class TorrentProvider extends SourceProvider { throw new Error('Torrent not found'); } - const src = `${this.proxyUrl}/magnet?link=${encodeURIComponent(torrent?.link)}&reiverr_token=${context.token}`; + const src = `${this.getProxyUrl(context.sourceId)}/magnet?link=${encodeURIComponent(torrent?.link)}&reiverr_token=${context.token}`; const files = await getFiles(context.userId, torrent.link); @@ -146,7 +146,7 @@ class TorrentProvider extends SourceProvider { .filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext))) .map((f) => ({ kind: 'subtitles', - src: `${this.proxyUrl}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&file=${f.name}`, + src: `${this.getProxyUrl(context.sourceId)}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&file=${f.name}`, label: f.name, lang: 'unknown', })); @@ -193,7 +193,7 @@ class TorrentProvider extends SourceProvider { throw new Error('Torrent not found'); } - const src = `${this.proxyUrl}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&season=${metadata.season}&episode=${metadata.episode}`; + const src = `${this.getProxyUrl(context.sourceId)}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&season=${metadata.season}&episode=${metadata.episode}`; const files = await getFiles(context.userId, torrent.link); @@ -201,7 +201,7 @@ class TorrentProvider extends SourceProvider { .filter((f) => subtitleExtensions.some((ext) => f.name.endsWith(ext))) .map((f) => ({ kind: 'subtitles', - src: `${this.proxyUrl}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&file=${f.name}`, + src: `${this.getProxyUrl(context.sourceId)}/magnet?link=${encodeURIComponent(torrent.link)}&reiverr_token=${context.token}&file=${f.name}`, label: f.name, lang: 'unknown', })); diff --git a/backend/plugins/torrent-stream.plugin/src/lib/torrent-manager.ts b/backend/plugins/torrent-stream.plugin/src/lib/torrent-manager.ts index c7bbfc0..8afb347 100644 --- a/backend/plugins/torrent-stream.plugin/src/lib/torrent-manager.ts +++ b/backend/plugins/torrent-stream.plugin/src/lib/torrent-manager.ts @@ -2,57 +2,6 @@ import * as path from 'path'; import * as fs from 'fs'; import * as torrentStream from 'torrent-stream'; -// class StreamCache { -// private streamCacheFile = path.join( -// __dirname, -// '..', -// '..', -// 'stream-cache.json', -// ); -// cache: Record = this.readStreamCache(); - -// constructor() {} - -// private writeStreamCache(cache: Record) { -// this.cache = cache; -// fs.writeFileSync(this.streamCacheFile, JSON.stringify(cache)); -// } - -// private readStreamCache(): Record { -// if (fs.existsSync(this.streamCacheFile)) { -// const data = fs.readFileSync(this.streamCacheFile, 'utf8'); -// return JSON.parse(data); -// } - -// return {}; -// } - -// set(key: string, value: T) { -// this.cache[key] = value; -// this.writeStreamCache(this.cache); -// } - -// update(key: string, fn: (value: T | undefined) => T) { -// const n = fn(this.get(key)); -// this.cache[key] = n; -// this.writeStreamCache(this.cache); -// return n; -// } - -// remove(key: string) { -// delete this.cache[key]; -// this.writeStreamCache(this.cache); -// } - -// get(key: string): T | undefined { -// return this.cache[key]; -// } - -// getAll() { -// return this.cache; -// } -// } - class FileCache { private cache: T; @@ -126,46 +75,8 @@ class EngineCache { }); } } - - // this.purge(); } - // private async purge() { - // const metadata = this.userTorrentMetadata.get(); - // const activeTorrents = Object.keys(this.engineCache); - - // const toDelete: string[] = activeTorrents.filter((infoHash) => - // Object.values(metadata).some((m) => m.infoHash === infoHash), - // ); - - // for (const userId of Object.keys(metadata)) { - // const userMetadata = metadata[userId]; - - // if (userMetadata.lastAccessed < Date.now() - this.maxTorrentKeepAlive) { - // toDelete.push(userMetadata.infoHash); - // } - // } - - // const torrents = await Promise.all( - // Object.entries(this.engineCache).map( - // async ([key, value]) => [key, await value] as const, - // ), - // ); - - // torrents.sort(([_, a], [__, b]) => { - // return a!.metadata.lastAccessed - b!.metadata.lastAccessed; - // }); - // console.log('torrents sorted', torrents); - - // const tasks = torrents.map(async ([infoHash, torrent], index) => { - // if (index < this.maxActiveTorrentsPerUser) return undefined; - - // return this.destroyEngine(infoHash); - // }); - - // await Promise.all(tasks); - // } - private async destroyEngine(infoHash: string) { const engine = await this.engineCache[infoHash]; @@ -205,7 +116,6 @@ class EngineCache { res(engine); }); engine.on('download', (e) => console.log('onDownload', magnetLink, e)); - // engine.on('torrent', (e) => console.log('onTorrent', magnetLink, e)); engine.on('upload', (e) => console.log('onUpload', magnetLink, e)); }); diff --git a/backend/plugins/torrent-stream.plugin/src/types.ts b/backend/plugins/torrent-stream.plugin/src/types.ts index 2337492..4a43ac8 100644 --- a/backend/plugins/torrent-stream.plugin/src/types.ts +++ b/backend/plugins/torrent-stream.plugin/src/types.ts @@ -1,6 +1,9 @@ -import type { PluginSettings, UserContext } from '../../plugin-types'; +import type { + SourceProviderSettings, + UserContext, +} from '@aleksilassila/reiverr-plugin'; -export interface TorrentSettings extends PluginSettings { +export interface TorrentSettings extends SourceProviderSettings { apiKey: string; baseUrl: string; } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index aaf66cd..4654093 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { DatabaseModule } from './database/database.module'; -import { UsersModule } from './users/users.module'; -import { AuthModule } from './auth/auth.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { DatabaseModule } from './database/database.module'; +import { MediaSourcesModule } from './media-sources/media-sources.module'; import { MetadataModule } from './metadata/metadata.module'; -import { SourcePluginsModule } from './source-plugins/source-plugins.module'; +import { SourceProvidersModule } from './source-providers/source-providers.module'; +import { UserDataModule } from './user-data/user-data.module'; +import { UsersModule } from './users/users.module'; @Module({ imports: [ @@ -18,7 +20,9 @@ import { SourcePluginsModule } from './source-plugins/source-plugins.module'; rootPath: join(__dirname, '../dist'), }), MetadataModule, - SourcePluginsModule, + SourceProvidersModule, + MediaSourcesModule, + UserDataModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 95e5b89..b965825 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -21,6 +21,7 @@ export class SignInResponse { @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} + @HttpCode(HttpStatus.OK) @Post() @ApiOkResponse({ description: 'User found', type: SignInResponse }) diff --git a/backend/src/common/common.dto.ts b/backend/src/common/common.dto.ts index 919fdcc..0e1b297 100644 --- a/backend/src/common/common.dto.ts +++ b/backend/src/common/common.dto.ts @@ -6,7 +6,7 @@ import { PartialType, PickType, } from '@nestjs/swagger'; -import { PaginatedResponse, PaginationParams } from 'plugins/plugin-types'; +import { PaginatedResponse, PaginationParams } from '@aleksilassila/reiverr-plugin'; export const PickAndPartial = ( clazz: Type, diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts index 857bc80..f866cb5 100644 --- a/backend/src/database/database.module.ts +++ b/backend/src/database/database.module.ts @@ -1,6 +1,7 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { databaseProviders } from './database.providers'; +@Global() @Module({ providers: [...databaseProviders], exports: [...databaseProviders], diff --git a/backend/src/media-sources/media-source.dto.ts b/backend/src/media-sources/media-source.dto.ts new file mode 100644 index 0000000..6c7df59 --- /dev/null +++ b/backend/src/media-sources/media-source.dto.ts @@ -0,0 +1,25 @@ +import { OmitType, PartialType } from '@nestjs/swagger'; +import { PickAndPartial } from 'src/common/common.dto'; +import { MediaSource } from './media-source.entity'; + +export class MediaSourceDto extends PickAndPartial( + MediaSource, + ['id', 'pluginId', 'name', 'userId', 'adminControlled', 'enabled'], + ['pluginSettings'], +) {} + +export class UpdateOrCreateMediaSourceDto extends PickAndPartial( + MediaSource, + ['pluginSettings', 'pluginId'], + ['enabled', 'id', 'adminControlled', 'name', 'priority'], +) {} + +export class UpdateMediaSourceDto extends OmitType( + PartialType(MediaSourceDto), + ['id', 'pluginId', 'userId'], +) {} + +export class CreateMediaSourceDto extends OmitType(MediaSourceDto, [ + 'id', + 'userId', +]) {} diff --git a/backend/src/users/user-sources/user-source.entity.ts b/backend/src/media-sources/media-source.entity.ts similarity index 62% rename from backend/src/users/user-sources/user-source.entity.ts rename to backend/src/media-sources/media-source.entity.ts index 462d27b..0987f98 100644 --- a/backend/src/users/user-sources/user-source.entity.ts +++ b/backend/src/media-sources/media-source.entity.ts @@ -1,14 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; -import { PluginSettings } from 'plugins/plugin-types'; +import { SourceProviderSettings } from '@aleksilassila/reiverr-plugin'; import { User } from 'src/users/user.entity'; -import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, + PrimaryGeneratedColumn, +} from 'typeorm'; @Entity() export class MediaSource { @ApiProperty({ required: true, type: 'string' }) - @PrimaryColumn() + @PrimaryGeneratedColumn('uuid') id: string; + @ApiProperty({ required: true, type: 'string' }) + @Column() + pluginId: string; + + @ApiProperty() + @Column() + name: string; + @ApiProperty({ required: true, type: 'string' }) @PrimaryColumn() userId: string; @@ -28,6 +43,9 @@ export class MediaSource { @ApiProperty({ required: false, type: 'object' }) @Column('json', { default: '{}' }) - pluginSettings: PluginSettings = {}; - // Add other fields as necessary + pluginSettings: SourceProviderSettings = {}; + + @ApiProperty() + @Column({ default: 0 }) + priority: number = 0; } diff --git a/backend/src/media-sources/media-source.providers.ts b/backend/src/media-sources/media-source.providers.ts new file mode 100644 index 0000000..da0a2ae --- /dev/null +++ b/backend/src/media-sources/media-source.providers.ts @@ -0,0 +1,14 @@ +import { DataSource } from 'typeorm'; +import { MediaSource } from './media-source.entity'; +import { DATA_SOURCE } from 'src/database/database.providers'; + +export const MEIDA_SOURCE_REPOSITORY = 'USER_SOURCE_REPOSITORY'; + +export const mediaSourceProviders = [ + { + provide: MEIDA_SOURCE_REPOSITORY, + useFactory: (dataSource: DataSource) => + dataSource.getRepository(MediaSource), + inject: [DATA_SOURCE], + }, +]; diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/media-sources/media-sources.controller.ts similarity index 50% rename from backend/src/source-plugins/source-plugins.controller.ts rename to backend/src/media-sources/media-sources.controller.ts index c315473..efcbfa7 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/media-sources/media-sources.controller.ts @@ -1,15 +1,22 @@ +import { + EpisodeMetadata, + MovieMetadata, + SourceProvider, + SourceProviderError, +} from '@aleksilassila/reiverr-plugin'; import { All, BadRequestException, Body, + CanActivate, Controller, + ExecutionContext, Get, Injectable, InternalServerErrorException, NotFoundException, Param, ParseIntPipe, - PipeTransform, Post, Query, Req, @@ -18,13 +25,6 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Request, Response } from 'express'; -import { - EpisodeMetadata, - MovieMetadata, - SourceProvider, - SourceProviderError, -} from '@aleksilassila/reiverr-plugin'; import { GetAuthToken, GetAuthUser, @@ -39,47 +39,303 @@ import { PaginationParamsDto, } from 'src/common/common.dto'; import { MetadataService } from 'src/metadata/metadata.service'; -import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; -import { User } from 'src/users/user.entity'; import { IndexItemDto, PlaybackConfigDto, - PluginSettingsDto, - PluginSettingsTemplateDto, - SourceProviderCapabilitiesDto, StreamCandidatesDto, StreamDto, - ValidationResponseDto, -} from './source-plugins.dto'; -import { SourcePluginsService } from './source-plugins.service'; +} from 'src/source-providers/source-provider.dto'; +import { SourceProvidersService } from 'src/source-providers/source-providers.service'; +import { User } from 'src/users/user.entity'; +import { MediaSource } from './media-source.entity'; +import { MediaSourcesService } from './media-sources.service'; -export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; +type MediaSourceConnection = { + provider: SourceProvider; + mediaSource: MediaSource; +}; @Injectable() -export class ValidateSourcePluginPipe implements PipeTransform { - constructor(private readonly sourcesService: SourcePluginsService) {} +export class ServiceOwnershipValidator implements CanActivate { + constructor( + private mediaSourcesService: MediaSourcesService, + private sourceProvidersService: SourceProvidersService, + ) {} - async transform(sourceId: string) { - const plugin = this.sourcesService.getPlugin(sourceId); + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as User; - if (!plugin) { - throw new NotFoundException('Plugin not found'); + if (!user) return true; + + const sourceId = request.params.sourceId; + + if (!sourceId) return true; + + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); + + if (!mediaSource) throw new NotFoundException('Source not found'); + + if (mediaSource.userId !== user.id && !user.isAdmin) { + throw new UnauthorizedException(); } - return plugin; + return true; } } @ApiTags('sources') -@Controller() -@UseGuards(UserAccessControl) -export class SourcesController { +@Controller('sources') +@UseGuards(UserAccessControl, ServiceOwnershipValidator) +export class MediaSourcesController { constructor( - private sourcesService: SourcePluginsService, - private userSourcesService: UserSourcesService, + private mediaSourcesService: MediaSourcesService, + private sourceProvidersService: SourceProvidersService, private metadataService: MetadataService, ) {} + @Get(':sourceId/catalogue/movies') + @PaginatedApiOkResponse(IndexItemDto) + async getMovieCatalogue( + @GetAuthUser() user: User, + @Param('sourceId') + sourceId: string, + @GetAuthToken() token: string, + @GetPaginationParams() pagination: PaginationParamsDto, + ): Promise> { + const connection = await this.getConnection(sourceId); + + const catalogue = await connection.provider.getMovieCatalogue?.( + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + pagination, + ); + + return catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; + } + + @Get(':sourceId/catalogue/episodes') + @PaginatedApiOkResponse(IndexItemDto) + async getEpisodeCatalogue( + @GetAuthUser() user: User, + @Param('sourceId') sourceId: string, + @GetAuthToken() token: string, + @GetPaginationParams() pagination: PaginationParamsDto, + ): Promise> { + const connection = await this.getConnection(sourceId); + + const catalogue = await connection.provider.getEpisodeCatalogue?.( + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + pagination, + ); + + return catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; + } + + @Get(':sourceId/movies/tmdb/:tmdbId/streams') + @ApiOkResponse({ + description: 'Movie sources', + type: StreamCandidatesDto, + }) + async getMovieStreams( + @Param('sourceId') sourceId: string, + @Param('tmdbId') tmdbId: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + ): Promise { + const connection = await this.getConnection(sourceId); + const metadata = await this.getMovieMetadata(tmdbId); + + const streams = await connection.provider.getMovieStreams?.( + tmdbId, + metadata, + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + ); + + return streams ?? { candidates: [] }; + } + + @Get(':sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams') + @ApiOkResponse({ + description: 'Episode sources', + type: StreamCandidatesDto, + }) + async getEpisodeStreams( + @Param('sourceId') sourceId: string, + @Param('tmdbId') tmdbId: string, + @Param('season', ParseIntPipe) season: number, + @Param('episode', ParseIntPipe) episode: number, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + ): Promise { + const connection = await this.getConnection(sourceId); + const metadata = await this.getSeriesMetadata(tmdbId, season, episode); + + const streams = await connection.provider.getEpisodeStreams?.( + tmdbId, + metadata, + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + ); + + return streams ?? { candidates: [] }; + } + + @Post(':sourceId/movies/tmdb/:tmdbId/streams/:key') + @ApiOkResponse({ + description: 'Movie stream', + type: StreamDto, + }) + async getMovieStream( + @Param('tmdbId') tmdbId: string, + @Param('sourceId') sourceId: string, + @Param('key') key: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + @Body() config: PlaybackConfigDto, + ): Promise { + const connection = await this.getConnection(sourceId); + const metadata = await this.getMovieMetadata(tmdbId); + + const stream = await connection.provider + .getMovieStream?.( + tmdbId, + metadata, + key || '', + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + config, + ) + .catch((e) => { + if (e === SourceProviderError.StreamNotFound) { + throw new NotFoundException('Stream not found'); + } else { + console.error(e); + throw new InternalServerErrorException(); + } + }); + + if (!stream) { + throw new NotFoundException('Stream not found'); + } + + return stream; + } + + @Post( + ':sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams/:key', + ) + @ApiOkResponse({ + description: 'Show stream', + type: StreamDto, + }) + async getEpisodeStream( + @Param('sourceId') sourceId: string, + @Param('tmdbId') tmdbId: string, + @Param('season', ParseIntPipe) season: number, + @Param('episode', ParseIntPipe) episode: number, + @Param('key') key: string, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + @Body() config: PlaybackConfigDto, + ): Promise { + const connection = await this.getConnection(sourceId); + const metadata = await this.getSeriesMetadata(tmdbId, season, episode); + + const stream = await connection.provider + .getEpisodeStream?.( + tmdbId, + metadata, + key || '', + { + userId: user.id, + settings: connection.mediaSource.pluginSettings, + token, + sourceId: connection.mediaSource.id, + }, + config, + ) + .catch((e) => { + if (e === SourceProviderError.StreamNotFound) { + throw new NotFoundException('Stream not found'); + } else { + console.error(e); + throw new InternalServerErrorException(); + } + }); + + if (!stream) { + throw new NotFoundException('Stream not found'); + } + + return stream; + } + + /** @deprecated */ + @All([':sourceId/proxy', ':sourceId/proxy/*']) + async proxyHandler( + @Param() params: any, + @Query() query: any, + @Req() req: Request, + @Res() res: Response, + @GetAuthUser() user: User, + @GetAuthToken() token: string, + ) { + const sourceId = params.sourceId; + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); + + if (!mediaSource) throw new NotFoundException('Source not found'); + + const provider = this.sourceProvidersService.getProvider( + mediaSource.pluginId, + ); + + if (!provider) { + throw new NotFoundException('Plugin not found'); + } + + if (!provider.proxyHandler) { + throw new BadRequestException('Plugin does not support proxying'); + } + + const targetUrl = query.reiverr_proxy_url || undefined; + + await provider.proxyHandler?.(req, res, { + context: { + userId: user.id, + token, + sourceId, + settings: mediaSource.pluginSettings, + }, + uri: `/${params[0]}?${req.url.split('?').slice(1).join('?') || ''}`, + targetUrl, + }); + } + async getMovieMetadata(tmdbId: string): Promise { const metadata = await this.metadataService.getMovieByTmdbId(tmdbId); @@ -114,372 +370,22 @@ export class SourcesController { }; } - @Get('sources') - @ApiOkResponse({ - description: 'All source plugins found', - type: String, - isArray: true, - }) - async getSourcePlugins() { - return this.sourcesService - .getPlugins() - .then((plugins) => Object.keys(plugins)); - } + async getConnection(sourceId: string) { + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); - @Get('sources/:sourceId/settings/template') - @ApiOkResponse({ - description: 'Source settings template', - type: PluginSettingsTemplateDto, - }) - async getSourceSettingsTemplate( - @Param('sourceId') sourceId: string, - @GetAuthUser() callerUser: User, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); + if (!mediaSource.pluginId || !mediaSource.enabled) { + throw new BadRequestException('Source not configured'); } - // return plugin.getSettingsTemplate(callerUser.pluginSettings?.[sourceId]); - return { - settings: plugin.settingsManager.getSettingsTemplate(), - }; - } - - @Post('sources/:sourceId/settings/validate') - @ApiOkResponse({ - description: 'Source settings validation', - type: ValidationResponseDto, - }) - async validateSourceSettings( - @GetAuthUser() callerUser: User, - @Param('sourceId') sourceId: string, - @Body() settings: PluginSettingsDto, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); - } - - return plugin.settingsManager.validateSettings(settings.settings); - } - - @Get('sources/:sourceId/capabilities') - @ApiOkResponse({ - type: SourceProviderCapabilitiesDto, - }) - async getSourceCapabilities( - @GetAuthUser() user: User, - @Param('sourceId', ValidateSourcePluginPipe) plugin: SourceProvider, - @GetAuthToken() token: string, - ): Promise { - const settings = this.userSourcesService.getSourceSettings( - user, - plugin.name, + const provider = this.sourceProvidersService.getProvider( + mediaSource.pluginId, ); - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - return { - movieIndexing: !!plugin.getMovieCatalogue, - episodeIndexing: !!plugin.getEpisodeCatalogue, - moviePlayback: !!plugin.getMovieStreams && !!plugin.getMovieStream, - episodePlayback: !!plugin.getEpisodeStreams && !!plugin.getEpisodeStream, - }; - } - - @Get('sources/:sourceId/catalogue/movies') - @PaginatedApiOkResponse(IndexItemDto) - async getMovieCatalogue( - @GetAuthUser() user: User, - @Param('sourceId', ValidateSourcePluginPipe) plugin: SourceProvider, - @GetAuthToken() token: string, - @GetPaginationParams() pagination: PaginationParamsDto, - ): Promise> { - const settings = this.userSourcesService.getSourceSettings( - user, - plugin.name, - ); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - if (!plugin.getMovieCatalogue) { - throw new BadRequestException('Plugin does not support indexing'); - } - - const catalogue = await plugin.getMovieCatalogue?.( - { - userId: user.id, - settings, - token, - }, - pagination, - ); - - return catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; - } - - @Get('sources/:sourceId/catalogue/episodes') - @PaginatedApiOkResponse(IndexItemDto) - async getEpisodeCatalogue( - @GetAuthUser() user: User, - @Param('sourceId', ValidateSourcePluginPipe) plugin: SourceProvider, - @GetAuthToken() token: string, - @GetPaginationParams() pagination: PaginationParamsDto, - ): Promise> { - const settings = this.userSourcesService.getSourceSettings( - user, - plugin.name, - ); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - if (!plugin.getEpisodeCatalogue) { - throw new BadRequestException('Plugin does not support indexing'); - } - - const catalogue = await plugin.getEpisodeCatalogue?.( - { - userId: user.id, - settings, - token, - }, - pagination, - ); - - return catalogue ?? { items: [], total: 0, itemsPerPage: 0, page: 0 }; - } - - @Get('sources/:sourceId/movies/tmdb/:tmdbId/streams') - @ApiOkResponse({ - description: 'Movie sources', - type: StreamCandidatesDto, - }) - async getMovieStreams( - @Param('sourceId') sourceId: string, - @Param('tmdbId') tmdbId: string, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { + if (!provider) { throw new NotFoundException('Plugin not found'); } - const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - const metadata = await this.getMovieMetadata(tmdbId); - - const streams = await plugin.getMovieStreams?.(tmdbId, metadata, { - userId: user.id, - settings, - token, - }); - - return streams ?? { candidates: [] }; - } - - @Get( - 'sources/:sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams', - ) - @ApiOkResponse({ - description: 'Episode sources', - type: StreamCandidatesDto, - }) - async getEpisodeStreams( - @Param('sourceId') sourceId: string, - @Param('tmdbId') tmdbId: string, - @Param('season', ParseIntPipe) season: number, - @Param('episode', ParseIntPipe) episode: number, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); - } - - const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - const metadata = await this.getSeriesMetadata(tmdbId, season, episode); - - const streams = await plugin.getEpisodeStreams?.(tmdbId, metadata, { - userId: user.id, - settings, - token, - }); - - return streams ?? { candidates: [] }; - } - - @Post('sources/:sourceId/movies/tmdb/:tmdbId/streams/:key') - @ApiOkResponse({ - description: 'Movie stream', - type: StreamDto, - }) - async getMovieStream( - @Param('tmdbId') tmdbId: string, - @Param('sourceId') sourceId: string, - // @Query('key') key: string, - @Param('key') key: string, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - @Body() config: PlaybackConfigDto, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); - } - - const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - const metadata = await this.getMovieMetadata(tmdbId); - - const stream = await plugin - .getMovieStream?.( - tmdbId, - metadata, - key || '', - { - userId: user.id, - settings, - token, - }, - config, - ) - .catch((e) => { - if (e === SourceProviderError.StreamNotFound) { - throw new NotFoundException('Stream not found'); - } else { - console.error(e); - throw new InternalServerErrorException(); - } - }); - - if (!stream) { - throw new NotFoundException('Stream not found'); - } - - return stream; - } - - @Post( - 'sources/:sourceId/shows/tmdb/:tmdbId/season/:season/episode/:episode/streams/:key', - ) - @ApiOkResponse({ - description: 'Show stream', - type: StreamDto, - }) - async getEpisodeStream( - @Param('sourceId') sourceId: string, - @Param('tmdbId') tmdbId: string, - @Param('season', ParseIntPipe) season: number, - @Param('episode', ParseIntPipe) episode: number, - @Param('key') key: string, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - @Body() config: PlaybackConfigDto, - ): Promise { - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); - } - - const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - if (!settings) { - throw new BadRequestException('Source configuration not found'); - } - - const metadata = await this.getSeriesMetadata(tmdbId, season, episode); - - const stream = await plugin - .getEpisodeStream?.( - tmdbId, - metadata, - key || '', - { - userId: user.id, - settings, - token, - }, - config, - ) - .catch((e) => { - if (e === SourceProviderError.StreamNotFound) { - throw new NotFoundException('Stream not found'); - } else { - console.error(e); - throw new InternalServerErrorException(); - } - }); - - if (!stream) { - throw new NotFoundException('Stream not found'); - } - - return stream; - } - - /** @deprecated */ - @All(['sources/:sourceId/proxy', 'sources/:sourceId/proxy/*']) - async proxyHandler( - @Param() params: any, - @Query() query: any, - @Req() req: Request, - @Res() res: Response, - @GetAuthUser() user: User, - @GetAuthToken() token: string, - ) { - const sourceId = params.sourceId; - const settings = this.userSourcesService.getSourceSettings(user, sourceId); - - if (!settings) throw new UnauthorizedException(); - - const plugin = this.sourcesService.getPlugin(sourceId); - - if (!plugin) { - throw new NotFoundException('Plugin not found'); - } - - if (!plugin.proxyHandler) { - throw new BadRequestException('Plugin does not support proxying'); - } - - const targetUrl = query.reiverr_proxy_url || undefined; - - await plugin.proxyHandler?.(req, res, { - context: { - userId: user.id, - token, - settings, - }, - uri: `/${params[0]}?${req.url.split('?').slice(1).join('?') || ''}`, - targetUrl, - }); + return { provider, mediaSource }; } } diff --git a/backend/src/media-sources/media-sources.module.ts b/backend/src/media-sources/media-sources.module.ts new file mode 100644 index 0000000..9e6642e --- /dev/null +++ b/backend/src/media-sources/media-sources.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { mediaSourceProviders } from './media-source.providers'; +import { MediaSourcesService } from './media-sources.service'; +import { MediaSourcesController } from './media-sources.controller'; +import { MediaSourcesSettingsController } from './media-sources.settings.controller'; +import { SourceProvidersModule } from 'src/source-providers/source-providers.module'; +import { MetadataModule } from 'src/metadata/metadata.module'; +import { UsersModule } from 'src/users/users.module'; + +@Module({ + imports: [UsersModule, SourceProvidersModule, MetadataModule], + providers: [...mediaSourceProviders, MediaSourcesService], + controllers: [MediaSourcesController, MediaSourcesSettingsController], + exports: [MediaSourcesService], +}) +export class MediaSourcesModule {} diff --git a/backend/src/media-sources/media-sources.service.ts b/backend/src/media-sources/media-sources.service.ts new file mode 100644 index 0000000..2b9f0a1 --- /dev/null +++ b/backend/src/media-sources/media-sources.service.ts @@ -0,0 +1,118 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { User } from 'src/users/user.entity'; +import { UsersService } from 'src/users/users.service'; +import { Repository } from 'typeorm'; +import { UpdateOrCreateMediaSourceDto } from './media-source.dto'; +import { MediaSource } from './media-source.entity'; +import { MEIDA_SOURCE_REPOSITORY } from './media-source.providers'; + +export enum MediaSourcesServiceError { + SourceNotFound = 'SourceNotFound', + Unauthorized = 'Unauthorized', +} + +@Injectable() +export class MediaSourcesService { + constructor( + @Inject(MEIDA_SOURCE_REPOSITORY) + private readonly mediaSourceRepository: Repository, + private readonly usersService: UsersService, + ) {} + + private async findUserMediaSources(userId: string): Promise { + return this.mediaSourceRepository.find({ + where: { + userId: userId, + }, + }); + } + + async findMediaSource(sourceId: string): Promise { + const source = await this.mediaSourceRepository.findOne({ + where: { + id: sourceId, + }, + }); + + return source; + } + + async deleteMediaSource(sourceId: string, callerUser: User) { + const source = await this.findMediaSource(sourceId); + + if (!source) { + throw MediaSourcesServiceError.SourceNotFound; + } + + if (source.userId !== callerUser.id && !callerUser.isAdmin) { + throw MediaSourcesServiceError.Unauthorized; + } + + await this.mediaSourceRepository.remove(source); + return this.usersService.findOne(source.userId); + } + + async updateOrCreateMediaSource( + user: User, + sourceDto: UpdateOrCreateMediaSourceDto, + callerUser: User = user, + ): Promise { + if (!callerUser.isAdmin || callerUser.id !== user.id) { + throw MediaSourcesServiceError.Unauthorized; + } + + const sources = await this.findUserMediaSources(user.id); + let source = sources.find((s) => s.id === sourceDto.id); + + // Create new if doesn't exist + if (!source) { + if (!sourceDto.pluginId) + throw new Error('Plugin ID is required when creating a new source'); + + source = new MediaSource(); + source.user = user; + source.pluginId = sourceDto.pluginId; + source.adminControlled = false; + source.name = sourceDto.name ?? sourceDto.pluginId; + source.priority = sourceDto.priority ?? sources.length; + } + + // Check for unauthorized access + if (source.adminControlled && !callerUser.isAdmin) { + throw MediaSourcesServiceError.Unauthorized; + } else if (sourceDto.adminControlled !== undefined && !callerUser.isAdmin) { + throw MediaSourcesServiceError.Unauthorized; + } + + source.adminControlled = + sourceDto.adminControlled ?? source.adminControlled; + source.enabled = sourceDto.enabled ?? source.enabled; + source.pluginSettings = sourceDto.pluginSettings ?? source.pluginSettings; + source.name = sourceDto.name ?? source.name; + + let priority = 0; + for (const other of sources.sort((a, b) => a.priority - b.priority)) { + if (other.id === source.id) continue; + + if (source.priority === priority) { + priority++; + } + + if (other.priority !== priority) { + other.priority = priority; + await this.mediaSourceRepository.save(other); + } + + priority++; + } + + await this.mediaSourceRepository.save(source); + return this.usersService.findOne(user.id); + } + + getMediaSourceSettings(user: User, sourceId: string) { + return user.mediaSources + ?.filter((s) => s?.enabled) + ?.find((source) => source.id === sourceId)?.pluginSettings; + } +} diff --git a/backend/src/users/user-sources/user-sources.controller.ts b/backend/src/media-sources/media-sources.settings.controller.ts similarity index 64% rename from backend/src/users/user-sources/user-sources.controller.ts rename to backend/src/media-sources/media-sources.settings.controller.ts index 5c73e7d..42c1f5b 100644 --- a/backend/src/users/user-sources/user-sources.controller.ts +++ b/backend/src/media-sources/media-sources.settings.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Delete, - Get, InternalServerErrorException, NotFoundException, Param, @@ -11,32 +10,31 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { UserAccessControl, GetAuthUser } from 'src/auth/auth.guard'; -import { UsersService } from '../users.service'; -import { User } from '../user.entity'; -import { UserDto } from '../user.dto'; -import { CreateSourceDto } from './user-source.dto'; +import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard'; +import { UserDto } from 'src/users/user.dto'; +import { User } from 'src/users/user.entity'; +import { UsersService } from 'src/users/users.service'; +import { UpdateOrCreateMediaSourceDto } from './media-source.dto'; import { - UserSourcesService, - UserSourcesServiceError, -} from './user-sources.service'; + MediaSourcesService, + MediaSourcesServiceError, +} from './media-sources.service'; @ApiTags('users') @Controller('users/:userId/sources') @UseGuards(UserAccessControl) -export class UserSourcesController { +export class MediaSourcesSettingsController { constructor( private usersService: UsersService, - private userSourcesService: UserSourcesService, + private mediaSourcesService: MediaSourcesService, ) {} - @Put(':sourceId') + @Put() @ApiOkResponse({ description: 'Source updated', type: UserDto }) async updateSource( @GetAuthUser() callerUser: User, - @Param('sourceId') sourceId: string, @Param('userId') userId: string, - @Body() sourceDto: CreateSourceDto, + @Body() sourceDto: UpdateOrCreateMediaSourceDto, ): Promise { const user = await this.usersService.findOne(userId); @@ -44,10 +42,10 @@ export class UserSourcesController { throw new NotFoundException('User not found'); } - const updatedUser = await this.userSourcesService - .updateUserSource(user, sourceId, sourceDto, callerUser) + const updatedUser = await this.mediaSourcesService + .updateOrCreateMediaSource(user, sourceDto, callerUser) .catch((e) => { - if (e === UserSourcesServiceError.Unauthorized) { + if (e === MediaSourcesServiceError.Unauthorized) { throw new UnauthorizedException(); } else { throw new InternalServerErrorException('Failed to update source'); @@ -68,8 +66,7 @@ export class UserSourcesController { @Param('sourceId') sourceId: string, @Param('userId') userId: string, ): Promise { - const updatedUser = await this.userSourcesService.deleteUserSource( - userId, + const updatedUser = await this.mediaSourcesService.deleteMediaSource( sourceId, callerUser, ); diff --git a/backend/src/metadata/metadata.controller.ts b/backend/src/metadata/metadata.controller.ts index aaca2c8..957bc3d 100644 --- a/backend/src/metadata/metadata.controller.ts +++ b/backend/src/metadata/metadata.controller.ts @@ -1,18 +1,4 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { - GetAuthUser, - OptionalAccessControl, - UserAccessControl, -} from 'src/auth/auth.guard'; -import { - GetPaginationParams, - PaginatedApiOkResponse, -} from 'src/common/common.decorator'; -import { PaginationParamsDto } from 'src/common/common.dto'; -import { LibraryService } from 'src/users/library/library.service'; -import { User } from 'src/users/user.entity'; -import { MovieDto } from './metadata.dto'; +import { Controller } from '@nestjs/common'; import { MetadataService } from './metadata.service'; // @UseGuards(OptionalAccessControl) diff --git a/backend/src/metadata/metadata.module.ts b/backend/src/metadata/metadata.module.ts index 835d7d9..e3d1d9f 100644 --- a/backend/src/metadata/metadata.module.ts +++ b/backend/src/metadata/metadata.module.ts @@ -1,15 +1,12 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { MetadataController } from './metadata.controller'; -import { MetadataService as MetadataService } from './metadata.service'; -import { metadataProviders } from './metadata.providers'; +import { Module } from '@nestjs/common'; import { DatabaseModule } from 'src/database/database.module'; -import { TmdbController } from './tmdb/tmdb.controller'; -import { tmdbProviders } from './tmdb/tmdb.providers'; -import { UsersModule } from 'src/users/users.module'; +import { MetadataController } from './metadata.controller'; +import { metadataProviders } from './metadata.providers'; +import { MetadataService } from './metadata.service'; import { TmdbModule } from './tmdb/tmdb.module'; @Module({ - imports: [DatabaseModule, forwardRef(() => UsersModule), TmdbModule], + imports: [TmdbModule], controllers: [MetadataController], providers: [...metadataProviders, MetadataService], exports: [MetadataService], diff --git a/backend/src/metadata/tmdb/tmdb.providers.ts b/backend/src/metadata/tmdb/tmdb.providers.ts index 7e91a89..de48f45 100644 --- a/backend/src/metadata/tmdb/tmdb.providers.ts +++ b/backend/src/metadata/tmdb/tmdb.providers.ts @@ -4,7 +4,7 @@ import { Api } from './tmdb.v3.openapi'; export const TMDB_API = 'TMDB_API_V3'; export const TMDB_API_V4 = 'TMDB_API_V4'; -export type TmdbApi = Api<{}>; +export type TmdbApi = Api; export const tmdbProviders = [ { diff --git a/backend/src/source-plugins/source-plugins.module.ts b/backend/src/source-plugins/source-plugins.module.ts deleted file mode 100644 index 8b6a6ba..0000000 --- a/backend/src/source-plugins/source-plugins.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { DynamicModule } from '@nestjs/common'; -import { SourcePluginsService } from './source-plugins.service'; -import { SourcesController } from './source-plugins.controller'; -import { UsersModule } from 'src/users/users.module'; -import { MetadataModule } from 'src/metadata/metadata.module'; - -@Module({ - providers: [SourcePluginsService], - controllers: [SourcesController], - exports: [SourcePluginsService], - imports: [forwardRef(() => UsersModule), forwardRef(() => MetadataModule)], -}) -export class SourcePluginsModule {} diff --git a/backend/src/source-plugins/device-profile.dto.ts b/backend/src/source-providers/device-profile.dto.ts similarity index 99% rename from backend/src/source-plugins/device-profile.dto.ts rename to backend/src/source-providers/device-profile.dto.ts index b418bcb..469f666 100644 --- a/backend/src/source-plugins/device-profile.dto.ts +++ b/backend/src/source-providers/device-profile.dto.ts @@ -7,7 +7,7 @@ import { ProfileCondition, SubtitleProfile, TranscodingProfile, -} from 'plugins/plugin-types'; +} from '@aleksilassila/reiverr-plugin'; export class DirectPlayProfileDto implements DirectPlayProfile { @ApiProperty({ diff --git a/backend/src/source-plugins/source-plugins.dto.ts b/backend/src/source-providers/source-provider.dto.ts similarity index 100% rename from backend/src/source-plugins/source-plugins.dto.ts rename to backend/src/source-providers/source-provider.dto.ts diff --git a/backend/src/source-providers/source-providers.controller.ts b/backend/src/source-providers/source-providers.controller.ts new file mode 100644 index 0000000..c3af4f5 --- /dev/null +++ b/backend/src/source-providers/source-providers.controller.ts @@ -0,0 +1,129 @@ +import { SourceProvider } from '@aleksilassila/reiverr-plugin'; +import { + Body, + Controller, + Get, + Injectable, + NotFoundException, + Param, + PipeTransform, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { + GetAuthToken, + GetAuthUser, + UserAccessControl, +} from 'src/auth/auth.guard'; +import { User } from 'src/users/user.entity'; +import { + PluginSettingsDto, + PluginSettingsTemplateDto, + SourceProviderCapabilitiesDto, + ValidationResponseDto, +} from './source-provider.dto'; +import { SourceProvidersService } from './source-providers.service'; + +export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; + +@Injectable() +export class GetSourceProviderPipe implements PipeTransform { + constructor(private readonly sourcesService: SourceProvidersService) {} + + async transform(providerId: string) { + const provider = this.sourcesService.getProvider(providerId); + + if (!provider) { + throw new NotFoundException('Plugin not found'); + } + + return provider; + } +} + +@ApiTags('providers') +@Controller('providers') +@UseGuards(UserAccessControl) +export class SourceProvidersController { + constructor(private sourceProvidersService: SourceProvidersService) {} + + @Get() + @ApiOkResponse({ + description: 'All source plugins found', + type: String, + isArray: true, + }) + async getSourceProviders() { + return this.sourceProvidersService + .getProviders() + .then((plugins) => Object.keys(plugins)); + } + + @Get(':providerId/settings/template') + @ApiOkResponse({ + description: 'Source settings template', + type: PluginSettingsTemplateDto, + }) + async getSourceSettingsTemplate( + @Param('providerId') providerId: string, + @GetAuthUser() callerUser: User, + ): Promise { + const provider = this.sourceProvidersService.getProvider(providerId); + + if (!provider) { + throw new NotFoundException('Plugin not found'); + } + + // return plugin.getSettingsTemplate(callerUser.pluginSettings?.[sourceId]); + return { + settings: provider.settingsManager.getSettingsTemplate(), + }; + } + + @Post(':providerId/settings/validate') + @ApiOkResponse({ + description: 'Source settings validation', + type: ValidationResponseDto, + }) + async validateSourceSettings( + @GetAuthUser() callerUser: User, + @Param('providerId') providerId: string, + @Body() settings: PluginSettingsDto, + ): Promise { + const provider = this.sourceProvidersService.getProvider(providerId); + + if (!provider) { + throw new NotFoundException('Plugin not found'); + } + + return provider.settingsManager.validateSettings(settings.settings); + } + + @Get(':providerId/capabilities') + @ApiOkResponse({ + type: SourceProviderCapabilitiesDto, + }) + async getSourceCapabilities( + @GetAuthUser() user: User, + @Param('providerId', GetSourceProviderPipe) provider: SourceProvider, + @GetAuthToken() token: string, + ): Promise { + // const settings = this.mediaSourcesService.getMediaSourceSettings( + // user, + // provider.name, + // ); + + // if (!settings) { + // throw new BadRequestException('Source configuration not found'); + // } + + return { + movieIndexing: !!provider.getMovieCatalogue, + episodeIndexing: !!provider.getEpisodeCatalogue, + moviePlayback: !!provider.getMovieStreams && !!provider.getMovieStream, + episodePlayback: + !!provider.getEpisodeStreams && !!provider.getEpisodeStream, + }; + } +} diff --git a/backend/src/source-providers/source-providers.module.ts b/backend/src/source-providers/source-providers.module.ts new file mode 100644 index 0000000..616d74a --- /dev/null +++ b/backend/src/source-providers/source-providers.module.ts @@ -0,0 +1,12 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { UsersModule } from 'src/users/users.module'; +import { SourceProvidersController } from './source-providers.controller'; +import { SourceProvidersService } from './source-providers.service'; + +@Module({ + providers: [SourceProvidersService], + controllers: [SourceProvidersController], + exports: [SourceProvidersService], + imports: [forwardRef(() => UsersModule)], +}) +export class SourceProvidersModule {} diff --git a/backend/src/source-plugins/source-plugins.service.ts b/backend/src/source-providers/source-providers.service.ts similarity index 77% rename from backend/src/source-plugins/source-plugins.service.ts rename to backend/src/source-providers/source-providers.service.ts index 6183573..033e305 100644 --- a/backend/src/source-plugins/source-plugins.service.ts +++ b/backend/src/source-providers/source-providers.service.ts @@ -4,23 +4,23 @@ import * as path from 'path'; import { PluginProvider, SourceProvider } from '@aleksilassila/reiverr-plugin'; @Injectable() -export class SourcePluginsService { - private plugins: Record = {}; +export class SourceProvidersService { + private providers: Record = {}; constructor() { console.log('Loading source plugins...'); - this.plugins = this.loadPlugins( + this.providers = this.loadPlugins( path.join(require.main.path, '..', '..', 'plugins'), ); console.log( - `Loaded source plugins: ${Object.keys(this.plugins).join(', ')}`, + `Loaded source plugins: ${Object.keys(this.providers).join(', ')}`, ); } - async getPlugins(): Promise> { - return this.plugins; + async getProviders(): Promise> { + return this.providers; } private loadPlugins(rootDirectory: string): Record { @@ -54,7 +54,7 @@ export class SourcePluginsService { return plugins; } - getPlugin(pluginName: string): SourceProvider | undefined { - return this.plugins[pluginName]; + getProvider(pluginName: string): SourceProvider | undefined { + return this.providers[pluginName]; } } diff --git a/backend/src/users/library/library.controller.ts b/backend/src/user-data/library/library.controller.ts similarity index 88% rename from backend/src/users/library/library.controller.ts rename to backend/src/user-data/library/library.controller.ts index 4aed53a..d11a9c6 100644 --- a/backend/src/users/library/library.controller.ts +++ b/backend/src/user-data/library/library.controller.ts @@ -10,7 +10,10 @@ import { } from '@nestjs/common'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { UserAccessControl } from 'src/auth/auth.guard'; -import { LibraryService } from './library.service'; +import { + GetPaginationParams, + PaginatedApiOkResponse, +} from 'src/common/common.decorator'; import { MediaType, PaginatedResponseDto, @@ -18,22 +21,13 @@ import { SuccessResponseDto, } from 'src/common/common.dto'; import { LibraryItemDto } from './library.dto'; -import { - GetPaginationParams, - PaginatedApiOkResponse, -} from 'src/common/common.decorator'; -import { UsersService } from '../users.service'; -import { MetadataService } from 'src/metadata/metadata.service'; -import { PlayStateService } from '../play-state/play-state.service'; +import { LibraryService } from './library.service'; @ApiTags('users') @Controller('users/:userId/library') @UseGuards(UserAccessControl) export class LibraryController { - constructor( - private userService: UsersService, - private libraryService: LibraryService, - ) {} + constructor(private libraryService: LibraryService) {} @Get() @PaginatedApiOkResponse(LibraryItemDto) diff --git a/backend/src/users/library/library.dto.ts b/backend/src/user-data/library/library.dto.ts similarity index 100% rename from backend/src/users/library/library.dto.ts rename to backend/src/user-data/library/library.dto.ts diff --git a/backend/src/users/library/library.entity.ts b/backend/src/user-data/library/library.entity.ts similarity index 95% rename from backend/src/users/library/library.entity.ts rename to backend/src/user-data/library/library.entity.ts index e0f8827..f041159 100644 --- a/backend/src/users/library/library.entity.ts +++ b/backend/src/user-data/library/library.entity.ts @@ -1,18 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; +import { MediaType } from 'src/common/common.dto'; +import { User } from 'src/users/user.entity'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany, - OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique, } from 'typeorm'; -import { User } from '../user.entity'; import { PlayState } from '../play-state/play-state.entity'; -import { MediaType } from 'src/common/common.dto'; @Entity() @Unique(['tmdbId', 'userId']) diff --git a/backend/src/user-data/library/library.module.ts b/backend/src/user-data/library/library.module.ts new file mode 100644 index 0000000..9b431d4 --- /dev/null +++ b/backend/src/user-data/library/library.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { MetadataModule } from 'src/metadata/metadata.module'; +import { LibraryController } from './library.controller'; +import { libraryProviders } from './library.providers'; +import { LibraryService } from './library.service'; +import { UsersModule } from 'src/users/users.module'; + +@Module({ + providers: [...libraryProviders, LibraryService], + controllers: [LibraryController], + imports: [MetadataModule, UsersModule], + exports: [LibraryService], +}) +export class LibraryModule {} diff --git a/backend/src/user-data/library/library.providers.ts b/backend/src/user-data/library/library.providers.ts new file mode 100644 index 0000000..f800cf3 --- /dev/null +++ b/backend/src/user-data/library/library.providers.ts @@ -0,0 +1,14 @@ +import { DataSource } from 'typeorm'; +import { LibraryItem } from './library.entity'; +import { DATA_SOURCE } from 'src/database/database.providers'; + +export const USER_LIBRARY_REPOSITORY = 'USER_LIBRARY_REPOSITORY'; + +export const libraryProviders = [ + { + provide: USER_LIBRARY_REPOSITORY, + useFactory: (dataSource: DataSource) => + dataSource.getRepository(LibraryItem), + inject: [DATA_SOURCE], + }, +]; diff --git a/backend/src/users/library/library.service.ts b/backend/src/user-data/library/library.service.ts similarity index 97% rename from backend/src/users/library/library.service.ts rename to backend/src/user-data/library/library.service.ts index a7911d2..94bbe76 100644 --- a/backend/src/users/library/library.service.ts +++ b/backend/src/user-data/library/library.service.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; -import { USER_LIBRARY_REPOSITORY } from '../user.providers'; import { Repository } from 'typeorm'; import { LibraryItem } from './library.entity'; import { MediaType, PaginationParamsDto } from 'src/common/common.dto'; import { LibraryItemDto } from './library.dto'; import { MetadataService } from 'src/metadata/metadata.service'; +import { USER_LIBRARY_REPOSITORY } from './library.providers'; @Injectable() export class LibraryService { diff --git a/backend/src/users/play-state/play-state.dto.ts b/backend/src/user-data/play-state/play-state.dto.ts similarity index 59% rename from backend/src/users/play-state/play-state.dto.ts rename to backend/src/user-data/play-state/play-state.dto.ts index 1e09f47..bfa0e10 100644 --- a/backend/src/users/play-state/play-state.dto.ts +++ b/backend/src/user-data/play-state/play-state.dto.ts @@ -1,4 +1,4 @@ -import { OmitType, PartialType } from '@nestjs/swagger'; +import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; import { PlayState } from './play-state.entity'; export class PlayStateDto extends PlayState {} @@ -24,3 +24,25 @@ export class PlayStateDto extends PlayState {} export class UpdatePlayStateDto extends PartialType( OmitType(PlayStateDto, ['userId']), ) {} + +export class MovieUserDataDto { + @ApiProperty() + tmdbId: string; + + @ApiProperty() + inLibrary: boolean; + + @ApiProperty({ type: PlayStateDto, required: false }) + playState?: PlayStateDto; +} + +export class SeriesUserDataDto { + @ApiProperty() + tmdbId: string; + + @ApiProperty() + inLibrary: boolean; + + @ApiProperty({ type: [PlayStateDto] }) + playStates: PlayStateDto[]; +} diff --git a/backend/src/users/play-state/play-state.entity.ts b/backend/src/user-data/play-state/play-state.entity.ts similarity index 96% rename from backend/src/users/play-state/play-state.entity.ts rename to backend/src/user-data/play-state/play-state.entity.ts index c5e3d90..0d76f42 100644 --- a/backend/src/users/play-state/play-state.entity.ts +++ b/backend/src/user-data/play-state/play-state.entity.ts @@ -9,10 +9,9 @@ import { Unique, UpdateDateColumn, } from 'typeorm'; -import { User } from '../user.entity'; import { LibraryItem } from '../library/library.entity'; -import { UserDto } from '../user.dto'; import { MediaType } from 'src/common/common.dto'; +import { User } from 'src/users/user.entity'; @Entity() @Unique(['tmdbId', 'userId', 'season', 'episode']) diff --git a/backend/src/user-data/play-state/play-state.providers.ts b/backend/src/user-data/play-state/play-state.providers.ts new file mode 100644 index 0000000..3358464 --- /dev/null +++ b/backend/src/user-data/play-state/play-state.providers.ts @@ -0,0 +1,13 @@ +import { DataSource } from 'typeorm'; +import { PlayState } from './play-state.entity'; +import { DATA_SOURCE } from 'src/database/database.providers'; + +export const USER_PLAY_STATE_REPOSITORY = 'USER_PLAY_STATE_REPOSITORY'; + +export const playStateProviders = [ + { + provide: USER_PLAY_STATE_REPOSITORY, + useFactory: (dataSource: DataSource) => dataSource.getRepository(PlayState), + inject: [DATA_SOURCE], + }, +]; diff --git a/backend/src/users/play-state/play-state.controller.ts b/backend/src/user-data/play-state/play-states.controller.ts similarity index 87% rename from backend/src/users/play-state/play-state.controller.ts rename to backend/src/user-data/play-state/play-states.controller.ts index 7d9a3d7..de9dfd2 100644 --- a/backend/src/users/play-state/play-state.controller.ts +++ b/backend/src/user-data/play-state/play-states.controller.ts @@ -3,23 +3,20 @@ import { Controller, Delete, Param, - ParseEnumPipe, ParseIntPipe, Put, - Query, UseGuards, } from '@nestjs/common'; -import { PlayStateService } from './play-state.service'; +import { ApiTags } from '@nestjs/swagger'; import { UserAccessControl } from 'src/auth/auth.guard'; -import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { UpdatePlayStateDto } from './play-state.dto'; -import { MediaType } from 'src/common/common.dto'; +import { PlayStatesService } from './play-states.service'; @ApiTags('users') @Controller('users/:userId/play-state') @UseGuards(UserAccessControl) -export class PlayStateController { - constructor(private playStateService: PlayStateService) {} +export class PlayStatesController { + constructor(private playStateService: PlayStatesService) {} @Put('movie/tmdb/:tmdbId') // @ApiQuery({ name: 'mediaType', enum: MediaType, required: false }) diff --git a/backend/src/user-data/play-state/play-states.module.ts b/backend/src/user-data/play-state/play-states.module.ts new file mode 100644 index 0000000..bdac46d --- /dev/null +++ b/backend/src/user-data/play-state/play-states.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PlayStatesService } from './play-states.service'; +import { PlayStatesController as PlayStatesController } from './play-states.controller'; +import { playStateProviders } from './play-state.providers'; +import { UsersModule } from 'src/users/users.module'; + +@Module({ + providers: [...playStateProviders, PlayStatesService], + controllers: [PlayStatesController], + imports: [UsersModule], + exports: [PlayStatesService], +}) +export class PlayStatesModule {} diff --git a/backend/src/users/play-state/play-state.service.ts b/backend/src/user-data/play-state/play-states.service.ts similarity index 96% rename from backend/src/users/play-state/play-state.service.ts rename to backend/src/user-data/play-state/play-states.service.ts index cc0758b..940cf5c 100644 --- a/backend/src/users/play-state/play-state.service.ts +++ b/backend/src/user-data/play-state/play-states.service.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { UpdatePlayStateDto } from './play-state.dto'; -import { USER_PLAY_STATE_REPOSITORY } from '../user.providers'; -import { PlayState } from './play-state.entity'; -import { Repository } from 'typeorm'; import { MediaType } from 'src/common/common.dto'; +import { Repository } from 'typeorm'; +import { UpdatePlayStateDto } from './play-state.dto'; +import { PlayState } from './play-state.entity'; +import { USER_PLAY_STATE_REPOSITORY } from './play-state.providers'; @Injectable() -export class PlayStateService { +export class PlayStatesService { constructor( @Inject(USER_PLAY_STATE_REPOSITORY) private readonly playStateRepository: Repository, diff --git a/backend/src/user-data/user-data.controller.ts b/backend/src/user-data/user-data.controller.ts new file mode 100644 index 0000000..71dbf6d --- /dev/null +++ b/backend/src/user-data/user-data.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { UserAccessControl } from 'src/auth/auth.guard'; +import { LibraryService } from './library/library.service'; +import { + MovieUserDataDto, + SeriesUserDataDto, +} from './play-state/play-state.dto'; +import { PlayStatesService } from './play-state/play-states.service'; + +@ApiTags('users') +@Controller('users') +export class UserDataController { + constructor( + private libraryService: LibraryService, + private playStateService: PlayStatesService, + ) {} + + @UseGuards(UserAccessControl) + @Get(':userId/user-data/movie/tmdb/:tmdbId') + @ApiOkResponse({ + description: 'User movie data found', + type: MovieUserDataDto, + }) + async getMovieUserData( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + ): Promise { + const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); + const playState = await this.playStateService.findMoviePlayState( + userId, + tmdbId, + ); + + return { + tmdbId, + inLibrary: !!libraryItem, + playState: playState, + }; + } + + @UseGuards(UserAccessControl) + @Get(':userId/user-data/series/tmdb/:tmdbId') + @ApiOkResponse({ + description: 'User series data found', + type: SeriesUserDataDto, + }) + async getSeriesUserData( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + ): Promise { + const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); + const playState = await this.playStateService.findSeriesPlayStates( + userId, + tmdbId, + ); + + return { + tmdbId, + inLibrary: !!libraryItem, + playStates: playState, + }; + } + + @UseGuards(UserAccessControl) + @Get(':userId/user-data/series/tmdb/:tmdbId/season/:season/episode/:episode') + @ApiOkResponse({ + description: 'User series data found', + type: MovieUserDataDto, + }) + async getEpisodeUserData( + @Param('userId') userId: string, + @Param('tmdbId') tmdbId: string, + @Param('season', ParseIntPipe) season: number, + @Param('episode', ParseIntPipe) episode: number, + ): Promise { + const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); + const playState = await this.playStateService + .findSeriesPlayStates(userId, tmdbId, season, episode) + .then((states) => + states.find((s) => s.season === season && s.episode === episode), + ); + + return { + tmdbId, + inLibrary: !!libraryItem, + playState: playState, + }; + } +} diff --git a/backend/src/user-data/user-data.module.ts b/backend/src/user-data/user-data.module.ts new file mode 100644 index 0000000..33eee51 --- /dev/null +++ b/backend/src/user-data/user-data.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { LibraryModule } from './library/library.module'; +import { PlayStatesModule } from './play-state/play-states.module'; +import { UserDataController } from './user-data.controller'; +import { UsersModule } from 'src/users/users.module'; + +@Module({ + providers: [], + controllers: [UserDataController], + imports: [PlayStatesModule, LibraryModule, UsersModule], +}) +export class UserDataModule {} diff --git a/backend/src/users/user-sources/user-source.dto.ts b/backend/src/users/user-sources/user-source.dto.ts deleted file mode 100644 index 09890d3..0000000 --- a/backend/src/users/user-sources/user-source.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - ApiProperty, - IntersectionType, - OmitType, - PartialType, - PickType, -} from '@nestjs/swagger'; -import { MediaSource } from './user-source.entity'; -import { Type } from '@nestjs/common'; -import { PickAndPartial } from 'src/common/common.dto'; - -export class SourceDto extends PickAndPartial( - MediaSource, - ['id', 'userId', 'adminControlled', 'enabled'], - ['pluginSettings'], -) {} - -export class CreateSourceDto extends PickAndPartial( - MediaSource, - ['pluginSettings'], - ['enabled', 'adminControlled'], -) {} diff --git a/backend/src/users/user-sources/user-sources.service.ts b/backend/src/users/user-sources/user-sources.service.ts deleted file mode 100644 index d423caa..0000000 --- a/backend/src/users/user-sources/user-sources.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { User } from '../user.entity'; -import { CreateSourceDto } from './user-source.dto'; -import { USER_REPOSITORY, USER_SOURCE_REPOSITORY } from '../user.providers'; -import { MediaSource } from './user-source.entity'; -import { UsersService } from '../users.service'; - -export enum UserSourcesServiceError { - SourceNotFound = 'SourceNotFound', - Unauthorized = 'Unauthorized', -} - -@Injectable() -export class UserSourcesService { - constructor( - private readonly userService: UsersService, - @Inject(USER_SOURCE_REPOSITORY) - private readonly userSourceRepository: Repository, - ) {} - - private async findUserSource( - userId: string, - sourceId: string, - ): Promise { - const source = await this.userSourceRepository.findOne({ - where: { - id: sourceId, - userId: userId, - }, - }); - - return source; - } - - async deleteUserSource( - userId: string, - sourceId: string, - callerUser: User, - ): Promise { - if (!callerUser.isAdmin || callerUser.id !== userId) { - throw UserSourcesServiceError.Unauthorized; - } - - const source = await this.findUserSource(userId, sourceId); - - if (!source) { - throw UserSourcesServiceError.SourceNotFound; - } - - await this.userSourceRepository.remove(source); - return this.userService.findOne(userId); - } - - async updateUserSource( - user: User, - sourceId: string, - sourceDto: CreateSourceDto, - callerUser: User = user, - ): Promise { - if (!callerUser.isAdmin || callerUser.id !== user.id) { - throw UserSourcesServiceError.Unauthorized; - } - - let source = await this.findUserSource(user.id, sourceId); - - // Create new if doesn't exist - if (!source) { - source = new MediaSource(); - source.user = user; - source.id = sourceId; - source.adminControlled = false; - } - - // Check for unauthorized access - if (source.adminControlled && !callerUser.isAdmin) { - throw UserSourcesServiceError.Unauthorized; - } else if (sourceDto.adminControlled !== undefined && !callerUser.isAdmin) { - throw UserSourcesServiceError.Unauthorized; - } - - source.adminControlled = - sourceDto.adminControlled ?? source.adminControlled; - source.enabled = sourceDto.enabled ?? source.enabled; - source.pluginSettings = sourceDto.pluginSettings ?? source.pluginSettings; - - await this.userSourceRepository.save(source); - return this.userService.findOne(user.id); - } - - getSourceSettings(user: User, sourceId: string) { - return user.mediaSources - ?.filter((s) => s?.enabled) - ?.find((source) => source.id === sourceId)?.pluginSettings; - } -} diff --git a/backend/src/users/user.dto.ts b/backend/src/users/user.dto.ts index 87ac60f..0a0943f 100644 --- a/backend/src/users/user.dto.ts +++ b/backend/src/users/user.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger'; import { User } from './user.entity'; -import { PlayStateDto } from './play-state/play-state.dto'; export class UserDto extends OmitType(User, [ 'password', @@ -57,25 +56,3 @@ export class UpdateUserDto extends PartialType( } export class SignInDto extends PickType(User, ['name', 'password'] as const) {} - -export class MovieUserDataDto { - @ApiProperty() - tmdbId: string; - - @ApiProperty() - inLibrary: boolean; - - @ApiProperty({ type: PlayStateDto, required: false }) - playState?: PlayStateDto; -} - -export class SeriesUserDataDto { - @ApiProperty() - tmdbId: string; - - @ApiProperty() - inLibrary: boolean; - - @ApiProperty({ type: [PlayStateDto] }) - playStates: PlayStateDto[]; -} diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index e0a0271..0131abc 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -1,16 +1,8 @@ -import { - Column, - Entity, - ManyToOne, - OneToMany, - OneToOne, - PrimaryColumn, - PrimaryGeneratedColumn, -} from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; -import { MediaSource } from 'src/users/user-sources/user-source.entity'; -import { LibraryItem } from './library/library.entity'; -import { PlayState } from './play-state/play-state.entity'; +import { MediaSource } from 'src/media-sources/media-source.entity'; +import { LibraryItem } from 'src/user-data/library/library.entity'; +import { PlayState } from 'src/user-data/play-state/play-state.entity'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; export class SonarrSettings { @ApiProperty({ required: true }) diff --git a/backend/src/users/user.providers.ts b/backend/src/users/user.providers.ts index ce21985..2da4057 100644 --- a/backend/src/users/user.providers.ts +++ b/backend/src/users/user.providers.ts @@ -1,14 +1,8 @@ import { DataSource } from 'typeorm'; -import { User } from './user.entity'; import { DATA_SOURCE } from '../database/database.providers'; -import { MediaSource } from './user-sources/user-source.entity'; -import { PlayState } from './play-state/play-state.entity'; -import { LibraryItem } from './library/library.entity'; +import { User } from './user.entity'; export const USER_REPOSITORY = 'USER_REPOSITORY'; -export const USER_SOURCE_REPOSITORY = 'USER_SOURCE_REPOSITORY'; -export const USER_LIBRARY_REPOSITORY = 'USER_LIBRARY_REPOSITORY'; -export const USER_PLAY_STATE_REPOSITORY = 'USER_PLAY_STATE_REPOSITORY'; export const userProviders = [ { @@ -16,21 +10,4 @@ export const userProviders = [ useFactory: (dataSource: DataSource) => dataSource.getRepository(User), inject: [DATA_SOURCE], }, - { - provide: USER_SOURCE_REPOSITORY, - useFactory: (dataSource: DataSource) => - dataSource.getRepository(MediaSource), - inject: [DATA_SOURCE], - }, - { - provide: USER_PLAY_STATE_REPOSITORY, - useFactory: (dataSource: DataSource) => dataSource.getRepository(PlayState), - inject: [DATA_SOURCE], - }, - { - provide: USER_LIBRARY_REPOSITORY, - useFactory: (dataSource: DataSource) => - dataSource.getRepository(LibraryItem), - inject: [DATA_SOURCE], - }, ]; diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 2ac8b49..f2dbc97 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,3 +1,4 @@ +import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; import { BadRequestException, Body, @@ -7,41 +8,25 @@ import { InternalServerErrorException, NotFoundException, Param, - ParseIntPipe, Post, Put, UnauthorizedException, UseGuards, } from '@nestjs/common'; -import { UserServiceError, UsersService } from './users.service'; -import { - UserAccessControl, - GetAuthUser, - OptionalAccessControl, -} from '../auth/auth.guard'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { - CreateUserDto, - MovieUserDataDto, - SeriesUserDataDto, - UpdateUserDto, - UserDto, -} from './user.dto'; + GetAuthUser, + OptionalAccessControl, + UserAccessControl, +} from '../auth/auth.guard'; +import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto'; import { User } from './user.entity'; -import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; -import { LibraryService } from './library/library.service'; -import { PlayStateService } from './play-state/play-state.service'; -import { PlayState } from './play-state/play-state.entity'; -import { PlayStateDto } from './play-state/play-state.dto'; +import { UserServiceError, UsersService } from './users.service'; @ApiTags('users') @Controller('users') export class UsersController { - constructor( - private usersService: UsersService, - private libraryService: LibraryService, - private playStateService: PlayStateService, - ) {} + constructor(private usersService: UsersService) {} // @UseGuards(AuthGuard) // @Get() @@ -161,76 +146,4 @@ export class UsersController { await this.usersService.remove(id); } - - @UseGuards(UserAccessControl) - @Get(':userId/user-data/movie/tmdb/:tmdbId') - @ApiOkResponse({ - description: 'User movie data found', - type: MovieUserDataDto, - }) - async getMovieUserData( - @Param('userId') userId: string, - @Param('tmdbId') tmdbId: string, - ): Promise { - const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); - const playState = await this.playStateService.findMoviePlayState( - userId, - tmdbId, - ); - - return { - tmdbId, - inLibrary: !!libraryItem, - playState: playState, - }; - } - - @UseGuards(UserAccessControl) - @Get(':userId/user-data/series/tmdb/:tmdbId') - @ApiOkResponse({ - description: 'User series data found', - type: SeriesUserDataDto, - }) - async getSeriesUserData( - @Param('userId') userId: string, - @Param('tmdbId') tmdbId: string, - ): Promise { - const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); - const playState = await this.playStateService.findSeriesPlayStates( - userId, - tmdbId, - ); - - return { - tmdbId, - inLibrary: !!libraryItem, - playStates: playState, - }; - } - - @UseGuards(UserAccessControl) - @Get(':userId/user-data/series/tmdb/:tmdbId/season/:season/episode/:episode') - @ApiOkResponse({ - description: 'User series data found', - type: MovieUserDataDto, - }) - async getEpisodeUserData( - @Param('userId') userId: string, - @Param('tmdbId') tmdbId: string, - @Param('season', ParseIntPipe) season: number, - @Param('episode', ParseIntPipe) episode: number, - ): Promise { - const libraryItem = await this.libraryService.findByTmdbId(userId, tmdbId); - const playState = await this.playStateService - .findSeriesPlayStates(userId, tmdbId, season, episode) - .then((states) => - states.find((s) => s.season === season && s.episode === episode), - ); - - return { - tmdbId, - inLibrary: !!libraryItem, - playState: playState, - }; - } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index a260a73..7c94093 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,36 +1,13 @@ import { forwardRef, Module } from '@nestjs/common'; -import { UsersService } from './users.service'; +import { SourceProvidersModule } from 'src/source-providers/source-providers.module'; import { userProviders } from './user.providers'; import { UsersController } from './users.controller'; -import { DatabaseModule } from '../database/database.module'; -import { UserSourcesService } from './user-sources/user-sources.service'; -import { UserSourcesController } from './user-sources/user-sources.controller'; -import { LibraryService } from './library/library.service'; -import { PlayStateService } from './play-state/play-state.service'; -import { LibraryController } from './library/library.controller'; -import { PlayStateController } from './play-state/play-state.controller'; -import { SourcePluginsModule } from 'src/source-plugins/source-plugins.module'; -import { MetadataModule } from 'src/metadata/metadata.module'; +import { UsersService } from './users.service'; @Module({ - imports: [ - DatabaseModule, - forwardRef(() => SourcePluginsModule), - forwardRef(() => MetadataModule), - ], - providers: [ - ...userProviders, - UsersService, - UserSourcesService, - LibraryService, - PlayStateService, - ], - controllers: [ - UsersController, - UserSourcesController, - LibraryController, - PlayStateController, - ], - exports: [UsersService, UserSourcesService, LibraryService], + imports: [forwardRef(() => SourceProvidersModule)], + providers: [...userProviders, UsersService], + controllers: [UsersController], + exports: [UsersService], }) export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 9c17726..edf97d4 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,7 +3,7 @@ import { Repository } from 'typeorm'; import { User } from './user.entity'; import { CreateUserDto, UpdateUserDto } from './user.dto'; import { USER_REPOSITORY } from './user.providers'; -import { SourcePluginsService } from 'src/source-plugins/source-plugins.service'; +import { SourceProvidersService } from 'src/source-providers/source-providers.service'; export enum UserServiceError { PasswordMismatch = 'PasswordMismatch', @@ -16,8 +16,8 @@ export class UsersService { constructor( @Inject(USER_REPOSITORY) private readonly userRepository: Repository, - @Inject(forwardRef(() => SourcePluginsService)) - private readonly sourcePluginsService: SourcePluginsService, + @Inject(forwardRef(() => SourceProvidersService)) + private readonly sourceProvidersService: SourceProvidersService, ) {} // Finds @@ -143,12 +143,12 @@ export class UsersService { } private async filterMediaSources(user: User): Promise { - const mediaSources = await this.sourcePluginsService.getPlugins(); + const providers = await this.sourceProvidersService.getProviders(); return { ...user, mediaSources: user.mediaSources.filter( - (source) => !!mediaSources[source.id], + (source) => !!providers[source.pluginId], ), }; } diff --git a/package.json b/package.json index 2e30297..26e3e7d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:unit": "vitest", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write .", - "openapi:update": "npm run --prefix backend §generate && npm run openapi:codegen", + "openapi:update": "npm run --prefix backend generate && npm run openapi:codegen", "openapi:codegen": "openapi-typescript \"backend/swagger-spec.json\" -o src/lib/apis/reiverr/reiverr.generated.d.ts", "openapi:generate": "swagger-typescript-api -p \"backend/swagger-spec.json\" -o src/lib/apis/reiverr -n reiverr.openapi.ts --axios --module-name-first-tag", "openapi:generate-tmdb": "swagger-typescript-api -p \"backend/swagger-spec.json\" -o src/lib/apis/reiverr -n reiverr.openapi.ts --axios --module-name-first-tag" diff --git a/src/app.css b/src/app.css index e403e28..d58c43d 100644 --- a/src/app.css +++ b/src/app.css @@ -83,19 +83,19 @@ html[data-useragent*='Tizen'] .selectable-secondary { @apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2; } -.header1 { +.h4 { @apply font-semibold text-xl text-secondary-100; } -.header2 { +.h3 { @apply font-semibold text-2xl text-secondary-100; } -.header3 { +.h2 { @apply font-semibold text-3xl text-secondary-100; } -.header4 { +.h1 { @apply font-semibold text-4xl text-secondary-100 tracking-wider; } diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index e4b5e25..4f2d5f3 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -47,6 +47,8 @@ export interface Settings { export interface MediaSource { id: string; + pluginId: string; + name: string; userId: string; user: string; /** @default false */ @@ -54,6 +56,7 @@ export interface MediaSource { /** @default false */ adminControlled?: boolean; pluginSettings?: object; + priority: number; } export interface PlayState { @@ -149,101 +152,12 @@ export interface SeriesUserDataDto { playStates: PlayStateDto[]; } -export interface CreateSourceDto { - pluginSettings?: object; - /** @default false */ - enabled?: boolean; - /** @default false */ - adminControlled?: boolean; -} - export interface PaginatedResponseDto { total: number; page: number; itemsPerPage: number; } -export interface MovieDto { - id?: string; - tmdbId: string; - tmdbMovie?: object; -} - -export interface Series { - id?: string; - tmdbId: string; - tmdbSeries?: object; -} - -export interface LibraryItemDto { - tmdbId: string; - mediaType: 'Movie' | 'Series' | 'Episode'; - playStates?: PlayStateDto[]; - movieMetadata?: MovieDto; - seriesMetadata?: Series; -} - -export interface SuccessResponseDto { - success: boolean; -} - -export interface UpdatePlayStateDto { - id?: string; - tmdbId?: number; - mediaType?: 'Movie' | 'Series' | 'Episode'; - season?: number; - episode?: number; - /** - * Whether the user has watched this media - * @default false - */ - watched?: boolean; - /** - * A number between 0 and 1 - * @default false - * @example 0.5 - */ - progress?: number; - /** Last time the user played this media */ - lastPlayedAt?: string; -} - -export interface SignInDto { - name: string; - password: string; -} - -export interface SignInResponse { - accessToken: string; - user: UserDto; -} - -export interface PluginSettingsTemplateDto { - /** @example {"setting1":"string","setting2":{"type":"link","url":"https://example.com"}} */ - settings: Record; -} - -export interface PluginSettingsDto { - /** @example {"setting1":"some value","setting2":12345,"setting3":true,"setting4":{"nestedKey":"nestedValue"}} */ - settings: Record; -} - -export interface ValidationResponseDto { - /** @example true */ - isValid: boolean; - /** @example {"setting1":"error message","setting2":"another error message"} */ - errors: Record; - /** @example {"setting1":"new value","setting2":"another new value"} */ - replace: Record; -} - -export interface SourceProviderCapabilitiesDto { - moviePlayback: boolean; - episodePlayback: boolean; - movieIndexing: boolean; - episodeIndexing: boolean; -} - export interface IndexItemDto { id: string; } @@ -513,6 +427,99 @@ export interface StreamDto { subtitles: SubtitlesDto[]; } +export interface UpdateOrCreateMediaSourceDto { + pluginId: string; + pluginSettings?: object; + id?: string; + name?: string; + /** @default false */ + enabled?: boolean; + /** @default false */ + adminControlled?: boolean; + priority?: number; +} + +export interface MovieDto { + id?: string; + tmdbId: string; + tmdbMovie?: object; +} + +export interface Series { + id?: string; + tmdbId: string; + tmdbSeries?: object; +} + +export interface LibraryItemDto { + tmdbId: string; + mediaType: 'Movie' | 'Series' | 'Episode'; + playStates?: PlayStateDto[]; + movieMetadata?: MovieDto; + seriesMetadata?: Series; +} + +export interface SuccessResponseDto { + success: boolean; +} + +export interface UpdatePlayStateDto { + id?: string; + tmdbId?: number; + mediaType?: 'Movie' | 'Series' | 'Episode'; + season?: number; + episode?: number; + /** + * Whether the user has watched this media + * @default false + */ + watched?: boolean; + /** + * A number between 0 and 1 + * @default false + * @example 0.5 + */ + progress?: number; + /** Last time the user played this media */ + lastPlayedAt?: string; +} + +export interface SignInDto { + name: string; + password: string; +} + +export interface SignInResponse { + accessToken: string; + user: UserDto; +} + +export interface PluginSettingsTemplateDto { + /** @example {"setting1":"string","setting2":{"type":"link","url":"https://example.com"}} */ + settings: Record; +} + +export interface PluginSettingsDto { + /** @example {"setting1":"some value","setting2":12345,"setting3":true,"setting4":{"nestedKey":"nestedValue"}} */ + settings: Record; +} + +export interface ValidationResponseDto { + /** @example true */ + isValid: boolean; + /** @example {"setting1":"error message","setting2":"another error message"} */ + errors: Record; + /** @example {"setting1":"new value","setting2":"another new value"} */ + replace: Record; +} + +export interface SourceProviderCapabilitiesDto { + moviePlayback: boolean; + episodePlayback: boolean; + movieIndexing: boolean; + episodeIndexing: boolean; +} + import type { AxiosInstance, AxiosRequestConfig, @@ -854,16 +861,15 @@ export class Api extends HttpClient this.request({ - path: `/api/users/${userId}/sources/${sourceId}`, + path: `/api/users/${userId}/sources`, method: 'PUT', body: data, type: ContentType.Json, @@ -1022,217 +1028,7 @@ export class Api extends HttpClient - this.request< - SignInResponse, - { - /** @example 401 */ - statusCode: number; - /** @example "Unauthorized" */ - message: string; - /** @example "Unauthorized" */ - error?: string; - } - >({ - path: `/api/auth`, - method: 'POST', - body: data, - type: ContentType.Json, - format: 'json', - ...params - }), - - /** - * No description - * - * @name GetHello - * @request GET:/api - */ - getHello: (params: RequestParams = {}) => - this.request({ - path: `/api`, - method: 'GET', - ...params - }), - - /** - * No description - * - * @name TmdbProxyGet - * @request GET:/api/tmdb/v3/proxy/* - */ - tmdbProxyGet: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'GET', - ...params - }), - - /** - * No description - * - * @name TmdbProxyPost - * @request POST:/api/tmdb/v3/proxy/* - */ - tmdbProxyPost: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'POST', - ...params - }), - - /** - * No description - * - * @name TmdbProxyPut - * @request PUT:/api/tmdb/v3/proxy/* - */ - tmdbProxyPut: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'PUT', - ...params - }), - - /** - * No description - * - * @name TmdbProxyDelete - * @request DELETE:/api/tmdb/v3/proxy/* - */ - tmdbProxyDelete: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'DELETE', - ...params - }), - - /** - * No description - * - * @name TmdbProxyPatch - * @request PATCH:/api/tmdb/v3/proxy/* - */ - tmdbProxyPatch: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'PATCH', - ...params - }), - - /** - * No description - * - * @name TmdbProxyOptions - * @request OPTIONS:/api/tmdb/v3/proxy/* - */ - tmdbProxyOptions: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'OPTIONS', - ...params - }), - - /** - * No description - * - * @name TmdbProxyHead - * @request HEAD:/api/tmdb/v3/proxy/* - */ - tmdbProxyHead: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'HEAD', - ...params - }), - - /** - * No description - * - * @name TmdbProxySearch - * @request SEARCH:/api/tmdb/v3/proxy/* - */ - tmdbProxySearch: (params: RequestParams = {}) => - this.request({ - path: `/api/tmdb/v3/proxy/*`, - method: 'SEARCH', - ...params - }) - }; sources = { - /** - * No description - * - * @tags sources - * @name GetSourcePlugins - * @request GET:/api/sources - */ - getSourcePlugins: (params: RequestParams = {}) => - this.request({ - path: `/api/sources`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetSourceSettingsTemplate - * @request GET:/api/sources/{sourceId}/settings/template - */ - getSourceSettingsTemplate: (sourceId: string, params: RequestParams = {}) => - this.request({ - path: `/api/sources/${sourceId}/settings/template`, - method: 'GET', - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name ValidateSourceSettings - * @request POST:/api/sources/{sourceId}/settings/validate - */ - validateSourceSettings: ( - sourceId: string, - data: PluginSettingsDto, - params: RequestParams = {} - ) => - this.request({ - path: `/api/sources/${sourceId}/settings/validate`, - method: 'POST', - body: data, - type: ContentType.Json, - format: 'json', - ...params - }), - - /** - * No description - * - * @tags sources - * @name GetSourceCapabilities - * @request GET:/api/sources/{sourceId}/capabilities - */ - getSourceCapabilities: (sourceId: string, params: RequestParams = {}) => - this.request({ - path: `/api/sources/${sourceId}/capabilities`, - method: 'GET', - format: 'json', - ...params - }), - /** * No description * @@ -1597,4 +1393,215 @@ export class Api extends HttpClient + this.request< + SignInResponse, + { + /** @example 401 */ + statusCode: number; + /** @example "Unauthorized" */ + message: string; + /** @example "Unauthorized" */ + error?: string; + } + >({ + path: `/api/auth`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params + }), + + /** + * No description + * + * @name GetHello + * @request GET:/api + */ + getHello: (params: RequestParams = {}) => + this.request({ + path: `/api`, + method: 'GET', + ...params + }), + + /** + * No description + * + * @name TmdbProxyGet + * @request GET:/api/tmdb/v3/proxy/* + */ + tmdbProxyGet: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'GET', + ...params + }), + + /** + * No description + * + * @name TmdbProxyPost + * @request POST:/api/tmdb/v3/proxy/* + */ + tmdbProxyPost: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'POST', + ...params + }), + + /** + * No description + * + * @name TmdbProxyPut + * @request PUT:/api/tmdb/v3/proxy/* + */ + tmdbProxyPut: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'PUT', + ...params + }), + + /** + * No description + * + * @name TmdbProxyDelete + * @request DELETE:/api/tmdb/v3/proxy/* + */ + tmdbProxyDelete: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'DELETE', + ...params + }), + + /** + * No description + * + * @name TmdbProxyPatch + * @request PATCH:/api/tmdb/v3/proxy/* + */ + tmdbProxyPatch: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'PATCH', + ...params + }), + + /** + * No description + * + * @name TmdbProxyOptions + * @request OPTIONS:/api/tmdb/v3/proxy/* + */ + tmdbProxyOptions: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'OPTIONS', + ...params + }), + + /** + * No description + * + * @name TmdbProxyHead + * @request HEAD:/api/tmdb/v3/proxy/* + */ + tmdbProxyHead: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'HEAD', + ...params + }), + + /** + * No description + * + * @name TmdbProxySearch + * @request SEARCH:/api/tmdb/v3/proxy/* + */ + tmdbProxySearch: (params: RequestParams = {}) => + this.request({ + path: `/api/tmdb/v3/proxy/*`, + method: 'SEARCH', + ...params + }) + }; + providers = { + /** + * No description + * + * @tags providers + * @name GetSourceProviders + * @request GET:/api/providers + */ + getSourceProviders: (params: RequestParams = {}) => + this.request({ + path: `/api/providers`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags providers + * @name GetSourceSettingsTemplate + * @request GET:/api/providers/{providerId}/settings/template + */ + getSourceSettingsTemplate: (providerId: string, params: RequestParams = {}) => + this.request({ + path: `/api/providers/${providerId}/settings/template`, + method: 'GET', + format: 'json', + ...params + }), + + /** + * No description + * + * @tags providers + * @name ValidateSourceSettings + * @request POST:/api/providers/{providerId}/settings/validate + */ + validateSourceSettings: ( + providerId: string, + data: PluginSettingsDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/providers/${providerId}/settings/validate`, + method: 'POST', + body: data, + type: ContentType.Json, + format: 'json', + ...params + }), + + /** + * No description + * + * @tags providers + * @name GetSourceCapabilities + * @request GET:/api/providers/{providerId}/capabilities + */ + getSourceCapabilities: (providerId: string, params: RequestParams = {}) => + this.request({ + path: `/api/providers/${providerId}/capabilities`, + method: 'GET', + format: 'json', + ...params + }) + }; } diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index 87748bf..0da7a2c 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -49,7 +49,7 @@ -
+
{#if $$slots.icon}
diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index 4e0e8fb..444da12 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -81,7 +81,7 @@ {#if backdropUrl} {:else} -
+
{title}
{/if} diff --git a/src/lib/components/Carousel/Carousel.svelte b/src/lib/components/Carousel/Carousel.svelte index 984010f..8eb2232 100644 --- a/src/lib/components/Carousel/Carousel.svelte +++ b/src/lib/components/Carousel/Carousel.svelte @@ -1,5 +1,5 @@ -
+
{header}
diff --git a/src/lib/components/Dialog/CreateOrEditProfileModal.svelte b/src/lib/components/Dialog/CreateOrEditProfileModal.svelte index 54b0608..2ff5a67 100644 --- a/src/lib/components/Dialog/CreateOrEditProfileModal.svelte +++ b/src/lib/components/Dialog/CreateOrEditProfileModal.svelte @@ -165,7 +165,7 @@ -

+

{createNew ? 'Create Account' : 'Edit Profile'}

name @@ -236,7 +236,7 @@ detail.stopPropagation(); }} > -

Select Profile Picture

+

Select Profile Picture

- +
diff --git a/src/lib/components/Dialog/SelectDialog.svelte b/src/lib/components/Dialog/SelectDialog.svelte index 09aad9a..2a7dd88 100644 --- a/src/lib/components/Dialog/SelectDialog.svelte +++ b/src/lib/components/Dialog/SelectDialog.svelte @@ -4,6 +4,7 @@ import SelectItem from '../SelectItem.svelte'; import { modalStack } from '../Modal/modal.store'; + // TODO: Add labels to the options export let title: string = 'Select'; export let subtitle: string = ''; export let options: string[]; @@ -21,7 +22,7 @@
-

{title}

+

{title}

{subtitle}

diff --git a/src/lib/components/Dialog/UpdateDialog.svelte b/src/lib/components/Dialog/UpdateDialog.svelte index 5e46181..5834b54 100644 --- a/src/lib/components/Dialog/UpdateDialog.svelte +++ b/src/lib/components/Dialog/UpdateDialog.svelte @@ -24,7 +24,7 @@
-

Update Available

+

Update Available

Reiverr {version} is now available.
diff --git a/src/lib/components/FloatingIconButton.svelte b/src/lib/components/FloatingIconButton.svelte new file mode 100644 index 0000000..ed1e5df --- /dev/null +++ b/src/lib/components/FloatingIconButton.svelte @@ -0,0 +1,52 @@ + + +{#if !container} + +{:else} + +
+ +
+
+{/if} diff --git a/src/lib/components/HeroCarousel/HeroCarousel.svelte b/src/lib/components/HeroCarousel/HeroCarousel.svelte index 16e676d..86540c1 100644 --- a/src/lib/components/HeroCarousel/HeroCarousel.svelte +++ b/src/lib/components/HeroCarousel/HeroCarousel.svelte @@ -1,7 +1,7 @@ - - diff --git a/src/lib/components/Integrations/JellyfinIntegrationUsersDialog.svelte b/src/lib/components/Integrations/JellyfinIntegrationUsersDialog.svelte index f415e28..fbf8da2 100644 --- a/src/lib/components/Integrations/JellyfinIntegrationUsersDialog.svelte +++ b/src/lib/components/Integrations/JellyfinIntegrationUsersDialog.svelte @@ -15,7 +15,7 @@ -

Users

+

Users

{#each users as user} -

Connect a TMDB Account

+

Connect a TMDB Account

To connect your TMDB account, log in via the link below and then click "Complete Connection".
diff --git a/src/lib/components/LoginForm.svelte b/src/lib/components/LoginForm.svelte index 56c80d6..8208b1d 100644 --- a/src/lib/components/LoginForm.svelte +++ b/src/lib/components/LoginForm.svelte @@ -40,7 +40,7 @@ -

Login to Reiverr

+

Login to Reiverr

If this is your first time logging in, a new account will be created based on your credentials.
diff --git a/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte b/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte index a55c8d8..cd34bbd 100644 --- a/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte +++ b/src/lib/components/MediaManagerModal/MMAddToRadarrDialog.svelte @@ -126,7 +126,7 @@ >
-

Add {title} to Sonarr?

+

Add {title} to Sonarr?

Before you can fetch episodes, you need to add this series to Sonarr.
diff --git a/src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte b/src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte index e7cd4bd..df90ca3 100644 --- a/src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte +++ b/src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte @@ -127,7 +127,7 @@ >
-

Add {title} to Sonarr?

+

Add {title} to Sonarr?

Before you can fetch episodes, you need to add this series to Sonarr.
diff --git a/src/lib/components/MediaManagerModal/MMTitle.svelte b/src/lib/components/MediaManagerModal/MMTitle.svelte index 41ab9f7..92a9486 100644 --- a/src/lib/components/MediaManagerModal/MMTitle.svelte +++ b/src/lib/components/MediaManagerModal/MMTitle.svelte @@ -1,8 +1,8 @@
-
+
-
+
diff --git a/src/lib/components/MediaManagerModal/Releases/MMReleasesTab.svelte b/src/lib/components/MediaManagerModal/Releases/MMReleasesTab.svelte index 7edd46e..a3f5f05 100644 --- a/src/lib/components/MediaManagerModal/Releases/MMReleasesTab.svelte +++ b/src/lib/components/MediaManagerModal/Releases/MMReleasesTab.svelte @@ -64,10 +64,10 @@ -

+

-

+

diff --git a/src/lib/components/Panel.svelte b/src/lib/components/Panel.svelte index 4ee4a22..28c6697 100644 --- a/src/lib/components/Panel.svelte +++ b/src/lib/components/Panel.svelte @@ -1,12 +1,15 @@
+ {#if onClose} +
+ + + +
+ {/if}
diff --git a/src/lib/components/SelectField.svelte b/src/lib/components/SelectField.svelte index cf552be..898b852 100644 --- a/src/lib/components/SelectField.svelte +++ b/src/lib/components/SelectField.svelte @@ -6,6 +6,7 @@ const dispatch = createEventDispatcher<{ clickOrSelect: null }>(); + export let color: 'secondary' | 'primary' = 'secondary'; export let value: string; export let disabled: boolean = false; export let action: (() => Promise) | undefined = undefined; @@ -27,11 +28,13 @@
-

- -

- - {value} - + {#if !$$slots.content} +

+ +

+ + {value} + + {:else} + + {/if}
; export let size: 'hug' | 'stretch' = 'hug'; + export let direction: 'horizontal' | 'vertical' = 'horizontal'; let selectable: Selectable; @@ -46,11 +47,22 @@ disabled={!active} >
= index, - 'translate-x-10': !active && $openTab < index - })} + class={classNames( + $$restProps.class, + 'transition-[transform,opacity]', + { + 'opacity-0 pointer-events-none': !active + }, + direction === 'horizontal' + ? { + '-translate-x-10': !active && $openTab >= index, + 'translate-x-10': !active && $openTab < index + } + : { + '-translate-y-10': !active && $openTab >= index, + 'translate-y-10': !active && $openTab < index + } + )} >
diff --git a/src/lib/components/UpdateChecker.svelte b/src/lib/components/UpdateChecker.svelte index 4f054e9..50c83d5 100644 --- a/src/lib/components/UpdateChecker.svelte +++ b/src/lib/components/UpdateChecker.svelte @@ -1,6 +1,6 @@ -

+

Subtitles

diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index bca186a..c26637d 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -198,7 +198,7 @@
{subtitle}
-

{title}

+

{title}

{#if subtitleInfo?.availableSubtitles?.length} diff --git a/src/lib/pages/LibraryPage.svelte b/src/lib/pages/LibraryPage.svelte index 7e3cfc9..70bb98b 100644 --- a/src/lib/pages/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage.svelte @@ -102,7 +102,7 @@ {/await} --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Jellyfin

- - createModal(JellyfinIntegrationUsersDialog, { - selectedUser: detail.user, - users: detail.users, - handleSelectUser: detail.setJellyfinUser - })} - let:handleSave - let:stale +
+

Integrations

+ + + + + +

Tmdb Account

+ + {#if !connected} +
+ +
+ {/if} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + localSettings.update((p) => ({ ...p, animateScrolling: detail }))} + /> +
+
+ + + localSettings.update((p) => ({ ...p, useCssTransitions: detail }))} + /> +
+
+ + + localSettings.update((p) => ({ ...p, checkForUpdates: detail }))} + /> +
+
+ + +
+ Version: {REIVERR_VERSION} +
+
+ Mode: {import.meta.env.MODE} +
+
+ meta.env: {JSON.stringify(import.meta.env)} +
+ User agent: {window?.navigator?.userAgent} +
Last key code: {lastKeyCode}
+
Last key: {lastKey}
+ {#if tizenMediaKey} +
Tizen media key: {tizenMediaKey}
+ {/if} +
+ +
+
+
+
diff --git a/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte b/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte new file mode 100644 index 0000000..470ec22 --- /dev/null +++ b/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte @@ -0,0 +1,19 @@ + + + handleEditPluginSettings()} +/> diff --git a/src/lib/pages/ManagePage/Plugins.ManagePage.svelte b/src/lib/pages/ManagePage/MediaSources.ManagePage.svelte similarity index 56% rename from src/lib/pages/ManagePage/Plugins.ManagePage.svelte rename to src/lib/pages/ManagePage/MediaSources.ManagePage.svelte index 48e580e..27ef027 100644 --- a/src/lib/pages/ManagePage/Plugins.ManagePage.svelte +++ b/src/lib/pages/ManagePage/MediaSources.ManagePage.svelte @@ -8,22 +8,18 @@ import SelectDialog from '../../components/Dialog/SelectDialog.svelte'; import { get } from 'svelte/store'; import { createErrorNotification } from '../../components/Notifications/notification.store'; - import PluginButton from './PluginButton.ManagePage.svelte'; + import MediaSourceButton from './MediaSourceButton.ManagePage.svelte'; - const availableSources = []; - const allPlugins = reiverrApiNew.sources.getSourcePlugins().then((r) => r.data); - let enabledPlugins = getEnabledPlugins(); - $: availablePlugins = Promise.all([allPlugins, enabledPlugins]).then( - ([allPlugins, enabledPlugins]) => allPlugins.filter((p) => !enabledPlugins.includes(p)) - ); + const allPlugins = reiverrApiNew.providers.getSourceProviders().then((r) => r.data); + let userSources = getUserMediaSources(); - function getEnabledPlugins() { + function getUserMediaSources() { return reiverrApiNew.users .findUserById($user?.id || '') - .then((r) => r.data.mediaSources?.map((source) => source.id) || []); + .then((r) => r.data.mediaSources?.sort((a, b) => a.priority - b.priority) ?? []); } - async function addSource(sourceId: string) { + async function addSource(pluginId: string) { const userId = get(user)?.id; if (!userId) { @@ -31,27 +27,27 @@ return; } - await reiverrApiNew.users.updateSource(sourceId, userId, { enabled: false }); - enabledPlugins = getEnabledPlugins(); + await reiverrApiNew.users.updateSource(userId, { pluginId, enabled: false }); + userSources = getUserMediaSources(); }
-

Media Soruces

+

Media Soruces

External media soruces allow reiverr to play content from different sources. Additional media sources can be added via external plugins.

- - {#await enabledPlugins then enabledPlugins} - {#each enabledPlugins as plugin} - + + {#await userSources then userSources} + {#each userSources as source} + {/each} {/await} - {#await availablePlugins then availablePlugins} + {#await allPlugins then availablePlugins} diff --git a/src/lib/pages/OnboardingPage.svelte b/src/lib/pages/OnboardingPage.svelte index 4c135c6..8b2b09e 100644 --- a/src/lib/pages/OnboardingPage.svelte +++ b/src/lib/pages/OnboardingPage.svelte @@ -55,7 +55,7 @@ detail.stopPropagation()}> -

Welcome to Reiverr

+

Welcome to Reiverr

Looks like this is a new account. This setup will get you started with connecting your services to get most out of Reiverr. @@ -76,7 +76,7 @@ -

Connect a TMDB Account

+

Connect a TMDB Account

Connect to TMDB for personalized recommendations based on your movie reviews and preferences. @@ -144,7 +144,7 @@ -

Connect to Jellyfin

+

Connect to Jellyfin

Connect to Jellyfin to watch movies and tv shows.
-

Select User

+

Select User

{#await jellyfinUsers then users} {#each users || [] as user} @@ -225,7 +225,7 @@ -

Connect to Sonarr

+

Connect to Sonarr

Connect to Sonarr for requesting and managing tv shows.
@@ -249,7 +249,7 @@
-

Connect to Radarr

+

Connect to Radarr

Connect to Radarr for requesting and managing movies.
@@ -276,7 +276,7 @@
-

All Set!

+

All Set!

Reiverr is now ready to use.
diff --git a/src/lib/pages/SearchPage.svelte b/src/lib/pages/SearchPage.svelte index 4dd1aa4..d352df9 100644 --- a/src/lib/pages/SearchPage.svelte +++ b/src/lib/pages/SearchPage.svelte @@ -40,7 +40,7 @@ {/if} -

{stream.title}

-

{stream.key}

+

{stream.title}

+

{stream.key}

{/if} -

{title}

-

{subtitle}

+

{title}

+

{subtitle}

{/if} -

{title}

-

{subtitle}

+

{title}

+

{subtitle}

- {capitalize(source.id)} + {capitalize(source.name)} {#if hasFocus} {/if} diff --git a/src/lib/pages/UsersPage.svelte b/src/lib/pages/UsersPage.svelte index 674d5a6..fcf1a33 100644 --- a/src/lib/pages/UsersPage.svelte +++ b/src/lib/pages/UsersPage.svelte @@ -37,7 +37,7 @@ {#await users then users} {#if users?.length} -

Who is watching?

+

Who is watching?

{#each users as item} {@const user = item.user} @@ -47,9 +47,7 @@ url={user?.profilePicture || profilePictures.keanu} on:clickOrSelect={() => user && handleSwitchUser(item)} /> -
+
{user?.name}
diff --git a/src/lib/stores/media-user-data.store.ts b/src/lib/stores/media-user-data.store.ts index 5022dbd..3b8a5fa 100644 --- a/src/lib/stores/media-user-data.store.ts +++ b/src/lib/stores/media-user-data.store.ts @@ -1,4 +1,3 @@ -import type { TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api'; import { createModal } from '$lib/components/Modal/modal.store'; import { createErrorNotification } from '$lib/components/Notifications/notification.store'; import { playerState } from '$lib/components/VideoPlayer/VideoPlayer'; @@ -11,15 +10,14 @@ import type { StreamCandidateDto } from '../apis/reiverr/reiverr.openapi'; import type { MediaType } from '../types'; -import { reiverrApiNew, sources, user } from './user.store'; import { episodeUserDataStore, libraryItemsDataStore, movieUserDataStore, seriesUserDataStore, - tmdbMovieDataStore, tmdbSeriesDataStore } from './data.store'; +import { reiverrApiNew, sources, user } from './user.store'; export type EpisodeData = { season: number; diff --git a/src/lib/stores/sources.store.ts b/src/lib/stores/sources.store.ts index f0d27cb..f2ace21 100644 --- a/src/lib/stores/sources.store.ts +++ b/src/lib/stores/sources.store.ts @@ -1,11 +1,14 @@ -import { derived, writable } from 'svelte/store'; -import type { MediaSource, SourcePluginCapabilitiesDto } from '../apis/reiverr/reiverr.openapi'; +import { writable } from 'svelte/store'; +import type { MediaSource, SourceProviderCapabilitiesDto } from '../apis/reiverr/reiverr.openapi'; import { reiverrApiNew, user } from './user.store'; function useSources() { - const sources = writable<{ source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[]>( - [] - ); + const sources = writable< + { + source: MediaSource; + capabilities: SourceProviderCapabilitiesDto; + }[] + >([]); user.subscribe(async (user) => { if (!user) { @@ -13,15 +16,15 @@ function useSources() { return; } - const out: { source: MediaSource; capabilities: SourcePluginCapabilitiesDto }[] = []; + const out: { source: MediaSource; capabilities: SourceProviderCapabilitiesDto }[] = []; user?.mediaSources ?.filter((s) => s.enabled) ?.forEach(async (s) => { out.push({ source: s, - capabilities: await reiverrApiNew.sources - .getSourceCapabilities(s.id, s.pluginSettings ?? ({} as any)) + capabilities: await reiverrApiNew.providers + .getSourceCapabilities(s.pluginId) .then((r) => r.data) }); }); diff --git a/src/lib/stores/user.store.ts b/src/lib/stores/user.store.ts index ace5780..0233cad 100644 --- a/src/lib/stores/user.store.ts +++ b/src/lib/stores/user.store.ts @@ -39,8 +39,8 @@ function useUser() { ?.map(async (s) => { out.push({ source: s, - capabilities: await reiverrApiNew.sources - .getSourceCapabilities(s.id, s.pluginSettings ?? ({} as any)) + capabilities: await reiverrApiNew.providers + .getSourceCapabilities(s.pluginId, s.pluginSettings ?? ({} as any)) .then((r) => r.data) .catch(() => ({ episodeIndexing: false,