refactor: pretty much the whole backend module hierarchy

This commit is contained in:
Aleksi Lassila
2025-02-11 02:40:41 +02:00
parent 6969525464
commit fa27f19975
96 changed files with 1786 additions and 2033 deletions

View File

@@ -0,0 +1,485 @@
import { ApiProperty } from '@nestjs/swagger';
import {
CodecProfile,
ContainerProfile,
DeviceProfile,
DirectPlayProfile,
ProfileCondition,
SubtitleProfile,
TranscodingProfile,
} from '@aleksilassila/reiverr-plugin';
export class DirectPlayProfileDto implements DirectPlayProfile {
@ApiProperty({
required: false,
description: 'Gets or sets the container.',
nullable: true,
})
Container?: string;
@ApiProperty({
required: false,
description: 'Gets or sets the audio codec.',
nullable: true,
})
AudioCodec?: string | null;
@ApiProperty({
required: false,
description: 'Gets or sets the video codec.',
nullable: true,
})
VideoCodec?: string | null;
@ApiProperty({
required: false,
description: 'Gets or sets the Dlna profile type.',
enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'],
})
Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric';
}
export class ProfileConditionDto implements ProfileCondition {
@ApiProperty({
description: 'Gets or sets the condition.',
enum: [
'Equals',
'NotEquals',
'LessThanEqual',
'GreaterThanEqual',
'EqualsAny',
],
nullable: true,
required: false,
})
Condition?:
| 'Equals'
| 'NotEquals'
| 'LessThanEqual'
| 'GreaterThanEqual'
| 'EqualsAny';
@ApiProperty({
description: 'Gets or sets the property.',
enum: [
'AudioChannels',
'AudioBitrate',
'AudioProfile',
'Width',
'Height',
'Has64BitOffsets',
'PacketLength',
'VideoBitDepth',
'VideoBitrate',
'VideoFramerate',
'VideoLevel',
'VideoProfile',
'VideoTimestamp',
'IsAnamorphic',
'RefFrames',
'NumAudioStreams',
'NumVideoStreams',
'IsSecondaryAudio',
'VideoCodecTag',
'IsAvc',
'IsInterlaced',
'AudioSampleRate',
'AudioBitDepth',
'VideoRangeType',
],
nullable: true,
required: false,
})
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'
| null;
@ApiProperty({
description: 'Gets or sets the value.',
nullable: true,
required: false,
})
Value?: string | null;
@ApiProperty({
description: 'Indicates if the condition is required.',
nullable: true,
required: false,
})
IsRequired?: boolean;
}
export class TranscodingProfileDto implements TranscodingProfile {
@ApiProperty({
description: 'Gets or sets the container.',
nullable: true,
required: false,
})
Container?: string;
@ApiProperty({
description: 'Gets or sets the DLNA profile type.',
enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'],
required: false,
})
Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric';
@ApiProperty({
description: 'Gets or sets the video codec.',
nullable: true,
required: false,
})
VideoCodec?: string;
@ApiProperty({
description: 'Gets or sets the audio codec.',
nullable: true,
required: false,
})
AudioCodec?: string;
@ApiProperty({
description: 'Media streaming protocol.',
enum: ['http', 'hls'],
required: false,
})
Protocol?: 'http' | 'hls';
@ApiProperty({
description: 'Indicates if the content length should be estimated.',
default: false,
required: false,
})
EstimateContentLength?: boolean;
@ApiProperty({
description: 'Indicates if M2TS mode is enabled.',
default: false,
required: false,
})
EnableMpegtsM2TsMode?: boolean;
@ApiProperty({
description: 'Gets or sets the transcoding seek info mode.',
default: 'Auto',
enum: ['Auto', 'Bytes'],
required: false,
})
TranscodeSeekInfo?: 'Auto' | 'Bytes';
@ApiProperty({
description: 'Indicates if timestamps should be copied.',
default: false,
required: false,
})
CopyTimestamps?: boolean;
@ApiProperty({
description: 'Gets or sets the encoding context.',
default: 'Streaming',
enum: ['Streaming', 'Static'],
required: false,
})
Context?: 'Streaming' | 'Static';
@ApiProperty({
description: 'Indicates if subtitles are allowed in the manifest.',
default: false,
required: false,
})
EnableSubtitlesInManifest?: boolean;
@ApiProperty({
description: 'Gets or sets the maximum audio channels.',
nullable: true,
required: false,
})
MaxAudioChannels?: string | null;
@ApiProperty({
description: 'Gets or sets the minimum amount of segments.',
format: 'int32',
default: 0,
required: false,
})
MinSegments?: number;
@ApiProperty({
description: 'Gets or sets the segment length.',
format: 'int32',
default: 0,
required: false,
})
SegmentLength?: number;
@ApiProperty({
description:
'Indicates if breaking the video stream on non-keyframes is supported.',
default: false,
required: false,
})
BreakOnNonKeyFrames?: boolean;
@ApiProperty({
description: 'Gets or sets the profile conditions.',
type: [ProfileConditionDto],
nullable: true,
required: false,
})
Conditions?: ProfileConditionDto[];
@ApiProperty({
description: 'Indicates if variable bitrate encoding is supported.',
default: true,
required: false,
})
EnableAudioVbrEncoding?: boolean;
}
export class ContainerProfileDto implements ContainerProfile {
@ApiProperty({
description:
'Gets or sets the MediaBrowser.Model.Dlna.DlnaProfileType which this container must meet.',
enum: ['Audio', 'Video', 'Photo', 'Subtitle', 'Lyric'],
nullable: true,
required: false,
})
Type?: 'Audio' | 'Video' | 'Photo' | 'Subtitle' | 'Lyric';
@ApiProperty({
description: 'Gets or sets the profile conditions.',
type: [ProfileConditionDto],
nullable: true,
required: false,
})
Conditions?: ProfileConditionDto[];
@ApiProperty({
description:
'Gets or sets the container(s) which this container must meet.',
nullable: true,
required: false,
})
Container?: string | null;
@ApiProperty({
description:
'Gets or sets the sub container(s) which this container must meet.',
nullable: true,
required: false,
})
SubContainer?: string | null;
}
export class CodecProfileDto implements CodecProfile {
@ApiProperty({
description:
'Gets or sets the MediaBrowser.Model.Dlna.CodecType which this container must meet.',
enum: ['Video', 'VideoAudio', 'Audio'],
nullable: true,
required: false,
})
Type?: 'Video' | 'VideoAudio' | 'Audio';
@ApiProperty({
description: 'Gets or sets the profile conditions.',
type: [ProfileConditionDto],
nullable: true,
required: false,
})
Conditions?: ProfileConditionDto[];
@ApiProperty({
description: 'Gets or sets the apply conditions if this profile is met.',
type: [ProfileConditionDto],
nullable: true,
required: false,
})
ApplyConditions?: ProfileConditionDto[];
@ApiProperty({
description: 'Gets or sets the codec(s) that this profile applies to.',
nullable: true,
required: false,
})
Codec?: string | null;
@ApiProperty({
description:
'Gets or sets the container(s) which this profile will be applied to.',
nullable: true,
required: false,
})
Container?: string | null;
@ApiProperty({
description:
'Gets or sets the sub-container(s) which this profile will be applied to.',
nullable: true,
required: false,
})
SubContainer?: string | null;
}
export class SubtitleProfileDto implements SubtitleProfile {
@ApiProperty({
description: 'Gets or sets the format.',
nullable: true,
required: false,
})
Format?: string | null;
@ApiProperty({
description: 'Gets or sets the delivery method.',
enum: ['Encode', 'Embed', 'External', 'Hls', 'Drop'],
nullable: true,
required: false,
})
Method?: 'Encode' | 'Embed' | 'External' | 'Hls' | 'Drop';
@ApiProperty({
description: 'Gets or sets the DIDL mode.',
nullable: true,
required: false,
})
DidlMode?: string | null;
@ApiProperty({
description: 'Gets or sets the language.',
nullable: true,
required: false,
})
Language?: string | null;
@ApiProperty({
description: 'Gets or sets the container.',
nullable: true,
required: false,
})
Container?: string | null;
}
/**
* A MediaBrowser.Model.Dlna.DeviceProfile represents a set of metadata which determines which content a certain device is able to play.
* <br />
* Specifically, it defines the supported <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.ContainerProfiles">containers</see> and
* <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.CodecProfiles">codecs</see> (video and/or audio, including codec profiles and levels)
* the device is able to direct play (without transcoding or remuxing),
* as well as which <see cref="P:MediaBrowser.Model.Dlna.DeviceProfile.TranscodingProfiles">containers/codecs to transcode to</see> in case it isn't.
*/
export class DeviceProfileDto implements DeviceProfile {
@ApiProperty({
description:
'Gets or sets the name of this device profile. User profiles must have a unique name.',
nullable: true,
required: false,
})
Name?: string | null;
@ApiProperty({
description: 'Gets or sets the unique internal identifier.',
format: 'uuid',
nullable: true,
required: false,
})
Id?: string | null;
@ApiProperty({
description:
'Gets or sets the maximum allowed bitrate for all streamed content.',
format: 'int32',
nullable: true,
required: false,
})
MaxStreamingBitrate?: number | null;
@ApiProperty({
description:
'Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files).',
format: 'int32',
nullable: true,
required: false,
})
MaxStaticBitrate?: number | null;
@ApiProperty({
description:
'Gets or sets the maximum allowed bitrate for transcoded music streams.',
format: 'int32',
nullable: true,
required: false,
})
MusicStreamingTranscodingBitrate?: number | null;
@ApiProperty({
description:
'Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files.',
format: 'int32',
nullable: true,
required: false,
})
MaxStaticMusicBitrate?: number | null;
@ApiProperty({
description: 'Gets or sets the direct play profiles.',
type: [DirectPlayProfileDto],
nullable: true,
required: false,
})
DirectPlayProfiles?: DirectPlayProfileDto[];
@ApiProperty({
description: 'Gets or sets the transcoding profiles.',
type: [TranscodingProfileDto],
nullable: true,
required: false,
})
TranscodingProfiles?: TranscodingProfileDto[];
@ApiProperty({
description: 'Gets or sets the container profiles.',
type: [ContainerProfileDto],
nullable: true,
required: false,
})
ContainerProfiles?: ContainerProfileDto[];
@ApiProperty({
description: 'Gets or sets the codec profiles.',
type: [CodecProfileDto],
nullable: true,
required: false,
})
CodecProfiles?: CodecProfileDto[];
@ApiProperty({
description: 'Gets or sets the subtitle profiles.',
type: [SubtitleProfileDto],
nullable: true,
required: false,
})
SubtitleProfiles?: SubtitleProfileDto[];
}

View File

@@ -0,0 +1,253 @@
import {
ApiProperty,
ApiPropertyOptional,
getSchemaPath,
} from '@nestjs/swagger';
import { DeviceProfileDto } from './device-profile.dto';
import {
AudioStream,
IndexItem,
PlaybackConfig,
SourceProviderSettings,
SourceProviderSettingsInput,
SourceProviderSettingsLink,
SourceProviderSettingsTemplate,
Quality,
Subtitles,
ValidationResponse,
StreamCandidate,
StreamProperty,
Stream,
} from '@aleksilassila/reiverr-plugin';
export class IndexItemDto implements IndexItem {
@ApiProperty()
id: string;
}
class PluginSettingsLinkDto implements SourceProviderSettingsLink {
@ApiProperty({ example: 'link', enum: ['link'] })
type: 'link';
@ApiProperty({ example: 'https://example.com' })
url: string;
@ApiProperty({ example: 'Example' })
label: string;
}
class PluginSettingsInputDto implements SourceProviderSettingsInput {
@ApiProperty({
example: 'string',
enum: ['string', 'number', 'boolean', 'password'],
})
type: 'string' | 'number' | 'boolean' | 'password';
@ApiProperty({ example: 'Example' })
label: string;
@ApiProperty({ example: 'Placeholder' })
placeholder: string;
}
export class PluginSettingsTemplateDto {
@ApiProperty({
example: {
setting1: 'string',
setting2: { type: 'link', url: 'https://example.com' },
},
type: 'object',
additionalProperties: {
oneOf: [
{ $ref: getSchemaPath(PluginSettingsInputDto) },
{ $ref: getSchemaPath(PluginSettingsLinkDto) },
],
},
})
settings: SourceProviderSettingsTemplate;
}
export class SourceProviderCapabilitiesDto {
@ApiProperty()
moviePlayback: boolean;
@ApiProperty()
episodePlayback: boolean;
@ApiProperty()
movieIndexing: boolean;
@ApiProperty()
episodeIndexing: boolean;
// @ApiProperty()
// requesting: boolean;
// @ApiProperty()
// deletion: boolean;
}
export class PluginSettingsDto {
@ApiProperty({
type: 'object',
additionalProperties: true, // Indicates that any properties are allowed
example: {
setting1: 'some value',
setting2: 12345,
setting3: true,
setting4: { nestedKey: 'nestedValue' },
},
})
settings: SourceProviderSettings;
}
export class ValidationResponseDto implements ValidationResponse {
@ApiProperty({ example: true })
isValid: boolean;
@ApiProperty({
example: {
setting1: 'error message',
setting2: 'another error message',
},
type: 'object',
additionalProperties: { type: 'string' },
})
errors: Record<string, string>;
@ApiProperty({
example: {
setting1: 'new value',
setting2: 'another new value',
},
type: 'object',
additionalProperties: true,
})
replace: Record<string, any>;
}
export class AudioStreamDto implements AudioStream {
@ApiProperty()
index: number;
@ApiProperty()
label: string;
@ApiProperty({ example: 'aac', required: false })
codec: string | undefined;
@ApiProperty({ example: 96_000, type: 'number', required: false })
bitrate: number | undefined;
}
export class QualityDto implements Quality {
@ApiProperty()
index: number;
@ApiProperty()
bitrate: number;
@ApiProperty()
label: string;
@ApiProperty({ required: false })
codec: string | undefined;
@ApiProperty()
original: boolean;
}
export class SubtitlesDto implements Subtitles {
@ApiProperty()
src: string;
@ApiProperty()
lang: string;
@ApiProperty({
type: 'string',
enum: ['subtitles', 'captions', 'descriptions'],
})
kind: 'subtitles' | 'captions' | 'descriptions';
@ApiProperty()
label: string;
}
export class VideoStreamPropertyDto implements StreamProperty {
@ApiProperty()
label: string;
@ApiProperty({
oneOf: [{ type: 'string' }, { type: 'number' }],
})
value: string | number;
@ApiProperty({ required: false })
formatted: string | undefined;
}
export class StreamCandidateDto implements StreamCandidate {
@ApiProperty()
key: string;
@ApiProperty()
title: string;
@ApiProperty({ type: [VideoStreamPropertyDto] })
properties: VideoStreamPropertyDto[];
}
export class StreamDto extends StreamCandidateDto implements Stream {
@ApiProperty()
src: string;
@ApiProperty()
directPlay: boolean;
@ApiProperty({ description: 'Duration in seconds' })
duration: number;
@ApiProperty({ description: 'Play progress as a number between 0 and 1' })
progress: number;
@ApiProperty({ type: [AudioStreamDto] })
audioStreams: AudioStreamDto[];
@ApiProperty()
audioStreamIndex: number;
@ApiProperty({ type: [QualityDto] })
qualities: QualityDto[];
@ApiProperty()
qualityIndex: number;
@ApiProperty({ type: [SubtitlesDto] })
subtitles: SubtitlesDto[];
}
export class PlaybackConfigDto implements PlaybackConfig {
@ApiPropertyOptional({ example: 0, required: false })
bitrate: number | undefined;
@ApiPropertyOptional({ example: 0, required: false })
audioStreamIndex: number | undefined;
@ApiPropertyOptional({ example: 0, required: false })
progress: number | undefined;
@ApiPropertyOptional({
example: 'en',
required: false,
type: DeviceProfileDto,
})
deviceProfile: DeviceProfileDto | undefined;
@ApiPropertyOptional({ example: 'en', required: false })
defaultLanguage: string | undefined;
}
export class StreamCandidatesDto {
@ApiProperty({
type: [StreamCandidateDto],
})
candidates: StreamCandidateDto[];
}

View File

@@ -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<PluginSettingsTemplateDto> {
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<ValidationResponseDto> {
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<SourceProviderCapabilitiesDto> {
// 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,
};
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { PluginProvider, SourceProvider } from '@aleksilassila/reiverr-plugin';
@Injectable()
export class SourceProvidersService {
private providers: Record<string, SourceProvider> = {};
constructor() {
console.log('Loading source plugins...');
this.providers = this.loadPlugins(
path.join(require.main.path, '..', '..', 'plugins'),
);
console.log(
`Loaded source plugins: ${Object.keys(this.providers).join(', ')}`,
);
}
async getProviders(): Promise<Record<string, SourceProvider>> {
return this.providers;
}
private loadPlugins(rootDirectory: string): Record<string, SourceProvider> {
const pluginDirectories = fs.readdirSync(rootDirectory);
const pluginPaths = [];
for (const directoryName of pluginDirectories) {
const directoryPath = path.join(rootDirectory, directoryName);
const directoryStat = fs.statSync(directoryPath);
if (directoryStat.isDirectory() && directoryName.endsWith('.plugin')) {
pluginPaths.push(directoryPath);
}
}
const plugins: Record<string, SourceProvider> = {};
for (const pluginPath of pluginPaths) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pluginModule = require(pluginPath);
const provider: typeof PluginProvider = pluginModule.default;
provider.getPlugins().forEach((plugin) => {
plugins[plugin.name] = plugin;
});
} catch (e) {
console.error(`Failed to load plugin from ${pluginPath}: ${e}`);
}
}
return plugins;
}
getProvider(pluginName: string): SourceProvider | undefined {
return this.providers[pluginName];
}
}