parent
094df71301
commit
591b569bdd
@ -0,0 +1,40 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
import { updateItem } from 'Store/Actions/baseActions';
|
||||
|
||||
type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
||||
|
||||
export const useLookupSeries = (query: string) => {
|
||||
return useApiQuery<AddSeries[]>({
|
||||
path: `/series/lookup?term=${query}`,
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddSeries = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onAddSuccess = useCallback(
|
||||
(data: Series) => {
|
||||
dispatch(updateItem({ section: 'series', ...data }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return useApiMutation<Series, AddSeriesPayload>({
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
});
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
@ -0,0 +1,49 @@
|
||||
import { createPersist } from 'Helpers/createPersist';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
||||
'add_series_options',
|
||||
() => {
|
||||
return {
|
||||
rootFolderPath: '',
|
||||
monitor: 'all',
|
||||
qualityProfileId: 0,
|
||||
seriesType: 'standard',
|
||||
seasonFolder: true,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const useAddSeriesOptions = () => {
|
||||
return addSeriesOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K
|
||||
) => {
|
||||
return addSeriesOptionsStore((state) => state[key]);
|
||||
};
|
||||
|
||||
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K,
|
||||
value: AddSeriesOptions[K]
|
||||
) => {
|
||||
addSeriesOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
@ -1,25 +0,0 @@
|
||||
import AppSectionState, { Error } from 'App/State/AppSectionState';
|
||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
interface AddSeriesAppState extends AppSectionState<AddSeries> {
|
||||
isAdding: boolean;
|
||||
isAdded: boolean;
|
||||
addError: Error | undefined;
|
||||
|
||||
defaults: {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
tags: number[];
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default AddSeriesAppState;
|
@ -0,0 +1,34 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import fetchJson, {
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface MutationOptions<T, TData>
|
||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||
method: 'POST' | 'PUT' | 'DELETE';
|
||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||
}
|
||||
|
||||
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
const requestOptions = useMemo(() => {
|
||||
return {
|
||||
...options,
|
||||
path: apiRoot + options.path,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useMutation<T, Error, TData>({
|
||||
...options.mutationOptions,
|
||||
mutationFn: async (data: TData) =>
|
||||
fetchJson<T, TData>({ ...requestOptions, body: data }),
|
||||
});
|
||||
}
|
||||
|
||||
export default useApiMutation;
|
@ -0,0 +1,20 @@
|
||||
import { create, type StateCreator } from 'zustand';
|
||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||
|
||||
export const createPersist = <T>(
|
||||
name: string,
|
||||
state: StateCreator<T>,
|
||||
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
|
||||
) => {
|
||||
const instanceName =
|
||||
window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') ?? 'sonarr';
|
||||
|
||||
const finalName = `${instanceName}_${name}`;
|
||||
|
||||
return create(
|
||||
persist<T>(state, {
|
||||
...options,
|
||||
name: finalName,
|
||||
})
|
||||
);
|
||||
};
|
@ -1,183 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import getNewSeries from 'Utilities/Series/getNewSeries';
|
||||
import monitorOptions from 'Utilities/Series/monitorOptions';
|
||||
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import { set, update, updateItem } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'addSeries';
|
||||
let abortCurrentRequest = null;
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isAdding: false,
|
||||
isAdded: false,
|
||||
addError: null,
|
||||
items: [],
|
||||
|
||||
defaults: {
|
||||
rootFolderPath: '',
|
||||
monitor: monitorOptions[0].key,
|
||||
qualityProfileId: 0,
|
||||
seriesType: seriesTypes.STANDARD,
|
||||
seasonFolder: true,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: []
|
||||
}
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'addSeries.defaults'
|
||||
];
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const LOOKUP_SERIES = 'addSeries/lookupSeries';
|
||||
export const ADD_SERIES = 'addSeries/addSeries';
|
||||
export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue';
|
||||
export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries';
|
||||
export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const lookupSeries = createThunk(LOOKUP_SERIES);
|
||||
export const addSeries = createThunk(ADD_SERIES);
|
||||
export const clearAddSeries = createAction(CLEAR_ADD_SERIES);
|
||||
export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT);
|
||||
|
||||
export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => {
|
||||
return {
|
||||
section,
|
||||
...payload
|
||||
};
|
||||
});
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
|
||||
[LOOKUP_SERIES]: function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isFetching: true }));
|
||||
|
||||
if (abortCurrentRequest) {
|
||||
abortCurrentRequest();
|
||||
}
|
||||
|
||||
const { request, abortRequest } = createAjaxRequest({
|
||||
url: '/series/lookup',
|
||||
data: {
|
||||
term: payload.term
|
||||
}
|
||||
});
|
||||
|
||||
abortCurrentRequest = abortRequest;
|
||||
|
||||
request.done((data) => {
|
||||
dispatch(batchActions([
|
||||
update({ section, data }),
|
||||
|
||||
set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
request.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: xhr.aborted ? null : xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[ADD_SERIES]: function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isAdding: true }));
|
||||
|
||||
const tvdbId = payload.tvdbId;
|
||||
const items = getState().addSeries.items;
|
||||
const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload);
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/series',
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(newSeries)
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
updateItem({ section: 'series', ...data }),
|
||||
|
||||
set({
|
||||
section,
|
||||
isAdding: false,
|
||||
isAdded: true,
|
||||
addError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isAdding: false,
|
||||
isAdded: false,
|
||||
addError: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section),
|
||||
|
||||
[SET_ADD_SERIES_DEFAULT]: function(state, { payload }) {
|
||||
const newState = getSectionState(state, section);
|
||||
|
||||
newState.defaults = {
|
||||
...newState.defaults,
|
||||
...payload
|
||||
};
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
},
|
||||
|
||||
[CLEAR_ADD_SERIES]: function(state) {
|
||||
const {
|
||||
defaults,
|
||||
...otherDefaultState
|
||||
} = defaultState;
|
||||
|
||||
return Object.assign({}, state, otherDefaultState);
|
||||
}
|
||||
|
||||
}, defaultState, section);
|
@ -0,0 +1,24 @@
|
||||
const anySignal = (
|
||||
...signals: (AbortSignal | null | undefined)[]
|
||||
): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
|
||||
for (const signal of signals.filter(Boolean) as AbortSignal[]) {
|
||||
if (signal.aborted) {
|
||||
// Break early if one of the signals is already aborted.
|
||||
controller.abort();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Listen for abort events on the provided signals and abort the controller.
|
||||
// Automatically removes listeners when the controller is aborted.
|
||||
signal.addEventListener('abort', () => controller.abort(signal.reason), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
export default anySignal;
|
@ -0,0 +1,86 @@
|
||||
import anySignal from './anySignal';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public statusCode: number;
|
||||
public statusText: string;
|
||||
public statusBody?: ApiErrorResponse;
|
||||
|
||||
public constructor(
|
||||
path: string,
|
||||
statusCode: number,
|
||||
statusText: string,
|
||||
statusBody?: ApiErrorResponse
|
||||
) {
|
||||
super(`Request Error: (${statusCode}) ${path}`);
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.statusText = statusText;
|
||||
this.statusBody = statusBody;
|
||||
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
message: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface FetchJsonOptions<TData> extends Omit<RequestInit, 'body'> {
|
||||
path: string;
|
||||
headers?: HeadersInit;
|
||||
body?: TData;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
|
||||
|
||||
async function fetchJson<T, TData>({
|
||||
body,
|
||||
path,
|
||||
signal,
|
||||
timeout,
|
||||
...options
|
||||
}: FetchJsonOptions<TData>): Promise<T> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
if (timeout) {
|
||||
timeoutID = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: anySignal(abortController.signal, signal),
|
||||
});
|
||||
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line init-declarations
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = (await response.json()) as ApiErrorResponse;
|
||||
} catch {
|
||||
throw new ApiError(path, response.status, response.statusText);
|
||||
}
|
||||
|
||||
throw new ApiError(path, response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
}
|
||||
|
||||
export default fetchJson;
|
@ -0,0 +1,374 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DataAugmentation.Scene;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Datastore.Events;
|
||||
using NzbDrone.Core.MediaCover;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.MediaFiles.Events;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.RootFolders;
|
||||
using NzbDrone.Core.SeriesStats;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Core.Tv.Commands;
|
||||
using NzbDrone.Core.Tv.Events;
|
||||
using NzbDrone.Core.Validation;
|
||||
using NzbDrone.Core.Validation.Paths;
|
||||
using NzbDrone.SignalR;
|
||||
using Sonarr.Http;
|
||||
using Sonarr.Http.Extensions;
|
||||
using Sonarr.Http.REST;
|
||||
using Sonarr.Http.REST.Attributes;
|
||||
|
||||
namespace Sonarr.Api.V5.Series
|
||||
{
|
||||
[V5ApiController]
|
||||
public class SeriesController : RestControllerWithSignalR<SeriesResource, NzbDrone.Core.Tv.Series>,
|
||||
IHandle<EpisodeImportedEvent>,
|
||||
IHandle<EpisodeFileDeletedEvent>,
|
||||
IHandle<SeriesUpdatedEvent>,
|
||||
IHandle<SeriesEditedEvent>,
|
||||
IHandle<SeriesDeletedEvent>,
|
||||
IHandle<SeriesRenamedEvent>,
|
||||
IHandle<SeriesBulkEditedEvent>,
|
||||
IHandle<MediaCoversUpdatedEvent>
|
||||
{
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly IAddSeriesService _addSeriesService;
|
||||
private readonly ISeriesStatisticsService _seriesStatisticsService;
|
||||
private readonly ISceneMappingService _sceneMappingService;
|
||||
private readonly IMapCoversToLocal _coverMapper;
|
||||
private readonly IManageCommandQueue _commandQueueManager;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
|
||||
public SeriesController(IBroadcastSignalRMessage signalRBroadcaster,
|
||||
ISeriesService seriesService,
|
||||
IAddSeriesService addSeriesService,
|
||||
ISeriesStatisticsService seriesStatisticsService,
|
||||
ISceneMappingService sceneMappingService,
|
||||
IMapCoversToLocal coverMapper,
|
||||
IManageCommandQueue commandQueueManager,
|
||||
IRootFolderService rootFolderService,
|
||||
RootFolderValidator rootFolderValidator,
|
||||
MappedNetworkDriveValidator mappedNetworkDriveValidator,
|
||||
SeriesPathValidator seriesPathValidator,
|
||||
SeriesExistsValidator seriesExistsValidator,
|
||||
SeriesAncestorValidator seriesAncestorValidator,
|
||||
SystemFolderValidator systemFolderValidator,
|
||||
QualityProfileExistsValidator qualityProfileExistsValidator,
|
||||
RootFolderExistsValidator rootFolderExistsValidator,
|
||||
SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator)
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
_seriesService = seriesService;
|
||||
_addSeriesService = addSeriesService;
|
||||
_seriesStatisticsService = seriesStatisticsService;
|
||||
_sceneMappingService = sceneMappingService;
|
||||
|
||||
_coverMapper = coverMapper;
|
||||
_commandQueueManager = commandQueueManager;
|
||||
_rootFolderService = rootFolderService;
|
||||
|
||||
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderValidator)
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(seriesPathValidator)
|
||||
.SetValidator(seriesAncestorValidator)
|
||||
.SetValidator(systemFolderValidator)
|
||||
.When(s => s.Path.IsNotNullOrWhiteSpace());
|
||||
|
||||
PostValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.When(s => s.RootFolderPath.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderExistsValidator)
|
||||
.SetValidator(seriesFolderAsRootFolderValidator)
|
||||
.When(s => s.Path.IsNullOrWhiteSpace());
|
||||
|
||||
PutValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath();
|
||||
|
||||
SharedValidator.RuleFor(s => s.QualityProfileId).Cascade(CascadeMode.Stop)
|
||||
.ValidId()
|
||||
.SetValidator(qualityProfileExistsValidator);
|
||||
|
||||
PostValidator.RuleFor(s => s.Title).NotEmpty();
|
||||
PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Produces("application/json")]
|
||||
public List<SeriesResource> AllSeries(int? tvdbId, bool includeSeasonImages = false)
|
||||
{
|
||||
var seriesStats = _seriesStatisticsService.SeriesStatistics();
|
||||
var seriesResources = new List<SeriesResource>();
|
||||
|
||||
if (tvdbId.HasValue)
|
||||
{
|
||||
seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value).ToResource(includeSeasonImages));
|
||||
}
|
||||
else
|
||||
{
|
||||
seriesResources.AddRange(_seriesService.GetAllSeries().Select(s => s.ToResource(includeSeasonImages)));
|
||||
}
|
||||
|
||||
MapCoversToLocal(seriesResources.ToArray());
|
||||
LinkSeriesStatistics(seriesResources, seriesStats.ToDictionary(x => x.SeriesId));
|
||||
PopulateAlternateTitles(seriesResources);
|
||||
seriesResources.ForEach(LinkRootFolderPath);
|
||||
|
||||
return seriesResources;
|
||||
}
|
||||
|
||||
[RestGetById]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [FromQuery]bool includeSeasonImages = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var series = GetSeriesResourceById(id, includeSeasonImages);
|
||||
|
||||
return series == null ? NotFound() : series;
|
||||
}
|
||||
catch (ModelNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
protected override SeriesResource? GetResourceById(int id)
|
||||
{
|
||||
var includeSeasonImages = Request?.GetBooleanQueryParameter("includeSeasonImages", false) ?? false;
|
||||
|
||||
// Parse IncludeImages and use it
|
||||
return GetSeriesResourceById(id, includeSeasonImages);
|
||||
}
|
||||
|
||||
private SeriesResource? GetSeriesResourceById(int id, bool includeSeasonImages = false)
|
||||
{
|
||||
var series = _seriesService.GetSeries(id);
|
||||
|
||||
// Parse IncludeImages and use it
|
||||
return GetSeriesResource(series, includeSeasonImages);
|
||||
}
|
||||
|
||||
[RestPostById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
|
||||
{
|
||||
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
|
||||
|
||||
return Created(series.Id);
|
||||
}
|
||||
|
||||
[RestPutById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false)
|
||||
{
|
||||
var series = _seriesService.GetSeries(seriesResource.Id);
|
||||
|
||||
if (moveFiles)
|
||||
{
|
||||
var sourcePath = series.Path;
|
||||
var destinationPath = seriesResource.Path;
|
||||
|
||||
_commandQueueManager.Push(new MoveSeriesCommand
|
||||
{
|
||||
SeriesId = series.Id,
|
||||
SourcePath = sourcePath,
|
||||
DestinationPath = destinationPath
|
||||
}, trigger: CommandTrigger.Manual);
|
||||
}
|
||||
|
||||
var model = seriesResource.ToModel(series);
|
||||
|
||||
_seriesService.UpdateSeries(model);
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, seriesResource);
|
||||
|
||||
return Accepted(seriesResource.Id);
|
||||
}
|
||||
|
||||
[RestDeleteById]
|
||||
public void DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false)
|
||||
{
|
||||
_seriesService.DeleteSeries(new List<int> { id }, deleteFiles, addImportListExclusion);
|
||||
}
|
||||
|
||||
private SeriesResource? GetSeriesResource(NzbDrone.Core.Tv.Series? series, bool includeSeasonImages)
|
||||
{
|
||||
if (series == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resource = series.ToResource(includeSeasonImages);
|
||||
MapCoversToLocal(resource);
|
||||
FetchAndLinkSeriesStatistics(resource);
|
||||
PopulateAlternateTitles(resource);
|
||||
LinkRootFolderPath(resource);
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
private void MapCoversToLocal(params SeriesResource[] series)
|
||||
{
|
||||
foreach (var seriesResource in series)
|
||||
{
|
||||
_coverMapper.ConvertToLocalUrls(seriesResource.Id, seriesResource.Images);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchAndLinkSeriesStatistics(SeriesResource resource)
|
||||
{
|
||||
LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id));
|
||||
}
|
||||
|
||||
private void LinkSeriesStatistics(List<SeriesResource> resources, Dictionary<int, SeriesStatistics> seriesStatistics)
|
||||
{
|
||||
foreach (var series in resources)
|
||||
{
|
||||
if (seriesStatistics.TryGetValue(series.Id, out var stats))
|
||||
{
|
||||
LinkSeriesStatistics(series, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LinkSeriesStatistics(SeriesResource resource, SeriesStatistics seriesStatistics)
|
||||
{
|
||||
// Only set last aired from statistics if it's missing from the series itself
|
||||
resource.LastAired ??= seriesStatistics.LastAired;
|
||||
|
||||
resource.PreviousAiring = seriesStatistics.PreviousAiring;
|
||||
resource.NextAiring = seriesStatistics.NextAiring;
|
||||
resource.Statistics = seriesStatistics.ToResource(resource.Seasons);
|
||||
|
||||
if (seriesStatistics.SeasonStatistics != null)
|
||||
{
|
||||
foreach (var season in resource.Seasons)
|
||||
{
|
||||
season.Statistics = seriesStatistics.SeasonStatistics?.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber)?.ToResource();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateAlternateTitles(List<SeriesResource> resources)
|
||||
{
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
PopulateAlternateTitles(resource);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateAlternateTitles(SeriesResource resource)
|
||||
{
|
||||
var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId);
|
||||
|
||||
if (mappings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resource.AlternateTitles = mappings.ConvertAll(AlternateTitleResourceMapper.ToResource);
|
||||
}
|
||||
|
||||
private void LinkRootFolderPath(SeriesResource resource)
|
||||
{
|
||||
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(EpisodeImportedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(EpisodeFileDeletedEvent message)
|
||||
{
|
||||
if (message.Reason == DeleteMediaFileReason.Upgrade)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.SeriesId);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesUpdatedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesEditedEvent message)
|
||||
{
|
||||
var resource = GetSeriesResource(message.Series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
resource.EpisodesChanged = message.EpisodesChanged;
|
||||
BroadcastResourceChange(ModelAction.Updated, resource);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesDeletedEvent message)
|
||||
{
|
||||
foreach (var series in message.Series)
|
||||
{
|
||||
var resource = GetSeriesResource(series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Deleted, resource);
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesRenamedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(SeriesBulkEditedEvent message)
|
||||
{
|
||||
foreach (var series in message.Series)
|
||||
{
|
||||
var resource = GetSeriesResource(series, false);
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BroadcastResourceChange(ModelAction.Updated, resource);
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(MediaCoversUpdatedEvent message)
|
||||
{
|
||||
if (message.Updated)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Series.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
using FluentValidation.Validators;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Organizer;
|
||||
|
||||
namespace Sonarr.Api.V5.Series
|
||||
{
|
||||
public class SeriesFolderAsRootFolderValidator : PropertyValidator
|
||||
{
|
||||
private readonly IBuildFileNames _fileNameBuilder;
|
||||
|
||||
public SeriesFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder)
|
||||
{
|
||||
_fileNameBuilder = fileNameBuilder;
|
||||
}
|
||||
|
||||
protected override string GetDefaultMessageTemplate() => "Root folder path '{rootFolderPath}' contains series folder '{seriesFolder}'";
|
||||
|
||||
protected override bool IsValid(PropertyValidatorContext context)
|
||||
{
|
||||
if (context.PropertyValue == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (context.InstanceToValidate is not SeriesResource seriesResource)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rootFolderPath = context.PropertyValue.ToString();
|
||||
|
||||
if (rootFolderPath.IsNullOrWhiteSpace())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var rootFolder = new DirectoryInfo(rootFolderPath!).Name;
|
||||
var series = seriesResource.ToModel();
|
||||
var seriesFolder = _fileNameBuilder.GetSeriesFolder(series);
|
||||
|
||||
context.MessageFormatter.AppendArgument("rootFolderPath", rootFolderPath);
|
||||
context.MessageFormatter.AppendArgument("seriesFolder", seriesFolder);
|
||||
|
||||
if (seriesFolder == rootFolder)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distance = seriesFolder.LevenshteinDistance(rootFolder);
|
||||
|
||||
return distance >= Math.Max(1, seriesFolder.Length * 0.2);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue