mirror of
https://github.com/Radarr/Radarr.git
synced 2024-09-11 12:02:35 +02:00
decision engine now reports it's own errors rather than just dying.
This commit is contained in:
parent
9bbbc19869
commit
d6d524e624
@ -31,8 +31,8 @@ public Response HandleException(NancyContext context, Exception exception)
|
||||
if (validationException != null)
|
||||
{
|
||||
_logger.Warn("Invalid request {0}", validationException.Message);
|
||||
|
||||
|
||||
|
||||
|
||||
return validationException.Errors.AsResponse(HttpStatusCode.BadRequest);
|
||||
|
||||
}
|
||||
@ -42,8 +42,8 @@ public Response HandleException(NancyContext context, Exception exception)
|
||||
|
||||
return new ErrorModel()
|
||||
{
|
||||
Message = exception.Message,
|
||||
Description = exception.ToString()
|
||||
Message = exception.Message,
|
||||
Description = exception.ToString()
|
||||
}.AsResponse(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,6 @@ public class ReleaseResource : RestResource
|
||||
public Int32 SeasonNumber { get; set; }
|
||||
public Language Language { get; set; }
|
||||
public DateTime? AirDate { get; set; }
|
||||
public String OriginalString { get; set; }
|
||||
public String SeriesTitle { get; set; }
|
||||
public int[] EpisodeNumbers { get; set; }
|
||||
public Boolean Approved { get; set; }
|
||||
|
@ -29,7 +29,7 @@ public void UpdateMappings_purge_existing_mapping_and_add_new_ones()
|
||||
{
|
||||
Mocker.GetMock<ISceneMappingProxy>().Setup(c => c.Fetch()).Returns(_fakeMappings);
|
||||
|
||||
Subject.UpdateMappings();
|
||||
Subject.Execute(new UpdateSceneMappingCommand());
|
||||
|
||||
AssertMappingUpdated();
|
||||
}
|
||||
@ -42,7 +42,7 @@ public void UpdateMappings_should_not_delete_if_fetch_fails()
|
||||
|
||||
Mocker.GetMock<ISceneMappingProxy>().Setup(c => c.Fetch()).Throws(new WebException());
|
||||
|
||||
Subject.UpdateMappings();
|
||||
Subject.Execute(new UpdateSceneMappingCommand());
|
||||
|
||||
AssertNoUpdate();
|
||||
|
||||
@ -56,7 +56,7 @@ public void UpdateMappings_should_not_delete_if_fetch_returns_empty_list()
|
||||
|
||||
Mocker.GetMock<ISceneMappingProxy>().Setup(c => c.Fetch()).Returns(new List<SceneMapping>());
|
||||
|
||||
Subject.UpdateMappings();
|
||||
Subject.Execute(new UpdateSceneMappingCommand());
|
||||
|
||||
AssertNoUpdate();
|
||||
|
||||
|
@ -3,34 +3,61 @@
|
||||
using NLog;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.Lifecycle;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.DataAugmentation.Scene
|
||||
{
|
||||
public interface ISceneMappingService
|
||||
{
|
||||
void UpdateMappings();
|
||||
string GetSceneName(int seriesId, int seasonNumber = -1);
|
||||
string GetSceneName(int tvdbId, int seasonNumber = -1);
|
||||
Nullable<int> GetTvDbId(string cleanName);
|
||||
string GetCleanName(int tvdbId);
|
||||
}
|
||||
|
||||
public class SceneMappingService : ISceneMappingService,IHandleAsync<ApplicationStartedEvent>
|
||||
public class SceneMappingService : ISceneMappingService,
|
||||
IHandleAsync<ApplicationStartedEvent>,
|
||||
IExecute<UpdateSceneMappingCommand>
|
||||
{
|
||||
private readonly ISceneMappingRepository _repository;
|
||||
private readonly ISceneMappingProxy _sceneMappingProxy;
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public SceneMappingService(ISceneMappingRepository repository, ISceneMappingProxy sceneMappingProxy, ISeriesService seriesService, Logger logger)
|
||||
public SceneMappingService(ISceneMappingRepository repository, ISceneMappingProxy sceneMappingProxy, Logger logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_sceneMappingProxy = sceneMappingProxy;
|
||||
_seriesService = seriesService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void UpdateMappings()
|
||||
public string GetSceneName(int tvdbId, int seasonNumber = -1)
|
||||
{
|
||||
var mapping = _repository.FindByTvdbId(tvdbId);
|
||||
|
||||
if (mapping == null) return null;
|
||||
|
||||
return mapping.SceneName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Nullable<Int32> GetTvDbId(string cleanName)
|
||||
{
|
||||
var mapping = _repository.FindByCleanTitle(cleanName);
|
||||
|
||||
if (mapping == null)
|
||||
return null;
|
||||
|
||||
return mapping.TvdbId;
|
||||
}
|
||||
|
||||
|
||||
public void HandleAsync(ApplicationStartedEvent message)
|
||||
{
|
||||
if (!_repository.HasItems())
|
||||
{
|
||||
UpdateMappings();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -52,45 +79,9 @@ public void UpdateMappings()
|
||||
}
|
||||
}
|
||||
|
||||
public string GetSceneName(int seriesId, int seasonNumber = -1)
|
||||
public void Execute(UpdateSceneMappingCommand message)
|
||||
{
|
||||
var tvDbId = _seriesService.FindByTvdbId(seriesId).TvdbId;
|
||||
|
||||
var mapping = _repository.FindByTvdbId(tvDbId);
|
||||
|
||||
if (mapping == null) return null;
|
||||
|
||||
return mapping.SceneName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Nullable<Int32> GetTvDbId(string cleanName)
|
||||
{
|
||||
var mapping = _repository.FindByCleanTitle(cleanName);
|
||||
|
||||
if (mapping == null)
|
||||
return null;
|
||||
|
||||
return mapping.TvdbId;
|
||||
}
|
||||
|
||||
|
||||
public string GetCleanName(int tvdbId)
|
||||
{
|
||||
var mapping = _repository.FindByTvdbId(tvdbId);
|
||||
|
||||
if (mapping == null) return null;
|
||||
|
||||
return mapping.CleanTitle;
|
||||
}
|
||||
|
||||
public void HandleAsync(ApplicationStartedEvent message)
|
||||
{
|
||||
if (!_repository.HasItems())
|
||||
{
|
||||
UpdateMappings();
|
||||
}
|
||||
UpdateMappings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
using NzbDrone.Common.Messaging;
|
||||
|
||||
namespace NzbDrone.Core.DataAugmentation.Scene
|
||||
{
|
||||
public class UpdateSceneMappingCommand : ICommand
|
||||
{
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Core.DecisionEngine.Specifications.Search;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser;
|
||||
@ -18,38 +19,26 @@ public class DownloadDecisionMaker : IMakeDownloadDecision
|
||||
{
|
||||
private readonly IEnumerable<IRejectWithReason> _specifications;
|
||||
private readonly IParsingService _parsingService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public DownloadDecisionMaker(IEnumerable<IRejectWithReason> specifications, IParsingService parsingService)
|
||||
public DownloadDecisionMaker(IEnumerable<IRejectWithReason> specifications, IParsingService parsingService, Logger logger)
|
||||
{
|
||||
_specifications = specifications;
|
||||
_parsingService = parsingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public List<DownloadDecision> GetRssDecision(IEnumerable<ReportInfo> reports)
|
||||
{
|
||||
return GetDecisions(reports, GetGeneralRejectionReasons).ToList();
|
||||
return GetDecisions(reports).ToList();
|
||||
}
|
||||
|
||||
public List<DownloadDecision> GetSearchDecision(IEnumerable<ReportInfo> reports, SearchDefinitionBase searchDefinitionBase)
|
||||
{
|
||||
return GetDecisions(reports, remoteEpisode =>
|
||||
{
|
||||
var generalReasons = GetGeneralRejectionReasons(remoteEpisode);
|
||||
var searchReasons = GetSearchRejectionReasons(remoteEpisode, searchDefinitionBase);
|
||||
return generalReasons.Union(searchReasons);
|
||||
}).ToList();
|
||||
return GetDecisions(reports).ToList();
|
||||
}
|
||||
|
||||
|
||||
private IEnumerable<string> GetGeneralRejectionReasons(RemoteEpisode report)
|
||||
{
|
||||
return _specifications
|
||||
.OfType<IDecisionEngineSpecification>()
|
||||
.Where(spec => !spec.IsSatisfiedBy(report))
|
||||
.Select(spec => spec.RejectionReason);
|
||||
}
|
||||
|
||||
private IEnumerable<DownloadDecision> GetDecisions(IEnumerable<ReportInfo> reports, Func<RemoteEpisode, IEnumerable<string>> decisionCallback)
|
||||
private IEnumerable<DownloadDecision> GetDecisions(IEnumerable<ReportInfo> reports, SearchDefinitionBase searchDefinition = null)
|
||||
{
|
||||
foreach (var report in reports)
|
||||
{
|
||||
@ -62,7 +51,7 @@ private IEnumerable<DownloadDecision> GetDecisions(IEnumerable<ReportInfo> repor
|
||||
|
||||
if (remoteEpisode.Series != null)
|
||||
{
|
||||
yield return new DownloadDecision(remoteEpisode, decisionCallback(remoteEpisode).ToArray());
|
||||
yield return GetDecisionForReport(remoteEpisode, searchDefinition);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -72,12 +61,40 @@ private IEnumerable<DownloadDecision> GetDecisions(IEnumerable<ReportInfo> repor
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetSearchRejectionReasons(RemoteEpisode report, SearchDefinitionBase searchDefinitionBase)
|
||||
private DownloadDecision GetDecisionForReport(RemoteEpisode remoteEpisode, SearchDefinitionBase searchDefinition = null)
|
||||
{
|
||||
return _specifications
|
||||
.OfType<IDecisionEngineSearchSpecification>()
|
||||
.Where(spec => !spec.IsSatisfiedBy(report, searchDefinitionBase))
|
||||
.Select(spec => spec.RejectionReason);
|
||||
var reasons = _specifications.Select(c => EvaluateSpec(c, remoteEpisode, searchDefinition))
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c));
|
||||
|
||||
return new DownloadDecision(remoteEpisode, reasons.ToArray());
|
||||
}
|
||||
|
||||
private string EvaluateSpec(IRejectWithReason spec, RemoteEpisode remoteEpisode, SearchDefinitionBase searchDefinitionBase = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchSpecification = spec as IDecisionEngineSearchSpecification;
|
||||
if (searchSpecification != null && searchDefinitionBase != null)
|
||||
{
|
||||
if (!searchSpecification.IsSatisfiedBy(remoteEpisode, searchDefinitionBase))
|
||||
{
|
||||
return spec.RejectionReason;
|
||||
}
|
||||
}
|
||||
|
||||
var generalSpecification = spec as IDecisionEngineSpecification;
|
||||
if (generalSpecification != null && !generalSpecification.IsSatisfiedBy(remoteEpisode))
|
||||
{
|
||||
return spec.RejectionReason;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.ErrorException("Couldn't evaluate decision", e);
|
||||
return string.Format("{0}: {1}", spec.GetType().Name, e.Message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications.Search
|
||||
{
|
||||
public class SingleEpisodeMatchSpecification : IDecisionEngineSpecification
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
|
||||
public SingleEpisodeMatchSpecification(Logger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string RejectionReason
|
||||
{
|
||||
get
|
||||
{
|
||||
return "Episode doesn't match";
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchDefinitionBase searchDefinitionBase)
|
||||
{
|
||||
var singleEpisodeSpec = searchDefinitionBase as SingleEpisodeSearchDefinition;
|
||||
if (singleEpisodeSpec == null) return true;
|
||||
|
||||
if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber)
|
||||
{
|
||||
_logger.Trace("Season number does not match searched season number, skipping.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!remoteEpisode.Episodes.Select(c => c.EpisodeNumber).Contains(singleEpisodeSpec.EpisodeNumber))
|
||||
{
|
||||
_logger.Trace("Episode number does not match searched episode number, skipping.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
@ -101,8 +101,10 @@ private List<DownloadDecision> PartialSeasonSearch(SeasonSearchDefinition search
|
||||
{
|
||||
var spec = new TSpec();
|
||||
|
||||
var tvdbId = _seriesService.GetSeries(seriesId).TvdbId;
|
||||
|
||||
spec.SeriesId = seriesId;
|
||||
spec.SceneTitle = _sceneMapping.GetSceneName(seriesId, seasonNumber);
|
||||
spec.SceneTitle = _sceneMapping.GetSceneName(tvdbId, seasonNumber);
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
@ -1,33 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.DataAugmentation;
|
||||
using NzbDrone.Core.DataAugmentation.Scene;
|
||||
using NzbDrone.Core.Model.Notification;
|
||||
|
||||
namespace NzbDrone.Core.Jobs.Implementations
|
||||
{
|
||||
public class UpdateSceneMappingsJob : IJob
|
||||
{
|
||||
private readonly SceneMappingService _sceneNameMappingService;
|
||||
|
||||
public UpdateSceneMappingsJob(SceneMappingService sceneNameMappingService)
|
||||
{
|
||||
_sceneNameMappingService = sceneNameMappingService;
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get { return "Update Scene Mappings"; }
|
||||
}
|
||||
|
||||
public TimeSpan DefaultInterval
|
||||
{
|
||||
get { return TimeSpan.FromHours(6); }
|
||||
}
|
||||
|
||||
public virtual void Start(ProgressNotification notification, dynamic options)
|
||||
{
|
||||
_sceneNameMappingService.UpdateMappings();
|
||||
}
|
||||
}
|
||||
}
|
@ -186,6 +186,7 @@
|
||||
<Compile Include="DataAugmentation\Scene\SceneMappingService.cs" />
|
||||
<Compile Include="DataAugmentation\Scene\SceneMappingProxy.cs" />
|
||||
<Compile Include="DataAugmentation\Scene\SceneMappingRepository.cs" />
|
||||
<Compile Include="DataAugmentation\Scene\UpdateSceneMappingCommand.cs" />
|
||||
<Compile Include="Datastore\Converters\BooleanIntConverter.cs" />
|
||||
<Compile Include="Datastore\Converters\QualityIntConverter.cs" />
|
||||
<Compile Include="Datastore\Converters\Int32Converter.cs" />
|
||||
@ -295,7 +296,6 @@
|
||||
<Compile Include="Jobs\Implementations\RenameSeriesJob.cs" />
|
||||
<Compile Include="Jobs\Implementations\RssSyncJob.cs" />
|
||||
<Compile Include="Jobs\Implementations\UpdateInfoJob.cs" />
|
||||
<Compile Include="Jobs\Implementations\UpdateSceneMappingsJob.cs" />
|
||||
<Compile Include="Jobs\Implementations\XemUpdateJob.cs" />
|
||||
<Compile Include="Jobs\JobController.cs" />
|
||||
<Compile Include="Jobs\JobDefinition.cs" />
|
||||
@ -394,6 +394,7 @@
|
||||
<Compile Include="Instrumentation\TrimLogsJob.cs" />
|
||||
<Compile Include="SeriesStats\SeriesStatistics.cs" />
|
||||
<Compile Include="SeriesStats\SeriesStatisticsRepository.cs" />
|
||||
<Compile Include="Tv\SeriesTypes.cs" />
|
||||
<Compile Include="Update\AppUpdateJob.cs" />
|
||||
<Compile Include="Model\Xbmc\TvShowResponse.cs" />
|
||||
<Compile Include="Model\Xbmc\TvShow.cs" />
|
||||
|
@ -2,7 +2,6 @@
|
||||
using System.Collections.Generic;
|
||||
using Marr.Data;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.MetadataSource.Trakt;
|
||||
using NzbDrone.Core.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.RootFolders;
|
||||
@ -10,13 +9,6 @@
|
||||
|
||||
namespace NzbDrone.Core.Tv
|
||||
{
|
||||
public enum SeriesTypes
|
||||
{
|
||||
Standard = 0,
|
||||
Daily = 1,
|
||||
Anime = 2,
|
||||
}
|
||||
|
||||
public class Series : ModelBase
|
||||
{
|
||||
public Series()
|
||||
|
@ -2,7 +2,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.SeriesStats;
|
||||
|
||||
namespace NzbDrone.Core.Tv
|
||||
{
|
||||
@ -13,7 +12,6 @@ public interface ISeriesRepository : IBasicRepository<Series>
|
||||
Series FindByTitle(string cleanTitle);
|
||||
Series FindByTvdbId(int tvdbId);
|
||||
void SetSeriesType(int seriesId, SeriesTypes seriesTypes);
|
||||
void SetTvRageId(int seriesId, int tvRageId);
|
||||
}
|
||||
|
||||
public class SeriesRepository : BasicRepository<Series>, ISeriesRepository
|
||||
@ -48,9 +46,5 @@ public void SetSeriesType(int seriesId, SeriesTypes seriesType)
|
||||
SetFields(new Series { Id = seriesId, SeriesType = seriesType }, s => s.SeriesType);
|
||||
}
|
||||
|
||||
public void SetTvRageId(int seriesId, int tvRageId)
|
||||
{
|
||||
SetFields(new Series { Id = seriesId, TvRageId = tvRageId }, s => s.TvRageId);
|
||||
}
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.DataAugmentation.Scene;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.MetadataSource;
|
||||
using NzbDrone.Core.Model;
|
||||
@ -42,18 +43,20 @@ public class SeriesService : ISeriesService, IHandleAsync<SeriesAddedEvent>
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IProvideSeriesInfo _seriesInfoProxy;
|
||||
private readonly IMessageAggregator _messageAggregator;
|
||||
private readonly ISceneMappingService _sceneMappingService;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
private readonly DiskProvider _diskProvider;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public SeriesService(ISeriesRepository seriesRepository, IConfigService configServiceService,
|
||||
IProvideSeriesInfo seriesInfoProxy, IMessageAggregator messageAggregator,
|
||||
IProvideSeriesInfo seriesInfoProxy, IMessageAggregator messageAggregator, ISceneMappingService sceneMappingService,
|
||||
IRootFolderService rootFolderService, DiskProvider diskProvider, Logger logger)
|
||||
{
|
||||
_seriesRepository = seriesRepository;
|
||||
_configService = configServiceService;
|
||||
_seriesInfoProxy = seriesInfoProxy;
|
||||
_messageAggregator = messageAggregator;
|
||||
_sceneMappingService = sceneMappingService;
|
||||
_rootFolderService = rootFolderService;
|
||||
_diskProvider = diskProvider;
|
||||
_logger = logger;
|
||||
@ -146,6 +149,13 @@ public Series FindByTvdbId(int tvdbId)
|
||||
|
||||
public Series FindByTitle(string title)
|
||||
{
|
||||
var tvdbId = _sceneMappingService.GetTvDbId(title);
|
||||
|
||||
if (tvdbId.HasValue)
|
||||
{
|
||||
return FindByTvdbId(tvdbId.Value);
|
||||
}
|
||||
|
||||
return _seriesRepository.FindByTitle(Parser.Parser.NormalizeTitle(title));
|
||||
}
|
||||
|
||||
|
9
NzbDrone.Core/Tv/SeriesTypes.cs
Normal file
9
NzbDrone.Core/Tv/SeriesTypes.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace NzbDrone.Core.Tv
|
||||
{
|
||||
public enum SeriesTypes
|
||||
{
|
||||
Standard = 0,
|
||||
Daily = 1,
|
||||
Anime = 2,
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Api.Indexers;
|
||||
|
||||
namespace NzbDrone.Integration.Test
|
||||
{
|
||||
@ -9,9 +10,25 @@ public class ReleaseIntegrationTest : IntegrationTest
|
||||
[Test]
|
||||
public void should_only_have_unknown_series_releases()
|
||||
{
|
||||
Releases.All().Should().OnlyContain(c => c.Rejections.Contains("Unknown Series"));
|
||||
var releases = Releases.All();
|
||||
|
||||
releases.Should().OnlyContain(c => c.Rejections.Contains("Unknown Series"));
|
||||
releases.Should().OnlyContain(c=>BeValidRelease(c));
|
||||
}
|
||||
|
||||
|
||||
|
||||
private bool BeValidRelease(ReleaseResource releaseResource)
|
||||
{
|
||||
releaseResource.Age.Should().BeGreaterOrEqualTo(-1);
|
||||
releaseResource.Title.Should().NotBeBlank();
|
||||
releaseResource.NzbInfoUrl.Should().NotBeBlank();
|
||||
releaseResource.NzbUrl.Should().NotBeBlank();
|
||||
releaseResource.SeriesTitle.Should().NotBeBlank();
|
||||
releaseResource.Size.Should().BeGreaterThan(0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user