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