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