diff --git a/backend/package-lock.json b/backend/package-lock.json index b04a661..bfc580e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10577,7 +10577,7 @@ }, "packages/reiverr-plugin": { "name": "@aleksilassila/reiverr-plugin", - "version": "2.1.0", + "version": "3.0.0", "license": "ISC", "devDependencies": {} }, diff --git a/backend/package.json b/backend/package.json index e98422e..3ae4811 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,9 +18,10 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "publish:patch": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false patch && npm run -w packages/reiverr-plugin build && npm publish -w packages/reiverr-plugin", - "publish:minor": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false minor && npm run -w packages/reiverr-plugin build && npm publish -w packages/reiverr-plugin", - "publish:major": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false major && npm run -w packages/reiverr-plugin build && npm publish -w packages/reiverr-plugin", + "plugin-api:version:patch": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false patch && npm run -w packages/reiverr-plugin build && npm i", + "plugin-api:version:minor": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false minor && npm run -w packages/reiverr-plugin build && npm i", + "plugin-api:version:major": "npm version -w packages/reiverr-plugin --git-tag-version false --workspaces-update false major && npm run -w packages/reiverr-plugin build && npm i", + "plugin-api:publish": "npm publish -w packages/reiverr-plugin", "openapi:generate:tmdb": "ts-node scripts/generate-tmdb-openapi.ts", "openapi:generate-spec": "ts-node scripts/generate-openapi-spec.ts", "typeorm": "ts-node ./node_modules/typeorm/cli", @@ -100,4 +101,4 @@ "packages/jellyfin.plugin", "packages/torrent-stream.plugin" ] -} +} \ No newline at end of file diff --git a/backend/packages/jellyfin.plugin/src/index.ts b/backend/packages/jellyfin.plugin/src/index.ts index 438f49f..ebeef36 100644 --- a/backend/packages/jellyfin.plugin/src/index.ts +++ b/backend/packages/jellyfin.plugin/src/index.ts @@ -1,4 +1,3 @@ -import { BaseItemKind, ItemFields } from './jellyfin.openapi'; import { EpisodeMetadata, IndexItem, @@ -16,11 +15,8 @@ import { UserContext, } from '@aleksilassila/reiverr-plugin'; import { Readable } from 'stream'; -import { - JellyfinSettings, - JellyfinUserContext, - PluginContext, -} from './plugin-context'; +import { BaseItemKind, ItemFields } from './jellyfin.openapi'; +import { JellyfinSettings, PluginContext } from './plugin-context'; import { JellyfinSettingsManager } from './settings'; import { bitrateQualities, diff --git a/backend/packages/jellyfin.plugin/src/settings.ts b/backend/packages/jellyfin.plugin/src/settings.ts index ae33197..bad9afd 100644 --- a/backend/packages/jellyfin.plugin/src/settings.ts +++ b/backend/packages/jellyfin.plugin/src/settings.ts @@ -35,7 +35,7 @@ export class JellyfinSettingsManager extends SettingsManager { if (isValid) { const context = new PluginContext(settings as any); let [user, err] = await context.api.users - .getUserById(settings.userId) + .getUserById(settings.userId, { timeout: 5000 }) .then((res) => [res.data, undefined]) .catch((err) => [undefined, err.message]); @@ -64,7 +64,7 @@ export class JellyfinSettingsManager extends SettingsManager { return { isValid, errors, - replace, + settings: { ...settings, ...replace }, }; }; @@ -73,16 +73,19 @@ export class JellyfinSettingsManager extends SettingsManager { type: 'string', label: 'Base URL', placeholder: 'http://localhost:8096', + required: true, }, apiKey: { type: 'password', label: 'API Key', placeholder: '', + required: true, }, userId: { type: 'string', label: 'Username or User ID', placeholder: 'username or user id', + required: true, }, }); } diff --git a/backend/packages/reiverr-plugin/package.json b/backend/packages/reiverr-plugin/package.json index 7978d54..be8c127 100644 --- a/backend/packages/reiverr-plugin/package.json +++ b/backend/packages/reiverr-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@aleksilassila/reiverr-plugin", - "version": "2.1.0", + "version": "3.0.0", "main": "dist/src/index", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/backend/packages/reiverr-plugin/src/plugin.ts b/backend/packages/reiverr-plugin/src/plugin.ts index 4799551..57642d1 100644 --- a/backend/packages/reiverr-plugin/src/plugin.ts +++ b/backend/packages/reiverr-plugin/src/plugin.ts @@ -36,7 +36,7 @@ export class SettingsManager { ) => Promise = async () => ({ isValid: true, errors: {}, - replace: {}, + settings: {}, }); } diff --git a/backend/packages/reiverr-plugin/src/types.ts b/backend/packages/reiverr-plugin/src/types.ts index 84810d1..4a3543b 100644 --- a/backend/packages/reiverr-plugin/src/types.ts +++ b/backend/packages/reiverr-plugin/src/types.ts @@ -4,13 +4,17 @@ export enum SourceProviderError { StreamNotFound = 'StreamNotFound', } -export type SourceProviderSettingsLink = { +export type SourceProviderSetting = { + required?: boolean; +}; + +export type SourceProviderSettingsLink = SourceProviderSetting & { type: 'link'; url: string; label: string; }; -export type SourceProviderSettingsInput = { +export type SourceProviderSettingsInput = SourceProviderSetting & { type: 'string' | 'number' | 'boolean' | 'password'; label: string; placeholder: string; @@ -55,7 +59,7 @@ export type SourceProviderSettings = Record; export type ValidationResponse = { isValid: boolean; errors: Record; - replace: Record; + settings: Record; }; export type AudioStream = { diff --git a/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts b/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts index 977e882..754a945 100644 --- a/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts +++ b/backend/packages/torrent-stream.plugin/src/lib/jackett.api.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { XMLParser } from 'fast-xml-parser'; import { StreamCandidate } from '@aleksilassila/reiverr-plugin'; import { TorrentSettings } from '../types'; @@ -194,3 +194,24 @@ export function getStreamCandidates( }; }); } + +export async function testConnection(settings: TorrentSettings) { + return axios + .get(`/api`, { + baseURL: settings.baseUrl, + params: { + apikey: settings.apiKey, + }, + timeout: 5000, + }) + .then((res) => { + if (res.status >= 400) { + return 'Could not connect to Jackett: ' + res.statusText; + } + + const data = jackettXmlParser.parse(res.data); + if (data.error) { + return data.error['@_description'] || 'Unknown error'; + } + }); +} diff --git a/backend/packages/torrent-stream.plugin/src/settings.ts b/backend/packages/torrent-stream.plugin/src/settings.ts index bafbc79..9f22e63 100644 --- a/backend/packages/torrent-stream.plugin/src/settings.ts +++ b/backend/packages/torrent-stream.plugin/src/settings.ts @@ -1,20 +1,61 @@ import { SettingsManager, SourceProviderSettingsTemplate, + ValidationResponse, } from '@aleksilassila/reiverr-plugin'; +import { testConnection } from './lib/jackett.api'; export class TorrentSettingsManager extends SettingsManager { + validateSettings = async ( + settings: Record, + ): Promise => { + const { baseUrl, apiKey } = settings; + let isValid = true; + const errors = { + baseUrl: '', + apiKey: '', + }; + + if (!baseUrl) { + isValid = false; + errors.baseUrl = 'Base URL is required'; + } + + if (!apiKey) { + isValid = false; + errors.apiKey = 'API Key is required'; + } + + if (isValid) { + await testConnection({ baseUrl, apiKey }) + .then((err) => { + if (err) { + isValid = false; + errors.apiKey = err; + } + }) + .catch((e) => { + isValid = false; + errors.baseUrl = e.message ?? 'Invalid URL'; + }); + } + + return { isValid, errors, settings }; + }; + getSettingsTemplate = (): SourceProviderSettingsTemplate => ({ baseUrl: { type: 'string', label: 'Jackett URL', placeholder: 'http://127.0.0.1:9117/api/v2.0/indexers/indexer/results/torznab/', + required: true, }, apiKey: { type: 'password', label: 'Jackett API Key', placeholder: '', + required: true, }, }); } diff --git a/backend/src/media-sources/media-source.dto.ts b/backend/src/media-sources/media-source.dto.ts index 934a96c..8384648 100644 --- a/backend/src/media-sources/media-source.dto.ts +++ b/backend/src/media-sources/media-source.dto.ts @@ -1,10 +1,19 @@ -import { OmitType, PartialType } from '@nestjs/swagger'; +import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; import { PickAndPartial } from 'src/common/common.dto'; import { MediaSource } from './media-source.entity'; +import { ValidationResponseDto } from 'src/source-providers/source-provider.dto'; export class MediaSourceDto extends PickAndPartial( MediaSource, - ['id', 'pluginId', 'name', 'userId', 'adminControlled', 'enabled'], + [ + 'id', + 'pluginId', + 'name', + 'userId', + 'adminControlled', + 'enabled', + 'priority', + ], ['pluginSettings'], ) {} @@ -23,3 +32,11 @@ export class CreateMediaSourceDto extends OmitType(MediaSourceDto, [ 'id', 'userId', ]) {} + +export class UpdateMediaSourceResponse { + @ApiProperty({ type: MediaSourceDto }) + mediaSource: MediaSourceDto; + + @ApiProperty({ type: ValidationResponseDto, required: false }) + validationResponse: ValidationResponseDto | undefined; +} diff --git a/backend/src/media-sources/media-sources.controller.ts b/backend/src/media-sources/media-sources.controller.ts index bec3c82..10b1d07 100644 --- a/backend/src/media-sources/media-sources.controller.ts +++ b/backend/src/media-sources/media-sources.controller.ts @@ -69,9 +69,8 @@ export class ServiceOwnershipValidator implements CanActivate { if (!sourceId) return true; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -303,9 +302,8 @@ export class MediaSourcesController { @GetAuthToken() token: string, ) { const sourceId = params.sourceId; - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource) throw new NotFoundException('Source not found'); @@ -370,9 +368,8 @@ export class MediaSourcesController { } async getConnection(sourceId: string) { - const mediaSource = await this.mediaSourcesService.findMediaSource( - sourceId, - ); + const mediaSource = + await this.mediaSourcesService.findMediaSource(sourceId); if (!mediaSource.pluginId || !mediaSource.enabled) { throw new BadRequestException('Source not configured'); diff --git a/backend/src/media-sources/media-sources.service.ts b/backend/src/media-sources/media-sources.service.ts index b232806..a393133 100644 --- a/backend/src/media-sources/media-sources.service.ts +++ b/backend/src/media-sources/media-sources.service.ts @@ -6,6 +6,7 @@ import { UpdateOrCreateMediaSourceDto } from './media-source.dto'; import { MediaSource } from './media-source.entity'; import { MEIDA_SOURCE_REPOSITORY } from './media-source.providers'; import { SourceProvidersService } from 'src/source-providers/source-providers.service'; +import { ValidationResponse } from '@aleksilassila/reiverr-plugin'; export enum MediaSourcesServiceError { SourceNotFound = 'SourceNotFound', @@ -58,7 +59,7 @@ export class MediaSourcesService { user: User, sourceDto: UpdateOrCreateMediaSourceDto, callerUser: User = user, - ): Promise { + ) { if (!callerUser.isAdmin || callerUser.id !== user.id) { throw MediaSourcesServiceError.Unauthorized; } @@ -88,18 +89,21 @@ export class MediaSourcesService { source.adminControlled = sourceDto.adminControlled ?? source.adminControlled; + let validationResponse: ValidationResponse | undefined; if (sourceDto.pluginSettings !== undefined) { let valid = false; const provider = this.sourceProvidersService.getProvider(source.pluginId); if (provider) { - const validationRes = await provider.settingsManager.validateSettings( + validationResponse = await provider.settingsManager.validateSettings( sourceDto.pluginSettings, ); - valid = validationRes.isValid; + valid = validationResponse.isValid; + source.pluginSettings = validationResponse.settings; + } else { + source.pluginSettings = sourceDto.pluginSettings; } - source.pluginSettings = sourceDto.pluginSettings; source.enabled = !!valid; } source.name = sourceDto.name ?? source.name; @@ -120,8 +124,10 @@ export class MediaSourcesService { priority++; } - await this.mediaSourceRepository.save(source); - return this.usersService.findOne(user.id); + return { + mediaSource: await this.mediaSourceRepository.save(source), + validationResponse, + }; } getMediaSourceSettings(user: User, sourceId: string) { diff --git a/backend/src/media-sources/media-sources.settings.controller.ts b/backend/src/media-sources/media-sources.settings.controller.ts index 42c1f5b..748e631 100644 --- a/backend/src/media-sources/media-sources.settings.controller.ts +++ b/backend/src/media-sources/media-sources.settings.controller.ts @@ -14,11 +14,15 @@ 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 { + UpdateMediaSourceResponse, + UpdateOrCreateMediaSourceDto, +} from './media-source.dto'; import { MediaSourcesService, MediaSourcesServiceError, } from './media-sources.service'; +import { MediaSource } from './media-source.entity'; @ApiTags('users') @Controller('users/:userId/sources') @@ -30,33 +34,40 @@ export class MediaSourcesSettingsController { ) {} @Put() - @ApiOkResponse({ description: 'Source updated', type: UserDto }) + @ApiOkResponse({ + description: 'Source updated', + type: UpdateMediaSourceResponse, + }) async updateSource( @GetAuthUser() callerUser: User, @Param('userId') userId: string, @Body() sourceDto: UpdateOrCreateMediaSourceDto, - ): Promise { + ): Promise { const user = await this.usersService.findOne(userId); if (!user) { throw new NotFoundException('User not found'); } - const updatedUser = await this.mediaSourcesService - .updateOrCreateMediaSource(user, sourceDto, callerUser) - .catch((e) => { - if (e === MediaSourcesServiceError.Unauthorized) { - throw new UnauthorizedException(); - } else { - throw new InternalServerErrorException('Failed to update source'); - } - }); + const { mediaSource: updatedSource, validationResponse } = + await this.mediaSourcesService + .updateOrCreateMediaSource(user, sourceDto, callerUser) + .catch((e) => { + if (e === MediaSourcesServiceError.Unauthorized) { + throw new UnauthorizedException(); + } else { + throw new InternalServerErrorException('Failed to update source'); + } + }); - if (!updatedUser) { + if (!updatedSource) { throw new InternalServerErrorException('Failed to update source'); } - return UserDto.fromEntity(updatedUser); + return { + mediaSource: updatedSource, + validationResponse, + }; } @Delete(':sourceId') diff --git a/backend/src/source-providers/source-provider.dto.ts b/backend/src/source-providers/source-provider.dto.ts index 32a367b..e5c6f62 100644 --- a/backend/src/source-providers/source-provider.dto.ts +++ b/backend/src/source-providers/source-provider.dto.ts @@ -123,7 +123,7 @@ export class ValidationResponseDto implements ValidationResponse { type: 'object', additionalProperties: true, }) - replace: Record; + settings: Record; } export class AudioStreamDto implements AudioStream { diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index 5fcce97..c64ab47 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -169,7 +169,7 @@ export interface ValidationResponseDto { /** @example {"setting1":"error message","setting2":"another error message"} */ errors: Record; /** @example {"setting1":"new value","setting2":"another new value"} */ - replace: Record; + settings: Record; } export interface SourceProviderCapabilitiesDto { @@ -464,6 +464,24 @@ export interface UpdateOrCreateMediaSourceDto { priority?: number; } +export interface MediaSourceDto { + id: string; + pluginId: string; + name: string; + userId: string; + /** @default false */ + enabled?: boolean; + /** @default false */ + adminControlled?: boolean; + priority: number; + pluginSettings?: Record; +} + +export interface UpdateMediaSourceResponse { + mediaSource: MediaSourceDto; + validationResponse?: ValidationResponseDto; +} + export interface MovieUserDataDto { tmdbId: string; inLibrary: boolean; @@ -821,7 +839,7 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/api/users/${userId}/sources`, method: 'PUT', body: data, diff --git a/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte b/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte index a2462f6..1063c06 100644 --- a/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte +++ b/src/lib/pages/ManagePage/EditMediaSourceDialog.ManagePage.svelte @@ -15,6 +15,7 @@ createErrorNotification, createInfoNotification } from '$lib/components/Notifications/notification.store'; + import { sources } from '$lib/stores/sources.store'; export let modalId: symbol; @@ -55,41 +56,50 @@ async function handleSave() { const source = await mediaSource; - const res = await validateForm(); - const replacedSettings = { - ...get(settings) - }; - if (res?.replace) { - Object.keys(res?.replace).forEach((key) => { - replacedSettings[key] = res?.replace[key]; - }); - } - const updatedSource = await reiverrApiNew.users - .updateSource(get(user)?.id || '', { - id: sourceId, - name: $name, - pluginId: source.pluginId, - pluginSettings: replacedSettings - }) - .then((r) => r.data) - .then((user) => user.mediaSources?.find((s) => s.id === sourceId)); - if (updatedSource?.enabled) { - createInfoNotification('Media source updated', `${updatedSource.name} has been enabled`); + const updateResponse = await sources.updateSource({ + ...source, + name: $name, + pluginSettings: get(settings) + }); + + if (updateResponse?.validationResponse?.isValid) { + createInfoNotification( + 'Media source updated', + `${updateResponse.mediaSource.name} has been enabled` + ); + modalStack.close(modalId); } else { createErrorNotification( 'Incomplete configuration', - `${updatedSource?.name} has been disabled` + `${updateResponse.mediaSource.name} has been disabled` ); + validationResponse = updateResponse.validationResponse; } - await mediaSourcesDataStore.refresh(); - modalStack.close(modalId); + + // const updatedSource = await reiverrApiNew.users + // .updateSource(get(user)?.id || '', { + // id: sourceId, + // name: $name, + // pluginId: source.pluginId, + // pluginSettings: get(settings) + // }) + // .then((r) => r.data) + // .then((user) => user.mediaSources?.find((s) => s.id === sourceId)); + // if (updatedSource?.enabled) { + // createInfoNotification('Media source updated', `${updatedSource.name} has been enabled`); + // modalStack.close(modalId); + // } else { + // createErrorNotification( + // 'Incomplete configuration', + // `${updatedSource?.name} has been disabled` + // ); + // } + // await mediaSourcesDataStore.refresh(); } async function handleRemovePlugin() { - await mediaSource.then((s) => reiverrApiNew.users.deleteSource(s.id, get(user)?.id || '')); - await user.refreshUser(); - await mediaSourcesDataStore.refresh(); + await sources.deleteSource(sourceId); modalStack.close(modalId); } @@ -116,7 +126,6 @@ on:change={({ detail }) => settings.update((s) => ({ ...s, [key]: detail }))} value={$settings[key] || ''} placeholder={template.placeholder} - on:blur={() => stale && validateForm()} > {template.label} diff --git a/src/lib/pages/ManagePage/ManagePage.svelte b/src/lib/pages/ManagePage/ManagePage.svelte index 8ccf17c..ec7658b 100644 --- a/src/lib/pages/ManagePage/ManagePage.svelte +++ b/src/lib/pages/ManagePage/ManagePage.svelte @@ -18,7 +18,7 @@ import { localSettings } from '../../stores/localstorage.store'; import { sessions } from '../../stores/session.store'; import { reiverrApiNew, user } from '../../stores/user.store'; - import Plugins from './MediaSources.ManagePage.svelte'; + import MediaSources from './MediaSources.ManagePage.svelte'; enum Tabs { Account, @@ -208,7 +208,7 @@ - +

Integrations

diff --git a/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte b/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte index 0c6a8d6..e3951a9 100644 --- a/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte +++ b/src/lib/pages/ManagePage/MediaSourceButton.ManagePage.svelte @@ -1,12 +1,12 @@ @@ -38,20 +24,22 @@

- {#await $userSources then userSources} - {#each userSources as source} + {#if $isLoading} +

Loading...

+ {:else} + {#each $userSources as source} {/each} - {/await} + {/if}
- {#await allPlugins then availablePlugins} + {#await allProviders then availablePlugins}