diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 627263fff..5a12e6e72 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -124,29 +124,29 @@ class MediaManagement extends Component { { - isFetching && + isFetching ?
-
+ : null } { - !isFetching && error && + !isFetching && error ?
{translate('UnableToLoadMediaManagementSettings')} -
+ : null } { - hasSettings && !isFetching && !error && + hasSettings && !isFetching && !error ?
{ - advancedSettings && + advancedSettings ?
-
+ : null } { - advancedSettings && + advancedSettings ?
@@ -245,6 +245,41 @@ class MediaManagement extends Component { /> + + {translate('ImportUsingScript')} + + + + + { + settings.useScriptImport.value ? + + {translate('ImportScriptPath')} + + + : null + } + {translate('ImportExtraFiles')} @@ -279,7 +314,7 @@ class MediaManagement extends Component { /> : null } -
+ : null }
{ - advancedSettings && !isWindows && + advancedSettings && !isWindows ?
@@ -483,9 +518,9 @@ class MediaManagement extends Component { {...settings.chownGroup} /> -
+
: null } -
+ : null } diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs index 8a19c6175..c1be6cbef 100644 --- a/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs @@ -32,6 +32,7 @@ public MediaManagementConfigController(IConfigService configService, .When(c => !string.IsNullOrWhiteSpace(c.RecycleBin)); SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx)); + SharedValidator.RuleFor(c => c.ScriptImportPath).IsValidPath().When(c => c.UseScriptImport); SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100); } diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs index e318c3c82..7f6e3030c 100644 --- a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -25,6 +25,9 @@ public class MediaManagementConfigResource : RestResource public bool SkipFreeSpaceCheckWhenImporting { get; set; } public int MinimumFreeSpaceWhenImporting { get; set; } public bool CopyUsingHardlinks { get; set; } + public bool EnableMediaInfo { get; set; } + public bool UseScriptImport { get; set; } + public string ScriptImportPath { get; set; } public bool ImportExtraFiles { get; set; } public string ExtraFileExtensions { get; set; } } @@ -53,6 +56,9 @@ public static MediaManagementConfigResource ToResource(IConfigService model) SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting, CopyUsingHardlinks = model.CopyUsingHardlinks, + EnableMediaInfo = model.EnableMediaInfo, + UseScriptImport = model.UseScriptImport, + ScriptImportPath = model.ScriptImportPath, ImportExtraFiles = model.ImportExtraFiles, ExtraFileExtensions = model.ExtraFileExtensions, }; diff --git a/src/Lidarr.Api.V1/openapi.json b/src/Lidarr.Api.V1/openapi.json index 4c0462717..3d3f3abec 100644 --- a/src/Lidarr.Api.V1/openapi.json +++ b/src/Lidarr.Api.V1/openapi.json @@ -10870,6 +10870,12 @@ "copyUsingHardlinks": { "type": "boolean" }, + "useScriptImport": { + "type": "boolean" + }, + "scriptImportPath": { + "type": "string" + }, "importExtraFiles": { "type": "boolean" }, diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 34b94c927..6983f1b54 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -207,6 +207,27 @@ public bool CopyUsingHardlinks set { SetValue("CopyUsingHardlinks", value); } } + public bool EnableMediaInfo + { + get { return GetValueBoolean("EnableMediaInfo", true); } + + set { SetValue("EnableMediaInfo", value); } + } + + public bool UseScriptImport + { + get { return GetValueBoolean("UseScriptImport", false); } + + set { SetValue("UseScriptImport", value); } + } + + public string ScriptImportPath + { + get { return GetValue("ScriptImportPath"); } + + set { SetValue("ScriptImportPath", value); } + } + public bool ImportExtraFiles { get { return GetValueBoolean("ImportExtraFiles", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 1665334d4..c78f060ae 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -33,6 +33,9 @@ public interface IConfigService bool SkipFreeSpaceCheckWhenImporting { get; set; } int MinimumFreeSpaceWhenImporting { get; set; } bool CopyUsingHardlinks { get; set; } + bool EnableMediaInfo { get; set; } + bool UseScriptImport { get; set; } + string ScriptImportPath { get; set; } bool ImportExtraFiles { get; set; } string ExtraFileExtensions { get; set; } bool WatchLibraryForChanges { get; set; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index ade1d9d2b..2b1605702 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -594,6 +594,10 @@ "ImportLists": "Import Lists", "ImportListsSettingsSummary": "Import from another {appName} instance or Trakt lists and manage list exclusions", "ImportMechanismHealthCheckMessage": "Enable Completed Download Handling", + "ImportScriptPath": "Import Script Path", + "ImportScriptPathHelpText": "The path to the script to use for importing", + "ImportUsingScript": "Import Using Script", + "ImportUsingScriptHelpText": "Copy files for importing using a script (ex. for transcoding)", "ImportedTo": "Imported To", "Importing": "Importing", "Inactive": "Inactive", diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs new file mode 100644 index 000000000..fc0404ca0 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs @@ -0,0 +1,125 @@ +using System.Collections.Specialized; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Processes; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tags; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IImportScript + { + public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null); + } + + public class ImportScriptService : IImportScript + { + private readonly IConfigFileProvider _configFileProvider; + private readonly IAudioTagService _audioTagService; + private readonly IProcessProvider _processProvider; + private readonly IConfigService _configService; + private readonly ITagRepository _tagRepository; + private readonly ICustomFormatCalculationService _customFormatCalculationService; + private readonly Logger _logger; + + public ImportScriptService(IProcessProvider processProvider, + IAudioTagService audioTagService, + IConfigService configService, + IConfigFileProvider configFileProvider, + ITagRepository tagRepository, + ICustomFormatCalculationService customFormatCalculationService, + Logger logger) + { + _processProvider = processProvider; + _audioTagService = audioTagService; + _configService = configService; + _configFileProvider = configFileProvider; + _tagRepository = tagRepository; + _customFormatCalculationService = customFormatCalculationService; + _logger = logger; + } + + public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null) + { + var artist = localTrack.Artist; + var album = localTrack.Album; + var downloadClientInfo = downloadClientItem?.DownloadClientInfo; + var downloadId = downloadClientItem?.DownloadId; + + if (!_configService.UseScriptImport) + { + return ScriptImportDecision.DeferMove; + } + + var environmentVariables = new StringDictionary + { + { "Lidarr_SourcePath", sourcePath }, + { "Lidarr_DestinationPath", destinationFilePath }, + { "Lidarr_InstanceName", _configFileProvider.InstanceName }, + { "Lidarr_ApplicationUrl", _configService.ApplicationUrl }, + { "Lidarr_TransferMode", mode.ToString() }, + { "Lidarr_Artist_Id", artist.Id.ToString() }, + { "Lidarr_Artist_Name", artist.Name }, + { "Lidarr_Artist_Path", artist.Path }, + { "Lidarr_Artist_MBId", artist.ForeignArtistId }, + { "Lidarr_Artist_Tags", string.Join("|", artist.Tags.Select(t => _tagRepository.Get(t).Label)) }, + { "Lidarr_Album_Id", album.Id.ToString() }, + { "Lidarr_Album_Title", album.Title }, + { "Lidarr_Album_MBId", album.ForeignAlbumId }, + { "Lidarr_Album_ReleaseDate", album.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty }, + { "Lidarr_Album_Genres", string.Join("|", album.Genres) }, + { "Lidarr_TrackFile_TrackCount", localTrack.Tracks.Count.ToString() }, + { "Lidarr_TrackFile_TrackIds", string.Join(",", localTrack.Tracks.Select(t => t.Id)) }, + { "Lidarr_TrackFile_TrackNumbers", string.Join(",", localTrack.Tracks.Select(t => t.TrackNumber)) }, + { "Lidarr_TrackFile_TrackTitles", string.Join("|", localTrack.Tracks.Select(t => t.Title)) }, + { "Lidarr_TrackFile_Quality", localTrack.Quality.Quality.Name }, + { "Lidarr_TrackFile_QualityVersion", localTrack.Quality.Revision.Version.ToString() }, + { "Lidarr_TrackFile_ReleaseGroup", localTrack.ReleaseGroup ?? string.Empty }, + { "Lidarr_TrackFile_SceneName", localTrack.SceneName ?? string.Empty }, + { "Lidarr_Download_Client", downloadClientInfo?.Name ?? string.Empty }, + { "Lidarr_Download_Client_Type", downloadClientInfo?.Type ?? string.Empty }, + { "Lidarr_Download_Id", downloadId ?? string.Empty } + }; + + // Audio-specific MediaInfo (no video properties for music files) + if (localTrack.FileTrackInfo?.MediaInfo != null) + { + var mediaInfo = localTrack.FileTrackInfo.MediaInfo; + environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioChannels", mediaInfo.AudioChannels.ToString()); + environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioCodec", mediaInfo.AudioFormat ?? string.Empty); + environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioBitRate", mediaInfo.AudioBitrate.ToString()); + environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioSampleRate", mediaInfo.AudioSampleRate.ToString()); + environmentVariables.Add("Lidarr_TrackFile_MediaInfo_BitsPerSample", mediaInfo.AudioBits.ToString()); + } + + // CustomFormats for music files + var customFormats = _customFormatCalculationService.ParseCustomFormat(localTrack); + environmentVariables.Add("Lidarr_TrackFile_CustomFormat", string.Join("|", customFormats.Select(x => x.Name))); + + _logger.Debug("Executing external script: {0}", _configService.ScriptImportPath); + + var processOutput = _processProvider.StartAndCapture(_configService.ScriptImportPath, $"\"{sourcePath}\" \"{destinationFilePath}\"", environmentVariables); + + _logger.Debug("Executed external script: {0} - Status: {1}", _configService.ScriptImportPath, processOutput.ExitCode); + _logger.Debug("Script Output: \r\n{0}", string.Join("\r\n", processOutput.Lines)); + + switch (processOutput.ExitCode) + { + case 0: // Copy complete + return ScriptImportDecision.MoveComplete; + case 2: // Copy complete, file potentially changed, should try renaming again + trackFile.MediaInfo = _audioTagService.ReadTags(destinationFilePath).MediaInfo; + trackFile.Path = null; + return ScriptImportDecision.RenameRequested; + case 3: // Let Lidarr handle it + return ScriptImportDecision.DeferMove; + default: // Error, fail to import + throw new ScriptImportException("Moving with script failed! Exit code {0}", processOutput.ExitCode); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs new file mode 100644 index 000000000..fb2eb4f6f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.MediaFiles +{ + public enum ScriptImportDecision + { + MoveComplete, + RenameRequested, + RejectExtra, + DeferMove + } +} diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs new file mode 100644 index 000000000..9ac0f49d4 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.MediaFiles +{ + public class ScriptImportException : NzbDroneException + { + public ScriptImportException(string message) + : base(message) + { + } + + public ScriptImportException(string message, params object[] args) + : base(message, args) + { + } + + public ScriptImportException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs index 68bc6c21d..151fee3ff 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs @@ -35,6 +35,7 @@ public class TrackFileMovingService : IMoveTrackFiles private readonly IMediaFileAttributeService _mediaFileAttributeService; private readonly IRootFolderService _rootFolderService; private readonly IEventAggregator _eventAggregator; + private readonly IImportScript _scriptImportDecider; private readonly IConfigService _configService; private readonly Logger _logger; @@ -48,6 +49,7 @@ public TrackFileMovingService(ITrackService trackService, IMediaFileAttributeService mediaFileAttributeService, IRootFolderService rootFolderService, IEventAggregator eventAggregator, + IImportScript scriptImportDecider, IConfigService configService, Logger logger) { @@ -61,6 +63,7 @@ public TrackFileMovingService(ITrackService trackService, _mediaFileAttributeService = mediaFileAttributeService; _rootFolderService = rootFolderService; _eventAggregator = eventAggregator; + _scriptImportDecider = scriptImportDecider; _configService = configService; _logger = logger; } @@ -86,7 +89,7 @@ public TrackFile MoveTrackFile(TrackFile trackFile, LocalTrack localTrack) _logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath); - return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move); + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move, localTrack); } public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack) @@ -98,14 +101,14 @@ public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack) if (_configService.CopyUsingHardlinks) { _logger.Debug("Attempting to hardlink track file: {0} to {1}", trackFile.Path, filePath); - return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy); + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy, localTrack); } _logger.Debug("Copying track file: {0} to {1}", trackFile.Path, filePath); - return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy); + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy, localTrack); } - private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode) + private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode, LocalTrack localTrack = null) { Ensure.That(trackFile, () => trackFile).IsNotNull(); Ensure.That(artist, () => artist).IsNotNull(); @@ -123,8 +126,31 @@ private TrackFile TransferFile(TrackFile trackFile, Artist artist, List t throw new SameFilenameException("File not moved, source and destination are the same", trackFilePath); } - _rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath); - _diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode); + var transfer = true; + + if (localTrack is not null) + { + var scriptImportDecision = _scriptImportDecider.TryImport(trackFilePath, destinationFilePath, localTrack, trackFile, mode, null); + + switch (scriptImportDecision) + { + case ScriptImportDecision.DeferMove: + break; + case ScriptImportDecision.RenameRequested: + MoveTrackFile(trackFile, artist); + transfer = false; + break; + case ScriptImportDecision.MoveComplete: + transfer = false; + break; + } + } + + if (transfer) + { + _rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath); + _diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode); + } trackFile.Path = destinationFilePath;