diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 4d5f5e121f..8d51a56aa5 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,7 +4,7 @@
"name": "Whisparr",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
"features": {
- "ghcr.io/devcontainers/features/node:1": {
+ "ghcr.io/devcontainers/features/node:2": {
"nodeGypDependencies": true,
"version": "20",
"nvmVersion": "latest"
diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml
index 1415f4e15c..7bc4ca44c1 100644
--- a/.github/actions/build/action.yml
+++ b/.github/actions/build/action.yml
@@ -8,6 +8,9 @@ inputs:
version:
description: "Version number to build"
required: true
+ assembly_version:
+ description: "Assembly version (major.minor.patch.build) to stamp"
+ required: true
framework:
description: ".net framework used for the build"
required: true
@@ -38,7 +41,7 @@ runs:
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
echo "WHISPARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV"
- echo "WHISPARR_ASSEMBLY_VERSION=${{ env.WHISPARR_ASSEMBLY_VERSION }}" >> "$GITHUB_ENV"
+ echo "WHISPARR_ASSEMBLY_VERSION=${{ inputs.assembly_version }}" >> "$GITHUB_ENV"
if [ "$RUNNER_OS" == "Windows" ]; then
echo "NUGET_PACKAGES=D:\nuget\packages" >> "$GITHUB_ENV"
diff --git a/.github/workflows/build_v3.yml b/.github/workflows/build_v3.yml
index c8aabae6da..13687b082b 100644
--- a/.github/workflows/build_v3.yml
+++ b/.github/workflows/build_v3.yml
@@ -24,8 +24,10 @@ env:
FRAMEWORK: net10.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
WHISPARR_MAJOR_VERSION: 3
- VERSION: 3.3.4
- WHISPARR_ASSEMBLY_VERSION: 3.3.4.${{ github.run_number }}
+ # Base version now comes from the newest base tag (vX.Y.Z) reachable from the
+ # building branch; this is only the fallback for the seed window before any
+ # base tag exists. See prepare job below.
+ VERSION_FALLBACK: 3.3.4
jobs:
prepare:
@@ -34,12 +36,36 @@ jobs:
framework: ${{ steps.variables.outputs.framework }}
major_version: ${{ steps.variables.outputs.major_version }}
version: ${{ steps.variables.outputs.version }}
+ assembly_version: ${{ steps.variables.outputs.assembly_version }}
branch: ${{ steps.variables.outputs.branch }}
steps:
+ - name: Check out
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0 # fetches tags; needed to read the version marker
+
- name: Setup Environment Variables
id: variables
shell: bash
run: |
+ # Base version = highest base tag (strictly vX.Y.Z) reachable from THIS
+ # branch's history. --merged keeps it reachability-scoped so eros-develop
+ # and eros each resolve their own base; the strict regex rejects both the
+ # CI-minted -develop.N/-release.N tags and the legacy 4-part vX.Y.Z.B tags
+ # inherited from upstream (which glob-based matching cannot separate).
+ # `|| true` keeps the default bash `-eo pipefail` shell from aborting the
+ # step when no base tag matches yet (seed window) or when `head` closes the
+ # pipe early (SIGPIPE).
+ BASE_TAG=$(git tag --merged HEAD --sort=-v:refname 2>/dev/null \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
+ if [ -n "$BASE_TAG" ]; then
+ BASE="${BASE_TAG#v}"
+ echo "Base version ${BASE} (from tag ${BASE_TAG})"
+ else
+ BASE="${{ env.VERSION_FALLBACK }}"
+ echo "Base version ${BASE} (from VERSION_FALLBACK; no vX.Y.Z tag reachable)"
+ fi
+
if [[ "${{ github.ref_name }}" == "eros-develop" ]]; then
SEMVER_LABEL="develop"
else
@@ -47,7 +73,8 @@ jobs:
fi
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
echo "major_version=${{ env.WHISPARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
- echo "version=${{ env.VERSION }}-${SEMVER_LABEL}.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
+ echo "version=${BASE}-${SEMVER_LABEL}.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
+ echo "assembly_version=${BASE}.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
backend:
@@ -99,6 +126,7 @@ jobs:
with:
branch: ${{ needs.prepare.outputs.branch }}
version: ${{ needs.prepare.outputs.version }}
+ assembly_version: ${{ needs.prepare.outputs.assembly_version }}
framework: ${{ needs.prepare.outputs.framework }}
runtime: ${{ matrix.runtime }}
package_tests: ${{ matrix.package_tests }}
diff --git a/frontend/src/Store/Actions/addMovieActions.js b/frontend/src/Store/Actions/addMovieActions.js
index 86ecf68351..f1184189a9 100644
--- a/frontend/src/Store/Actions/addMovieActions.js
+++ b/frontend/src/Store/Actions/addMovieActions.js
@@ -6,7 +6,6 @@ import createAjaxRequest from 'Utilities/createAjaxRequest';
import getNewPerformer from 'Utilities/Performer/getNewPerformer';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
-import getNewStudio from 'Utilities/Studio/getNewStudio';
import { set, update, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
@@ -28,8 +27,6 @@ export const defaultState = {
isAdded: false,
addError: null,
items: [],
- studiosWithStatus: [],
-
movieDefaults: {
rootFolderPath: '',
monitor: 'movieOnly',
@@ -65,31 +62,24 @@ export const persistState = [
//
// Actions Types
-export const LOOKUP_STUDIO = 'addMovie/lookupStudio';
export const LOOKUP_PERFORMER = 'addMovie/lookupPerformer';
export const ADD_PERFORMER = 'addMovie/addPerformer';
-export const ADD_STUDIO = 'addMovie/addStudio';
export const SET_ADD_MOVIE_VALUE = 'addMovie/setAddMovieValue';
export const SET_ADD_PERFORMER_VALUE = 'addMovie/setAddPerformerValue';
-export const SET_ADD_STUDIO_VALUE = 'addMovie/setAddStudioValue';
export const CLEAR_ADD_MOVIE = 'addMovie/clearAddMovie';
export const SET_ADD_MOVIE_DEFAULT = 'addMovie/setAddMovieDefault';
export const SET_ADD_PERFORMER_DEFAULT = 'addMovie/setAddPerformerDefault';
export const SET_ADD_STUDIO_DEFAULT = 'addMovie/setAddStudioDefault';
-export const SET_STUDIOS_WITH_STATUS = 'addMovie/setStudiosWithStatus';
//
// Action Creators
-export const lookupStudio = createThunk(LOOKUP_STUDIO);
export const lookupPerformer = createThunk(LOOKUP_PERFORMER);
export const addPerformer = createThunk(ADD_PERFORMER);
-export const addStudio = createThunk(ADD_STUDIO);
export const clearAddMovie = createAction(CLEAR_ADD_MOVIE);
export const setAddMovieDefault = createAction(SET_ADD_MOVIE_DEFAULT);
export const setAddPerformerDefault = createAction(SET_ADD_PERFORMER_DEFAULT);
export const setAddStudioDefault = createAction(SET_ADD_STUDIO_DEFAULT);
-export const setStudiosWithStatus = createAction(SET_STUDIOS_WITH_STATUS);
export const setAddMovieValue = createAction(SET_ADD_MOVIE_VALUE, (payload) => {
return {
@@ -106,67 +96,12 @@ export const setAddPerformerValue = createAction(
};
}
);
-export const setAddStudioValue = createAction(
- SET_ADD_STUDIO_VALUE,
- (payload) => {
- return {
- section,
- ...payload,
- };
- }
-);
+
//
// Action Handlers
export const actionHandlers = handleThunks({
- [LOOKUP_STUDIO]: function (getState, payload, dispatch) {
- dispatch(set({ section, isFetching: true }));
-
- if (abortCurrentRequest) {
- abortCurrentRequest();
- }
-
- const { request, abortRequest } = createAjaxRequest({
- url: '/lookup/studio',
- data: {
- term: payload.term,
- },
- });
-
- abortCurrentRequest = abortRequest;
-
- request.done((data) => {
- data = data.map((movie) => ({
- ...movie,
- internalId: movie.id,
- id: movie.foreignId,
- }));
- 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,
- })
- );
- });
- },
[LOOKUP_PERFORMER]: function (getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
@@ -270,55 +205,7 @@ export const actionHandlers = handleThunks({
});
},
- [ADD_STUDIO]: function (getState, payload, dispatch) {
- dispatch(set({ section, isAdding: true }));
-
- const foreignId = payload.foreignId;
- const items = getState().addMovie.items;
- const itemToAdd = _.find(items, { foreignId });
- const newStudio = getNewStudio(_.cloneDeep(itemToAdd.studio), payload);
- newStudio.id = 0;
-
- const promise = createAjaxRequest({
- url: '/studio',
- method: 'POST',
- dataType: 'json',
- contentType: 'application/json',
- data: JSON.stringify(newStudio),
- }).request;
-
- promise.done((data) => {
- const updatedItem = _.cloneDeep(data);
- updatedItem.internalId = updatedItem.id;
- updatedItem.id = updatedItem.foreignId;
- delete updatedItem.images;
-
- const actions = [
- updateItem({ section: 'studios', ...data }),
- updateItem({ section: 'addMovie', ...updatedItem }),
-
- set({
- section,
- isAdding: false,
- isAdded: true,
- addError: null,
- }),
- ];
- dispatch(batchActions(actions));
- });
-
- promise.fail((xhr) => {
- dispatch(
- set({
- section,
- isAdding: false,
- isAdded: false,
- addError: xhr,
- })
- );
- });
- },
});
//
@@ -328,7 +215,6 @@ export const reducers = createHandleActions(
{
[SET_ADD_MOVIE_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_PERFORMER_VALUE]: createSetSettingValueReducer(section),
- [SET_ADD_STUDIO_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_MOVIE_DEFAULT]: function (state, { payload }) {
const newState = getSectionState(state, section);
@@ -361,12 +247,6 @@ export const reducers = createHandleActions(
return updateSectionState(state, section, newState);
},
- [SET_STUDIOS_WITH_STATUS]: function (state, { payload }) {
- const newState = getSectionState(state, section);
- newState.studiosWithStatus = payload;
- return updateSectionState(state, section, newState);
- },
-
[CLEAR_ADD_MOVIE]: function (state) {
const {
movieDefaults,
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
index b92d076b03..87a2edfe56 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
@@ -8,7 +8,6 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
-import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewStudioSearchResult from './AddNewStudioSearchResult';
import useAddNewStudio from './useAddNewStudio';
@@ -84,7 +83,9 @@ function AddNewStudio(props: AddNewStudioProps) {
{translate('FailedLoadingSearchResults')}
- {getErrorMessage(error)}
+
+ {error?.statusBody?.message ?? error?.message ?? ''}
+
{translate('WhySearchesCouldBeFailing')}
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
index 4a977ecd66..d8370bd463 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
@@ -1,11 +1,12 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
-import { Image } from 'Studio/Studio';
+import Studio, { Image } from 'Studio/Studio';
import AddNewStudioModalContent from './AddNewStudioModalContent';
interface AddNewStudioModalProps {
isOpen: boolean;
onModalClose: () => void;
+ studio: Studio;
foreignId: string;
title: string;
images: Image[];
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
index be40092c3f..b4773fa78c 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
@@ -10,7 +10,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
-import { Image } from 'Studio/Studio';
+import Studio, { Image } from 'Studio/Studio';
import StudioLogo from 'Studio/StudioLogo';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@@ -18,6 +18,7 @@ import { useAddNewStudioModalContent } from './useAddNewStudio';
import styles from './AddNewStudioModalContent.css';
interface AddNewStudioModalContentProps {
+ studio: Studio;
foreignId: string;
title: string;
images: Image[];
@@ -25,7 +26,7 @@ interface AddNewStudioModalContentProps {
}
function AddNewStudioModalContent(props: AddNewStudioModalContentProps) {
- const { foreignId, title, images, onModalClose } = props;
+ const { title, images, onModalClose, studio } = props;
const {
isAdding,
@@ -35,7 +36,7 @@ function AddNewStudioModalContent(props: AddNewStudioModalContentProps) {
settings,
onInputChange,
onAddStudioPress,
- } = useAddNewStudioModalContent(foreignId);
+ } = useAddNewStudioModalContent(studio);
const onQualityProfileIdChange = React.useCallback(
({ value }: EnhancedSelectInputChanged
) => {
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
index 5414a49dcb..4c42f37060 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
@@ -134,6 +134,7 @@ function AddNewStudioSearchResult({
;
}
-interface AddMovieState {
- isPopulated: boolean;
- error: AppError | null;
- isAdding: boolean;
- isFetching: boolean;
- isAdded: boolean;
- addError: AppError | null;
- items: LookupStudioItem[];
- studiosWithStatus: StudioWithExistingStatus[];
- studioDefaults: StudioDefaults;
-}
-
-type RootState = AppState & {
- addMovie: AddMovieState;
-};
-
const defaultStudioDefaults: StudioDefaults = {
rootFolderPath: '',
monitored: true,
@@ -86,111 +61,75 @@ const defaultStudioDefaults: StudioDefaults = {
tags: [],
};
+interface SearchResource {
+ foreignId: string;
+ studio: Studio;
+ isExisting: boolean;
+}
+
function useAddNewStudio() {
const dispatch = useDispatch();
- const addMovie = useSelector((state: RootState) => state.addMovie);
const uiSettings = useSelector(createUISettingsSelector());
const existingStudiosCount = useSelector(
(state: AppState) => state.studios.items.length
);
const [term, setTerm] = useState('');
-
- const studioLookupTimeout = React.useRef | null>(null);
+ const [debouncedTerm, setDebouncedTerm] = useState('');
+ const timeoutRef = useRef | null>(null);
React.useEffect(() => {
dispatch(fetchRootFolders());
dispatch(fetchQueueDetails());
return () => {
- if (studioLookupTimeout.current) {
- clearTimeout(studioLookupTimeout.current);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
}
- dispatch(clearAddMovie());
dispatch(clearQueueDetails());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // When lookup results change, check which studios already exist
- React.useEffect(() => {
- if (addMovie?.items && addMovie.items.length > 0) {
- const foreignIds = addMovie.items
- .map((item: LookupStudioItem) => item.studio.foreignId)
- .filter((id: string | undefined) => id);
-
- if (foreignIds.length > 0) {
- const { request } = createAjaxRequest({
- url: '/studio/list',
- method: 'POST',
- contentType: 'application/json',
- data: JSON.stringify(foreignIds),
- });
-
- request.done((existingStudios: Studio[]) => {
- // Create a map of foreignId to full studio object
- const existingStudioMap = new Map(
- existingStudios.map((s) => [s.foreignId, s])
- );
-
- // Map over lookup items, using full studio data if available
- const mapped = addMovie.items.map((item: LookupStudioItem) => {
- const fullStudio = existingStudioMap.get(item.studio.foreignId);
- return {
- studio: fullStudio || item.studio,
- isExistingStudio: !!fullStudio,
- };
- });
-
- dispatch(setStudiosWithStatus(mapped));
- });
-
- request.fail(() => {
- // If the request fails, assume none exist
- const mapped = addMovie.items.map((item: LookupStudioItem) => ({
- studio: item.studio,
- isExistingStudio: false,
- }));
-
- dispatch(setStudiosWithStatus(mapped));
- });
- }
+ const {
+ data: searchResources = [],
+ isFetching,
+ error,
+ } = useApiQuery({
+ path: '/lookup/studio',
+ queryParams: { term: debouncedTerm },
+ queryOptions: { enabled: !!debouncedTerm.trim() },
+ });
+
+ const onStudioLookupChange = React.useCallback((value: string) => {
+ setTerm(value);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ if (value.trim() === '') {
+ setDebouncedTerm('');
} else {
- dispatch(setStudiosWithStatus([]));
+ timeoutRef.current = setTimeout(() => {
+ setDebouncedTerm(value);
+ }, 300);
}
- }, [addMovie?.items, addMovie?.isAdding, dispatch]);
-
- const onStudioLookupChange = React.useCallback(
- (value: string) => {
- setTerm(value);
- if (studioLookupTimeout.current) {
- clearTimeout(studioLookupTimeout.current);
- }
- if (value.trim() === '') {
- dispatch(clearAddMovie());
- } else {
- studioLookupTimeout.current = setTimeout(() => {
- dispatch(lookupStudio({ term: value }));
- }, 300);
- }
- },
- [dispatch]
- );
+ }, []);
const onClearStudioLookupPress = React.useCallback(() => {
setTerm('');
- dispatch(clearAddMovie());
- }, [dispatch]);
+ setDebouncedTerm('');
+ }, []);
return {
- isPopulated: addMovie?.isPopulated || false,
- error: addMovie?.error,
- isAdding: addMovie?.isAdding || false,
- isFetching: addMovie?.isFetching || false,
- isAdded: addMovie?.isAdded || false,
- addError: addMovie?.addError,
- items: addMovie?.items || [],
- studiosWithStatus: addMovie?.studiosWithStatus || [],
+ isPopulated: !!debouncedTerm.trim() && !isFetching,
+ error,
+ isAdding: false,
+ isFetching: isFetching && !!debouncedTerm.trim(),
+ isAdded: false,
+ addError: null,
+ items: searchResources,
+ studiosWithStatus: searchResources.map((r) => ({
+ studio: r.studio,
+ isExistingStudio: r.isExisting,
+ })),
term,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasExistingStudios: existingStudiosCount > 0,
@@ -211,25 +150,39 @@ export function useAddNewStudioSearchResult() {
};
}
-export function useAddNewStudioModalContent(foreignId: string) {
+export function useAddNewStudioModalContent(studio: Studio) {
const dispatch = useDispatch();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const systemStatus = useSelector(createSystemStatusSelector());
const safeForWorkMode = useSelector(
(state: AppState) => state.settings.safeForWorkMode
);
- const addMovieState = useSelector((state: RootState) => state.addMovie);
- const {
- isAdding = false,
- addError,
- studioDefaults = defaultStudioDefaults,
- } = addMovieState || {};
+ const addMovieState = useSelector(
+ (
+ state: AppState & {
+ addMovie: { studioDefaults: StudioDefaults; addError?: ApiError };
+ }
+ ) => state.addMovie
+ );
+
+ const { studioDefaults = defaultStudioDefaults } = addMovieState || {};
+
+ const mutation = useApiMutation({
+ method: 'POST',
+ path: '/studio',
+ mutationOptions: {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/studio/paged'] });
+ queryClient.invalidateQueries({ queryKey: ['/lookup/studio'] });
+ },
+ },
+ });
const { settings, validationErrors, validationWarnings } = selectSettings(
studioDefaults,
{},
- addError
+ mutation.error
) as {
settings: AddStudioSettings;
validationErrors: unknown[];
@@ -244,22 +197,21 @@ export function useAddNewStudioModalContent(foreignId: string) {
);
const onAddStudioPress = React.useCallback(() => {
- dispatch(
- addStudio({
- foreignId,
- rootFolderPath: settings.rootFolderPath.value,
- monitored: settings.monitored.value === true,
- moviesMonitored: settings.moviesMonitored.value === true,
- qualityProfileId: settings.qualityProfileId.value,
- searchForMovie: settings.searchForMovie.value,
- tags: settings.tags.value,
- })
- );
- }, [dispatch, foreignId, settings]);
+ const studioToAdd = getNewStudio(cloneDeep(studio) as object, {
+ rootFolderPath: settings.rootFolderPath.value,
+ monitored: settings.monitored.value === true,
+ moviesMonitored: settings.moviesMonitored.value === true,
+ qualityProfileId: settings.qualityProfileId.value,
+ searchForMovie: settings.searchForMovie.value,
+ tags: settings.tags.value,
+ }) as Studio;
+ studioToAdd.id = 0;
+ mutation.mutate(studioToAdd);
+ }, [studio, settings, mutation]);
return {
- addError,
- isAdding,
+ addError: mutation.error,
+ isAdding: mutation.isPending,
isSmallScreen,
isWindows: systemStatus.isWindows,
safeForWorkMode,
diff --git a/package.json b/package.json
index 004f8ef47a..a41dfb791b 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"mousetrap": "1.6.5",
"normalize.css": "8.0.1",
"prop-types": "15.8.1",
- "qs": "6.15.1",
+ "qs": "6.15.2",
"react": "19.2.4",
"react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0",
@@ -93,7 +93,7 @@
"use-debounce": "10.1.0"
},
"devDependencies": {
- "@babel/core": "7.29.0",
+ "@babel/core": "7.29.6",
"@babel/eslint-parser": "7.28.6",
"@babel/plugin-proposal-export-default-from": "7.27.1",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
@@ -138,7 +138,7 @@
"html-webpack-plugin": "5.6.6",
"loader-utils": "3.3.1",
"mini-css-extract-plugin": "2.10.1",
- "postcss": "8.5.10",
+ "postcss": "8.5.12",
"postcss-color-function": "4.1.0",
"postcss-loader": "8.2.1",
"postcss-mixins": "12.1.2",
diff --git a/src/NzbDrone.Api.Test/v3/Movies/MovieResourceFixture.cs b/src/NzbDrone.Api.Test/v3/Movies/MovieResourceFixture.cs
new file mode 100644
index 0000000000..4d1782fb17
--- /dev/null
+++ b/src/NzbDrone.Api.Test/v3/Movies/MovieResourceFixture.cs
@@ -0,0 +1,94 @@
+using System.Collections.Generic;
+using FluentAssertions;
+using NLog;
+using NUnit.Framework;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Profiles;
+using NzbDrone.Core.Profiles.Qualities;
+using NzbDrone.Core.Qualities;
+using Whisparr.Api.V3.Movies;
+
+namespace NzbDrone.Api.Test.v3.Movies
+{
+ // MovieFileResource.ToResource dereferences Movie.QualityProfile to compute QualityCutoffNotMet,
+ // so hydrating MovieFile without a profile throws. The repository tests can't catch that because
+ // they never map a resource. These cover the shape the paged index returns.
+ [Parallelizable(ParallelScope.All)]
+ public class MovieResourceFixture
+ {
+ // QualityCutoffNotMet uses neither dependency.
+ private readonly IUpgradableSpecification _upgradableSpecification =
+ new UpgradableSpecification(null, LogManager.GetCurrentClassLogger());
+
+ private static QualityProfile Profile() => new QualityProfile
+ {
+ Id = 1,
+ Name = "TestProfile",
+ UpgradeAllowed = true,
+ Cutoff = Quality.Bluray1080p.Id,
+ MinFormatScore = 0,
+ FormatItems = new List(),
+ Items = new List
+ {
+ new QualityProfileQualityItem { Quality = Quality.DVD, Allowed = true },
+ new QualityProfileQualityItem { Quality = Quality.HDTV720p, Allowed = true },
+ new QualityProfileQualityItem { Quality = Quality.Bluray1080p, Allowed = true }
+ }
+ };
+
+ private static Movie MovieWithFile(Quality quality, QualityProfile profile) => new Movie
+ {
+ Id = 1,
+ Path = "/movies/some.movie",
+ QualityProfileId = profile.Id,
+ QualityProfile = profile,
+ MovieFileId = 1,
+ MovieMetadata = new MovieMetadata { Title = "Some Movie", ItemType = ItemType.Scene },
+ MovieFile = new MovieFile
+ {
+ Id = 1,
+ MovieId = 1,
+ RelativePath = "some.movie.mkv",
+ Quality = new QualityModel(quality)
+ }
+ };
+
+ [Test]
+ public void should_map_movie_file_quality()
+ {
+ var resource = MovieWithFile(Quality.HDTV720p, Profile()).ToResource(0, _upgradableSpecification);
+
+ resource.MovieFile.Should().NotBeNull();
+ resource.MovieFile.Quality.Quality.Id.Should().Be(Quality.HDTV720p.Id);
+ }
+
+ [Test]
+ public void should_map_quality_cutoff_not_met_when_below_cutoff()
+ {
+ // Profile cutoff is Bluray1080p, so a 720p file has not met it.
+ var resource = MovieWithFile(Quality.HDTV720p, Profile()).ToResource(0, _upgradableSpecification);
+
+ resource.MovieFile.QualityCutoffNotMet.Should().BeTrue();
+ }
+
+ [Test]
+ public void should_map_quality_cutoff_met_when_at_cutoff()
+ {
+ var resource = MovieWithFile(Quality.Bluray1080p, Profile()).ToResource(0, _upgradableSpecification);
+
+ resource.MovieFile.QualityCutoffNotMet.Should().BeFalse();
+ }
+
+ [Test]
+ public void should_not_map_movie_file_when_movie_has_none()
+ {
+ var movie = MovieWithFile(Quality.HDTV720p, Profile());
+ movie.MovieFile = null;
+ movie.MovieFileId = 0;
+
+ movie.ToResource(0, _upgradableSpecification).MovieFile.Should().BeNull();
+ }
+ }
+}
diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs
index 32e1661384..9921ed6427 100644
--- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs
+++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs
@@ -796,8 +796,13 @@ public async Task should_parse_malformed_cloudflare_cookie(string culture)
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
try
{
- // the date is bad in the below - should be 13-Jul-2026
- var malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly";
+ // The two digit year is the malformed part: a server should send 2027, not 27. Generate the date
+ // rather than hardcoding it, otherwise the cookie eventually expires and the test starts failing
+ // on a fixed date for everyone. Format as invariant because that is what a server sends; the
+ // thread culture stays set to the test case's culture so we still cover parsing it as a client
+ // running under a non-English locale.
+ var expires = DateTime.UtcNow.AddYears(1).ToString("ddd, dd-MMM-yy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
+ var malformedCookie = $"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires={expires}; path=/; HttpOnly";
var requestSet = new HttpRequestBuilder($"https://{_httpBinHost}/response-headers")
.AddQueryParam("Set-Cookie", malformedCookie)
.Build();
diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
index 5953ff1571..21a90f4cda 100644
--- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs
+++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Threading;
using NLog;
+using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@@ -15,6 +16,22 @@ public abstract class DiskProviderBase : IDiskProvider
{
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DiskProviderBase));
+ // Enumerating the mount table is expensive: on Linux it reads /proc/mounts and stats every mount point,
+ // which on a host with a lot of network mounts is hundreds of syscalls (and round trips) per call.
+ // GetMount is called once per movie path by MountCheck, so a library with a few hundred thousand movies
+ // performed that enumeration a few hundred thousand times.
+ //
+ // Only the mount topology is cached. Free space is not: IMount implementations read it live from the
+ // underlying DriveInfo/UnixDriveInfo on every property access, so cached mounts never report stale space.
+ private static readonly TimeSpan MountCacheLifetime = TimeSpan.FromSeconds(15);
+
+ private readonly ICached> _mountCache;
+
+ protected DiskProviderBase(ICacheManager cacheManager)
+ {
+ _mountCache = cacheManager.GetCache>(GetType(), "AllMounts");
+ }
+
public static StringComparison PathStringComparison
{
get
@@ -138,7 +155,7 @@ public bool FolderWritable(string path)
}
catch (Exception e)
{
- Logger.Trace("Directory '{0}' isn't writable. {1}", path, e.Message);
+ Logger.Trace(e, "Directory '{0}' isn't writable.", path);
return false;
}
}
@@ -388,11 +405,11 @@ public void FileSetLastWriteTime(string path, DateTime dateTime)
File.SetLastWriteTime(path, dateTime);
}
- public bool IsFileLocked(string file)
+ public bool IsFileLocked(string path)
{
try
{
- using (File.Open(file, FileMode.Open, FileAccess.Read, FileShare.None))
+ using (File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None))
{
return false;
}
@@ -438,20 +455,6 @@ private static void RemoveReadOnly(string path)
}
}
- private static void RemoveReadOnlyFolder(string path)
- {
- if (Directory.Exists(path))
- {
- var dirInfo = new DirectoryInfo(path);
-
- if (dirInfo.Attributes.HasFlag(FileAttributes.ReadOnly))
- {
- var newAttributes = dirInfo.Attributes & ~FileAttributes.ReadOnly;
- dirInfo.Attributes = newAttributes;
- }
- }
- }
-
public FileAttributes GetFileAttributes(string path)
{
return File.GetAttributes(path);
@@ -509,7 +512,16 @@ public List GetMounts()
return GetAllMounts().Where(d => !IsSpecialMount(d)).ToList();
}
- protected virtual List GetAllMounts()
+ // The returned list is shared between callers for the lifetime of the cache entry, treat it as read-only.
+ private List GetAllMounts()
+ {
+ // The lifetime has to be passed explicitly, GetCache has no default and a null lifetime never expires.
+ return _mountCache.Get("all", FetchAllMounts, MountCacheLifetime);
+ }
+
+ // Don't call this from a constructor, overrides depend on fields that derived constructors haven't
+ // assigned yet. Going through GetAllMounts() keeps it lazy.
+ protected virtual List FetchAllMounts()
{
return GetDriveInfoMounts().Where(d => d.DriveType == DriveType.Fixed || d.DriveType == DriveType.Network || d.DriveType == DriveType.Removable)
.Select(d => new DriveInfoMount(d))
@@ -526,11 +538,14 @@ public virtual IMount GetMount(string path)
{
try
{
- var mounts = GetAllMounts();
+ // IsParentPath walks the path back up to the root, and that walk is identical for every mount, so
+ // do it once here rather than once per mount. MountCheck performs a lookup per movie path, so on a
+ // large library this comparison runs hundreds of thousands of times.
+ var ancestors = GetAncestors(path);
- return mounts.Where(drive => drive.RootDirectory.PathEquals(path) ||
- drive.RootDirectory.IsParentPath(path))
- .MaxBy(drive => drive.RootDirectory.Length);
+ return GetAllMounts()
+ .Where(drive => drive.RootDirectory.PathEquals(path) || IsAncestor(ancestors, drive.RootDirectory))
+ .MaxBy(drive => drive.RootDirectory.Length);
}
catch (Exception ex)
{
@@ -539,7 +554,26 @@ public virtual IMount GetMount(string path)
}
}
- protected List GetDriveInfoMounts()
+ private static List GetAncestors(string path)
+ {
+ var ancestors = new List();
+
+ for (var directory = new OsPath(path).Directory; directory != OsPath.Null; directory = directory.Directory)
+ {
+ ancestors.Add(directory);
+ }
+
+ return ancestors;
+ }
+
+ private static bool IsAncestor(List ancestors, string parentPath)
+ {
+ var parent = new OsPath(parentPath);
+
+ return ancestors.Any(a => a.Equals(parent, true));
+ }
+
+ protected static List GetDriveInfoMounts()
{
return DriveInfo.GetDrives()
.Where(d => d.IsReady)
diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/MountCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/MountCheckFixture.cs
new file mode 100644
index 0000000000..d51dd43850
--- /dev/null
+++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/MountCheckFixture.cs
@@ -0,0 +1,186 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using NzbDrone.Common.Disk;
+using NzbDrone.Core.HealthCheck.Checks;
+using NzbDrone.Core.Localization;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Test.Framework;
+using NzbDrone.Test.Common;
+
+namespace NzbDrone.Core.Test.HealthCheck.Checks
+{
+ [TestFixture]
+ public class MountCheckFixture : CoreTest
+ {
+ [SetUp]
+ public void Setup()
+ {
+ Mocker.GetMock()
+ .Setup(s => s.GetLocalizedString(It.IsAny()))
+ .Returns("Mount is read only: ");
+
+ Mocker.GetMock()
+ .Setup(s => s.GetMount(It.IsAny()))
+ .Returns((IMount)null);
+ }
+
+ private void GivenMoviePaths(params string[] paths)
+ {
+ Mocker.GetMock()
+ .Setup(s => s.AllMoviePaths())
+ .Returns(paths.Select((path, index) => new { index, path })
+ .ToDictionary(x => x.index, x => x.path));
+ }
+
+ private void GivenMount(string name, string rootDirectory, MountOptions mountOptions, params string[] paths)
+ {
+ var mount = new Mock();
+
+ mount.SetupGet(m => m.RootDirectory).Returns(rootDirectory);
+ mount.SetupGet(m => m.Name).Returns(name);
+ mount.SetupGet(m => m.MountOptions).Returns(mountOptions);
+
+ foreach (var path in paths)
+ {
+ Mocker.GetMock()
+ .Setup(s => s.GetMount(path))
+ .Returns(mount.Object);
+ }
+ }
+
+ private static MountOptions ReadOnly()
+ {
+ return new MountOptions(new Dictionary { { "ro", string.Empty } });
+ }
+
+ private static MountOptions Writable()
+ {
+ return new MountOptions(new Dictionary());
+ }
+
+ [Test]
+ public void should_not_return_error_when_no_movies()
+ {
+ GivenMoviePaths();
+
+ Subject.Check().ShouldBeOk();
+ }
+
+ [Test]
+ public void should_not_return_error_when_mount_cannot_be_resolved()
+ {
+ GivenMoviePaths(@"C:\Movies\movie".AsOsAgnostic());
+
+ Subject.Check().ShouldBeOk();
+ }
+
+ [Test]
+ public void should_not_return_error_when_mount_options_are_unavailable()
+ {
+ // DriveInfoMount is constructed without mount options, so MountOptions is always null on Windows.
+ var path = @"C:\Movies\movie".AsOsAgnostic();
+
+ GivenMoviePaths(path);
+ GivenMount("Movies", @"C:\Movies".AsOsAgnostic(), null, path);
+
+ Subject.Check().ShouldBeOk();
+ }
+
+ [Test]
+ public void should_not_return_error_when_mount_is_writable()
+ {
+ var path = @"C:\Movies\movie".AsOsAgnostic();
+
+ GivenMoviePaths(path);
+ GivenMount("Movies", @"C:\Movies".AsOsAgnostic(), Writable(), path);
+
+ Subject.Check().ShouldBeOk();
+ }
+
+ [Test]
+ public void should_return_error_when_mount_is_read_only()
+ {
+ var path = @"C:\Movies\movie".AsOsAgnostic();
+
+ GivenMoviePaths(path);
+ GivenMount("Movies", @"C:\Movies".AsOsAgnostic(), ReadOnly(), path);
+
+ var result = Subject.Check();
+
+ result.ShouldBeError(wikiFragment: "#movie-mount-ro");
+ result.Message.Should().Contain("Movies");
+ result.Message.Should().Contain(path);
+ }
+
+ [Test]
+ public void should_only_report_each_mount_once()
+ {
+ var paths = new[] { @"C:\Movies\first", @"C:\Movies\second", @"C:\Movies\third" }
+ .Select(p => p.AsOsAgnostic())
+ .ToArray();
+
+ GivenMoviePaths(paths);
+ GivenMount("ReadOnlyMount", @"C:\Movies".AsOsAgnostic(), ReadOnly(), paths);
+
+ var result = Subject.Check();
+
+ result.ShouldBeError();
+ Regex.Matches(result.Message, "ReadOnlyMount").Count.Should().Be(1);
+ }
+
+ [Test]
+ public void should_report_the_first_path_for_a_mount()
+ {
+ var paths = new[] { @"C:\Movies\first", @"C:\Movies\second", @"C:\Movies\third" }
+ .Select(p => p.AsOsAgnostic())
+ .ToArray();
+
+ GivenMoviePaths(paths);
+ GivenMount("ReadOnlyMount", @"C:\Movies".AsOsAgnostic(), ReadOnly(), paths);
+
+ var result = Subject.Check();
+
+ result.Message.Should().Contain(paths[0]);
+ result.Message.Should().NotContain(paths[1]);
+ result.Message.Should().NotContain(paths[2]);
+ }
+
+ [Test]
+ public void should_report_each_read_only_mount()
+ {
+ var first = @"C:\First\movie".AsOsAgnostic();
+ var second = @"C:\Second\movie".AsOsAgnostic();
+
+ GivenMoviePaths(first, second);
+ GivenMount("FirstMount", @"C:\First".AsOsAgnostic(), ReadOnly(), first);
+ GivenMount("SecondMount", @"C:\Second".AsOsAgnostic(), ReadOnly(), second);
+
+ var result = Subject.Check();
+
+ result.ShouldBeError();
+ result.Message.Should().Contain(first);
+ result.Message.Should().Contain(second);
+ }
+
+ [Test]
+ public void should_only_report_read_only_mounts()
+ {
+ var readOnly = @"C:\First\movie".AsOsAgnostic();
+ var writable = @"C:\Second\movie".AsOsAgnostic();
+
+ GivenMoviePaths(readOnly, writable);
+ GivenMount("FirstMount", @"C:\First".AsOsAgnostic(), ReadOnly(), readOnly);
+ GivenMount("SecondMount", @"C:\Second".AsOsAgnostic(), Writable(), writable);
+
+ var result = Subject.Check();
+
+ result.ShouldBeError();
+ result.Message.Should().Contain(readOnly);
+ result.Message.Should().NotContain(writable);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs
index 7f5cefc17e..a41e1023d0 100644
--- a/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs
+++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs
@@ -24,9 +24,9 @@ public void Setup()
.Returns("Some Warning Message");
}
- private void GivenMissingRootFolder(string rootFolderPath)
+ private void GivenMissingRootFolder(string rootFolderPath, int movieCount = 1)
{
- var movies = Builder.CreateListOfSize(1)
+ var movies = Builder.CreateListOfSize(movieCount)
.Build()
.ToList();
@@ -35,7 +35,11 @@ private void GivenMissingRootFolder(string rootFolderPath)
.Returns(movies.ToDictionary(x => x.Id, x => x.Path));
Mocker.GetMock()
- .Setup(s => s.GetBestRootFolderPath(It.IsAny(), null))
+ .Setup(s => s.All())
+ .Returns(new List());
+
+ Mocker.GetMock()
+ .Setup(s => s.GetBestRootFolderPath(It.IsAny(), It.IsAny>()))
.Returns(rootFolderPath);
Mocker.GetMock()
@@ -78,5 +82,20 @@ public void should_return_error_if_series_path_is_for_windows()
Subject.Check().ShouldBeError();
}
+
+ [Test]
+ public void should_only_fetch_root_folders_once_regardless_of_movie_count()
+ {
+ GivenMissingRootFolder(@"C:\Movies".AsOsAgnostic(), 10);
+
+ Subject.Check();
+
+ // Omitting the root folders makes GetBestRootFolderPath re-query them on every cache miss, once per movie.
+ Mocker.GetMock()
+ .Verify(s => s.GetBestRootFolderPath(It.IsAny(), null), Times.Never());
+
+ Mocker.GetMock()
+ .Verify(s => s.All(), Times.Once());
+ }
}
}
diff --git a/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs b/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs
index ddf040d9ae..e08b4c29c8 100644
--- a/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs
+++ b/src/NzbDrone.Core.Test/MovieTests/MovieRepositoryTests/MovieRepositoryFixture.cs
@@ -4,6 +4,10 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
@@ -29,6 +33,58 @@ public void Setup()
.Returns(new List());
}
+ private QualityProfile GivenProfile()
+ {
+ var profile = new QualityProfile
+ {
+ Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p),
+ FormatItems = CustomFormatsTestHelpers.GetDefaultFormatItems(),
+ MinFormatScore = 0,
+ Cutoff = Quality.Bluray1080p.Id,
+ Name = "TestProfile"
+ };
+
+ _profileRepository.Insert(profile);
+
+ return profile;
+ }
+
+ private MovieFile GivenMovieFile(Quality quality)
+ {
+ var movieFile = Builder.CreateNew()
+ .With(f => f.Id = 0)
+ .With(f => f.Quality = new QualityModel(quality))
+ .With(f => f.Languages = new List { Language.English })
+ .With(f => f.MediaInfo = new MediaInfoModel { RawStreamData = "{ \"streams\": [] }", VideoFormat = "h264" })
+ .BuildNew();
+
+ return Db.Insert(movieFile);
+ }
+
+ // PagedBuilder INNER JOINs MovieMetadata, so a paged movie needs a metadata row to match.
+ private void GivenPagedMovie(int qualityProfileId, int movieFileId, int year = 2020)
+ {
+ var metadata = Db.Insert(Builder.CreateNew()
+ .With(m => m.Id = 0)
+ .With(m => m.Year = year)
+ .BuildNew());
+
+ var movie = Builder.CreateNew()
+ .With(m => m.Id = 0)
+ .With(m => m.MovieMetadataId = metadata.Id)
+ .With(m => m.QualityProfileId = qualityProfileId)
+ .With(m => m.MovieFileId = movieFileId)
+ .BuildNew();
+
+ Subject.Insert(movie);
+ }
+
+ private static PagingSpec PagingSpec() => new PagingSpec
+ {
+ Page = 1,
+ PageSize = 10
+ };
+
[Test]
public void should_load_quality_profile()
{
@@ -50,5 +106,81 @@ public void should_load_quality_profile()
Subject.All().Single().QualityProfile.Should().NotBeNull();
}
+
+ [Test]
+ public void should_load_movie_file_on_paged()
+ {
+ var profile = GivenProfile();
+ var movieFile = GivenMovieFile(Quality.HDTV720p);
+ GivenPagedMovie(profile.Id, movieFile.Id);
+
+ var movie = Subject.GetPaged(PagingSpec()).Records.Single();
+
+ movie.MovieFile.Should().NotBeNull();
+ movie.MovieFile.Quality.Quality.Id.Should().Be(Quality.HDTV720p.Id);
+ }
+
+ [Test]
+ public void should_load_quality_profile_on_paged()
+ {
+ var profile = GivenProfile();
+ var movieFile = GivenMovieFile(Quality.HDTV720p);
+ GivenPagedMovie(profile.Id, movieFile.Id);
+
+ // MovieFileResource.ToResource dereferences this to compute QualityCutoffNotMet.
+ Subject.GetPaged(PagingSpec()).Records.Single().QualityProfile.Should().NotBeNull();
+ }
+
+ [Test]
+ public void should_not_load_media_info_on_paged()
+ {
+ var profile = GivenProfile();
+ var movieFile = GivenMovieFile(Quality.HDTV720p);
+ GivenPagedMovie(profile.Id, movieFile.Id);
+
+ // The paged index never renders MediaInfo, so its ~7KB blob is left out of the query.
+ Subject.GetPaged(PagingSpec()).Records.Single().MovieFile.MediaInfo.Should().BeNull();
+ }
+
+ [Test]
+ public void should_not_load_movie_file_when_movie_has_none()
+ {
+ var profile = GivenProfile();
+ GivenPagedMovie(profile.Id, 0);
+
+ Subject.GetPaged(PagingSpec()).Records.Single().MovieFile.Should().BeNull();
+ }
+
+ // A dangling QualityProfileId must still resolve to a profile: MovieFileResource.ToResource
+ // dereferences it unconditionally, so a null here would throw when mapping the resource.
+ [Test]
+ public void should_fall_back_to_a_profile_when_quality_profile_is_missing()
+ {
+ var profile = GivenProfile();
+ var movieFile = GivenMovieFile(Quality.HDTV720p);
+ GivenPagedMovie(qualityProfileId: 999, movieFileId: movieFile.Id);
+
+ var movie = Subject.GetPaged(PagingSpec()).Records.Single();
+
+ movie.QualityProfile.Should().NotBeNull();
+ movie.QualityProfile.Id.Should().Be(profile.Id);
+ movie.MovieFile.Should().NotBeNull();
+ }
+
+ // MoviesWithoutFiles shares PagedQuery with GetPaged but GROUPs BY Movies.Id, so it must
+ // keep selecting only Movies/MovieMetadata columns: a bare MovieFile column under that
+ // GROUP BY errors on Postgres.
+ [Test]
+ public void should_still_page_movies_without_files()
+ {
+ var profile = GivenProfile();
+ GivenPagedMovie(profile.Id, movieFileId: 0);
+
+ var spec = PagingSpec();
+ Subject.MoviesWithoutFiles(spec);
+
+ spec.Records.Should().HaveCount(1);
+ spec.TotalRecords.Should().Be(1);
+ }
}
}
diff --git a/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs
index 533ecf35eb..81d69eabf5 100644
--- a/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs
+++ b/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs
@@ -71,6 +71,7 @@ public void should_parse_as_scene(string title, bool expected)
[TestCase("[Vixen] Matthew Meie, Erica Mori & Era Queen - Bratty College Girls Have Naughty Threesome (2025-12-03) [2160p]", "Vixen")]
[TestCase("[Vixen] Matthew Meie, Erica Mori & Era Queen - Bratty College Girls Have Naughty Threesome (2025-12-03) [2160p].mp4", "Vixen")]
[TestCase("[Studio.com] (E09) Perfomer(aka Alias) [2015 г., All Sex, BJ, IR, 720p]", "Studio")]
+ [TestCase("[EvilAngel.com / Gonzo.com] Some Performer - Multi Brand Test (2024-01-15)", "EvilAngel")]
public void should_correctly_parse_studio_names(string title, string result)
{
Parser.Parser.ParseMovieTitle(title).StudioTitle.Should().Be(result);
@@ -127,6 +128,7 @@ public void should_correctly_parse_release_date(string title, string result)
[TestCase("[Vixen] Matthew Meie, Erica Mori & Era Queen - Bratty College Girls Have Naughty Threesome (2025-12-03) [2160p]", "matthew meie erica mori and era queen bratty college girls have naughty threesome")]
[TestCase("[Vixen] Matthew Meie, Erica Mori & Era Queen - Bratty College Girls Have Naughty Threesome (2025-12-03) [2160p].mp4", "matthew meie erica mori and era queen bratty college girls have naughty threesome")]
[TestCase("[Studio.com] (E09) Perfomer(aka Alias) [2015 г., All Sex, BJ, IR, 720p]", "perfomer aka alias")]
+ [TestCase("[EvilAngel.com / Gonzo.com] Some Performer - Multi Brand Test (2024-01-15)", "some performer multi brand test")]
public void should_correctly_parse_normalize_release_token(string title, string result)
{
var releaseTokens = Parser.Parser.ParseMovieTitle(title).ReleaseTokens;
@@ -169,6 +171,33 @@ public void should_parse_stash_id_as_fallback_when_no_regex_matches(string title
result.IsScene.Should().BeTrue();
}
+ // The title capture is measured against a string that has already had the quality and codec
+ // tokens stripped, so replacing the title on those offsets used to overrun into the quality
+ // block and shred the codec token that Release Title custom formats match on.
+ [TestCase("Studio.26.07.09.Performer.Its.Great.In.The.Sample.XXX.1080p.x265-GROUP", "x265")]
+ [TestCase("Studio.26.07.09.Performer.Title.XXX.1080p.x265-GROUP", "x265")]
+ [TestCase("Studio.26.07.09.Performer.A.Much.Longer.Scene.Title.XXX.1080p.x265-GROUP", "x265")]
+ [TestCase("Studio2.24.07.26.Performer.Plain.Title.Words.Here.XXX.1080p.HEVC.x265.PRT", "x265")]
+ [TestCase("Studio.22.10.18.Title.XXX.720p.HEVC.x265.PRT[XvX]", "x265")]
+ [TestCase("Studio.26.07.09.Performer.Title.XXX.1080p.h264-GROUP", "h264")]
+ public void should_keep_codec_token_in_simple_release_title(string title, string codec)
+ {
+ var result = Parser.Parser.ParseMovieTitle(title);
+
+ result.Should().NotBeNull();
+ result.SimpleReleaseTitle.Should().Contain(codec);
+ }
+
+ [TestCase("Studio.26.07.09.Performer.Its.Great.In.The.Sample.XXX.1080p.x265-GROUP", "Studio.26.07.09.A.Movie.XXX.1080p.x265-GROUP")]
+ [TestCase("Studio.26.07.09.Performer.Title.XXX.1080p.x265-GROUP", "Studio.26.07.09.A.Movie.XXX.1080p.x265-GROUP")]
+ public void should_replace_only_the_title_tokens_in_simple_release_title(string title, string expected)
+ {
+ var result = Parser.Parser.ParseMovieTitle(title);
+
+ result.Should().NotBeNull();
+ result.SimpleReleaseTitle.Should().Be(expected);
+ }
+
[TestCase("something random 77f1b861-91c1-4e6f-b0b1-3b1c46733fb2 anything")]
[TestCase("prefix 77f1b861-91c1-4e6f-b0b1-3b1c46733fb2 suffix")]
public void should_populate_all_required_fields_on_stash_id_fallback(string title)
diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs
index fc8c4ca87b..620de04309 100644
--- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs
+++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs
@@ -70,8 +70,8 @@ public void should_remove_accents_from_title()
[TestCase("Roccos.Abbondanza.7.XXX.DVDRip.x264-HORNDOGS", "Roccos Abbondanza 7")]
[TestCase("Oil Explosion 3 (Elegant Angel) XXX DVDRip NEW 2018", "Oil Explosion 3")]
[TestCase("Elegant Angel.2024.Oil Explosion 8.1080p-VERIFIED", "Oil Explosion 8")]
- [TestCase("[EvilAngel] Strap Attack #16 [2012] [1080p]", "Strap Attack #16")]
[TestCase("Blacked.2024.Level Up Vol. 3.1080p-VERIFIED", "Level Up Vol 3")]
+ [TestCase("[EvilAngel] Strap Attack #16 [2012] [1080p]", "Strap Attack #16")]
public void should_parse_movie_title(string postTitle, string title)
{
Parser.Parser.ParseMovieTitle(postTitle).PrimaryMovieTitle.Should().Be(title);
diff --git a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs
index fc8e185a1c..0861567c82 100644
--- a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs
+++ b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs
@@ -52,9 +52,12 @@ private IEnumerable GetMoviesRootPaths()
// Get all movie paths and find the correct root folder for each. For each unique root folder path,
// ensure the path exists and get its path root and return all unique path roots.
+ // Pass the root folders in, otherwise every cache miss re-queries them from the database, once per movie.
+ var allRootFolders = _rootFolderService.All();
+
return _movieService.AllMoviePaths()
.Where(s => s.Value.IsPathValid(PathValidationType.CurrentOs))
- .Select(s => _rootFolderService.GetBestRootFolderPath(s.Value))
+ .Select(s => _rootFolderService.GetBestRootFolderPath(s.Value, allRootFolders))
.Distinct()
.Where(r => _diskProvider.FolderExists(r))
.Select(r => _diskProvider.GetPathRoot(r))
diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs
index fb4687dc4d..e46be2f7e6 100644
--- a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs
+++ b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs
@@ -30,8 +30,11 @@ public RootFolderCheck(IMovieService movieService, IDiskProvider diskProvider, I
public override HealthCheck Check()
{
+ // Pass the root folders in, otherwise every cache miss re-queries them from the database, once per movie.
+ var allRootFolders = _rootFolderService.All();
+
var rootFolders = _movieService.AllMoviePaths()
- .Select(s => _rootFolderService.GetBestRootFolderPath(s.Value))
+ .Select(s => _rootFolderService.GetBestRootFolderPath(s.Value, allRootFolders))
.Distinct();
var missingRootFolders = rootFolders.Where(s => !s.IsPathValid(PathValidationType.CurrentOs) || !_diskProvider.FolderExists(s))
diff --git a/src/NzbDrone.Core/Movies/MovieRepository.cs b/src/NzbDrone.Core/Movies/MovieRepository.cs
index 5c8ec8f700..104056ec09 100644
--- a/src/NzbDrone.Core/Movies/MovieRepository.cs
+++ b/src/NzbDrone.Core/Movies/MovieRepository.cs
@@ -63,6 +63,30 @@ public MovieRepository(IMainDatabase database,
_alternativeTitleRepository = alternativeTitleRepository;
}
+ // MovieFiles.MediaInfo holds the full ffprobe dump (~7KB a row) that no paged consumer
+ // renders, so the file columns are listed explicitly instead of "MovieFiles".*. Built
+ // from the mapper rather than hardcoded so a new column can't silently go missing.
+ private static readonly string _pagedMovieFileColumns = BuildPagedMovieFileColumns();
+
+ private static string BuildPagedMovieFileColumns()
+ {
+ var table = TableMapping.Mapper.TableNameMapping(typeof(MovieFile));
+ var excluded = TableMapping.Mapper.ExcludeProperties(typeof(MovieFile)).Select(x => x.Name).ToList();
+
+ var columns = typeof(MovieFile).GetProperties()
+ .Where(x => x.IsMappableProperty() &&
+ !excluded.Contains(x.Name) &&
+ x.Name != nameof(MovieFile.MediaInfo))
+ .Select(x => x.Name)
+
+ // Dapper splits on the first "Id" column, so the file's must lead its segment.
+ .OrderBy(x => x == nameof(ModelBase.Id) ? 0 : 1)
+ .ThenBy(x => x, StringComparer.Ordinal)
+ .Select(x => $"\"{table}\".\"{x}\"");
+
+ return string.Join(", ", columns);
+ }
+
protected override IEnumerable PagedQuery(SqlBuilder builder) =>
_database.QueryJoined(builder, (movie, movieMetadata) =>
{
@@ -73,12 +97,50 @@ protected override IEnumerable PagedQuery(SqlBuilder builder) =>
// Paged queries omit the AlternativeTitles JOIN to prevent duplicate rows.
// The one-to-many JOIN in Builder() multiplies rows; PagedQuery maps each
// SQL row directly without the deduplication that Query() performs via Map().
- // QualityProfile is intentionally excluded: QualityProfileId on Movies is sufficient
- // for sort/filter; the client resolves the display name from its own store.
+ // QualityProfile is intentionally not joined: QualityProfileId on Movies is sufficient
+ // for sort/filter, and GetPaged hydrates the profile itself from the profile repository.
protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType)
.Join((m, p) => m.MovieMetadataId == p.Id)
.LeftJoin((m, f) => m.MovieFileId == f.Id);
+ public override PagingSpec GetPaged(PagingSpec pagingSpec)
+ {
+ pagingSpec.Records = GetPagedRecords(PagedBuilder(), pagingSpec, PagedQueryWithFile);
+ pagingSpec.TotalRecords = GetPagedRecordCount(PagedBuilder().SelectCount(), pagingSpec);
+
+ return pagingSpec;
+ }
+
+ // The index renders the file's quality, so the paged index needs MovieFile hydrated.
+ // MoviesWithoutFiles and MoviesWhereCutoffUnmet deliberately keep using PagedQuery: they
+ // GROUP BY Movies.Id, and selecting a non-aggregated file column under that errors on
+ // Postgres. QualityProfile comes from the repository rather than a JOIN because
+ // MovieFileResource.ToResource dereferences it to compute QualityCutoffNotMet.
+ private IEnumerable PagedQueryWithFile(SqlBuilder builder)
+ {
+ var profiles = _profileRepository.All().ToDictionary(x => x.Id);
+
+ // A movie can point at a profile that no longer exists, and QualityCutoffNotMet
+ // dereferences the profile, so fall back the way All() does rather than leave it null.
+ var fallbackProfile = profiles.Values.FirstOrDefault(x => x.Fallback) ?? profiles.Values.FirstOrDefault();
+
+ var sql = builder
+ .Select($"\"{_table}\".*, \"MovieMetadata\".*, {_pagedMovieFileColumns}")
+ .AddSelectTemplate(typeof(Movie));
+
+ return _database.Query(
+ sql.RawSql,
+ (movie, movieMetadata, movieFile) =>
+ {
+ movie.MovieMetadata = movieMetadata;
+ movie.MovieFile = movieFile;
+ movie.QualityProfile = profiles.TryGetValue(movie.QualityProfileId, out var profile) ? profile : fallbackProfile;
+
+ return movie;
+ },
+ sql.Parameters);
+ }
+
protected override SqlBuilder Builder() => new SqlBuilder(_database.DatabaseType)
.Join((m, p) => m.QualityProfileId == p.Id)
.Join((m, p) => m.MovieMetadataId == p.Id)
diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs
index 81c93caa7d..0f674e38a8 100644
--- a/src/NzbDrone.Core/Parser/Parser.cs
+++ b/src/NzbDrone.Core/Parser/Parser.cs
@@ -232,6 +232,9 @@ public static class Parser
private static readonly Regex RequestInfoRegex = new Regex(@"^(?:\[.+?\])+", RegexOptions.Compiled);
+ // Strips domain suffixes (Site.com -> Site) anywhere in the studio token, not just the trailing one.
+ private static readonly Regex StudioDomainSuffixRegex = new Regex(@"\.(com|net|org|tv|xxx|co|io)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
+
private static readonly string[] Numbers = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" };
private static Dictionary _umlautMappings = new Dictionary
{
@@ -341,21 +344,33 @@ public static ParsedMovieInfo ParseMovieTitle(string title, bool isDir = false)
if (result != null)
{
- // TODO: Add tests for this!
var simpleReleaseTitle = SimpleReleaseTitleRegex.Replace(releaseTitle, string.Empty);
var simpleTitleReplaceString = match[0].Groups["title"].Success ? match[0].Groups["title"].Value : result.PrimaryMovieTitle;
if (simpleTitleReplaceString.IsNotNullOrWhiteSpace())
{
- if (match[0].Groups["title"].Success && match[0].Groups["title"].Index < simpleReleaseTitle.Length)
+ var titleReplacement = simpleTitleReplaceString.Contains('.') ? "A.Movie" : "A Movie";
+ var releaseTokens = result.ReleaseTokens?.Trim('.', ' ', '-', '_');
+
+ // For a scene release the "title" capture runs to the end of the string, so it spans
+ // the quality block as well as the title, and its offsets count characters in
+ // simpleTitle, which has had the quality and codec tokens deleted. Replacing on those
+ // offsets overruns into simpleReleaseTitle's quality block and shreds the codec token.
+ // ReleaseTokens is cut from releaseTitle at the title boundary, so swapping it out by
+ // value keeps the quality block intact for custom formats to match against.
+ if (releaseTokens.IsNotNullOrWhiteSpace() && simpleReleaseTitle.Contains(releaseTokens))
+ {
+ simpleReleaseTitle = simpleReleaseTitle.Replace(releaseTokens, titleReplacement);
+ }
+ else if (match[0].Groups["title"].Success && match[0].Groups["title"].Index < simpleReleaseTitle.Length)
{
simpleReleaseTitle = simpleReleaseTitle.Remove(match[0].Groups["title"].Index, match[0].Groups["title"].Length)
- .Insert(match[0].Groups["title"].Index, simpleTitleReplaceString.Contains('.') ? "A.Movie" : "A Movie");
+ .Insert(match[0].Groups["title"].Index, titleReplacement);
}
else
{
- simpleReleaseTitle = simpleReleaseTitle.Replace(simpleTitleReplaceString, simpleTitleReplaceString.Contains('.') ? "A.Movie" : "A Movie");
+ simpleReleaseTitle = simpleReleaseTitle.Replace(simpleTitleReplaceString, titleReplacement);
}
}
@@ -916,7 +931,14 @@ private static ParsedMovieInfo ParseMatchCollection(MatchCollection matchCollect
result.ReleaseDate = airDate.ToString(Movie.RELEASE_DATE_FORMAT);
}
- var studioTitle = matchCollection[0].Groups["studiotitle"].Value.TrimAtEnd(".com").Replace('.', ' ').Replace('_', ' ');
+ // Scene sites are frequently cross-posted under several umbrella brands, e.g.
+ // "[SiteA.com / SiteB.com]" or "[SiteC.com / SiteD.com]". Take the
+ // first (most specific) brand and strip domain suffixes so the token resolves to a known
+ // studio. Previously this produced e.g. "SiteA com / SiteB", which matched nothing.
+ var studioTitleToken = matchCollection[0].Groups["studiotitle"].Value.Split(new[] { '/', '|' })[0];
+ studioTitleToken = StudioDomainSuffixRegex.Replace(studioTitleToken, string.Empty);
+
+ var studioTitle = studioTitleToken.Replace('.', ' ').Replace('_', ' ');
studioTitle = RequestInfoRegex.Replace(studioTitle, "").Trim(' ');
var lastSeasonEpisodeStringIndex = matchCollection[0].Groups["studiotitle"].EndIndex();
diff --git a/src/NzbDrone.Host/Whisparr.Host.csproj b/src/NzbDrone.Host/Whisparr.Host.csproj
index 068a63f516..7ffd9d0e52 100644
--- a/src/NzbDrone.Host/Whisparr.Host.csproj
+++ b/src/NzbDrone.Host/Whisparr.Host.csproj
@@ -5,9 +5,9 @@
-
-
-
+
+
+
diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
index a09a68a98b..c1df606875 100644
--- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
+++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
@@ -54,7 +54,7 @@ protected override void SetWritePermissions(string path, bool writable)
SetWritePermissionsInternal(path, writable, false);
}
- protected void SetWritePermissionsInternal(string path, bool writable, bool setgid)
+ protected static void SetWritePermissionsInternal(string path, bool writable, bool setgid)
{
// Remove Write permissions, we're still owner so we can clean it up, but we'll have to do that explicitly.
Syscall.stat(path, out var stat);
@@ -175,6 +175,62 @@ public void should_return_special_mount_when_queried(string rootDir)
mount.RootDirectory.Should().Be(rootDir);
}
+ private void GivenMounts(params string[] rootDirs)
+ {
+ Mocker.GetMock()
+ .Setup(v => v.GetCompleteRealPath(It.IsAny()))
+ .Returns(s => s);
+
+ Mocker.GetMock()
+ .Setup(v => v.GetMounts())
+ .Returns(rootDirs.Select(r =>
+ (IMount)new ProcMount(DriveType.Fixed, r, r, "myfs", new MountOptions(new Dictionary())))
+ .ToList());
+ }
+
+ [Test]
+ public void should_return_the_longest_matching_mount()
+ {
+ GivenMounts("/mnt/nested-test", "/mnt/nested-test/media");
+
+ var mount = Subject.GetMount("/mnt/nested-test/media/movie/file.mkv");
+
+ mount.RootDirectory.Should().Be("/mnt/nested-test/media");
+ }
+
+ [Test]
+ public void should_return_mount_when_path_is_the_mount_root()
+ {
+ GivenMounts("/mnt/exact-test");
+
+ var mount = Subject.GetMount("/mnt/exact-test");
+
+ mount.RootDirectory.Should().Be("/mnt/exact-test");
+ }
+
+ [Test]
+ public void should_return_mount_for_path_containing_relative_segments()
+ {
+ GivenMounts("/mnt/relative-test");
+
+ var mount = Subject.GetMount("/mnt/other/../relative-test");
+
+ mount.RootDirectory.Should().Be("/mnt/relative-test");
+ }
+
+ [Test]
+ public void should_only_enumerate_mounts_once_for_repeated_lookups()
+ {
+ GivenSpecialMount("/mnt/cache-test");
+
+ Subject.GetMount("/mnt/cache-test/dir/one.mkv");
+ Subject.GetMount("/mnt/cache-test/dir/two.mkv");
+ Subject.GetMounts();
+
+ Mocker.GetMock()
+ .Verify(v => v.GetMounts(), Times.Once());
+ }
+
[Test]
public void should_copy_folder_permissions()
{
diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs
index c341e52e84..2d956ca15b 100644
--- a/src/NzbDrone.Mono/Disk/DiskProvider.cs
+++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs
@@ -5,6 +5,7 @@
using Mono.Unix;
using Mono.Unix.Native;
using NLog;
+using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo;
@@ -23,7 +24,8 @@ public class DiskProvider : DiskProviderBase
private readonly ISymbolicLinkResolver _symLinkResolver;
private readonly ICreateRefLink _createRefLink;
- public DiskProvider(IProcMountProvider procMountProvider, ISymbolicLinkResolver symLinkResolver, ICreateRefLink createRefLink, Logger logger)
+ public DiskProvider(IProcMountProvider procMountProvider, ISymbolicLinkResolver symLinkResolver, ICreateRefLink createRefLink, ICacheManager cacheManager, Logger logger)
+ : base(cacheManager)
{
_procMountProvider = procMountProvider;
_symLinkResolver = symLinkResolver;
@@ -166,7 +168,7 @@ public override void CopyPermissions(string sourcePath, string targetPath)
}
}
- protected override List GetAllMounts()
+ protected override List FetchAllMounts()
{
var mounts = new List();
diff --git a/src/NzbDrone.Windows/Disk/DiskProvider.cs b/src/NzbDrone.Windows/Disk/DiskProvider.cs
index 88bdc8fdbb..bd975e5ef0 100644
--- a/src/NzbDrone.Windows/Disk/DiskProvider.cs
+++ b/src/NzbDrone.Windows/Disk/DiskProvider.cs
@@ -5,6 +5,7 @@
using System.Security.AccessControl;
using System.Security.Principal;
using NLog;
+using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Instrumentation;
@@ -15,6 +16,11 @@ public class DiskProvider : DiskProviderBase
{
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DiskProvider));
+ public DiskProvider(ICacheManager cacheManager)
+ : base(cacheManager)
+ {
+ }
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetDiskFreeSpaceEx(string lpDirectoryName,
diff --git a/src/Whisparr.Api.V3/Movies/MovieController.cs b/src/Whisparr.Api.V3/Movies/MovieController.cs
index 9bf91f4aad..d35264fd2e 100644
--- a/src/Whisparr.Api.V3/Movies/MovieController.cs
+++ b/src/Whisparr.Api.V3/Movies/MovieController.cs
@@ -610,7 +610,9 @@ public ActionResult> GetPaged([FromBody] MoviePagi
var pageSpec = new NzbDrone.Core.Datastore.PagingSpec
{
Page = request.Page ?? 1,
- PageSize = request.PageSize ?? 10,
+
+ // The index page size options allow up to 1000 inclusive.
+ PageSize = request.PageSize > 0 && request.PageSize <= 1000 ? request.PageSize.Value : 10,
SortKey = Sorting.GetSortKeyNormalized(_allowedMovieSortKeys.Contains(request.SortKey ?? "") ? request.SortKey : null),
SortDirection = request.SortDirection ?? NzbDrone.Core.Datastore.SortDirection.Ascending,
DefaultSortKeys = new List { "movieMetadata.ReleaseDate", "movieMetadata.StudioTitle", "movieMetadata.SortTitle" }
diff --git a/src/Whisparr.Api.V3/Search/SearchController.cs b/src/Whisparr.Api.V3/Search/SearchController.cs
index 1fee1f3ae0..d1abd1e9f3 100644
--- a/src/Whisparr.Api.V3/Search/SearchController.cs
+++ b/src/Whisparr.Api.V3/Search/SearchController.cs
@@ -27,6 +27,7 @@ public class SearchController : Controller
private readonly IConfigService _configService;
private readonly IImportListExclusionService _exclusionService;
private readonly IMovieService _movieService;
+ private readonly IStudioService _studioService;
public SearchController(ISearchForNewMovie searchProxy,
IBuildFileNames fileNameBuilder,
@@ -34,7 +35,8 @@ public SearchController(ISearchForNewMovie searchProxy,
IMapCoversToLocal coverMapper,
IConfigService configService,
IImportListExclusionService exclusionService,
- IMovieService movieService)
+ IMovieService movieService,
+ IStudioService studioService)
{
_searchProxy = searchProxy;
_fileNameBuilder = fileNameBuilder;
@@ -43,6 +45,7 @@ public SearchController(ISearchForNewMovie searchProxy,
_configService = configService;
_exclusionService = exclusionService;
_movieService = movieService;
+ _studioService = studioService;
}
[HttpGet("scene")]
@@ -67,7 +70,9 @@ public object SearchMovie([FromQuery] string term)
public object SearchStudio([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewStudio(term);
- return MapToResource(searchResults).ToList();
+ var searchResources = MapToResource(searchResults).ToList();
+ MapToExistingStudios(searchResources);
+ return searchResources;
}
[HttpGet("performer")]
@@ -90,6 +95,19 @@ private void MapToExistingMovies(List searchResources)
}
}
+ private void MapToExistingStudios(List searchResources)
+ {
+ var matches = _studioService.FindByForeignIds(searchResources.Select(s => s.ForeignId).ToList());
+ foreach (var s in searchResources)
+ {
+ var match = matches.Where(m => m.ForeignId == s.ForeignId);
+ if (match.Any())
+ {
+ s.isExisting = true;
+ }
+ }
+ }
+
private IEnumerable MapToResource(IEnumerable