diff --git a/src/components/Utilities/ReleaseManagement/ReleaseManagementPreviewModal.tsx b/src/components/Utilities/ReleaseManagement/ReleaseManagementPreviewModal.tsx index 65d93edc5..c7d75669c 100644 --- a/src/components/Utilities/ReleaseManagement/ReleaseManagementPreviewModal.tsx +++ b/src/components/Utilities/ReleaseManagement/ReleaseManagementPreviewModal.tsx @@ -165,9 +165,12 @@ const ReleaseManagementPreviewModal = ({ show && !mixMatchSelection, ); + // Mix & Match always fetches with includeVariations=true, so a selection may legitimately + // include a variation's PlaceID - this must match or the endpoint 400s on an "unknown" ID. const mixMatchPreviewQuery = useReleaseMixMatchDeletionPreviewQuery( selectedSeries?.[0] ?? 0, { selectedPlaceIDs: mixMatchSelection ?? [] }, + true, show && !!mixMatchSelection && !!selectedSeries, ); const mixMatchPreviewData = mixMatchPreviewQuery.data ? [mixMatchPreviewQuery.data] : undefined; diff --git a/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesDetail.tsx b/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesDetail.tsx index 44ba21f5b..2f598dc20 100644 --- a/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesDetail.tsx +++ b/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesDetail.tsx @@ -95,9 +95,18 @@ const ReleaseManagementSeriesDetail = () => { const [mixMatchSelection, setMixMatchSelection] = useImmer>(new Map()); const [mixMatchUnassignedCount, setMixMatchUnassignedCount] = useState(0); + // Candidates tab passes through the list page's includeVariations toggle, same as the batch + // preview. Mix & Match always wants every file - variations included - as a pickable option + // regardless of that toggle, so it fetches independently with includeVariations forced true. const seriesQuery = useReleaseManagementSeriesDetailQuery(seriesId, includeVariations, seriesId > 0); const series = seriesQuery.data; + const mixMatchSeriesQuery = useReleaseManagementSeriesDetailQuery( + seriesId, + true, + seriesId > 0 && activeTab === 'mixmatch', + ); + useEffect(() => { if (seriesQuery.isError) { toast.error(`Series ${seriesId} is invalid or does not have multiple releases`); @@ -219,10 +228,24 @@ const ReleaseManagementSeriesDetail = () => { /> )} - {series && activeTab === 'mixmatch' && ( + {activeTab === 'mixmatch' && mixMatchSeriesQuery.isPending && ( +
+ +
+ )} + + {activeTab === 'mixmatch' && mixMatchSeriesQuery.isError && ( +
+ + Failed to load Mix & Match options. + +
+ )} + + {activeTab === 'mixmatch' && mixMatchSeriesQuery.data && ( diff --git a/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesList.tsx b/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesList.tsx index 4531d6ab1..8537a0b42 100644 --- a/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesList.tsx +++ b/src/components/Utilities/ReleaseManagement/ReleaseManagementSeriesList.tsx @@ -150,11 +150,7 @@ const ReleaseManagementSeriesList = ({ const lastRowIndex = useRef(undefined); const handleRowClick = (event: MouseEvent, index: number) => { if (!autoDeleteMode) { - navigate( - `${series[index].SeriesID.toString()}?tab=candidates&includeVariations=${ - searchParams.get('includeVariations') ?? 'true' - }`, - ); + navigate(`${series[index].SeriesID.toString()}?tab=candidates&includeVariations=${includeVariations}`); return; } diff --git a/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCard.tsx b/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCard.tsx index c95a8325e..cb9b3d430 100644 --- a/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCard.tsx +++ b/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCard.tsx @@ -36,8 +36,11 @@ const CandidateCard = ({ const [markVariationsPending, setMarkVariationsPending] = useState(false); const coverageString = buildEpisodeCoverageString(candidate.Episodes); - const totalSize = candidate.Files.reduce((sum, file) => sum + file.FileSize, 0); + const realFiles = candidate.Files.filter(file => !file.IsVariation); + const variationFiles = candidate.Files.filter(file => file.IsVariation); + const totalSize = realFiles.reduce((sum, file) => sum + file.FileSize, 0); const redundantEpisodeStr = buildEpisodeCoverageString(candidate.RedundantEpisodes); + const multiFileEpisodeCount = candidate.Episodes.filter(episode => episode.PlaceIDs.length > 1).length; const isVersion = candidate.DecidingSignal === 'Version'; @@ -53,9 +56,9 @@ const CandidateCard = ({ const handleMarkAllAsVariations = async () => { try { setMarkVariationsPending(true); - await Promise.all(candidate.Files.map(file => markVariation({ fileId: file.VideoLocalID, variation: true }))); + await Promise.all(realFiles.map(file => markVariation({ fileId: file.VideoLocalID, variation: true }))); resetQueries(['release-management']); - toast.success(`Marked ${candidate.Files.length} file${candidate.Files.length !== 1 ? 's' : ''} as variations`); + toast.success(`Marked ${realFiles.length} file${realFiles.length !== 1 ? 's' : ''} as variations`); } catch (_error) { toast.error('Failed to mark files as variations'); } @@ -171,10 +174,23 @@ const CandidateCard = ({
Coverage:  {coverageString !== '' ? coverageString : 'None'} + {multiFileEpisodeCount > 0 && ( + + ( + {multiFileEpisodeCount} +   + {multiFileEpisodeCount === 1 ? 'episode has' : 'episodes have'} +  multiple files) + + )}
Files:  - {candidate.Files.length} + {realFiles.length} {candidate.RedundantFileCount > 0 && ( <>  ( @@ -185,6 +201,14 @@ const CandidateCard = ({ ) )} + {variationFiles.length > 0 && ( + <> +  (+ + {variationFiles.length} +   + {variationFiles.length === 1 ? 'variation' : 'variations'}) + + )}  ,  {prettyBytes(totalSize, { binary: true })}
diff --git a/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCardFileList.tsx b/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCardFileList.tsx index a8452ee77..fc929a5fa 100644 --- a/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCardFileList.tsx +++ b/src/components/Utilities/ReleaseManagement/SeriesDetail/CandidateCardFileList.tsx @@ -39,8 +39,10 @@ const CandidateCardFileList = ({ candidate, isAiring, isPrimary, primaryEpisodes const fileCoverage = buildEpisodeCoverageString(file.Episodes); const fileStreamSummary = buildFileStreamSummary(file); - let fileState: 'redundant' | 'kept' | 'also-delete' | 'required' | 'unknown'; - if (isPrimary) { + let fileState: 'redundant' | 'kept' | 'also-delete' | 'required' | 'unknown' | 'variation'; + if (file.IsVariation) { + fileState = 'variation'; + } else if (isPrimary) { fileState = 'kept'; } else if (isAiring) { fileState = file.IsRedundant ? 'redundant' : 'kept'; @@ -86,6 +88,7 @@ const CandidateCardFileList = ({ candidate, isAiring, isPrimary, primaryEpisodes {fileState === 'also-delete' && Could also delete} {fileState === 'kept' && Kept} {fileState === 'required' && Required - no other copy} + {fileState === 'variation' && Variation - shown for reference} {fileState === 'unknown' && ( + {file.IsVariation && Variation} + {file.IsChaptered != null && (file.IsChaptered || isChapteredAnomaly) && ( { + if (fileEntry.IsVariation) return false; if (primaryPlaceIds.has(fileEntry.PlaceID)) return false; if (candidateRedundant) return true; return series.IsAiring diff --git a/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchEpisode.tsx b/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchEpisode.tsx index a1969d9f7..f774d1625 100644 --- a/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchEpisode.tsx +++ b/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchEpisode.tsx @@ -5,6 +5,8 @@ import cx from 'classnames'; import prettyBytes from 'pretty-bytes'; import { useToggle } from 'usehooks-ts'; +import { Badge } from '@/components/Badge'; + export type FileOption = { placeID: number; absolutePath?: string; @@ -13,6 +15,7 @@ export type FileOption = { groupLabel: string; version: number; isChaptered?: boolean; + isVariation: boolean; subtitleStreamCount: number; source?: string; resolution?: string; @@ -88,6 +91,7 @@ export const MixAndMatchEpisode = ({ {selectedOption && ( <> {selectedOption.groupLabel}{' '} + {selectedOption.isVariation && Variation}{' '} - {getOptionSummary(selectedOption)}, {prettyBytes(selectedOption.fileSize, { binary: true })} @@ -106,7 +110,8 @@ export const MixAndMatchEpisode = ({ {isExpanded && (
{episode.options.map((option) => { - const { absolutePath, audioLanguages, fileSize, groupLabel, placeID, subtitleLanguages } = option; + const { absolutePath, audioLanguages, fileSize, groupLabel, isVariation, placeID, subtitleLanguages } = + option; const isSelected = selectedPlaceID === placeID; const fileName = absolutePath?.split(/[/\\]/).pop() ?? `Place ${placeID}`; const summary = getOptionSummary(option); @@ -130,7 +135,10 @@ export const MixAndMatchEpisode = ({ />
-
{groupLabel}
+
+
{groupLabel}
+ {isVariation && Variation} +
{summary}
{(audioLanguages.length > 0 || subtitleLanguages.length > 0) && (
diff --git a/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchTab.tsx b/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchTab.tsx index c9f5c1ed8..aa0931614 100644 --- a/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchTab.tsx +++ b/src/components/Utilities/ReleaseManagement/SeriesDetail/MixAndMatchTab.tsx @@ -57,6 +57,8 @@ const buildEpisodeMap = (items: ReleaseCandidateType[] | ReleaseOverrideType[]) groupLabel: item.Name, version: file.Version, isChaptered: file.IsChaptered, + // OverrideFileType (used for partial-coverage groups) has no IsVariation field. + isVariation: 'IsVariation' in file && file.IsVariation, subtitleStreamCount: item.SubtitleStreamCount, source: item.Source, resolution: item.Resolution, diff --git a/src/core/react-query/release-management/queries.ts b/src/core/react-query/release-management/queries.ts index bf9c44d74..327b7b4e4 100644 --- a/src/core/react-query/release-management/queries.ts +++ b/src/core/react-query/release-management/queries.ts @@ -60,10 +60,14 @@ export const useReleaseDeletionPreviewQuery = ( export const useReleaseMixMatchDeletionPreviewQuery = ( seriesId: number, body: ReleaseMixMatchDeletionPreviewRequestType, + includeVariations = false, enabled = true, ) => useQuery({ - queryKey: ['release-management', 'preview', 'mix-match', seriesId, body], - queryFn: () => axios.post(`ReleaseManagement/Series/${seriesId}/Override`, body), + queryKey: ['release-management', 'preview', 'mix-match', seriesId, body, includeVariations], + queryFn: () => + axios.post(`ReleaseManagement/Series/${seriesId}/Override`, body, { + params: { includeVariations }, + }), enabled, }); diff --git a/src/core/types/api/release-management.ts b/src/core/types/api/release-management.ts index 7cb1af5a2..1abd3d33f 100644 --- a/src/core/types/api/release-management.ts +++ b/src/core/types/api/release-management.ts @@ -4,6 +4,8 @@ export type EpisodeCoverageType = { Type: EpisodeTypeEnum; Number: number; GroupShortName?: string; + /** PlaceIDs of every file covering this episode. Only populated on candidate-level `Episodes`; always empty on a file's own `Episodes`. More than one entry means multiple files legitimately cover this episode. */ + PlaceIDs: number[]; }; export type ReleaseCandidateFileType = { @@ -12,6 +14,7 @@ export type ReleaseCandidateFileType = { AbsolutePath?: string; FileSize: number; Version: number; + IsVariation: boolean; IsRedundant: boolean; IsChaptered?: boolean; IsCensored?: boolean;