mirror of
https://github.com/Radarr/Radarr
synced 2026-05-08 12:00:27 +02:00
Adds a new naming token that resolves to the highest quality audio codec across all streams in a file, rather than just the primary stream. This addresses the common issue where release groups mux the lowest quality codec first (e.g. AC3), causing Radarr to rename files with the wrong codec and triggering unnecessary upgrade loops. Centralizes audio codec identification into an AudioCodec enum and AudioCodecHelper class (following the HdrFormat pattern), eliminating the duplicated matching logic between MediaInfoFormatter and VideoFileInfoReader. Also adds the token to the naming modal so users can discover it. Fixes #6488
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
|
import FieldSet from 'Components/FieldSet';
|
|
import SelectInput from 'Components/Form/SelectInput';
|
|
import TextInput from 'Components/Form/TextInput';
|
|
import Button from 'Components/Link/Button';
|
|
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
|
import Modal from 'Components/Modal/Modal';
|
|
import ModalBody from 'Components/Modal/ModalBody';
|
|
import ModalContent from 'Components/Modal/ModalContent';
|
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
|
import { sizes } from 'Helpers/Props';
|
|
import NamingConfig from 'typings/Settings/NamingConfig';
|
|
import translate from 'Utilities/String/translate';
|
|
import NamingOption from './NamingOption';
|
|
import TokenCase from './TokenCase';
|
|
import TokenSeparator from './TokenSeparator';
|
|
import styles from './NamingModal.css';
|
|
|
|
const separatorOptions: { key: TokenSeparator; value: string }[] = [
|
|
{
|
|
key: ' ',
|
|
get value() {
|
|
return `${translate('Space')} ( )`;
|
|
},
|
|
},
|
|
{
|
|
key: '.',
|
|
get value() {
|
|
return `${translate('Period')} (.)`;
|
|
},
|
|
},
|
|
{
|
|
key: '_',
|
|
get value() {
|
|
return `${translate('Underscore')} (_)`;
|
|
},
|
|
},
|
|
{
|
|
key: '-',
|
|
get value() {
|
|
return `${translate('Dash')} (-)`;
|
|
},
|
|
},
|
|
];
|
|
|
|
const caseOptions: { key: TokenCase; value: string }[] = [
|
|
{
|
|
key: 'title',
|
|
get value() {
|
|
return translate('DefaultCase');
|
|
},
|
|
},
|
|
{
|
|
key: 'lower',
|
|
get value() {
|
|
return translate('Lowercase');
|
|
},
|
|
},
|
|
{
|
|
key: 'upper',
|
|
get value() {
|
|
return translate('Uppercase');
|
|
},
|
|
},
|
|
];
|
|
|
|
const fileNameTokens = [
|
|
{
|
|
token:
|
|
'{Movie Title} ({Release Year}) - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
|
|
example:
|
|
'The Movie - Title (2010) - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
|
|
},
|
|
{
|
|
token:
|
|
'{Movie CleanTitle} {Release Year} - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
|
|
example:
|
|
'The Movie Title 2010 - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
|
|
},
|
|
{
|
|
token:
|
|
'{Movie.CleanTitle}{.Release.Year}{.Edition.Tags}{.Custom.Formats}{.Quality.Full}{-Release Group}',
|
|
example:
|
|
'The.Movie.Title.2010.Ultimate.Extended.Edition.Surround.Sound.x264.Bluray-1080p.Proper-EVOLVE',
|
|
},
|
|
];
|
|
|
|
const movieTokens = [
|
|
{ token: '{Movie Title}', example: "Movie's Title", footNotes: '1' },
|
|
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNotes: '1' },
|
|
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNotes: '1' },
|
|
{
|
|
token: '{Movie CleanTitle:DE}',
|
|
example: 'Titel des Films',
|
|
footNotes: '1',
|
|
},
|
|
{ token: '{Movie TitleThe}', example: "Movie's Title, The", footNotes: '1' },
|
|
{
|
|
token: '{Movie CleanTitleThe}',
|
|
example: 'Movies Title, The',
|
|
footNotes: '1',
|
|
},
|
|
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNotes: '1' },
|
|
{
|
|
token: '{Movie CleanOriginalTitle}',
|
|
example: 'Τίτλος ταινίας',
|
|
footNotes: '1',
|
|
},
|
|
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
|
|
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
|
|
{
|
|
token: '{Movie Collection}',
|
|
example: 'The Movie Collection',
|
|
footNotes: '1',
|
|
},
|
|
{
|
|
token: '{Movie CollectionThe}',
|
|
example: "Movie's Collection, The",
|
|
footNotes: '1',
|
|
},
|
|
{
|
|
token: '{Movie CleanCollectionThe}',
|
|
example: 'Movies Collection, The',
|
|
footNotes: '1',
|
|
},
|
|
{ token: '{Movie Certification}', example: 'R' },
|
|
{ token: '{Release Year}', example: '2009' },
|
|
];
|
|
|
|
const movieIdTokens = [
|
|
{ token: '{ImdbId}', example: 'tt12345' },
|
|
{ token: '{TmdbId}', example: '123456' },
|
|
];
|
|
|
|
const qualityTokens = [
|
|
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
|
|
{ token: '{Quality Title}', example: 'HDTV-720p' },
|
|
];
|
|
|
|
const mediaInfoTokens = [
|
|
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
|
|
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNotes: '1' },
|
|
|
|
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
|
|
{ token: '{MediaInfo BestAudioCodec}', example: 'DTS-HD MA' },
|
|
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
|
|
{
|
|
token: '{MediaInfo AudioLanguages}',
|
|
example: '[EN+DE]',
|
|
footNotes: '1,2',
|
|
},
|
|
{
|
|
token: '{MediaInfo AudioLanguagesAll}',
|
|
example: '[EN]',
|
|
footNotes: '1',
|
|
},
|
|
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNotes: '1' },
|
|
|
|
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
|
|
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
|
|
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
|
|
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
|
|
{ token: '{MediaInfo 3D}', example: '3D' },
|
|
];
|
|
|
|
const releaseGroupTokens = [
|
|
{ token: '{Release Group}', example: 'Rls Grp', footNotes: '1' },
|
|
];
|
|
|
|
const editionTokens = [
|
|
{ token: '{Edition Tags}', example: 'IMAX', footNotes: '1' },
|
|
];
|
|
|
|
const customFormatTokens = [
|
|
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
|
|
{ token: '{Custom Format:FormatName}', example: 'AMZN' },
|
|
];
|
|
|
|
const originalTokens = [
|
|
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
|
|
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' },
|
|
];
|
|
|
|
interface NamingModalProps {
|
|
isOpen: boolean;
|
|
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
|
|
value: string;
|
|
movie?: boolean;
|
|
additional?: boolean;
|
|
onInputChange: ({ name, value }: { name: string; value: string }) => void;
|
|
onModalClose: () => void;
|
|
}
|
|
|
|
function NamingModal(props: NamingModalProps) {
|
|
const {
|
|
isOpen,
|
|
name,
|
|
value,
|
|
movie = false,
|
|
additional = false,
|
|
onInputChange,
|
|
onModalClose,
|
|
} = props;
|
|
|
|
const [tokenSeparator, setTokenSeparator] = useState<TokenSeparator>(' ');
|
|
const [tokenCase, setTokenCase] = useState<TokenCase>('title');
|
|
const [selectionStart, setSelectionStart] = useState<number | null>(null);
|
|
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
|
|
|
|
const handleTokenSeparatorChange = useCallback(
|
|
({ value }: { value: TokenSeparator }) => {
|
|
setTokenSeparator(value);
|
|
},
|
|
[setTokenSeparator]
|
|
);
|
|
|
|
const handleTokenCaseChange = useCallback(
|
|
({ value }: { value: TokenCase }) => {
|
|
setTokenCase(value);
|
|
},
|
|
[setTokenCase]
|
|
);
|
|
|
|
const handleInputSelectionChange = useCallback(
|
|
(selectionStart: number | null, selectionEnd: number | null) => {
|
|
setSelectionStart(selectionStart);
|
|
setSelectionEnd(selectionEnd);
|
|
},
|
|
[setSelectionStart, setSelectionEnd]
|
|
);
|
|
|
|
const handleOptionPress = useCallback(
|
|
({
|
|
isFullFilename,
|
|
tokenValue,
|
|
}: {
|
|
isFullFilename: boolean;
|
|
tokenValue: string;
|
|
}) => {
|
|
if (isFullFilename) {
|
|
onInputChange({ name, value: tokenValue });
|
|
} else if (selectionStart == null || selectionEnd == null) {
|
|
onInputChange({
|
|
name,
|
|
value: `${value}${tokenValue}`,
|
|
});
|
|
} else {
|
|
const start = value.substring(0, selectionStart);
|
|
const end = value.substring(selectionEnd);
|
|
const newValue = `${start}${tokenValue}${end}`;
|
|
|
|
onInputChange({ name, value: newValue });
|
|
|
|
setSelectionStart(newValue.length - 1);
|
|
setSelectionEnd(newValue.length - 1);
|
|
}
|
|
},
|
|
[name, value, selectionEnd, selectionStart, onInputChange]
|
|
);
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
|
<ModalContent onModalClose={onModalClose}>
|
|
<ModalHeader>
|
|
{movie ? translate('FileNameTokens') : translate('FolderNameTokens')}
|
|
</ModalHeader>
|
|
|
|
<ModalBody>
|
|
<div className={styles.namingSelectContainer}>
|
|
<SelectInput
|
|
className={styles.namingSelect}
|
|
name="separator"
|
|
value={tokenSeparator}
|
|
values={separatorOptions}
|
|
onChange={handleTokenSeparatorChange}
|
|
/>
|
|
|
|
<SelectInput
|
|
className={styles.namingSelect}
|
|
name="case"
|
|
value={tokenCase}
|
|
values={caseOptions}
|
|
onChange={handleTokenCaseChange}
|
|
/>
|
|
</div>
|
|
|
|
{movie ? (
|
|
<FieldSet legend={translate('FileNames')}>
|
|
<div className={styles.groups}>
|
|
{fileNameTokens.map(({ token, example }) => (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
isFullFilename={true}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
size={sizes.LARGE}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
))}
|
|
</div>
|
|
</FieldSet>
|
|
) : null}
|
|
|
|
<FieldSet legend={translate('Movie')}>
|
|
<div className={styles.groups}>
|
|
{movieTokens.map(({ token, example, footNotes }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
footNotes={footNotes}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.footNote}>
|
|
<sup className={styles.identifier}>1</sup>
|
|
<InlineMarkdown data={translate('MovieFootNote')} />
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('MovieID')}>
|
|
<div className={styles.groups}>
|
|
{movieIdTokens.map(({ token, example }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</FieldSet>
|
|
|
|
{additional ? (
|
|
<div>
|
|
<FieldSet legend={translate('Quality')}>
|
|
<div className={styles.groups}>
|
|
{qualityTokens.map(({ token, example }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('MediaInfo')}>
|
|
<div className={styles.groups}>
|
|
{mediaInfoTokens.map(({ token, example, footNotes }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
footNotes={footNotes}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.footNote}>
|
|
<sup className={styles.identifier}>1</sup>
|
|
<InlineMarkdown data={translate('MediaInfoFootNote')} />
|
|
</div>
|
|
|
|
<div className={styles.footNote}>
|
|
<sup className={styles.identifier}>2</sup>
|
|
<InlineMarkdown data={translate('MediaInfoFootNote2')} />
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('ReleaseGroup')}>
|
|
<div className={styles.groups}>
|
|
{releaseGroupTokens.map(({ token, example, footNotes }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
footNotes={footNotes}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.footNote}>
|
|
<sup className={styles.identifier}>1</sup>
|
|
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('Edition')}>
|
|
<div className={styles.groups}>
|
|
{editionTokens.map(({ token, example, footNotes }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
footNotes={footNotes}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.footNote}>
|
|
<sup className={styles.identifier}>1</sup>
|
|
<InlineMarkdown data={translate('EditionFootNote')} />
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('CustomFormats')}>
|
|
<div className={styles.groups}>
|
|
{customFormatTokens.map(({ token, example }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</FieldSet>
|
|
|
|
<FieldSet legend={translate('Original')}>
|
|
<div className={styles.groups}>
|
|
{originalTokens.map(({ token, example }) => {
|
|
return (
|
|
<NamingOption
|
|
key={token}
|
|
token={token}
|
|
example={example}
|
|
tokenSeparator={tokenSeparator}
|
|
tokenCase={tokenCase}
|
|
size={sizes.LARGE}
|
|
onPress={handleOptionPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</FieldSet>
|
|
</div>
|
|
) : null}
|
|
</ModalBody>
|
|
|
|
<ModalFooter>
|
|
<TextInput
|
|
name={name}
|
|
value={value}
|
|
onChange={onInputChange}
|
|
onSelectionChange={handleInputSelectionChange}
|
|
/>
|
|
|
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
|
</ModalFooter>
|
|
</ModalContent>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default NamingModal;
|