New: Option to Import via Script

Closes #791
pull/5677/head
JeWe37 2 years ago committed by GitHub
parent 365a6e77a6
commit 9f1e215120
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -68,27 +68,27 @@ class MediaManagement extends Component {
<NamingConnector />
{
isFetching &&
isFetching ?
<FieldSet legend="Naming Settings">
<LoadingIndicator />
</FieldSet>
</FieldSet> : null
}
{
!isFetching && error &&
!isFetching && error ?
<FieldSet legend="Naming Settings">
<div>Unable to load Media Management settings</div>
</FieldSet>
</FieldSet> : null
}
{
hasSettings && !isFetching && !error &&
hasSettings && !isFetching && !error ?
<Form
id="mediaManagementSettings"
{...otherProps}
>
{
advancedSettings &&
advancedSettings ?
<FieldSet legend="Folders">
<FormGroup
advancedSettings={advancedSettings}
@ -121,11 +121,11 @@ class MediaManagement extends Component {
{...settings.deleteEmptyFolders}
/>
</FormGroup>
</FieldSet>
</FieldSet> : null
}
{
advancedSettings &&
advancedSettings ?
<FieldSet
legend="Importing"
>
@ -200,6 +200,41 @@ class MediaManagement extends Component {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>Import Using Script</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="useScriptImport"
helpText="Copy files for importing using a script (ex. for transcoding)"
onChange={onInputChange}
{...settings.useScriptImport}
/>
</FormGroup>
{
settings.useScriptImport.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>Import Script Path</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
includeFiles={true}
name="scriptImportPath"
helpText="The path to the script to use for importing"
onChange={onInputChange}
{...settings.scriptImportPath}
/>
</FormGroup> : null
}
<FormGroup size={sizes.MEDIUM}>
<FormLabel>Import Extra Files</FormLabel>
@ -213,7 +248,7 @@ class MediaManagement extends Component {
</FormGroup>
{
settings.importExtraFiles.value &&
settings.importExtraFiles.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
@ -230,9 +265,9 @@ class MediaManagement extends Component {
onChange={onInputChange}
{...settings.extraFileExtensions}
/>
</FormGroup>
</FormGroup> : null
}
</FieldSet>
</FieldSet> : null
}
<FieldSet
@ -358,7 +393,7 @@ class MediaManagement extends Component {
</FieldSet>
{
advancedSettings && !isWindows &&
advancedSettings && !isWindows ?
<FieldSet
legend="Permissions"
>
@ -411,9 +446,9 @@ class MediaManagement extends Component {
{...settings.chownGroup}
/>
</FormGroup>
</FieldSet>
</FieldSet> : null
}
</Form>
</Form> : null
}
<FieldSet legend="Root Folders">

@ -207,6 +207,20 @@ namespace NzbDrone.Core.Configuration
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); }

@ -33,6 +33,8 @@ namespace NzbDrone.Core.Configuration
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; }
RescanAfterRefreshType RescanAfterRefresh { get; set; }

@ -31,6 +31,7 @@ namespace NzbDrone.Core.MediaFiles
private readonly IDiskTransferService _diskTransferService;
private readonly IDiskProvider _diskProvider;
private readonly IMediaFileAttributeService _mediaFileAttributeService;
private readonly IImportScript _scriptImportDecider;
private readonly IEventAggregator _eventAggregator;
private readonly IConfigService _configService;
private readonly Logger _logger;
@ -41,6 +42,7 @@ namespace NzbDrone.Core.MediaFiles
IDiskTransferService diskTransferService,
IDiskProvider diskProvider,
IMediaFileAttributeService mediaFileAttributeService,
IImportScript scriptImportDecider,
IEventAggregator eventAggregator,
IConfigService configService,
Logger logger)
@ -51,6 +53,7 @@ namespace NzbDrone.Core.MediaFiles
_diskTransferService = diskTransferService;
_diskProvider = diskProvider;
_mediaFileAttributeService = mediaFileAttributeService;
_scriptImportDecider = scriptImportDecider;
_eventAggregator = eventAggregator;
_configService = configService;
_logger = logger;
@ -59,6 +62,11 @@ namespace NzbDrone.Core.MediaFiles
public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, Series series)
{
var episodes = _episodeService.GetEpisodesByFileId(episodeFile.Id);
return MoveEpisodeFile(episodeFile, series, episodes);
}
private EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, Series series, List<Episode> episodes)
{
var filePath = _buildFileNames.BuildFilePath(episodes, series, episodeFile, Path.GetExtension(episodeFile.RelativePath));
EnsureEpisodeFolder(episodeFile, series, episodes.Select(v => v.SeasonNumber).First(), filePath);
@ -76,7 +84,7 @@ namespace NzbDrone.Core.MediaFiles
_logger.Debug("Moving episode file: {0} to {1}", episodeFile.Path, filePath);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Move);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Move, localEpisode);
}
public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode)
@ -88,14 +96,14 @@ namespace NzbDrone.Core.MediaFiles
if (_configService.CopyUsingHardlinks)
{
_logger.Debug("Hardlinking episode file: {0} to {1}", episodeFile.Path, filePath);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.HardLinkOrCopy);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.HardLinkOrCopy, localEpisode);
}
_logger.Debug("Copying episode file: {0} to {1}", episodeFile.Path, filePath);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Copy);
return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Copy, localEpisode);
}
private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List<Episode> episodes, string destinationFilePath, TransferMode mode)
private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List<Episode> episodes, string destinationFilePath, TransferMode mode, LocalEpisode localEpisode = null)
{
Ensure.That(episodeFile, () => episodeFile).IsNotNull();
Ensure.That(series, () => series).IsNotNull();
@ -113,10 +121,33 @@ namespace NzbDrone.Core.MediaFiles
throw new SameFilenameException("File not moved, source and destination are the same", episodeFilePath);
}
_diskTransferService.TransferFile(episodeFilePath, destinationFilePath, mode);
var transfer = true;
episodeFile.RelativePath = series.Path.GetRelativePath(destinationFilePath);
if (localEpisode is not null)
{
var scriptImportDecision = _scriptImportDecider.TryImport(episodeFilePath, destinationFilePath, localEpisode, episodeFile, mode);
switch (scriptImportDecision)
{
case ScriptImportDecision.DeferMove:
break;
case ScriptImportDecision.RenameRequested:
MoveEpisodeFile(episodeFile, series, episodeFile.Episodes);
transfer = false;
break;
case ScriptImportDecision.MoveComplete:
transfer = false;
break;
}
}
if (transfer)
{
_diskTransferService.TransferFile(episodeFilePath, destinationFilePath, mode);
}
_updateEpisodeFileService.ChangeFileDateForFile(episodeFile, series, episodes);
try

@ -9,6 +9,7 @@ using NzbDrone.Core.Download;
using NzbDrone.Core.Extras;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser.Model;
@ -110,8 +111,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
episodeFile.SceneName = localEpisode.SceneName;
episodeFile.OriginalFilePath = GetOriginalFilePath(downloadClientItem, localEpisode);
var moveResult = _episodeFileUpgrader.UpgradeEpisodeFile(episodeFile, localEpisode, copyOnly);
oldFiles = moveResult.OldFiles;
oldFiles = _episodeFileUpgrader.UpgradeEpisodeFile(episodeFile, localEpisode, copyOnly).OldFiles;
}
else
{

@ -89,6 +89,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
{
Series = series,
DownloadClientEpisodeInfo = downloadClientItemInfo,
DownloadItem = downloadClientItem,
FolderEpisodeInfo = folderInfo,
Path = file,
SceneSource = sceneSource,

@ -161,6 +161,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
localEpisode.Episodes = _episodeService.GetEpisodes(episodeIds);
localEpisode.FileEpisodeInfo = Parser.Parser.ParsePath(path);
localEpisode.DownloadClientEpisodeInfo = downloadClientItem == null ? null : Parser.Parser.ParseTitle(downloadClientItem.Title);
localEpisode.DownloadItem = downloadClientItem;
localEpisode.Path = path;
localEpisode.SceneSource = SceneSource(series, rootFolder);
localEpisode.ExistingFile = series.Path.IsParentPath(path);
@ -187,6 +188,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
DownloadClientEpisodeInfo = downloadClientItem == null
? null
: Parser.Parser.ParseTitle(downloadClientItem.Title),
DownloadItem = downloadClientItem,
Path = path,
SceneSource = SceneSource(series, rootFolder),
ExistingFile = series.Path.IsParentPath(path),
@ -479,6 +481,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
{
trackedDownload = _trackedDownloadService.Find(file.DownloadId);
localEpisode.DownloadClientEpisodeInfo = trackedDownload?.RemoteEpisode?.ParsedEpisodeInfo;
localEpisode.DownloadItem = trackedDownload?.DownloadItem;
}
if (file.FolderName.IsNotNullOrWhiteSpace())

@ -12,6 +12,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo
public interface IUpdateMediaInfo
{
bool Update(EpisodeFile episodeFile, Series series);
bool UpdateMediaInfo(EpisodeFile episodeFile, Series series);
}
public class UpdateMediaInfoService : IUpdateMediaInfo, IHandle<SeriesScannedEvent>
@ -65,7 +66,7 @@ namespace NzbDrone.Core.MediaFiles.MediaInfo
return UpdateMediaInfo(episodeFile, series);
}
private bool UpdateMediaInfo(EpisodeFile episodeFile, Series series)
public bool UpdateMediaInfo(EpisodeFile episodeFile, Series series)
{
var path = Path.Combine(series.Path, episodeFile.RelativePath);

@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Processes;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles
{
public interface IImportScript
{
public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalEpisode localEpisode, EpisodeFile episodeFile, TransferMode mode);
}
public class ImportScriptService : IImportScript
{
private readonly IConfigFileProvider _configFileProvider;
private readonly IVideoFileInfoReader _videoFileInfoReader;
private readonly IProcessProvider _processProvider;
private readonly IConfigService _configService;
private readonly Logger _logger;
public ImportScriptService(IProcessProvider processProvider,
IVideoFileInfoReader videoFileInfoReader,
IConfigService configService,
IConfigFileProvider configFileProvider,
Logger logger)
{
_processProvider = processProvider;
_videoFileInfoReader = videoFileInfoReader;
_configService = configService;
_configFileProvider = configFileProvider;
_logger = logger;
}
public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalEpisode localEpisode, EpisodeFile episodeFile, TransferMode mode)
{
var series = localEpisode.Series;
var oldFiles = localEpisode.OldFiles;
var downloadClientInfo = localEpisode.DownloadItem?.DownloadClientInfo;
var downloadId = localEpisode.DownloadItem?.DownloadId;
if (!_configService.UseScriptImport)
{
return ScriptImportDecision.DeferMove;
}
var environmentVariables = new StringDictionary();
environmentVariables.Add("Sonarr_SourcePath", sourcePath);
environmentVariables.Add("Sonarr_DestinationPath", destinationFilePath);
environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName);
environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl);
environmentVariables.Add("Sonarr_TransferMode", mode.ToString());
environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString());
environmentVariables.Add("Sonarr_Series_Title", series.Title);
environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug);
environmentVariables.Add("Sonarr_Series_Path", series.Path);
environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString());
environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString());
environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty);
environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", localEpisode.Episodes.Count.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeIds", string.Join(",", localEpisode.Episodes.Select(e => e.Id)));
environmentVariables.Add("Sonarr_EpisodeFile_SeasonNumber", localEpisode.SeasonNumber.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeNumbers", string.Join(",", localEpisode.Episodes.Select(e => e.EpisodeNumber)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDates", string.Join(",", localEpisode.Episodes.Select(e => e.AirDate)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDatesUtc", string.Join(",", localEpisode.Episodes.Select(e => e.AirDateUtc)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeTitles", string.Join("|", localEpisode.Episodes.Select(e => e.Title)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeOverviews", string.Join("|", localEpisode.Episodes.Select(e => e.Overview)));
environmentVariables.Add("Sonarr_EpisodeFile_Quality", localEpisode.Quality.Quality.Name);
environmentVariables.Add("Sonarr_EpisodeFile_QualityVersion", localEpisode.Quality.Revision.Version.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_ReleaseGroup", localEpisode.ReleaseGroup ?? string.Empty);
environmentVariables.Add("Sonarr_EpisodeFile_SceneName", localEpisode.SceneName ?? string.Empty);
environmentVariables.Add("Sonarr_Download_Client", downloadClientInfo?.Name ?? string.Empty);
environmentVariables.Add("Sonarr_Download_Client_Type", downloadClientInfo?.Type ?? string.Empty);
environmentVariables.Add("Sonarr_Download_Id", downloadId ?? string.Empty);
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_AudioChannels", MediaInfoFormatter.FormatAudioChannels(localEpisode.MediaInfo).ToString());
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_AudioCodec", MediaInfoFormatter.FormatAudioCodec(localEpisode.MediaInfo, null));
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_AudioLanguages", localEpisode.MediaInfo.AudioLanguages.Distinct().ConcatToString(" / "));
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_Languages", localEpisode.MediaInfo.AudioLanguages.ConcatToString(" / "));
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_Height", localEpisode.MediaInfo.Height.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_Width", localEpisode.MediaInfo.Width.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_Subtitles", localEpisode.MediaInfo.Subtitles.ConcatToString(" / "));
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_VideoCodec", MediaInfoFormatter.FormatVideoCodec(localEpisode.MediaInfo, null));
environmentVariables.Add("Sonarr_EpisodeFile_MediaInfo_VideoDynamicRangeType", MediaInfoFormatter.FormatVideoDynamicRangeType(localEpisode.MediaInfo));
environmentVariables.Add("Sonarr_EpisodeFile_CustomFormat", string.Join("|", localEpisode.CustomFormats));
environmentVariables.Add("Sonarr_EpisodeFile_CustomFormatScore", localEpisode.CustomFormatScore.ToString());
if (oldFiles.Any())
{
environmentVariables.Add("Sonarr_DeletedRelativePaths", string.Join("|", oldFiles.Select(e => e.RelativePath)));
environmentVariables.Add("Sonarr_DeletedPaths", string.Join("|", oldFiles.Select(e => Path.Combine(series.Path, e.RelativePath))));
environmentVariables.Add("Sonarr_DeletedDateAdded", string.Join("|", oldFiles.Select(e => e.DateAdded)));
}
_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
episodeFile.MediaInfo = _videoFileInfoReader.GetMediaInfo(destinationFilePath);
episodeFile.Path = null;
return ScriptImportDecision.RenameRequested;
case 3: // Let Sonarr handle it
return ScriptImportDecision.DeferMove;
default: // Error, fail to import
throw new ScriptImportException("Moving with script failed! Exit code {0}", processOutput.ExitCode);
}
}
}
}

@ -0,0 +1,10 @@
namespace NzbDrone.Core.MediaFiles
{
public enum ScriptImportDecision
{
MoveComplete,
RenameRequested,
RejectExtra,
DeferMove
}
}

@ -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)
{
}
}
}

@ -4,6 +4,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles
@ -68,6 +69,8 @@ namespace NzbDrone.Core.MediaFiles
_mediaFileService.Delete(file, DeleteMediaFileReason.Upgrade);
}
localEpisode.OldFiles = moveFileResult.OldFiles;
if (copyOnly)
{
moveFileResult.EpisodeFile = _episodeFileMover.CopyEpisodeFile(episodeFile, localEpisode);

@ -2,7 +2,9 @@ using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Download;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Tv;
@ -22,9 +24,11 @@ namespace NzbDrone.Core.Parser.Model
public long Size { get; set; }
public ParsedEpisodeInfo FileEpisodeInfo { get; set; }
public ParsedEpisodeInfo DownloadClientEpisodeInfo { get; set; }
public DownloadClientItem DownloadItem { get; set; }
public ParsedEpisodeInfo FolderEpisodeInfo { get; set; }
public Series Series { get; set; }
public List<Episode> Episodes { get; set; }
public List<EpisodeFile> OldFiles { get; set; }
public QualityModel Quality { get; set; }
public List<Language> Languages { get; set; }
public MediaInfoModel MediaInfo { get; set; }

@ -34,6 +34,8 @@ namespace Sonarr.Api.V3.Config
.SetValidator(seriesPathValidator)
.When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
SharedValidator.RuleFor(c => c.ScriptImportPath).IsValidPath().When(c => c.UseScriptImport);
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
}

@ -25,6 +25,8 @@ namespace Sonarr.Api.V3.Config
public bool SkipFreeSpaceCheckWhenImporting { get; set; }
public int MinimumFreeSpaceWhenImporting { get; set; }
public bool CopyUsingHardlinks { get; set; }
public bool UseScriptImport { get; set; }
public string ScriptImportPath { get; set; }
public bool ImportExtraFiles { get; set; }
public string ExtraFileExtensions { get; set; }
public bool EnableMediaInfo { get; set; }
@ -53,6 +55,8 @@ namespace Sonarr.Api.V3.Config
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks,
UseScriptImport = model.UseScriptImport,
ScriptImportPath = model.ScriptImportPath,
ImportExtraFiles = model.ImportExtraFiles,
ExtraFileExtensions = model.ExtraFileExtensions,
EnableMediaInfo = model.EnableMediaInfo

@ -9177,6 +9177,12 @@
"copyUsingHardlinks": {
"type": "boolean"
},
"useScriptImport": {
"type": "boolean"
},
"scriptImportPath": {
"type": "string"
},
"importExtraFiles": {
"type": "boolean"
},

Loading…
Cancel
Save