From a0a6a3d209ac3aef72ea2fd70f751086048c731c Mon Sep 17 00:00:00 2001 From: Mothball <158273459+Mothball7205@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:00:54 +0200 Subject: [PATCH 01/21] chore: migrate "add new studio" lookup and add flow to tanstack query (#301) --- frontend/src/Store/Actions/addMovieActions.js | 122 +--------- .../src/Studio/AddNewStudio/AddNewStudio.tsx | 5 +- .../Studio/AddNewStudio/AddNewStudioModal.tsx | 3 +- .../AddNewStudio/AddNewStudioModalContent.tsx | 7 +- .../AddNewStudio/AddNewStudioSearchResult.tsx | 1 + .../Studio/AddNewStudio/useAddNewStudio.ts | 222 +++++++----------- .../Search/SearchController.cs | 22 +- 7 files changed, 118 insertions(+), 264 deletions(-) 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/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 results) { var id = 1; From 1a414a13bf790d54a06da3e0532e104bcddc0cf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:55:43 -0400 Subject: [PATCH 02/21] Chore: Bump postcss from 8.5.10 to 8.5.11 (#299) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 004f8ef47a..c4a6f33593 100644 --- a/package.json +++ b/package.json @@ -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.11", "postcss-color-function": "4.1.0", "postcss-loader": "8.2.1", "postcss-mixins": "12.1.2", diff --git a/yarn.lock b/yarn.lock index 665e14d310..2f8647927d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6501,10 +6501,10 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.5.10, postcss@^8.0.0, postcss@^8.4.35, postcss@^8.4.40, postcss@^8.5.6, postcss@^8.5.8: - version "8.5.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" - integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== +postcss@8.5.11, postcss@^8.0.0, postcss@^8.4.35, postcss@^8.4.40, postcss@^8.5.6, postcss@^8.5.8: + version "8.5.11" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.11.tgz#42ed344ff95c002a76f73678416adabecc8607dd" + integrity sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" From c529a10009f6bca139bb73e6ee09828830e19592 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:39:15 -0400 Subject: [PATCH 03/21] Chore: Bump ghcr.io/devcontainers/features/node from 1.7.1 to 2.1.0 (#315) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 04f2b833c30f6930fb88fd1596ca9f917049aeec Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:39:31 -0400 Subject: [PATCH 04/21] chore: bump swashbuckle (#321) --- src/NzbDrone.Host/Whisparr.Host.csproj | 6 +++--- src/Whisparr.Api.V3/Whisparr.Api.V3.csproj | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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/Whisparr.Api.V3/Whisparr.Api.V3.csproj b/src/Whisparr.Api.V3/Whisparr.Api.V3.csproj index c063bb8003..00fc3eac74 100644 --- a/src/Whisparr.Api.V3/Whisparr.Api.V3.csproj +++ b/src/Whisparr.Api.V3/Whisparr.Api.V3.csproj @@ -13,7 +13,7 @@ - + From 0736790a8896fef53f18b17ae763f679a35b8b8c Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:47:32 -0400 Subject: [PATCH 05/21] Chore: Cloudflare cookie test expiring and reddening CI (#325) --- src/NzbDrone.Common.Test/Http/HttpClientFixture.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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(); From ae2248f5b9f9140b02ca30270795318b25e30770 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:32:06 -0400 Subject: [PATCH 06/21] test: add MountCheck coverage MountCheck had no tests. Pins current behaviour before optimising the mount lookups it performs: the null-mount and null-MountOptions guards (MountOptions is always null on Windows, since DriveInfoMount is built without them), the DistinctBy on root directory, and the reported path ordering. --- .../HealthCheck/Checks/MountCheckFixture.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/NzbDrone.Core.Test/HealthCheck/Checks/MountCheckFixture.cs 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); + } + } +} From c2e4645b9327737f8d17717acbf9d6e7d7e07873 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:35:44 -0400 Subject: [PATCH 07/21] fix: cache the mount list instead of enumerating it per lookup GetMount() re-enumerated the entire OS mount table on every call. On Linux that reads /proc/mounts and stats every mount point, so on a host with many network mounts it is hundreds of syscalls per call. MountCheck calls GetMount() once per movie path, which made Check Health take over 24 hours on a 264k scene library. DiskTransferService also pays this twice per file transfer. Cache the mount list for 15 seconds. That collapses a health check run from hundreds of thousands of enumerations to roughly one every 15s, while staying short enough that a newly mounted share still shows up in the folder browser without any invalidation hooks. GetMount() stays per-path: MountCheck resolves mounts from movie paths specifically so symlinks and junctions are handled by the provider, so the number of lookups is unchanged, only their cost. Only topology is cached. Free space is unaffected, since IMount reads it live from the underlying DriveInfo/UnixDriveInfo on each property access. Fixes Whisparr/Whisparr#1102 --- src/NzbDrone.Common/Disk/DiskProviderBase.cs | 28 ++++++++++++++++++- .../DiskProviderTests/DiskProviderFixture.cs | 13 +++++++++ src/NzbDrone.Mono/Disk/DiskProvider.cs | 6 ++-- src/NzbDrone.Windows/Disk/DiskProvider.cs | 6 ++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 5953ff1571..ba99cc125e 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 @@ -509,7 +526,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)) diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index a09a68a98b..08d815d0c6 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -175,6 +175,19 @@ public void should_return_special_mount_when_queried(string rootDir) mount.RootDirectory.Should().Be(rootDir); } + [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, From 7ec3325726d0ccbd0db831cc818250fd54d72c81 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:38:44 -0400 Subject: [PATCH 08/21] fix: stop re-querying root folders once per movie RootFolderCheck and DiskSpaceService called GetBestRootFolderPath without passing the root folders. The result is cached per path, but on a miss it falls back to querying every root folder from the database, so a cold cache meant one query per movie. RootFolderCheck runs on startup, when the cache is always cold, which on a 264k scene library is 264k queries. Pass the root folders in via the existing overload, which is there for exactly this. --- .../Checks/RootFolderCheckFixture.cs | 25 ++++++++++++++++--- .../DiskSpace/DiskSpaceService.cs | 5 +++- .../HealthCheck/Checks/RootFolderCheck.cs | 5 +++- 3 files changed, 30 insertions(+), 5 deletions(-) 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/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)) From 09f0744c3b77d625695d1ea9d8382a0b835c83e4 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:49:19 -0400 Subject: [PATCH 09/21] perf: walk the path once per mount lookup, not once per mount GetMount tested every mount with IsParentPath, which walks the path back up to the root each time. That walk only depends on the path, so it was being repeated identically for every mount on the system. Hoist it: build the ancestors once per lookup and compare each mount root against them. PathEquals is untouched, and the comparison is the same OsPath equality IsParentPath performed, so paths resolve exactly as before, including relative segments that PathEquals resolves via CleanFilePath. Measured over 130 mounts, roughly 160us -> 62us per lookup, or ~42s -> ~16s across a 264k scene library. --- src/NzbDrone.Common/Disk/DiskProviderBase.cs | 30 +++++++++++-- .../DiskProviderTests/DiskProviderFixture.cs | 43 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index ba99cc125e..9c2e3cd77f 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -552,11 +552,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) { @@ -565,6 +568,25 @@ public virtual IMount GetMount(string path) } } + 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 List GetDriveInfoMounts() { return DriveInfo.GetDrives() diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 08d815d0c6..6d9fb9775a 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -175,6 +175,49 @@ 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() { From dd1c29ea9aa70d503b66f8a5575f76918c51608f Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:53:27 -0400 Subject: [PATCH 10/21] chore: address SonarQube findings in disk provider None of these sit on lines the mount check perf work touches; they are pre-existing findings inherited from upstream. Keeping them in their own commit so that PR stays reviewable and the divergence is easy to drop. - Pass the caught exception to the logger in FolderWritable (S6667). The NLog layout renders ${exception:format=ToString} on any call carrying one, so the inline copy of e.Message goes away rather than being printed twice. Net gain is the exception type, which the bare message did not carry. - Rename IsFileLocked's parameter to path, matching IDiskProvider (S927). No caller passes it by name. - Drop RemoveReadOnlyFolder, which has no callers (S1144). - Make GetDriveInfoMounts static (S2325). It was never virtual, so no subclass could have overridden it. - Make SetWritePermissionsInternal static in the Mono test fixture (S2325). --- src/NzbDrone.Common/Disk/DiskProviderBase.cs | 22 ++++--------------- .../DiskProviderTests/DiskProviderFixture.cs | 2 +- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 9c2e3cd77f..21a90f4cda 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -155,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; } } @@ -405,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; } @@ -455,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); @@ -587,7 +573,7 @@ private static bool IsAncestor(List ancestors, string parentPath) return ancestors.Any(a => a.Equals(parent, true)); } - protected List GetDriveInfoMounts() + protected static List GetDriveInfoMounts() { return DriveInfo.GetDrives() .Where(d => d.IsReady) diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 6d9fb9775a..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); From 7a66a5f18707613e0bbe025209abd7e4081bced9 Mon Sep 17 00:00:00 2001 From: v8eta <85236239+v8eta@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:32:21 +0800 Subject: [PATCH 11/21] fix: multi-brand studio token parsing for cross-posted scenes (#320) --- .../ParserTests/ParseMovieTitleFixture.cs | 2 ++ src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs | 2 +- src/NzbDrone.Core/Parser/Parser.cs | 12 +++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs index 533ecf35eb..84c0fb9e3d 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; 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/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 81c93caa7d..6dd1b7f639 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 { @@ -916,7 +919,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(); From 30185887b82001c81cc9413e5232a18470480f74 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:52:33 -0400 Subject: [PATCH 12/21] Fixed: Movie file quality missing from the paged movie/scene index The index status bar showed a generic "Downloaded" instead of the file's quality. Every index view (table, poster, overview, for both scenes and movies) already renders movieFile.quality.quality.name and falls back to "Downloaded" when it is absent -- the paged API simply never sent it. PagedBuilder already LEFT JOINs MovieFiles, but QueryJoined builds its SELECT from its type parameters, so PagedQuery's emitted only Movies.* and MovieMetadata.*. Movie.MovieFile is a plain property rather than LazyLoaded, so nothing back-filled it and ToResource's null-conditional dropped the file. GetPaged now maps MovieFile via its own query. Two constraints shaped it: - PagedQuery is shared with MoviesWithoutFiles and MoviesWhereCutoffUnmet, which GROUP BY Movies.Id. Selecting a non-aggregated file column under that GROUP BY errors on Postgres, so those keep using PagedQuery and only GetPaged changes. Neither needs the file: their rows fetch it separately. - MovieFileResource.ToResource dereferences Movie.QualityProfile to compute QualityCutoffNotMet, so hydrating MovieFile without a profile would throw. The profile is hydrated from the repository, falling back the way All() does when a movie points at a profile that no longer exists. MediaInfo is left out of the query: it stores the full ffprobe dump (~7KB a row, measured) that no index view renders, and it would otherwise be deserialized for every row on a page of up to 1000. Fixes Whisparr/Whisparr#1104 --- .../v3/Movies/MovieResourceFixture.cs | 94 +++++++++++++ .../MovieRepositoryFixture.cs | 132 ++++++++++++++++++ src/NzbDrone.Core/Movies/MovieRepository.cs | 66 ++++++++- 3 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 src/NzbDrone.Api.Test/v3/Movies/MovieResourceFixture.cs 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.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/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) From cfdf27dfdacebb8f080fcc7c0f815b0816585f53 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:56:16 -0400 Subject: [PATCH 13/21] Fixed: Unbounded pageSize on the paged movie endpoint POST /movie/paged took request.PageSize verbatim, so a client could ask for any number of records while the index page size options cap at 1000. The tag-filter branch of this same endpoint, StudioController and PerformerController all already guard their page size. Uses <= 1000 rather than the < 1000 those three use: the page size options allow 1000 inclusive, so the exclusive form would quietly serve 10 records to anyone who picked the documented maximum. Out-of-range values fall back to 10, matching the existing guards. This is a behavior change for any client that passes a page size above 1000. --- src/Whisparr.Api.V3/Movies/MovieController.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" } From 154f63de68deb0f27510405eafe9138685c08868 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:26:59 -0400 Subject: [PATCH 14/21] Fixed: Scene title replacement eating codec tokens from simpleReleaseTitle (#327) --- .../ParserTests/ParseMovieTitleFixture.cs | 27 +++++++++++++++++++ src/NzbDrone.Core/Parser/Parser.cs | 20 +++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs index 84c0fb9e3d..81d69eabf5 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParseMovieTitleFixture.cs @@ -171,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/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 6dd1b7f639..0f674e38a8 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -344,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); } } From f11a7310170d7d6d18f94ef1b1a0be286fc49d30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:33:45 -0400 Subject: [PATCH 15/21] Chore: Bump ws from 7.5.10 to 7.5.12 (#328) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2f8647927d..efcc35cbbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8628,9 +8628,9 @@ write-file-atomic@^7.0.0: signal-exit "^4.0.1" ws@^7.5.10: - version "7.5.10" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + version "7.5.12" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.12.tgz#4ca2c04966db4dcd2f9bc4d1419d23cd1e4a18db" + integrity sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg== xxhashjs@~0.2.2: version "0.2.2" From 954c64a53320fb213b2d00a953848d38e1670745 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:37:59 -0400 Subject: [PATCH 16/21] Chore: Bump qs from 6.15.1 to 6.15.2 (#292) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c4a6f33593..ce6b8a0da7 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", diff --git a/yarn.lock b/yarn.lock index efcc35cbbc..879a5d7cee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6592,10 +6592,10 @@ qified@^0.6.0: dependencies: hookified "^1.14.0" -qs@6.15.1, qs@^6.4.0: - version "6.15.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" - integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== +qs@6.15.2, qs@^6.4.0: + version "6.15.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9" + integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== dependencies: side-channel "^1.1.0" From 00d5522b3ce4cda70ddb11ed5c3c7963a639bd4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:40:53 -0400 Subject: [PATCH 17/21] Chore: Bump @babel/plugin-transform-modules-systemjs from 7.29.0 to 7.29.7 (#298) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index 879a5d7cee..ae4367a2c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,6 +38,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.2.tgz#4183f9e642fd84e74e3eea7ffa93a412e3b102c9" @@ -100,6 +109,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" + integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== + dependencies: + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz#63f02dbfa1f7cb75a9bdb832f300582f30bb8972" @@ -192,6 +212,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + "@babel/helper-member-expression-to-functions@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz#ea1211276be93e798ce19037da6f06fbb994fa44" @@ -224,6 +249,14 @@ "@babel/traverse" "^7.28.6" "@babel/types" "^7.28.6" +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-module-transforms@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz#e1663b8b71d2de948da5c4fb2a20ca4f3ec27a6f" @@ -242,6 +275,15 @@ "@babel/helper-validator-identifier" "^7.28.5" "@babel/traverse" "^7.28.6" +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/helper-optimise-call-expression@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200" @@ -264,6 +306,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== +"@babel/helper-plugin-utils@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz#c0a0766f1a13617d8a17407d7ab8f9d486225ea4" + integrity sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw== + "@babel/helper-remap-async-to-generator@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6" @@ -309,6 +356,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + "@babel/helper-validator-identifier@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz#77b7f60c40b15c97df735b38a66ba1d7c3e93da5" @@ -319,6 +371,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -372,6 +429,13 @@ dependencies: "@babel/types" "^7.28.5" +"@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz#fbde57974707bbfa0376d34d425ff4fa6c732421" @@ -671,14 +735,14 @@ "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-modules-systemjs@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz#e458a95a17807c415924106a3ff188a3b8dee964" - integrity sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ== + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz#e575dd2ab9882906de120ff7dc9dee9914d8b6f3" + integrity sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ== dependencies: - "@babel/helper-module-transforms" "^7.28.6" - "@babel/helper-plugin-utils" "^7.28.6" - "@babel/helper-validator-identifier" "^7.28.5" - "@babel/traverse" "^7.29.0" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helper-plugin-utils" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" "@babel/plugin-transform-modules-umd@^7.27.1": version "7.27.1" @@ -1054,6 +1118,15 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/traverse@^7.27.1": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" @@ -1080,6 +1153,19 @@ "@babel/types" "^7.29.0" debug "^4.3.1" +"@babel/traverse@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" + integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + debug "^4.3.1" + "@babel/types@^7.25.7", "@babel/types@^7.4.4": version "7.25.8" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.8.tgz#5cf6037258e8a9bcad533f4979025140cb9993e1" @@ -1113,6 +1199,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@cacheable/memory@^2.0.8": version "2.0.8" resolved "https://registry.yarnpkg.com/@cacheable/memory/-/memory-2.0.8.tgz#244b735e4d087c7826f2ce3ea45b57a56b272792" From f8f0faf503477006e8501b754b05b906edb94669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:42:45 -0400 Subject: [PATCH 18/21] Chore: Bump postcss from 8.5.11 to 8.5.12 (#318) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ce6b8a0da7..9df8c6b722 100644 --- a/package.json +++ b/package.json @@ -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.11", + "postcss": "8.5.12", "postcss-color-function": "4.1.0", "postcss-loader": "8.2.1", "postcss-mixins": "12.1.2", diff --git a/yarn.lock b/yarn.lock index ae4367a2c3..005f98ce00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6595,10 +6595,10 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.5.11, postcss@^8.0.0, postcss@^8.4.35, postcss@^8.4.40, postcss@^8.5.6, postcss@^8.5.8: - version "8.5.11" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.11.tgz#42ed344ff95c002a76f73678416adabecc8607dd" - integrity sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg== +postcss@8.5.12, postcss@^8.0.0, postcss@^8.4.35, postcss@^8.4.40, postcss@^8.5.6, postcss@^8.5.8: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" From 39be23411f58370f81726f58b1d92a87445678f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:56:18 -0400 Subject: [PATCH 19/21] Chore: Bump @babel/core from 7.29.0 to 7.29.6 (#311) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 275 +++++---------------------------------------------- 2 files changed, 25 insertions(+), 252 deletions(-) diff --git a/package.json b/package.json index 9df8c6b722..a41dfb791b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 005f98ce00..310537f36e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,33 +12,7 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7" - integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g== - dependencies: - "@babel/highlight" "^7.25.7" - picocolors "^1.0.0" - -"@babel/code-frame@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" - integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== - dependencies: - "@babel/helper-validator-identifier" "^7.27.1" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" - integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== - dependencies: - "@babel/helper-validator-identifier" "^7.28.5" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.29.7": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.29.0", "@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== @@ -47,27 +21,22 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/compat-data@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.2.tgz#4183f9e642fd84e74e3eea7ffa93a412e3b102c9" - integrity sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ== - "@babel/compat-data@^7.28.6", "@babel/compat-data@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== -"@babel/core@7.29.0", "@babel/core@^7.24.4": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" - integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== +"@babel/core@7.29.6", "@babel/core@^7.24.4": + version "7.29.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.6.tgz#2f2c2ca1728ae73c9b68ece91d1ae6dee18ff83a" + integrity sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA== dependencies: "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" + "@babel/generator" "^7.29.6" "@babel/helper-compilation-targets" "^7.28.6" "@babel/helper-module-transforms" "^7.28.6" - "@babel/helpers" "^7.28.6" - "@babel/parser" "^7.29.0" + "@babel/helpers" "^7.29.2" + "@babel/parser" "^7.29.3" "@babel/template" "^7.28.6" "@babel/traverse" "^7.29.0" "@babel/types" "^7.29.0" @@ -87,29 +56,7 @@ eslint-visitor-keys "^2.1.0" semver "^6.3.1" -"@babel/generator@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" - integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== - dependencies: - "@babel/parser" "^7.28.5" - "@babel/types" "^7.28.5" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" - jsesc "^3.0.2" - -"@babel/generator@^7.29.0": - version "7.29.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" - integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== - dependencies: - "@babel/parser" "^7.29.0" - "@babel/types" "^7.29.0" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" - jsesc "^3.0.2" - -"@babel/generator@^7.29.7": +"@babel/generator@^7.29.6", "@babel/generator@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== @@ -134,18 +81,7 @@ dependencies: "@babel/types" "^7.27.3" -"@babel/helper-compilation-targets@^7.27.1": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" - integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== - dependencies: - "@babel/compat-data" "^7.27.2" - "@babel/helper-validator-option" "^7.27.1" - browserslist "^4.24.0" - lru-cache "^5.1.1" - semver "^6.3.1" - -"@babel/helper-compilation-targets@^7.28.6": +"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== @@ -257,25 +193,7 @@ "@babel/traverse" "^7.29.7" "@babel/types" "^7.29.7" -"@babel/helper-module-transforms@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz#e1663b8b71d2de948da5c4fb2a20ca4f3ec27a6f" - integrity sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g== - dependencies: - "@babel/helper-module-imports" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@babel/traverse" "^7.27.1" - -"@babel/helper-module-transforms@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" - integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== - dependencies: - "@babel/helper-module-imports" "^7.28.6" - "@babel/helper-validator-identifier" "^7.28.5" - "@babel/traverse" "^7.28.6" - -"@babel/helper-module-transforms@^7.29.7": +"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6", "@babel/helper-module-transforms@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== @@ -346,31 +264,11 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" -"@babel/helper-string-parser@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz#d50e8d37b1176207b4fe9acedec386c565a44a54" - integrity sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g== - -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== - "@babel/helper-string-parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== -"@babel/helper-validator-identifier@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz#77b7f60c40b15c97df735b38a66ba1d7c3e93da5" - integrity sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg== - -"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - "@babel/helper-validator-identifier@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" @@ -390,46 +288,15 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" -"@babel/helpers@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" - integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== - dependencies: - "@babel/template" "^7.28.6" - "@babel/types" "^7.28.6" - -"@babel/highlight@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.25.7.tgz#20383b5f442aa606e7b5e3043b0b1aafe9f37de5" - integrity sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw== - dependencies: - "@babel/helper-validator-identifier" "^7.25.7" - chalk "^2.4.2" - js-tokens "^4.0.0" - picocolors "^1.0.0" - -"@babel/parser@^7.24.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" - integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== - dependencies: - "@babel/types" "^7.29.0" - -"@babel/parser@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.2.tgz#577518bedb17a2ce4212afd052e01f7df0941127" - integrity sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw== - dependencies: - "@babel/types" "^7.27.1" - -"@babel/parser@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" - integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== +"@babel/helpers@^7.29.2": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== dependencies: - "@babel/types" "^7.28.5" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" -"@babel/parser@^7.29.7": +"@babel/parser@^7.24.4", "@babel/parser@^7.29.3", "@babel/parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== @@ -1100,25 +967,7 @@ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.1.tgz#9fce313d12c9a77507f264de74626e87fd0dc541" integrity sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog== -"@babel/template@^7.27.1", "@babel/template@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" - integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/parser" "^7.27.2" - "@babel/types" "^7.27.1" - -"@babel/template@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" - integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== - dependencies: - "@babel/code-frame" "^7.28.6" - "@babel/parser" "^7.28.6" - "@babel/types" "^7.28.6" - -"@babel/template@^7.29.7": +"@babel/template@^7.27.1", "@babel/template@^7.28.6", "@babel/template@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== @@ -1127,33 +976,7 @@ "@babel/parser" "^7.29.7" "@babel/types" "^7.29.7" -"@babel/traverse@^7.27.1": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" - integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.5" - "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.5" - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.5" - debug "^4.3.1" - -"@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" - integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== - dependencies: - "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" - "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.29.0" - "@babel/template" "^7.28.6" - "@babel/types" "^7.29.0" - debug "^4.3.1" - -"@babel/traverse@^7.29.7": +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0", "@babel/traverse@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== @@ -1166,40 +989,7 @@ "@babel/types" "^7.29.7" debug "^4.3.1" -"@babel/types@^7.25.7", "@babel/types@^7.4.4": - version "7.25.8" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.8.tgz#5cf6037258e8a9bcad533f4979025140cb9993e1" - integrity sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg== - dependencies: - "@babel/helper-string-parser" "^7.25.7" - "@babel/helper-validator-identifier" "^7.25.7" - to-fast-properties "^2.0.0" - -"@babel/types@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560" - integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - -"@babel/types@^7.27.3", "@babel/types@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - -"@babel/types@^7.28.6", "@babel/types@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" - integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - -"@babel/types@^7.29.7": +"@babel/types@^7.25.7", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.29.7", "@babel/types@^7.4.4": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== @@ -2898,7 +2688,7 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -3341,20 +3131,13 @@ debug@^3.1.0, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.0.0, debug@^4.4.3: +debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - decode-named-character-reference@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed" @@ -7441,12 +7224,7 @@ semver@^6.0.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5: - version "7.6.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" - integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== - -semver@^7.6.2, semver@^7.6.3, semver@^7.7.3: +semver@^7.3.4, semver@^7.3.5, semver@^7.6.2, semver@^7.6.3, semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -8065,11 +7843,6 @@ to-camel-case@1.0.0, to-camel-case@^1.0.0: dependencies: to-space-case "^1.0.0" -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - to-no-case@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" From 874970f05ca9efe920a229e118693287847c5edb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:14:32 -0400 Subject: [PATCH 20/21] Chore: Bump js-yaml from 4.1.1 to 4.3.0 (#319) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 310537f36e..718c531087 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5099,9 +5099,9 @@ jquery@3.7.1: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^4.1.0, js-yaml@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" - integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + version "4.3.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592" + integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q== dependencies: argparse "^2.0.1" From 39b1db6574483ef7e726d14f53d97f6c5933b1e5 Mon Sep 17 00:00:00 2001 From: plz12345 <132735020+plz12345@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:40:00 -0400 Subject: [PATCH 21/21] Chore: derive base release version from git tag instead of hardcoded YAML (#329) --- .github/actions/build/action.yml | 5 ++++- .github/workflows/build_v3.yml | 34 +++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) 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 }}