diff --git a/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs b/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs index 1cff8601f..54d0e8dcc 100644 --- a/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs +++ b/src/NzbDrone.Api.Test/MappingTests/ResourceMappingFixture.cs @@ -50,7 +50,6 @@ namespace NzbDrone.Api.Test.MappingTests MappingValidation.ValidateMapping(modelType, resourceType); } - [Test] public void should_map_lay_loaded_values_should_not_be_inject_if_not_loaded() { diff --git a/src/NzbDrone.Api/Metadata/MetadataModule.cs b/src/NzbDrone.Api/Metadata/MetadataModule.cs new file mode 100644 index 000000000..89a6374b0 --- /dev/null +++ b/src/NzbDrone.Api/Metadata/MetadataModule.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Metadata; + +namespace NzbDrone.Api.Metadata +{ + public class MetadataModule : ProviderModuleBase + { + public MetadataModule(IMetadataFactory metadataFactory) + : base(metadataFactory, "metadata") + { + } + + protected override void Validate(MetadataDefinition definition) + { + if (!definition.Enable) return; + base.Validate(definition); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Metadata/MetadataResource.cs b/src/NzbDrone.Api/Metadata/MetadataResource.cs new file mode 100644 index 000000000..fc705ee60 --- /dev/null +++ b/src/NzbDrone.Api/Metadata/MetadataResource.cs @@ -0,0 +1,9 @@ +using System; + +namespace NzbDrone.Api.Metadata +{ + public class MetadataResource : ProviderResource + { + public Boolean Enable { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Notifications/NotificationModule.cs b/src/NzbDrone.Api/Notifications/NotificationModule.cs index 62fedc40a..fb6130e2c 100644 --- a/src/NzbDrone.Api/Notifications/NotificationModule.cs +++ b/src/NzbDrone.Api/Notifications/NotificationModule.cs @@ -2,9 +2,9 @@ namespace NzbDrone.Api.Notifications { - public class IndexerModule : ProviderModuleBase + public class NotificationModule : ProviderModuleBase { - public IndexerModule(NotificationFactory notificationFactory) + public NotificationModule(NotificationFactory notificationFactory) : base(notificationFactory, "notification") { } diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 7a98eedf5..bda4694cc 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -120,6 +120,8 @@ + + diff --git a/src/NzbDrone.Api/Qualities/QualityProfileModule.cs b/src/NzbDrone.Api/Qualities/QualityProfileModule.cs index ee7ce95cb..6cb1b16df 100644 --- a/src/NzbDrone.Api/Qualities/QualityProfileModule.cs +++ b/src/NzbDrone.Api/Qualities/QualityProfileModule.cs @@ -10,7 +10,6 @@ namespace NzbDrone.Api.Qualities private readonly IQualityProfileService _qualityProfileService; public QualityProfileModule(IQualityProfileService qualityProfileService) - : base("/qualityprofiles") { _qualityProfileService = qualityProfileService; SharedValidator.RuleFor(c => c.Name).NotEmpty(); diff --git a/src/NzbDrone.Api/Qualities/QualityProfileSchemaModule.cs b/src/NzbDrone.Api/Qualities/QualityProfileSchemaModule.cs index 64caeefab..9fb05c48a 100644 --- a/src/NzbDrone.Api/Qualities/QualityProfileSchemaModule.cs +++ b/src/NzbDrone.Api/Qualities/QualityProfileSchemaModule.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Api.Qualities private readonly IQualityDefinitionService _qualityDefinitionService; public QualityProfileSchemaModule(IQualityDefinitionService qualityDefinitionService) - : base("/qualityprofiles/schema") + : base("/qualityprofile/schema") { _qualityDefinitionService = qualityDefinitionService; diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs index dc7cc3479..3e09fd3cf 100644 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ b/src/NzbDrone.Api/Series/SeriesResource.cs @@ -59,5 +59,7 @@ namespace NzbDrone.Api.Series public String ImdbId { get; set; } public String TitleSlug { get; set; } public String RootFolderPath { get; set; } + public String Certification { get; set; } + public List Genres { get; set; } } } diff --git a/src/NzbDrone.Common.Test/DiskProviderTests/IsParentFixtureBase.cs b/src/NzbDrone.Common.Test/DiskProviderTests/IsParentFixtureBase.cs index 7ae8c326f..a9bc32930 100644 --- a/src/NzbDrone.Common.Test/DiskProviderTests/IsParentFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskProviderTests/IsParentFixtureBase.cs @@ -5,7 +5,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Common.Test.DiskProviderTests { - public class IsParentFixtureBase : TestBase where TSubject : class, IDiskProvider + public class IsParentFixture : TestBase { private string _parent = @"C:\Test".AsOsAgnostic(); @@ -14,7 +14,7 @@ namespace NzbDrone.Common.Test.DiskProviderTests { var path = @"C:\Another Folder".AsOsAgnostic(); - Subject.IsParent(_parent, path).Should().BeFalse(); + DiskProviderBase.IsParent(_parent, path).Should().BeFalse(); } [Test] @@ -22,7 +22,7 @@ namespace NzbDrone.Common.Test.DiskProviderTests { var path = @"C:\Test\TV".AsOsAgnostic(); - Subject.IsParent(_parent, path).Should().BeTrue(); + DiskProviderBase.IsParent(_parent, path).Should().BeTrue(); } [Test] @@ -30,7 +30,7 @@ namespace NzbDrone.Common.Test.DiskProviderTests { var path = @"C:\Test\30.Rock.S01E01.Pilot.avi".AsOsAgnostic(); - Subject.IsParent(_parent, path).Should().BeTrue(); + DiskProviderBase.IsParent(_parent, path).Should().BeTrue(); } } } diff --git a/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs b/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs index 1286d4b70..5a473fd74 100644 --- a/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs +++ b/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Common.Composition Container = new Container(new TinyIoCContainer(), _loadedTypes); AutoRegisterInterfaces(); Container.Register(args); - } + } private void AutoRegisterInterfaces() { diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index b1af0ccaa..a9ba52e26 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -5,6 +5,7 @@ using System.Security.AccessControl; using System.Security.Principal; using NLog; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Common.Disk @@ -24,6 +25,37 @@ namespace NzbDrone.Common.Disk public abstract void SetPermissions(string path, string mask, string user, string group); public abstract long? GetTotalSize(string path); + public static string GetRelativePath(string parentPath, string childPath) + { + if (!IsParent(parentPath, childPath)) + { + throw new NotParentException("{0} is not a child of {1}", childPath, parentPath); + } + + return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar); + } + + public static bool IsParent(string parentPath, string childPath) + { + parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); + childPath = childPath.TrimEnd(Path.DirectorySeparatorChar); + + var parent = new DirectoryInfo(parentPath); + var child = new DirectoryInfo(childPath); + + while (child.Parent != null) + { + if (child.Parent.FullName == parent.FullName) + { + return true; + } + + child = child.Parent; + } + + return false; + } + public DateTime GetLastFolderWrite(string path) { Ensure.That(path, () => path).IsValidPath(); @@ -333,27 +365,6 @@ namespace NzbDrone.Common.Disk } - public bool IsParent(string parentPath, string childPath) - { - parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); - childPath = childPath.TrimEnd(Path.DirectorySeparatorChar); - - var parent = new DirectoryInfo(parentPath); - var child = new DirectoryInfo(childPath); - - while (child.Parent != null) - { - if (child.Parent.FullName == parent.FullName) - { - return true; - } - - child = child.Parent; - } - - return false; - } - public void SetFolderWriteTime(string path, DateTime time) { Directory.SetLastWriteTimeUtc(path, time); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 0bb415452..098e451d7 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -37,7 +37,6 @@ namespace NzbDrone.Common.Disk string GetPathRoot(string path); string GetParentFolder(string path); void SetPermissions(string filename, WellKnownSidType accountSid, FileSystemRights rights, AccessControlType controlType); - bool IsParent(string parentPath, string childPath); void SetFolderWriteTime(string path, DateTime time); FileAttributes GetFileAttributes(string path); void EmptyFolder(string path); diff --git a/src/NzbDrone.Common/Exceptions/NotParentException.cs b/src/NzbDrone.Common/Exceptions/NotParentException.cs new file mode 100644 index 000000000..62d594e60 --- /dev/null +++ b/src/NzbDrone.Common/Exceptions/NotParentException.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Exceptions +{ + public class NotParentException : NzbDroneException + { + public NotParentException(string message, params object[] args) : base(message, args) + { + } + + public NotParentException(string message) : base(message) + { + } + } +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index 9378ab2f6..210c9f1c9 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -91,6 +91,7 @@ + diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs index cdb47174b..e36c86691 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -189,7 +189,11 @@ namespace NzbDrone.Common.Processes public void WaitForExit(Process process) { Logger.Trace("Waiting for process {0} to exit.", process.ProcessName); - process.WaitForExit(); + + if (!process.HasExited) + { + process.WaitForExit(); + } } public void SetPriority(int processId, ProcessPriorityClass priority) diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs new file mode 100644 index 000000000..0e6bb9fc6 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs @@ -0,0 +1,85 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedMetadataFilesFixture : DbTest + { + [Test] + public void should_delete_metadata_files_that_dont_have_a_coresponding_series() + { + var metadataFile = Builder.CreateNew() + .With(m => m.EpisodeFileId = null) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_metadata_files_that_have_a_coresponding_series() + { + var series = Builder.CreateNew() + .BuildNew(); + + Db.Insert(series); + + var metadataFile = Builder.CreateNew() + .With(m => m.SeriesId = series.Id) + .With(m => m.EpisodeFileId = null) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } + + [Test] + public void should_delete_metadata_files_that_dont_have_a_coresponding_episode_file() + { + var series = Builder.CreateNew() + .BuildNew(); + + Db.Insert(series); + + var metadataFile = Builder.CreateNew() + .With(m => m.SeriesId = series.Id) + .With(m => m.EpisodeFileId = 10) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_metadata_files_that_have_a_coresponding_episode_file() + { + var series = Builder.CreateNew() + .BuildNew(); + + var episodeFile = Builder.CreateNew() + .BuildNew(); + + Db.Insert(series); + Db.Insert(episodeFile); + + var metadataFile = Builder.CreateNew() + .With(m => m.SeriesId = series.Id) + .With(m => m.EpisodeFileId = episodeFile.Id) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotInUseSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotInUseSpecificationFixture.cs index 3cac9c319..2ede2be18 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotInUseSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotInUseSpecificationFixture.cs @@ -33,13 +33,6 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications _localEpisode.ExistingFile = true; } - private void GivenNewFile() - { - Mocker.GetMock() - .Setup(s => s.IsParent(_localEpisode.Series.Path, _localEpisode.Path)) - .Returns(false); - } - [Test] public void should_return_true_if_file_is_under_series_folder() { @@ -62,8 +55,6 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications [Test] public void should_return_false_if_file_is_in_use() { - GivenNewFile(); - Mocker.GetMock() .Setup(s => s.IsFileLocked(It.IsAny())) .Returns(true); @@ -74,8 +65,6 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications [Test] public void should_return_true_if_file_is_not_in_use() { - GivenNewFile(); - Mocker.GetMock() .Setup(s => s.IsFileLocked(It.IsAny())) .Returns(false); diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 59aa51f1c..a1c9a22e1 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -122,15 +122,6 @@ namespace NzbDrone.Core.Test.MediaFiles Times.Never()); } - [Test] - public void should_not_trigger_EpisodeImportedEvent_for_existing_files() - { - Subject.Import(new List { _approvedDecisions.First() }); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - } - [Test] public void should_import_larger_files_first() { diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs index 8fbdd09a5..3d04204be 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs @@ -36,10 +36,6 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(c => c.GetEpisodeBySeries(It.IsAny())) .Returns(_episodes); - - Mocker.GetMock() - .Setup(s => s.IsParent(It.IsAny(), It.IsAny())) - .Returns(true); } private void GivenEpisodeFiles(IEnumerable episodeFiles) @@ -58,13 +54,6 @@ namespace NzbDrone.Core.Test.MediaFiles .Returns(_episodes); } - private void GivenFileIsNotInSeriesFolder() - { - Mocker.GetMock() - .Setup(s => s.IsParent(It.IsAny(), It.IsAny())) - .Returns(false); - } - [Test] public void should_skip_files_that_exist_in_disk() { @@ -118,7 +107,6 @@ namespace NzbDrone.Core.Test.MediaFiles .Build(); GivenEpisodeFiles(episodeFiles); - GivenFileIsNotInSeriesFolder(); Subject.Execute(new CleanMediaFileDb(0)); diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 5c72045ef..bb3fa1367 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -135,6 +135,7 @@ + diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs index 338e3059c..67afd0a44 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/GetNewFilenameFixture.cs @@ -371,5 +371,39 @@ namespace NzbDrone.Core.Test.OrganizerTests Subject.BuildFilename(new List { _episode1 }, _series, _episodeFile) .Should().Be("30 Rock - 30.Rock.S01E01.xvid-LOL"); } + + [Test] + public void should_trim_periods_from_end_of_episode_title() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 3; + + var episode = Builder.CreateNew() + .With(e => e.Title = "Part 1.") + .With(e => e.SeasonNumber = 6) + .With(e => e.EpisodeNumber = 6) + .Build(); + + + Subject.BuildFilename(new List { episode }, new Series { Title = "30 Rock" }, _episodeFile) + .Should().Be("30 Rock - S06E06 - Part 1"); + } + + [Test] + public void should_trim_question_marks_from_end_of_episode_title() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; + _namingConfig.MultiEpisodeStyle = 3; + + var episode = Builder.CreateNew() + .With(e => e.Title = "Part 1?") + .With(e => e.SeasonNumber = 6) + .With(e => e.EpisodeNumber = 6) + .Build(); + + + Subject.BuildFilename(new List { episode }, new Series { Title = "30 Rock" }, _episodeFile) + .Should().Be("30 Rock - S06E06 - Part 1"); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index bb9475f5d..b0078900d 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -409,6 +409,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Burn.Notice.S04E15.Brotherly.Love.GERMAN.DUBBED.WS.WEBRiP.XviD.REPACK-TVP", Language.German)] [TestCase("Ray Donovan - S01E01.720p.HDtv.x264-Evolve (NLsub)", Language.Norwegian)] [TestCase("Shield,.The.1x13.Tueurs.De.Flics.FR.DVDRip.XviD", Language.French)] + [TestCase("True.Detective.S01E01.1080p.WEB-DL.Rus.Eng.TVKlondike", Language.Russian)] public void parse_language(string postTitle, Language language) { var result = Parser.Parser.ParseTitle(postTitle); diff --git a/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs b/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs new file mode 100644 index 000000000..fdc7f2545 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs @@ -0,0 +1,28 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(39)] + public class add_metadata_tables : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("Metadata") + .WithColumn("Enable").AsBoolean().NotNullable() + .WithColumn("Name").AsString().NotNullable() + .WithColumn("Implementation").AsString().NotNullable() + .WithColumn("Settings").AsString().NotNullable() + .WithColumn("ConfigContract").AsString().NotNullable(); + + Create.TableForModel("MetadataFiles") + .WithColumn("SeriesId").AsInt32().NotNullable() + .WithColumn("Consumer").AsString().NotNullable() + .WithColumn("Type").AsInt32().NotNullable() + .WithColumn("RelativePath").AsString().NotNullable() + .WithColumn("LastUpdated").AsDateTime().NotNullable() + .WithColumn("SeasonNumber").AsInt32().Nullable() + .WithColumn("EpisodeFileId").AsInt32().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs b/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs new file mode 100644 index 000000000..bf8119831 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs @@ -0,0 +1,22 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(40)] + public class add_metadata_to_episodes_and_series : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Series") + .AddColumn("Actors").AsString().Nullable() + .AddColumn("Ratings").AsString().Nullable() + .AddColumn("Genres").AsString().Nullable() + .AddColumn("Certification").AsString().Nullable(); + + Alter.Table("Episodes") + .AddColumn("Ratings").AsString().Nullable() + .AddColumn("Images").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index f97514f16..b5711cd18 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -11,6 +11,8 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Jobs; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata; +using NzbDrone.Core.Metadata.Files; using NzbDrone.Core.Notifications; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; @@ -35,8 +37,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("Indexers"); Mapper.Entity().RegisterModel("ScheduledTasks"); - Mapper.Entity() - .RegisterModel("Notifications"); + Mapper.Entity().RegisterModel("Notifications"); + Mapper.Entity().RegisterModel("Metadata"); Mapper.Entity().RegisterModel("SceneMappings"); @@ -59,16 +61,13 @@ namespace NzbDrone.Core.Datastore .Relationships.AutoMapICollectionOrComplexProperties(); Mapper.Entity().RegisterModel("QualityProfiles"); - Mapper.Entity().RegisterModel("QualityDefinitions"); - Mapper.Entity().RegisterModel("Logs"); - Mapper.Entity().RegisterModel("NamingConfig"); - Mapper.Entity().MapResultSet(); - Mapper.Entity().RegisterModel("Blacklist"); + + Mapper.Entity().RegisterModel("MetadataFiles"); } private static void RegisterMappers() @@ -85,6 +84,7 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(QualityModel), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(Dictionary), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); } private static void RegisterProviderSettingConverter() diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 4b3282b8f..41868977c 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -120,6 +120,11 @@ namespace NzbDrone.Core.History public void Handle(EpisodeImportedEvent message) { + if (!message.NewDownload) + { + return; + } + foreach (var episode in message.EpisodeInfo.Episodes) { var history = new History diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs new file mode 100644 index 000000000..a54497e30 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs @@ -0,0 +1,50 @@ +using NLog; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedMetadataFiles : IHousekeepingTask + { + private readonly IDatabase _database; + private readonly Logger _logger; + + public CleanupOrphanedMetadataFiles(IDatabase database, Logger logger) + { + _database = database; + _logger = logger; + } + + public void Clean() + { + _logger.Trace("Running orphaned episode files cleanup"); + + DeleteOrphanedBySeries(); + DeleteOrphanedByEpisodeFile(); + } + + private void DeleteOrphanedBySeries() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles + WHERE Id IN ( + SELECT MetadataFiles.Id FROM MetadataFiles + LEFT OUTER JOIN Series + ON MetadataFiles.SeriesId = Series.Id + WHERE Series.Id IS NULL)"); + } + + private void DeleteOrphanedByEpisodeFile() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles + WHERE Id IN ( + SELECT MetadataFiles.Id FROM MetadataFiles + LEFT OUTER JOIN EpisodeFiles + ON MetadataFiles.EpisodeFileId = EpisodeFiles.Id + WHERE MetadataFiles.EpisodeFileId > 0 + AND EpisodeFiles.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index e8712230e..7e92fd89d 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -14,14 +14,12 @@ namespace NzbDrone.Core.Indexers public class IndexerFactory : ProviderFactory, IIndexerFactory { private readonly IIndexerRepository _providerRepository; - private readonly IEnumerable _providers; private readonly INewznabTestService _newznabTestService; public IndexerFactory(IIndexerRepository providerRepository, IEnumerable providers, IContainer container, INewznabTestService newznabTestService, Logger logger) : base(providerRepository, providers, container, logger) { _providerRepository = providerRepository; - _providers = providers; _newznabTestService = newznabTestService; } diff --git a/src/NzbDrone.Core/MediaCover/MediaCover.cs b/src/NzbDrone.Core/MediaCover/MediaCover.cs index 8c0df5443..4b4f54b00 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCover.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCover.cs @@ -8,12 +8,24 @@ namespace NzbDrone.Core.MediaCover Unknown = 0, Poster = 1, Banner = 2, - Fanart = 3 + Fanart = 3, + Screenshot = 4, + Headshot = 5 } public class MediaCover : IEmbeddedDocument { public MediaCoverTypes CoverType { get; set; } public string Url { get; set; } + + public MediaCover() + { + } + + public MediaCover(MediaCoverTypes coverType, string url) + { + CoverType = coverType; + Url = url; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index cb81150b4..a208f28bd 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -13,6 +13,13 @@ using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.MediaCover { + public interface IMapCoversToLocal + { + void ConvertToLocalUrls(int seriesId, IEnumerable covers); + string GetCoverPath(int seriesId, MediaCoverTypes mediaCoverTypes); + + } + public class MediaCoverService : IHandleAsync, IHandleAsync, @@ -22,25 +29,53 @@ namespace NzbDrone.Core.MediaCover private readonly IDiskProvider _diskProvider; private readonly ICoverExistsSpecification _coverExistsSpecification; private readonly IConfigFileProvider _configFileProvider; + private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; private readonly string _coverRootFolder; - public MediaCoverService(IHttpProvider httpProvider, IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, - ICoverExistsSpecification coverExistsSpecification, IConfigFileProvider configFileProvider, Logger logger) + public MediaCoverService(IHttpProvider httpProvider, + IDiskProvider diskProvider, + IAppFolderInfo appFolderInfo, + ICoverExistsSpecification coverExistsSpecification, + IConfigFileProvider configFileProvider, + IEventAggregator eventAggregator, + Logger logger) { _httpProvider = httpProvider; _diskProvider = diskProvider; _coverExistsSpecification = coverExistsSpecification; _configFileProvider = configFileProvider; + _eventAggregator = eventAggregator; _logger = logger; _coverRootFolder = appFolderInfo.GetMediaCoverPath(); } - public void HandleAsync(SeriesUpdatedEvent message) + public string GetCoverPath(int seriesId, MediaCoverTypes coverTypes) { - EnsureCovers(message.Series); + return Path.Combine(GetSeriesCoverPath(seriesId), coverTypes.ToString().ToLower() + ".jpg"); + } + + public void ConvertToLocalUrls(int seriesId, IEnumerable covers) + { + foreach (var mediaCover in covers) + { + var filePath = GetCoverPath(seriesId, mediaCover.CoverType); + + mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + seriesId + "/" + mediaCover.CoverType.ToString().ToLower() + ".jpg"; + + if (_diskProvider.FileExists(filePath)) + { + var lastWrite = _diskProvider.GetLastFileWrite(filePath); + mediaCover.Url += "?lastWrite=" + lastWrite.Ticks; + } + } + } + + private string GetSeriesCoverPath(int seriesId) + { + return Path.Combine(_coverRootFolder, seriesId.ToString()); } private void EnsureCovers(Series series) @@ -72,7 +107,12 @@ namespace NzbDrone.Core.MediaCover _logger.Info("Downloading {0} for {1} {2}", cover.CoverType, series, cover.Url); _httpProvider.DownloadFile(cover.Url, fileName); + } + public void HandleAsync(SeriesUpdatedEvent message) + { + EnsureCovers(message.Series); + _eventAggregator.PublishEvent(new MediaCoversUpdatedEvent(message.Series)); } public void HandleAsync(SeriesDeletedEvent message) @@ -83,36 +123,5 @@ namespace NzbDrone.Core.MediaCover _diskProvider.DeleteFolder(path, true); } } - - private string GetCoverPath(int seriesId, MediaCoverTypes coverTypes) - { - return Path.Combine(GetSeriesCoverPath(seriesId), coverTypes.ToString().ToLower() + ".jpg"); - } - - private string GetSeriesCoverPath(int seriesId) - { - return Path.Combine(_coverRootFolder, seriesId.ToString()); - } - - public void ConvertToLocalUrls(int seriesId, IEnumerable covers) - { - foreach (var mediaCover in covers) - { - var filePath = GetCoverPath(seriesId, mediaCover.CoverType); - - mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + seriesId + "/" + mediaCover.CoverType.ToString().ToLower() + ".jpg"; - - if (_diskProvider.FileExists(filePath)) - { - var lastWrite = _diskProvider.GetLastFileWrite(filePath); - mediaCover.Url += "?lastWrite=" + lastWrite.Ticks; - } - } - } - } - - public interface IMapCoversToLocal - { - void ConvertToLocalUrls(int seriesId, IEnumerable covers); } } diff --git a/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs b/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs new file mode 100644 index 000000000..7335f7f9b --- /dev/null +++ b/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.MediaCover +{ + public class MediaCoversUpdatedEvent : IEvent + { + public Series Series { get; set; } + + public MediaCoversUpdatedEvent(Series series) + { + Series = series; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index a18b435c9..713e45a7d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -87,9 +87,10 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport _mediaFileService.Add(episodeFile); imported.Add(importDecision); + _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, newDownload)); + if (newDownload) { - _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile)); _eventAggregator.PublishEvent(new EpisodeDownloadedEvent(localEpisode, episodeFile, oldFiles)); } } diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs index 38db811a6..d7c1ee6e7 100644 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs +++ b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs @@ -1,4 +1,5 @@ -using NzbDrone.Common.Messaging; +using System; +using NzbDrone.Common.Messaging; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.Events @@ -7,11 +8,13 @@ namespace NzbDrone.Core.MediaFiles.Events { public LocalEpisode EpisodeInfo { get; private set; } public EpisodeFile ImportedEpisode { get; private set; } + public Boolean NewDownload { get; set; } - public EpisodeImportedEvent(LocalEpisode episodeInfo, EpisodeFile importedEpisode) + public EpisodeImportedEvent(LocalEpisode episodeInfo, EpisodeFile importedEpisode, bool newDownload) { EpisodeInfo = episodeInfo; ImportedEpisode = importedEpisode; + NewDownload = newDownload; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index 18107b1ba..0a7fe8a70 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -30,6 +30,5 @@ namespace NzbDrone.Core.MediaFiles .AndWhere(c => c.SeasonNumber == seasonNumber) .ToList(); } - } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index bef1bde40..3bc146fad 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using NLog; diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs index 4a7af0d36..735b80400 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs @@ -48,7 +48,7 @@ namespace NzbDrone.Core.MediaFiles continue; } - if (!_diskProvider.IsParent(series.Path, episodeFile.Path)) + if (!DiskProviderBase.IsParent(series.Path, episodeFile.Path)) { _logger.Trace("File [{0}] does not belong to this series, removing from db", episodeFile.Path); _mediaFileService.Delete(episodeFile); diff --git a/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs b/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs new file mode 100644 index 000000000..2eeb539ac --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata.Consumers.Fake +{ + public class FakeMetadata : MetadataBase + { + private readonly IDiskProvider _diskProvider; + private readonly IHttpProvider _httpProvider; + private readonly Logger _logger; + + public FakeMetadata(IDiskProvider diskProvider, IHttpProvider httpProvider, Logger logger) + : base(diskProvider, httpProvider, logger) + { + _diskProvider = diskProvider; + _httpProvider = httpProvider; + _logger = logger; + } + + public override void OnSeriesUpdated(Series series, List existingMetadataFiles) + { + throw new NotImplementedException(); + } + + public override void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload) + { + throw new NotImplementedException(); + } + + public override void AfterRename(Series series) + { + throw new NotImplementedException(); + } + + public override MetadataFile FindMetadataFile(Series series, string path) + { + return null; + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Fake/FakeSettings.cs b/src/NzbDrone.Core/MetaData/Consumers/Fake/FakeSettings.cs new file mode 100644 index 000000000..e94f4a589 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/Fake/FakeSettings.cs @@ -0,0 +1,41 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Metadata.Consumers.Fake +{ + public class FakeMetadataSettingsValidator : AbstractValidator + { + public FakeMetadataSettingsValidator() + { + } + } + + public class FakeMetadataSettings : IProviderConfig + { + private static readonly FakeMetadataSettingsValidator Validator = new FakeMetadataSettingsValidator(); + + public FakeMetadataSettings() + { + FakeSetting = true; + } + + [FieldDefinition(0, Label = "Fake Setting", Type = FieldType.Checkbox)] + public Boolean FakeSetting { get; set; } + + public bool IsValid + { + get + { + return true; + } + } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs new file mode 100644 index 000000000..a4d5ac080 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata.Consumers.Xbmc +{ + public class XbmcMetadata : MetadataBase + { + private readonly IEventAggregator _eventAggregator; + private readonly IMapCoversToLocal _mediaCoverService; + private readonly IMediaFileService _mediaFileService; + private readonly IMetadataFileService _metadataFileService; + private readonly IDiskProvider _diskProvider; + private readonly IHttpProvider _httpProvider; + private readonly Logger _logger; + + public XbmcMetadata(IEventAggregator eventAggregator, + IMapCoversToLocal mediaCoverService, + IMediaFileService mediaFileService, + IMetadataFileService metadataFileService, + IDiskProvider diskProvider, + IHttpProvider httpProvider, + Logger logger) + : base(diskProvider, httpProvider, logger) + { + _eventAggregator = eventAggregator; + _mediaCoverService = mediaCoverService; + _mediaFileService = mediaFileService; + _metadataFileService = metadataFileService; + _diskProvider = diskProvider; + _httpProvider = httpProvider; + _logger = logger; + } + + private static readonly Regex SeriesImagesRegex = new Regex(@"^(?poster|banner|fanart)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex SeasonImagesRegex = new Regex(@"^season(?\d{2,}|-all|-specials)-(?poster|banner|fanart)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex EpisodeImageRegex = new Regex(@"-thumb\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override void OnSeriesUpdated(Series series, List existingMetadataFiles) + { + if (Settings.SeriesMetadata) + { + EnsureFolder(series.Path); + WriteTvShowNfo(series, existingMetadataFiles); + } + + if (Settings.SeriesImages) + { + EnsureFolder(series.Path); + WriteSeriesImages(series, existingMetadataFiles); + } + + if (Settings.SeasonImages) + { + EnsureFolder(series.Path); + WriteSeasonImages(series, existingMetadataFiles); + } + } + + public override void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload) + { + if (Settings.EpisodeMetadata) + { + WriteEpisodeNfo(series, episodeFile); + } + + if (Settings.EpisodeImages) + { + WriteEpisodeImages(series, episodeFile); + } + } + + public override void AfterRename(Series series) + { + var episodeFiles = _mediaFileService.GetFilesBySeries(series.Id); + var episodeFilesMetadata = _metadataFileService.GetFilesBySeries(series.Id).Where(c => c.EpisodeFileId > 0).ToList(); + + foreach (var episodeFile in episodeFiles) + { + var metadataFiles = episodeFilesMetadata.Where(m => m.EpisodeFileId == episodeFile.Id).ToList(); + var episodeFilenameWithoutExtension = + Path.GetFileNameWithoutExtension(DiskProviderBase.GetRelativePath(series.Path, episodeFile.Path)); + + foreach (var metadataFile in metadataFiles) + { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(metadataFile.RelativePath); + var extension = Path.GetExtension(metadataFile.RelativePath); + + if (!fileNameWithoutExtension.Equals(episodeFilenameWithoutExtension)) + { + var source = Path.Combine(series.Path, metadataFile.RelativePath); + var destination = Path.Combine(series.Path, fileNameWithoutExtension + extension); + + _diskProvider.MoveFile(source, destination); + metadataFile.RelativePath = fileNameWithoutExtension + extension; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadataFile)); + } + } + } + } + + public override MetadataFile FindMetadataFile(Series series, string path) + { + var filename = Path.GetFileName(path); + + if (filename == null) return null; + + var metadata = new MetadataFile + { + SeriesId = series.Id, + Consumer = GetType().Name, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) + }; + + if (SeriesImagesRegex.IsMatch(filename)) + { + metadata.Type = MetadataType.SeriesImage; + return metadata; + } + + var seasonMatch = SeasonImagesRegex.Match(filename); + + if (seasonMatch.Success) + { + metadata.Type = MetadataType.SeasonImage; + + var seasonNumber = seasonMatch.Groups["season"].Value; + + if (seasonNumber.Contains("specials")) + { + metadata.SeasonNumber = 0; + } + + else + { + metadata.SeasonNumber = Convert.ToInt32(seasonNumber); + } + + return metadata; + } + + if (EpisodeImageRegex.IsMatch(filename)) + { + metadata.Type = MetadataType.EpisodeImage; + return metadata; + } + + if (filename.Equals("tvshow.nfo", StringComparison.InvariantCultureIgnoreCase)) + { + metadata.Type = MetadataType.SeriesMetadata; + return metadata; + } + + var parseResult = Parser.Parser.ParseTitle(filename); + + if (parseResult != null && + !parseResult.FullSeason && + Path.GetExtension(filename) == ".nfo") + { + metadata.Type = MetadataType.EpisodeMetadata; + return metadata; + } + + return null; + } + + private void WriteTvShowNfo(Series series, List existingMetadataFiles) + { + _logger.Trace("Generating tvshow.nfo for: {0}", series.Title); + var sb = new StringBuilder(); + var xws = new XmlWriterSettings(); + xws.OmitXmlDeclaration = true; + xws.Indent = false; + + using (var xw = XmlWriter.Create(sb, xws)) + { + var tvShow = new XElement("tvshow"); + + tvShow.Add(new XElement("title", series.Title)); + tvShow.Add(new XElement("rating", series.Ratings.Percentage)); + tvShow.Add(new XElement("plot", series.Overview)); + + //Todo: probably will need to use TVDB to use this feature... +// tvShow.Add(new XElement("episodeguide", new XElement("url", episodeGuideUrl))); +// tvShow.Add(new XElement("episodeguideurl", episodeGuideUrl)); + tvShow.Add(new XElement("mpaa", series.Certification)); + tvShow.Add(new XElement("id", series.TvdbId)); + + foreach (var genre in series.Genres) + { + tvShow.Add(new XElement("genre", genre)); + } + + if (series.FirstAired.HasValue) + { + tvShow.Add(new XElement("premiered", series.FirstAired.Value.ToString("yyyy-MM-dd"))); + } + + tvShow.Add(new XElement("studio", series.Network)); + + foreach (var actor in series.Actors) + { + tvShow.Add(new XElement("actor", + new XElement("name", actor.Name), + new XElement("role", actor.Character), + new XElement("thumb", actor.Images.First()) + )); + } + + var doc = new XDocument(tvShow); + doc.Save(xw); + + _logger.Debug("Saving tvshow.nfo for {0}", series.Title); + + var path = Path.Combine(series.Path, "tvshow.nfo"); + + _diskProvider.WriteAllText(path, doc.ToString()); + + var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.SeriesMetadata) ?? + new MetadataFile + { + SeriesId = series.Id, + Consumer = GetType().Name, + Type = MetadataType.SeriesMetadata, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) + }; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + } + } + + private void WriteSeriesImages(Series series, List existingMetadataFiles) + { + foreach (var image in series.Images) + { + var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType); + var destination = Path.Combine(series.Path, image.CoverType.ToString().ToLowerInvariant() + Path.GetExtension(source)); + + //TODO: Do we want to overwrite the file if it exists? + if (_diskProvider.FileExists(destination)) + { + _logger.Trace("Series image: {0} already exists.", image.CoverType); + continue; + } + + _diskProvider.CopyFile(source, destination, false); + + var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.SeriesImage) ?? + new MetadataFile + { + SeriesId = series.Id, + Consumer = GetType().Name, + Type = MetadataType.SeriesImage, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, destination) + }; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + } + } + + private void WriteSeasonImages(Series series, List existingMetadataFiles) + { + foreach (var season in series.Seasons) + { + foreach (var image in season.Images) + { + var filename = String.Format("season{0:00}-{1}.jpg", season.SeasonNumber, image.CoverType.ToString().ToLower()); + + if (season.SeasonNumber == 0) + { + filename = String.Format("season-specials-{0}.jpg", image.CoverType.ToString().ToLower()); + } + + var path = Path.Combine(series.Path, filename); + + DownloadImage(series, image.Url, path); + + var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.SeasonImage && + c.SeasonNumber == season.SeasonNumber) ?? + new MetadataFile + { + SeriesId = series.Id, + SeasonNumber = season.SeasonNumber, + Consumer = GetType().Name, + Type = MetadataType.SeriesMetadata, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) + }; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + } + } + } + + private void WriteEpisodeNfo(Series series, EpisodeFile episodeFile) + { + var filename = episodeFile.Path.Replace(Path.GetExtension(episodeFile.Path), ".nfo"); + + _logger.Debug("Generating {0} for: {1}", filename, episodeFile.Path); + + var xmlResult = String.Empty; + foreach (var episode in episodeFile.Episodes.Value) + { + var sb = new StringBuilder(); + var xws = new XmlWriterSettings(); + xws.OmitXmlDeclaration = true; + xws.Indent = false; + + using (var xw = XmlWriter.Create(sb, xws)) + { + var doc = new XDocument(); + + var details = new XElement("episodedetails"); + details.Add(new XElement("title", episode.Title)); + details.Add(new XElement("season", episode.SeasonNumber)); + details.Add(new XElement("episode", episode.EpisodeNumber)); + details.Add(new XElement("aired", episode.AirDate)); + details.Add(new XElement("plot", episode.Overview)); + details.Add(new XElement("displayseason", episode.SeasonNumber)); + details.Add(new XElement("displayepisode", episode.EpisodeNumber)); + details.Add(new XElement("thumb", episode.Images.Single(i => i.CoverType == MediaCoverTypes.Screenshot).Url)); + details.Add(new XElement("watched", "false")); + details.Add(new XElement("rating", episode.Ratings.Percentage)); + + //Todo: get guest stars, writer and director + //details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); + //details.Add(new XElement("director", tvdbEpisode.Directors.FirstOrDefault())); + + doc.Add(details); + doc.Save(xw); + + xmlResult += doc.ToString(); + xmlResult += Environment.NewLine; + } + } + + _logger.Debug("Saving episodedetails to: {0}", filename); + _diskProvider.WriteAllText(filename, xmlResult.Trim(Environment.NewLine.ToCharArray())); + + var metadata = new MetadataFile + { + SeriesId = series.Id, + EpisodeFileId = episodeFile.Id, + Consumer = GetType().Name, + Type = MetadataType.SeasonImage, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) + }; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + } + + private void WriteEpisodeImages(Series series, EpisodeFile episodeFile) + { + var screenshot = episodeFile.Episodes.Value.First().Images.Single(i => i.CoverType == MediaCoverTypes.Screenshot); + + var filename = Path.ChangeExtension(episodeFile.Path, "").Trim('.') + "-thumb.jpg"; + + DownloadImage(series, screenshot.Url, filename); + + var metadata = new MetadataFile + { + SeriesId = series.Id, + EpisodeFileId = episodeFile.Id, + Consumer = GetType().Name, + Type = MetadataType.SeasonImage, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) + }; + + _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadataSettings.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadataSettings.cs new file mode 100644 index 000000000..65b397883 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadataSettings.cs @@ -0,0 +1,57 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Metadata.Consumers.Xbmc +{ + public class XbmcSettingsValidator : AbstractValidator + { + public XbmcSettingsValidator() + { + } + } + + public class XbmcMetadataSettings : IProviderConfig + { + private static readonly XbmcSettingsValidator Validator = new XbmcSettingsValidator(); + + public XbmcMetadataSettings() + { + SeriesMetadata = true; + EpisodeMetadata = true; + SeriesImages = true; + SeasonImages = true; + EpisodeImages = true; + } + + [FieldDefinition(0, Label = "Series Metadata", Type = FieldType.Checkbox)] + public Boolean SeriesMetadata { get; set; } + + [FieldDefinition(1, Label = "Episode Metadata", Type = FieldType.Checkbox)] + public Boolean EpisodeMetadata { get; set; } + + [FieldDefinition(2, Label = "Series Images", Type = FieldType.Checkbox)] + public Boolean SeriesImages { get; set; } + + [FieldDefinition(3, Label = "Season Images", Type = FieldType.Checkbox)] + public Boolean SeasonImages { get; set; } + + [FieldDefinition(4, Label = "Episode Images", Type = FieldType.Checkbox)] + public Boolean EpisodeImages { get; set; } + + public bool IsValid + { + get + { + return true; + } + } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/IMetadata.cs b/src/NzbDrone.Core/MetaData/IMetadata.cs new file mode 100644 index 000000000..02a51554c --- /dev/null +++ b/src/NzbDrone.Core/MetaData/IMetadata.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata +{ + public interface IMetadata : IProvider + { + void OnSeriesUpdated(Series series, List existingMetadataFiles); + void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload); + void AfterRename(Series series); + MetadataFile FindMetadataFile(Series series, string path); + } +} diff --git a/src/NzbDrone.Core/MetaData/MetadataDefinition.cs b/src/NzbDrone.Core/MetaData/MetadataDefinition.cs new file mode 100644 index 000000000..c796eb8ab --- /dev/null +++ b/src/NzbDrone.Core/MetaData/MetadataDefinition.cs @@ -0,0 +1,10 @@ +using System; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Metadata +{ + public class MetadataDefinition : ProviderDefinition + { + public Boolean Enable { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetaData/MetadataFactory.cs b/src/NzbDrone.Core/MetaData/MetadataFactory.cs new file mode 100644 index 000000000..b9b1bd20b --- /dev/null +++ b/src/NzbDrone.Core/MetaData/MetadataFactory.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Composition; +using NzbDrone.Core.Metadata.Consumers.Fake; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Metadata +{ + public interface IMetadataFactory : IProviderFactory + { + List Enabled(); + } + + public class MetadataFactory : ProviderFactory, IMetadataFactory + { + private readonly IMetadataRepository _providerRepository; + + public MetadataFactory(IMetadataRepository providerRepository, IEnumerable providers, IContainer container, Logger logger) + : base(providerRepository, providers, container, logger) + { + _providerRepository = providerRepository; + } + + protected override void InitializeProviders() + { + var definitions = new List(); + + foreach (var provider in _providers) + { + if (provider.GetType() == typeof(FakeMetadata)) continue;; + + definitions.Add(new MetadataDefinition + { + Enable = false, + Name = provider.GetType().Name.Replace("Metadata", ""), + Implementation = provider.GetType().Name, + Settings = (IProviderConfig)Activator.CreateInstance(provider.ConfigContract) + }); + } + + var currentProviders = All(); + + var newProviders = definitions.Where(def => currentProviders.All(c => c.Implementation != def.Implementation)).ToList(); + + if (newProviders.Any()) + { + _providerRepository.InsertMany(newProviders.Cast().ToList()); + } + } + + public List Enabled() + { + return GetAvailableProviders().Where(n => ((MetadataDefinition)n.Definition).Enable).ToList(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetaData/MetadataRepository.cs b/src/NzbDrone.Core/MetaData/MetadataRepository.cs new file mode 100644 index 000000000..78b31f94f --- /dev/null +++ b/src/NzbDrone.Core/MetaData/MetadataRepository.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + + +namespace NzbDrone.Core.Metadata +{ + public interface IMetadataRepository : IProviderRepository + { + + } + + public class MetadataRepository : ProviderRepository, IMetadataRepository + { + public MetadataRepository(IDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetaData/MetadataService.cs b/src/NzbDrone.Core/MetaData/MetadataService.cs new file mode 100644 index 000000000..08708ab3e --- /dev/null +++ b/src/NzbDrone.Core/MetaData/MetadataService.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Metadata +{ + public class NotificationService + : IHandle, + IHandle, + IHandle + { + private readonly IMetadataFactory _metadataFactory; + private readonly MetadataFileService _metadataFileService; + private readonly Logger _logger; + + public NotificationService(IMetadataFactory metadataFactory, MetadataFileService metadataFileService, Logger logger) + { + _metadataFactory = metadataFactory; + _metadataFileService = metadataFileService; + _logger = logger; + } + + public void Handle(MediaCoversUpdatedEvent message) + { + var seriesMetadata = _metadataFileService.GetFilesBySeries(message.Series.Id); + + foreach (var consumer in _metadataFactory.Enabled()) + { + consumer.OnSeriesUpdated(message.Series, seriesMetadata.Where(c => c.Consumer == consumer.GetType().Name).ToList()); + } + } + + public void Handle(EpisodeImportedEvent message) + { + foreach (var consumer in _metadataFactory.Enabled()) + { + consumer.OnEpisodeImport(message.EpisodeInfo.Series, message.ImportedEpisode, message.NewDownload); + } + } + + public void Handle(SeriesRenamedEvent message) + { + foreach (var consumer in _metadataFactory.Enabled()) + { + consumer.AfterRename(message.Series); + } + } + } +} diff --git a/src/NzbDrone.Core/MetaData/MetadataType.cs b/src/NzbDrone.Core/MetaData/MetadataType.cs new file mode 100644 index 000000000..5dbfa0b24 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/MetadataType.cs @@ -0,0 +1,14 @@ +using System; + +namespace NzbDrone.Core.Metadata +{ + public enum MetadataType + { + Unknown = 0, + SeriesMetadata = 1, + EpisodeMetadata = 2, + SeriesImage = 3, + SeasonImage = 4, + EpisodeImage = 5 + } +} diff --git a/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs new file mode 100644 index 000000000..8331c03a2 --- /dev/null +++ b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Metadata +{ + public class ExistingMetadataService : IHandleAsync + { + private readonly IDiskProvider _diskProvider; + private readonly IMetadataFileService _metadataFileService; + private readonly IParsingService _parsingService; + private readonly Logger _logger; + private readonly List _consumers; + + public ExistingMetadataService(IDiskProvider diskProvider, + IEnumerable consumers, + IMetadataFileService metadataFileService, + IParsingService parsingService, + Logger logger) + { + _diskProvider = diskProvider; + _metadataFileService = metadataFileService; + _parsingService = parsingService; + _logger = logger; + _consumers = consumers.ToList(); + } + + public void HandleAsync(SeriesUpdatedEvent message) + { + if (!_diskProvider.FolderExists(message.Series.Path)) return; + + _logger.Trace("Looking for existing metadata in {0}", message.Series.Path); + + var filesOnDisk = _diskProvider.GetFiles(message.Series.Path, SearchOption.AllDirectories); + var possibleMetadataFiles = filesOnDisk.Where(c => !MediaFileExtensions.Extensions.Contains(Path.GetExtension(c).ToLower())).ToList(); + var filteredFiles = _metadataFileService.FilterExistingFiles(possibleMetadataFiles, message.Series); + + foreach (var possibleMetadataFile in filteredFiles) + { + foreach (var consumer in _consumers) + { + var metadata = consumer.FindMetadataFile(message.Series, possibleMetadataFile); + + if (metadata == null) continue; + + if (metadata.Type == MetadataType.EpisodeImage || + metadata.Type == MetadataType.EpisodeMetadata) + { + var localEpisode = _parsingService.GetEpisodes(possibleMetadataFile, message.Series, false); + + if (localEpisode == null) + { + _logger.Trace("Cannot find related episodes for: {0}", possibleMetadataFile); + break; + } + + if (localEpisode.Episodes.DistinctBy(e => e.EpisodeFileId).Count() > 1) + { + _logger.Trace("Metadata file: {0} does not match existing files.", possibleMetadataFile); + break; + } + + metadata.EpisodeFileId = localEpisode.Episodes.First().EpisodeFileId; + } + + _metadataFileService.Upsert(metadata); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFile.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFile.cs new file mode 100644 index 000000000..df52e2bc4 --- /dev/null +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFile.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Metadata.Files +{ + public class MetadataFile : ModelBase + { + public Int32 SeriesId { get; set; } + public String Consumer { get; set; } + public MetadataType Type { get; set; } + public String RelativePath { get; set; } + public DateTime LastUpdated { get; set; } + public Int32? EpisodeFileId { get; set; } + public Int32? SeasonNumber { get; set; } + } +} diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs new file mode 100644 index 000000000..38889fbb3 --- /dev/null +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Metadata.Files +{ + public interface IMetadataFileRepository : IBasicRepository + { + void DeleteForSeries(int seriesId); + void DeleteForSeason(int seriesId, int seasonNumber); + void DeleteForEpisodeFile(int episodeFileId); + List GetFilesBySeries(int seriesId); + List GetFilesBySeason(int seriesId, int seasonNumber); + List GetFilesByEpisodeFile(int episodeFileId); + MetadataFile FindByPath(string path); + } + + public class MetadataFileRepository : BasicRepository, IMetadataFileRepository + { + public MetadataFileRepository(IDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public void DeleteForSeries(int seriesId) + { + Delete(c => c.SeriesId == seriesId); + } + + public void DeleteForSeason(int seriesId, int seasonNumber) + { + Delete(c => c.SeriesId == seriesId && c.SeasonNumber == seasonNumber); + } + + public void DeleteForEpisodeFile(int episodeFileId) + { + Delete(c => c.EpisodeFileId == episodeFileId); + } + + public List GetFilesBySeries(int seriesId) + { + return Query.Where(c => c.SeriesId == seriesId); + } + + public List GetFilesBySeason(int seriesId, int seasonNumber) + { + return Query.Where(c => c.SeriesId == seriesId && c.SeasonNumber == seasonNumber); + } + + public List GetFilesByEpisodeFile(int episodeFileId) + { + return Query.Where(c => c.EpisodeFileId == episodeFileId); + } + + public MetadataFile FindByPath(string path) + { + return Query.SingleOrDefault(c => c.RelativePath == path); + } + } +} diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs new file mode 100644 index 000000000..f888bfdb8 --- /dev/null +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Events; + +namespace NzbDrone.Core.Metadata.Files +{ + public interface IMetadataFileService + { + List GetFilesBySeries(int seriesId); + List GetFilesByEpisodeFile(int episodeFileId); + MetadataFile FindByPath(string path); + List FilterExistingFiles(List files, Series series); + MetadataFile Upsert(MetadataFile metadataFile); + } + + public class MetadataFileService : IMetadataFileService, + IHandleAsync, + IHandleAsync, + IHandle + { + private readonly IMetadataFileRepository _repository; + private readonly ISeriesService _seriesService; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public MetadataFileService(IMetadataFileRepository repository, + ISeriesService seriesService, + IDiskProvider diskProvider, + Logger logger) + { + _repository = repository; + _seriesService = seriesService; + _diskProvider = diskProvider; + _logger = logger; + } + + public List GetFilesBySeries(int seriesId) + { + return _repository.GetFilesBySeries(seriesId); + } + + public List GetFilesByEpisodeFile(int episodeFileId) + { + return _repository.GetFilesByEpisodeFile(episodeFileId); + } + + public MetadataFile FindByPath(string path) + { + return _repository.FindByPath(path); + } + + public List FilterExistingFiles(List files, Series series) + { + var seriesFiles = GetFilesBySeries(series.Id).Select(f => Path.Combine(series.Path, f.RelativePath)).ToList(); + + if (!seriesFiles.Any()) return files; + + return files.Except(seriesFiles, PathEqualityComparer.Instance).ToList(); + } + + public MetadataFile Upsert(MetadataFile metadataFile) + { + metadataFile.LastUpdated = DateTime.UtcNow; + return _repository.Upsert(metadataFile); + } + + public void HandleAsync(SeriesDeletedEvent message) + { + _logger.Trace("Deleting Metadata from database for series: {0}", message.Series); + _repository.DeleteForSeries(message.Series.Id); + } + + public void HandleAsync(EpisodeFileDeletedEvent message) + { + var episodeFile = message.EpisodeFile; + var series = _seriesService.GetSeries(message.EpisodeFile.SeriesId); + + foreach (var metadata in _repository.GetFilesByEpisodeFile(episodeFile.Id)) + { + var path = Path.Combine(series.Path, metadata.RelativePath); + + if (_diskProvider.FileExists(path)) + { + _diskProvider.DeleteFile(path); + } + } + + _logger.Trace("Deleting Metadata from database for episode file: {0}", episodeFile); + _repository.DeleteForEpisodeFile(episodeFile.Id); + } + + public void Handle(MetadataFileUpdated message) + { + Upsert(message.Metadata); + } + } +} diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs new file mode 100644 index 000000000..7f7b4b189 --- /dev/null +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Metadata.Files +{ + public class MetadataFileUpdated : IEvent + { + public MetadataFile Metadata { get; set; } + + public MetadataFileUpdated(MetadataFile metadata) + { + Metadata = metadata; + } + } +} diff --git a/src/NzbDrone.Core/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Metadata/MetadataBase.cs new file mode 100644 index 000000000..dbd613f1d --- /dev/null +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata +{ + public abstract class MetadataBase : IMetadata where TSettings : IProviderConfig, new() + { + private readonly IDiskProvider _diskProvider; + private readonly IHttpProvider _httpProvider; + private readonly Logger _logger; + + protected MetadataBase(IDiskProvider diskProvider, IHttpProvider httpProvider, Logger logger) + { + _diskProvider = diskProvider; + _httpProvider = httpProvider; + _logger = logger; + } + + public Type ConfigContract + { + get + { + return typeof(TSettings); + } + } + + public IEnumerable DefaultDefinitions + { + get + { + return new List(); + } + } + + public ProviderDefinition Definition { get; set; } + + public abstract void OnSeriesUpdated(Series series, List existingMetadataFiles); + public abstract void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload); + public abstract void AfterRename(Series series); + public abstract MetadataFile FindMetadataFile(Series series, string path); + + protected TSettings Settings + { + get + { + return (TSettings)Definition.Settings; + } + } + + protected virtual void EnsureFolder(string path) + { + _diskProvider.CreateFolder(path); + } + + protected virtual void DownloadImage(Series series, string url, string path) + { + try + { + if (_diskProvider.FileExists(path)) + { + _logger.Trace("Image already exists: {0}, will not download again.", path); + return; + } + + _httpProvider.DownloadFile(url, path); + } + catch (WebException e) + { + _logger.Warn(string.Format("Couldn't download image {0} for {1}. {2}", url, series, e.Message)); + } + catch (Exception e) + { + _logger.ErrorException("Couldn't download image " + url + " for " + series, e); + } + } + + public override string ToString() + { + return GetType().Name; + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/Actor.cs b/src/NzbDrone.Core/MetadataSource/Trakt/Actor.cs new file mode 100644 index 000000000..cc16310e2 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Trakt/Actor.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MetadataSource.Trakt +{ + public class Actor + { + public string name { get; set; } + public string character { get; set; } + public Images images { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/Episode.cs b/src/NzbDrone.Core/MetadataSource/Trakt/Episode.cs index 7045f31cb..302fb49ef 100644 --- a/src/NzbDrone.Core/MetadataSource/Trakt/Episode.cs +++ b/src/NzbDrone.Core/MetadataSource/Trakt/Episode.cs @@ -13,5 +13,7 @@ public int first_aired_utc { get; set; } public string url { get; set; } public string screen { get; set; } + public Ratings ratings { get; set; } + public Images images { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/FullShow.cs b/src/NzbDrone.Core/MetadataSource/Trakt/FullShow.cs index c35ceee3f..d94675b26 100644 --- a/src/NzbDrone.Core/MetadataSource/Trakt/FullShow.cs +++ b/src/NzbDrone.Core/MetadataSource/Trakt/FullShow.cs @@ -29,6 +29,8 @@ namespace NzbDrone.Core.MetadataSource.Trakt public Images images { get; set; } public List genres { get; set; } public List seasons { get; set; } + public Ratings ratings { get; set; } + public People people { get; set; } } public class SearchShow diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/Images.cs b/src/NzbDrone.Core/MetadataSource/Trakt/Images.cs index f1e791132..aa8b15374 100644 --- a/src/NzbDrone.Core/MetadataSource/Trakt/Images.cs +++ b/src/NzbDrone.Core/MetadataSource/Trakt/Images.cs @@ -5,5 +5,7 @@ public string poster { get; set; } public string fanart { get; set; } public string banner { get; set; } + public string screen { get; set; } + public string headshot { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/People.cs b/src/NzbDrone.Core/MetadataSource/Trakt/People.cs new file mode 100644 index 000000000..31d736178 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Trakt/People.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.Trakt +{ + public class People + { + public List actors { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/Ratings.cs b/src/NzbDrone.Core/MetadataSource/Trakt/Ratings.cs new file mode 100644 index 000000000..6302071eb --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Trakt/Ratings.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.MetadataSource.Trakt +{ + public class Ratings + { + public Int32 percentage { get; set; } + public Int32 votes { get; set; } + public Int32 loved { get; set; } + public Int32 hated { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Trakt/Season.cs b/src/NzbDrone.Core/MetadataSource/Trakt/Season.cs index ac741b5c2..ac9bad579 100644 --- a/src/NzbDrone.Core/MetadataSource/Trakt/Season.cs +++ b/src/NzbDrone.Core/MetadataSource/Trakt/Season.cs @@ -8,5 +8,6 @@ namespace NzbDrone.Core.MetadataSource.Trakt public List episodes { get; set; } public string url { get; set; } public string poster { get; set; } + public Images images { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs index aa9391f7d..a731a0c86 100644 --- a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs @@ -8,7 +8,9 @@ using NLog; using NzbDrone.Common; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.Trakt; +using NzbDrone.Core.Notifications.Xbmc.Model; using NzbDrone.Core.Tv; +using Omu.ValueInjecter; using RestSharp; using Episode = NzbDrone.Core.Tv.Episode; using NzbDrone.Core.Rest; @@ -79,15 +81,16 @@ namespace NzbDrone.Core.MetadataSource series.AirTime = show.air_time_utc; series.TitleSlug = show.url.ToLower().Replace("http://trakt.tv/show/", ""); series.Status = GetSeriesStatus(show.status, show.ended); - - series.Seasons = show.seasons.Select(s => new Tv.Season - { - SeasonNumber = s.season - }).OrderByDescending(s => s.SeasonNumber).ToList(); + series.Ratings = GetRatings(show.ratings); + series.Genres = show.genres; + series.Certification = show.certification; + series.Actors = GetActors(show.people); + series.Seasons = GetSeasons(show); series.Images.Add(new MediaCover.MediaCover { CoverType = MediaCoverTypes.Banner, Url = show.images.banner }); series.Images.Add(new MediaCover.MediaCover { CoverType = MediaCoverTypes.Poster, Url = GetPosterThumbnailUrl(show.images.poster) }); series.Images.Add(new MediaCover.MediaCover { CoverType = MediaCoverTypes.Fanart, Url = show.images.fanart }); + return series; } @@ -101,6 +104,9 @@ namespace NzbDrone.Core.MetadataSource episode.Title = traktEpisode.title; episode.AirDate = FromIsoToString(traktEpisode.first_aired_iso); episode.AirDateUtc = FromIso(traktEpisode.first_aired_iso); + episode.Ratings = GetRatings(traktEpisode.ratings); + + episode.Images.Add(new MediaCover.MediaCover(MediaCoverTypes.Screenshot, traktEpisode.images.screen)); return episode; } @@ -175,5 +181,64 @@ namespace NzbDrone.Core.MetadataSource return year; } + + private static Tv.Ratings GetRatings(Trakt.Ratings ratings) + { + return new Tv.Ratings + { + Percentage = ratings.percentage, + Votes = ratings.votes, + Loved = ratings.loved, + Hated = ratings.hated + }; + } + + private static List GetActors(People people) + { + if (people == null) + { + return new List(); + } + + return GetActors(people.actors).ToList(); + } + + private static IEnumerable GetActors(IEnumerable trakcActors) + { + foreach (var traktActor in trakcActors) + { + var actor = new Tv.Actor + { + Name = traktActor.name, + Character = traktActor.character, + }; + + actor.Images.Add(new MediaCover.MediaCover(MediaCoverTypes.Headshot, traktActor.images.headshot)); + + yield return actor; + } + } + + private static List GetSeasons(Show show) + { + var seasons = new List(); + + foreach (var traktSeason in show.seasons.OrderByDescending(s => s.season)) + { + var season = new Tv.Season + { + SeasonNumber = traktSeason.season + }; + + if (traktSeason.images != null) + { + season.Images.Add(new MediaCover.MediaCover(MediaCoverTypes.Poster, traktSeason.images.poster)); + } + + seasons.Add(season); + } + + return seasons; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index c09e55f48..92c3429c2 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -195,6 +195,8 @@ + + @@ -259,9 +261,10 @@ - + + @@ -294,6 +297,7 @@ + @@ -310,7 +314,26 @@ + + + + + + + + + + + + + + + + + + + @@ -489,6 +512,7 @@ + @@ -500,6 +524,7 @@ + diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 2997be7d5..1ad4f3ec4 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -44,6 +44,8 @@ namespace NzbDrone.Core.Organizer public static readonly Regex SeriesTitleRegex = new Regex(@"(?\{(?:Series)(?\s|\.|-|_)Title\})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly char[] EpisodeTitleTrimCharaters = new[] { ' ', '.', '?' }; + public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManger cacheManger, @@ -88,7 +90,7 @@ namespace NzbDrone.Core.Organizer var pattern = namingConfig.StandardEpisodeFormat; var episodeTitles = new List { - sortedEpisodes.First().Title + sortedEpisodes.First().Title.TrimEnd(EpisodeTitleTrimCharaters) }; var tokenValues = new Dictionary(FilenameBuilderTokenEqualityComparer.Instance); @@ -140,7 +142,7 @@ namespace NzbDrone.Core.Organizer break; } - episodeTitles.Add(episode.Title); + episodeTitles.Add(episode.Title.TrimEnd(EpisodeTitleTrimCharaters)); } seasonEpisodePattern = ReplaceNumberTokens(seasonEpisodePattern, sortedEpisodes); diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 11e57ccb4..d62f12f3d 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -108,7 +108,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex MultiPartCleanupRegex = new Regex(@"\(\d+\)$", RegexOptions.Compiled); - private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?ita|italian)|(?german\b)|(?flemish)|(?greek)|(?(?:\W|_)FR)(?:\W|_)", + private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?ita|italian)|(?german\b)|(?flemish)|(?greek)|(?(?:\W|_)FR)(?:\W|_)|(?\brus\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex YearInTitleRegex = new Regex(@"^(?.+?)(?:\W|_)?(?<year>\d{4})", @@ -148,7 +148,6 @@ namespace NzbDrone.Core.Parser foreach (var regex in ReportTitleRegex) { - var regexString = regex.ToString(); var match = regex.Matches(simpleTitle); if (match.Count != 0) @@ -467,6 +466,9 @@ namespace NzbDrone.Core.Parser if (match.Groups["french"].Success) return Language.French; + if (match.Groups["russian"].Success) + return Language.Russian; + return Language.English; } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 37b450154..f8053db3d 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -64,7 +64,7 @@ namespace NzbDrone.Core.Parser Episodes = episodes, Path = filename, ParsedEpisodeInfo = parsedEpisodeInfo, - ExistingFile = _diskProvider.IsParent(series.Path, filename) + ExistingFile = DiskProviderBase.IsParent(series.Path, filename) }; } diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index edc273a52..bdb30d12b 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Core.ThingiProvider private readonly IContainer _container; private readonly Logger _logger; - private readonly List<TProvider> _providers; + protected readonly List<TProvider> _providers; protected ProviderFactory(IProviderRepository<TProviderDefinition> providerRepository, IEnumerable<TProvider> providers, diff --git a/src/NzbDrone.Core/Tv/Actor.cs b/src/NzbDrone.Core/Tv/Actor.cs new file mode 100644 index 000000000..3612e2f78 --- /dev/null +++ b/src/NzbDrone.Core/Tv/Actor.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tv +{ + public class Actor : IEmbeddedDocument + { + public Actor() + { + Images = new List<MediaCover.MediaCover>(); + } + + public String Name { get; set; } + public String Character { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tv/Episode.cs b/src/NzbDrone.Core/Tv/Episode.cs index 854dceb9c..a550709b0 100644 --- a/src/NzbDrone.Core/Tv/Episode.cs +++ b/src/NzbDrone.Core/Tv/Episode.cs @@ -1,14 +1,19 @@ using System; +using System.Collections.Generic; using Marr.Data; using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles; using NzbDrone.Common; - namespace NzbDrone.Core.Tv { public class Episode : ModelBase { + public Episode() + { + Images = new List<MediaCover.MediaCover>(); + } + public const string AIR_DATE_FORMAT = "yyyy-MM-dd"; public int SeriesId { get; set; } @@ -18,12 +23,13 @@ namespace NzbDrone.Core.Tv public string Title { get; set; } public string AirDate { get; set; } public DateTime? AirDateUtc { get; set; } - public string Overview { get; set; } public Boolean Monitored { get; set; } public Nullable<Int32> AbsoluteEpisodeNumber { get; set; } public int SceneSeasonNumber { get; set; } public int SceneEpisodeNumber { get; set; } + public Ratings Ratings { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } public String SeriesTitle { get; private set; } diff --git a/src/NzbDrone.Core/Tv/Ratings.cs b/src/NzbDrone.Core/Tv/Ratings.cs new file mode 100644 index 000000000..ffdabf95e --- /dev/null +++ b/src/NzbDrone.Core/Tv/Ratings.cs @@ -0,0 +1,13 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tv +{ + public class Ratings : IEmbeddedDocument + { + public Int32 Percentage { get; set; } + public Int32 Votes { get; set; } + public Int32 Loved { get; set; } + public Int32 Hated { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs index e60658104..357926f4e 100644 --- a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs +++ b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs @@ -65,6 +65,8 @@ namespace NzbDrone.Core.Tv episodeToUpdate.Overview = episode.Overview; episodeToUpdate.AirDate = episode.AirDate; episodeToUpdate.AirDateUtc = episode.AirDateUtc; + episodeToUpdate.Ratings = episode.Ratings; + episodeToUpdate.Images = episode.Images; successCount++; } diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 7400a3f43..8a1776642 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -50,6 +50,10 @@ namespace NzbDrone.Core.Tv series.Images = seriesInfo.Images; series.Network = seriesInfo.Network; series.FirstAired = seriesInfo.FirstAired; + series.Ratings = seriesInfo.Ratings; + series.Actors = seriesInfo.Actors; + series.Genres = seriesInfo.Genres; + series.Certification = seriesInfo.Certification; if (_dailySeriesService.IsDailySeries(series.TvdbId)) { diff --git a/src/NzbDrone.Core/Tv/Season.cs b/src/NzbDrone.Core/Tv/Season.cs index 5073ea2b4..9b3863f83 100644 --- a/src/NzbDrone.Core/Tv/Season.cs +++ b/src/NzbDrone.Core/Tv/Season.cs @@ -1,11 +1,18 @@ using System; +using System.Collections.Generic; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Tv { public class Season : IEmbeddedDocument { + public Season() + { + Images = new List<MediaCover.MediaCover>(); + } + public int SeasonNumber { get; set; } public Boolean Monitored { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs index 107162aaf..ace922cb1 100644 --- a/src/NzbDrone.Core/Tv/Series.cs +++ b/src/NzbDrone.Core/Tv/Series.cs @@ -13,6 +13,8 @@ namespace NzbDrone.Core.Tv public Series() { Images = new List<MediaCover.MediaCover>(); + Genres = new List<String>(); + Actors = new List<Actor>(); } public int TvdbId { get; set; } @@ -35,6 +37,10 @@ namespace NzbDrone.Core.Tv public string TitleSlug { get; set; } public string Path { get; set; } public int Year { get; set; } + public Ratings Ratings { get; set; } + public List<String> Genres { get; set; } + public List<Actor> Actors { get; set; } + public String Certification { get; set; } public string RootFolderPath { get; set; } diff --git a/src/NzbDrone.Host/NzbDrone.ico b/src/NzbDrone.Host/NzbDrone.ico index 94a68b293..1922557d6 100644 Binary files a/src/NzbDrone.Host/NzbDrone.ico and b/src/NzbDrone.Host/NzbDrone.ico differ diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/IsParentFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/IsParentFixture.cs deleted file mode 100644 index 179727ffb..000000000 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/IsParentFixture.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NUnit.Framework; -using NzbDrone.Common.Test.DiskProviderTests; - -namespace NzbDrone.Mono.Test.DiskProviderTests -{ - [TestFixture] - public class IsParentFixtureFixture : IsParentFixtureBase<DiskProvider> - { - public IsParentFixtureFixture() - { - LinuxOnly(); - } - } -} diff --git a/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj b/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj index 6b5b32c5b..ee7bd9dd5 100644 --- a/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj +++ b/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj @@ -68,7 +68,6 @@ <ItemGroup> <Compile Include="DiskProviderTests\DiskProviderFixture.cs" /> <Compile Include="DiskProviderTests\FreeSpaceFixture.cs" /> - <Compile Include="DiskProviderTests\IsParentFixture.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="ServiceFactoryFixture.cs" /> </ItemGroup> diff --git a/src/NzbDrone.Mono/NzbDroneProcessProvider.cs b/src/NzbDrone.Mono/NzbDroneProcessProvider.cs index 630b6e665..79a2b3579 100644 --- a/src/NzbDrone.Mono/NzbDroneProcessProvider.cs +++ b/src/NzbDrone.Mono/NzbDroneProcessProvider.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Mono return monoProcesses.Where(c => { - var processArgs = _processProvider.StartAndCapture("ps", String.Format("--pid {0} -o args=", c.Id)); + var processArgs = _processProvider.StartAndCapture("ps", String.Format("-p {0} -o args=", c.Id)); return processArgs.Standard.Any(p => p.Contains(ProcessProvider.NZB_DRONE_PROCESS_NAME + ".exe") || p.Contains(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME + ".exe")); diff --git a/src/NzbDrone.Windows.Test/DiskProviderTests/IsParentFixture.cs b/src/NzbDrone.Windows.Test/DiskProviderTests/IsParentFixture.cs deleted file mode 100644 index 00b6aa4c2..000000000 --- a/src/NzbDrone.Windows.Test/DiskProviderTests/IsParentFixture.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NUnit.Framework; -using NzbDrone.Common.Test.DiskProviderTests; - -namespace NzbDrone.Windows.Test.DiskProviderTests -{ - [TestFixture] - public class IsParentFixtureFixture : IsParentFixtureBase<DiskProvider> - { - public IsParentFixtureFixture() - { - WindowsOnly(); - } - } -} diff --git a/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj b/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj index 309005f99..65daabdf6 100644 --- a/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj +++ b/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj @@ -67,7 +67,6 @@ <Reference Include="System.Xml" /> </ItemGroup> <ItemGroup> - <Compile Include="DiskProviderTests\IsParentFixture.cs" /> <Compile Include="DiskProviderTests\DiskProviderFixture.cs" /> <Compile Include="DiskProviderTests\FreeSpaceFixture.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> diff --git a/src/UI/Cells/Edit/QualityCellEditor.js b/src/UI/Cells/Edit/QualityCellEditor.js index 2f238da38..934a36fa3 100644 --- a/src/UI/Cells/Edit/QualityCellEditor.js +++ b/src/UI/Cells/Edit/QualityCellEditor.js @@ -28,10 +28,12 @@ define( var templateName = self.template; self.schema = qualityProfileSchemaCollection.first(); - var selected = _.find(self.schema.get('available'), { 'id': self.model.get(self.column.get('name')).quality.id }); + var selected = _.find(self.schema.get('items'), function (model) { + return model.quality.id === self.model.get(self.column.get('name')).quality.id; + }); if (selected) { - selected.selected = true; + selected.quality.selected = true; } self.templateFunction = Marionette.TemplateCache.get(templateName); diff --git a/src/UI/Cells/Edit/QualityCellEditorTemplate.html b/src/UI/Cells/Edit/QualityCellEditorTemplate.html index 92d3bea99..4916cf284 100644 --- a/src/UI/Cells/Edit/QualityCellEditorTemplate.html +++ b/src/UI/Cells/Edit/QualityCellEditorTemplate.html @@ -1,7 +1,9 @@ -{{#each available}} - {{#if selected}} - <option value="{{id}}" selected="selected">{{name}}</option> - {{else}} - <option value="{{id}}">{{name}}</option> - {{/if}} +{{#each items}} + {{#with quality}} + {{#if selected}} + <option value="{{id}}" selected="selected">{{name}}</option> + {{else}} + <option value="{{id}}">{{name}}</option> + {{/if}} + {{/with}} {{/each}} \ No newline at end of file diff --git a/src/UI/Content/Images/background/black.png b/src/UI/Content/Images/background/black.png new file mode 100644 index 000000000..5c0ab5151 Binary files /dev/null and b/src/UI/Content/Images/background/black.png differ diff --git a/src/UI/Content/Images/background/logo.png b/src/UI/Content/Images/background/logo.png new file mode 100644 index 000000000..02628ff61 Binary files /dev/null and b/src/UI/Content/Images/background/logo.png differ diff --git a/src/UI/Content/Images/black_linen_v2.png b/src/UI/Content/Images/black_linen_v2.png deleted file mode 100644 index d125b4b8a..000000000 Binary files a/src/UI/Content/Images/black_linen_v2.png and /dev/null differ diff --git a/src/UI/Content/Images/favicon.ico b/src/UI/Content/Images/favicon.ico index 5ed464a9b..1922557d6 100644 Binary files a/src/UI/Content/Images/favicon.ico and b/src/UI/Content/Images/favicon.ico differ diff --git a/src/UI/Content/Images/logo.png b/src/UI/Content/Images/logo.png index 7916facc4..33387d7f9 100644 Binary files a/src/UI/Content/Images/logo.png and b/src/UI/Content/Images/logo.png differ diff --git a/src/UI/Content/Images/pattern.png b/src/UI/Content/Images/pattern.png deleted file mode 100644 index 2cff01c76..000000000 Binary files a/src/UI/Content/Images/pattern.png and /dev/null differ diff --git a/src/UI/Content/Images/touch/114.png b/src/UI/Content/Images/touch/114.png index 0b7f064c8..e41e7310c 100644 Binary files a/src/UI/Content/Images/touch/114.png and b/src/UI/Content/Images/touch/114.png differ diff --git a/src/UI/Content/Images/touch/144.png b/src/UI/Content/Images/touch/144.png index a411a72f8..160622d98 100644 Binary files a/src/UI/Content/Images/touch/144.png and b/src/UI/Content/Images/touch/144.png differ diff --git a/src/UI/Content/Images/touch/57.png b/src/UI/Content/Images/touch/57.png index fd482077f..39a0ec6da 100644 Binary files a/src/UI/Content/Images/touch/57.png and b/src/UI/Content/Images/touch/57.png differ diff --git a/src/UI/Content/Images/touch/72.png b/src/UI/Content/Images/touch/72.png index 1d27c4434..f45951e93 100644 Binary files a/src/UI/Content/Images/touch/72.png and b/src/UI/Content/Images/touch/72.png differ diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index 2ce038698..374dc607b 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -107,8 +107,10 @@ a, .btn { } body { - background-color : #1c1c1c; - background-image : url('../Content/Images/pattern.png'); + background: + url('../Content/Images/background/logo.png') 50px center no-repeat fixed, + #1c1c1c url('../Content/Images/background/black.png') top center e('/') cover repeat; + margin-bottom : 100px; p { font-size : 0.9em; diff --git a/src/UI/Content/variables.less b/src/UI/Content/variables.less index 0495a3c19..5a97d2217 100644 --- a/src/UI/Content/variables.less +++ b/src/UI/Content/variables.less @@ -1,2 +1,3 @@ @nzbdroneRed: #c4273c; -@nzbdronePurple: #7932ea; \ No newline at end of file +@nzbdronePurple: #7932ea; +@droneTeal: #35c5f4; \ No newline at end of file diff --git a/src/UI/Navbar/NavbarTemplate.html b/src/UI/Navbar/NavbarTemplate.html index df18cf702..19284def2 100644 --- a/src/UI/Navbar/NavbarTemplate.html +++ b/src/UI/Navbar/NavbarTemplate.html @@ -4,7 +4,7 @@ <ul id="main-menu-region"> <div class="pull-left logo"> <a href="{{UrlBase}}/"> - <img src="{{UrlBase}}/Content/Images/logo.png" alt="NzbDrone"> + <img src="{{UrlBase}}/Content/Images/logo.png?v=2" alt="NzbDrone"> </a> </div> <li> diff --git a/src/UI/Quality/QualityProfileCollection.js b/src/UI/Quality/QualityProfileCollection.js index dbed578d5..f9a33b99e 100644 --- a/src/UI/Quality/QualityProfileCollection.js +++ b/src/UI/Quality/QualityProfileCollection.js @@ -7,7 +7,7 @@ define( var QualityProfileCollection = Backbone.Collection.extend({ model: QualityProfileModel, - url : window.NzbDrone.ApiRoot + '/qualityprofiles' + url : window.NzbDrone.ApiRoot + '/qualityprofile' }); var profiles = new QualityProfileCollection(); diff --git a/src/UI/Settings/Indexers/DeleteView.js b/src/UI/Settings/Indexers/DeleteView.js new file mode 100644 index 000000000..b230684eb --- /dev/null +++ b/src/UI/Settings/Indexers/DeleteView.js @@ -0,0 +1,23 @@ +'use strict'; +define( + [ + 'vent', + 'marionette' + ], function (vent, Marionette) { + return Marionette.ItemView.extend({ + template: 'Settings/Indexers/DeleteViewTemplate', + + events: { + 'click .x-confirm-delete': '_removeNotification' + }, + + _removeNotification: function () { + this.model.destroy({ + wait : true, + success: function () { + vent.trigger(vent.Commands.CloseModalCommand); + } + }); + } + }); + }); diff --git a/src/UI/Settings/Indexers/DeleteTemplate.html b/src/UI/Settings/Indexers/DeleteViewTemplate.html similarity index 100% rename from src/UI/Settings/Indexers/DeleteTemplate.html rename to src/UI/Settings/Indexers/DeleteViewTemplate.html diff --git a/src/UI/Settings/Indexers/ItemView.js b/src/UI/Settings/Indexers/ItemView.js index 4f940cfc3..23ab0d00b 100644 --- a/src/UI/Settings/Indexers/ItemView.js +++ b/src/UI/Settings/Indexers/ItemView.js @@ -4,7 +4,7 @@ define( [ 'AppLayout', 'marionette', - 'Settings/Notifications/DeleteView', + 'Settings/Indexers/DeleteView', 'Mixins/AsModelBoundView', 'Mixins/AsValidatedView' ], function (AppLayout, Marionette, DeleteView, AsModelBoundView, AsValidatedView) { diff --git a/src/UI/Settings/Metadata/MetadataCollection.js b/src/UI/Settings/Metadata/MetadataCollection.js new file mode 100644 index 000000000..c8631e047 --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataCollection.js @@ -0,0 +1,12 @@ +'use strict'; +define( + [ + 'backbone', + 'Settings/Metadata/MetadataModel' + ], function (Backbone, MetadataModel) { + + return Backbone.Collection.extend({ + model: MetadataModel, + url : window.NzbDrone.ApiRoot + '/metadata' + }); + }); diff --git a/src/UI/Settings/Metadata/MetadataCollectionView.js b/src/UI/Settings/Metadata/MetadataCollectionView.js new file mode 100644 index 000000000..26ad1fbaf --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataCollectionView.js @@ -0,0 +1,13 @@ +'use strict'; +define( + [ + 'AppLayout', + 'marionette', + 'Settings/Metadata/MetadataItemView' + ], function (AppLayout, Marionette, MetadataItemView) { + return Marionette.CompositeView.extend({ + itemView : MetadataItemView, + itemViewContainer: '#x-metadata', + template : 'Settings/Metadata/MetadataCollectionViewTemplate' + }); + }); diff --git a/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.html b/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.html new file mode 100644 index 000000000..523afe5e4 --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.html @@ -0,0 +1,8 @@ +<fieldset> + <legend>Metadata</legend> + <div class="row"> + <div class="span12"> + <ul id="x-metadata" class="metadata-list"></ul> + </div> + </div> +</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataEditView.js b/src/UI/Settings/Metadata/MetadataEditView.js new file mode 100644 index 000000000..49e18d6ff --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataEditView.js @@ -0,0 +1,44 @@ +'use strict'; + +define( + [ + 'vent', + 'marionette', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (vent, Marionette, AsModelBoundView, AsValidatedView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/Metadata/MetadataEditViewTemplate', + + ui: { + activity: '.x-activity' + }, + + events: { + 'click .x-save' : '_save' + }, + + _save: function () { + this.ui.activity.html('<i class="icon-nd-spinner"></i>'); + + var self = this; + var promise = this.model.save(); + + if (promise) { + promise.done(function () { + vent.trigger(vent.Commands.CloseModalCommand); + }); + + promise.fail(function () { + self.ui.activity.empty(); + }); + } + } + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; + }); diff --git a/src/UI/Settings/Metadata/MetadataEditViewTemplate.html b/src/UI/Settings/Metadata/MetadataEditViewTemplate.html new file mode 100644 index 000000000..3a8f9643e --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataEditViewTemplate.html @@ -0,0 +1,39 @@ +<div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h3>Edit</h3> +</div> +<div class="modal-body"> + <div class="form-horizontal"> + <div class="control-group"> + <label class="control-label">Name</label> + + <div class="controls"> + <input type="text" name="name"/> + </div> + </div> + + <div class="control-group"> + <label class="control-label">Enable</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="enable"/> + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + </div> + </div> + + {{formBuilder}} + </div> +</div> +<div class="modal-footer"> + <span class="x-activity"></span> + + <button class="btn" data-dismiss="modal">cancel</button> + <button class="btn btn-primary x-save">save</button> +</div> diff --git a/src/UI/Settings/Metadata/MetadataItemView.js b/src/UI/Settings/Metadata/MetadataItemView.js new file mode 100644 index 000000000..3fa9f43f4 --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataItemView.js @@ -0,0 +1,30 @@ +'use strict'; + +define( + [ + 'AppLayout', + 'marionette', + 'Settings/Metadata/MetadataEditView', + 'Mixins/AsModelBoundView' + ], function (AppLayout, Marionette, EditView, AsModelBoundView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/Metadata/MetadataItemViewTemplate', + tagName : 'li', + + events: { + 'click .x-edit' : '_edit' + }, + + initialize: function () { + this.listenTo(this.model, 'sync', this.render); + }, + + _edit: function () { + var view = new EditView({ model: this.model}); + AppLayout.modalRegion.show(view); + } + }); + + return AsModelBoundView.call(view); + }); diff --git a/src/UI/Settings/Metadata/MetadataItemViewTemplate.html b/src/UI/Settings/Metadata/MetadataItemViewTemplate.html new file mode 100644 index 000000000..5761db26c --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataItemViewTemplate.html @@ -0,0 +1,26 @@ +<div class="metadata-item"> + <div> + <h3>{{name}}</h3> + <span class="btn-group pull-right"> + <button class="btn btn-mini btn-icon-only x-edit"><i class="icon-nd-edit"/></button> + </span> + </div> + + <div class="settings"> + {{#if enable}} + <span class="label label-success">Enabled</span> + {{else}} + <span class="label">Not Enabled</span> + {{/if}} + <hr> + {{#each fields}} + {{#if_eq type compare="checkbox"}} + {{#if value}} + <span class="label label-success">{{label}}</span> + {{else}} + <span class="label">{{label}}</span> + {{/if}} + {{/if_eq}} + {{/each}} + </div> +</div> diff --git a/src/UI/Settings/Metadata/MetadataLayout.js b/src/UI/Settings/Metadata/MetadataLayout.js new file mode 100644 index 000000000..e94a2b1ce --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataLayout.js @@ -0,0 +1,27 @@ +'use strict'; + +define( + [ + 'marionette', + 'Settings/Metadata/MetadataCollection', + 'Settings/Metadata/MetadataCollectionView' + ], function (Marionette, MetadataCollection, MetadataCollectionView) { + return Marionette.Layout.extend({ + template: 'Settings/Metadata/MetadataLayoutTemplate', + + regions: { + metadata : '#x-metadata-providers' + }, + + initialize: function (options) { + this.settings = options.settings; + this.metadataCollection = new MetadataCollection(); + this.metadataCollection.fetch(); + }, + + onShow: function () { + this.metadata.show(new MetadataCollectionView({collection: this.metadataCollection})); + } + }); + }); + diff --git a/src/UI/Settings/Metadata/MetadataLayoutTemplate.html b/src/UI/Settings/Metadata/MetadataLayoutTemplate.html new file mode 100644 index 000000000..a251cbd43 --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataLayoutTemplate.html @@ -0,0 +1,3 @@ +<div class="row"> + <div class="span12" id="x-metadata-providers"/> +</div> diff --git a/src/UI/Settings/Metadata/MetadataModel.js b/src/UI/Settings/Metadata/MetadataModel.js new file mode 100644 index 000000000..5e08858af --- /dev/null +++ b/src/UI/Settings/Metadata/MetadataModel.js @@ -0,0 +1,10 @@ +'use strict'; +define( + [ + 'backbone.deepmodel' + ], function (DeepModel) { + return DeepModel.DeepModel.extend({ + + }); + }); + diff --git a/src/UI/Settings/Metadata/metadata.less b/src/UI/Settings/Metadata/metadata.less new file mode 100644 index 000000000..919503592 --- /dev/null +++ b/src/UI/Settings/Metadata/metadata.less @@ -0,0 +1,36 @@ +@import "../../Shared/Styles/card"; + +.metadata-list { + li { + display: inline-block; + vertical-align: top; + } +} + +.metadata-item { + + .card; + + width: 200px; + height: 230px; + padding: 10px 15px; + + h3 { + margin-top: 0px; + display: inline-block; + width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .btn-group { + margin-top: 8px; + } + + .label { + margin-top : 3px; + display : block; + text-align : center; + } +} \ No newline at end of file diff --git a/src/UI/Settings/Quality/Profile/QualityProfileSchemaCollection.js b/src/UI/Settings/Quality/Profile/QualityProfileSchemaCollection.js index 7632544cd..46cc94027 100644 --- a/src/UI/Settings/Quality/Profile/QualityProfileSchemaCollection.js +++ b/src/UI/Settings/Quality/Profile/QualityProfileSchemaCollection.js @@ -8,6 +8,6 @@ define( return Backbone.Collection.extend({ model: QualityProfileModel, - url : window.NzbDrone.ApiRoot + '/qualityprofiles/schema' + url : window.NzbDrone.ApiRoot + '/qualityprofile/schema' }); }); diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js index 2770feee5..47973005e 100644 --- a/src/UI/Settings/SettingsLayout.js +++ b/src/UI/Settings/SettingsLayout.js @@ -15,6 +15,7 @@ define( 'Settings/DownloadClient/Layout', 'Settings/Notifications/CollectionView', 'Settings/Notifications/Collection', + 'Settings/Metadata/MetadataLayout', 'Settings/General/GeneralView', 'Shared/LoadingView', 'Config' @@ -32,6 +33,7 @@ define( DownloadClientLayout, NotificationCollectionView, NotificationCollection, + MetadataLayout, GeneralView, LoadingView, Config) { @@ -44,6 +46,7 @@ define( indexers : '#indexers', downloadClient : '#download-client', notifications : '#notifications', + metadata : '#metadata', general : '#general', loading : '#loading-region' }, @@ -54,8 +57,9 @@ define( indexersTab : '.x-indexers-tab', downloadClientTab : '.x-download-client-tab', notificationsTab : '.x-notifications-tab', + metadataTab : '.x-metadata-tab', generalTab : '.x-general-tab', - advancedSettings : '.x-advanced-settings' + advancedSettings : '.x-advanced-settings' }, events: { @@ -64,6 +68,7 @@ define( 'click .x-indexers-tab' : '_showIndexers', 'click .x-download-client-tab' : '_showDownloadClient', 'click .x-notifications-tab' : '_showNotifications', + 'click .x-metadata-tab' : '_showMetadata', 'click .x-general-tab' : '_showGeneral', 'click .x-save-settings' : '_save', 'change .x-advanced-settings' : '_toggleAdvancedSettings' @@ -99,6 +104,7 @@ define( self.indexers.show(new IndexerLayout({ settings: self.settings, indexersCollection: self.indexerSettings })); self.downloadClient.show(new DownloadClientLayout({ model: self.settings })); self.notifications.show(new NotificationCollectionView({ collection: self.notificationSettings })); + self.metadata.show(new MetadataLayout()); self.general.show(new GeneralView({ model: self.generalSettings })); } }); @@ -123,6 +129,9 @@ define( case 'notifications': this._showNotifications(); break; + case 'metadata': + this._showMetadata(); + break; case 'general': this._showGeneral(); break; @@ -176,6 +185,15 @@ define( this._navigate('settings/connect'); }, + _showMetadata: function (e) { + if (e) { + e.preventDefault(); + } + + this.ui.metadataTab.tab('show'); + this._navigate('settings/metadata'); + }, + _showGeneral: function (e) { if (e) { e.preventDefault(); diff --git a/src/UI/Settings/SettingsLayoutTemplate.html b/src/UI/Settings/SettingsLayoutTemplate.html index c5f22dd23..62ee238fb 100644 --- a/src/UI/Settings/SettingsLayoutTemplate.html +++ b/src/UI/Settings/SettingsLayoutTemplate.html @@ -4,6 +4,7 @@ <li><a href="#indexers" class="x-indexers-tab no-router">Indexers</a></li> <li><a href="#download-client" class="x-download-client-tab no-router">Download Client</a></li> <li><a href="#notifications" class="x-notifications-tab no-router">Connect</a></li> + <li><a href="#metadata" class="x-metadata-tab no-router">Metadata</a></li> <li><a href="#general" class="x-general-tab no-router">General</a></li> <li class="pull-right"><button class="btn btn-primary x-save-settings">Save</button></li> <li class="pull-right advanced-settings-toggle"> @@ -27,6 +28,7 @@ <div class="tab-pane" id="indexers"></div> <div class="tab-pane" id="download-client"></div> <div class="tab-pane" id="notifications"></div> + <div class="tab-pane" id="metadata"></div> <div class="tab-pane" id="general"></div> </div> diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less index 528a7f37b..69a44d8ca 100644 --- a/src/UI/Settings/settings.less +++ b/src/UI/Settings/settings.less @@ -3,6 +3,7 @@ @import "Indexers/indexers"; @import "Quality/quality"; @import "Notifications/notifications"; +@import "Metadata/metadata"; li.save-and-add { .clickable; diff --git a/src/UI/index.html b/src/UI/index.html index c6911fc0f..c5ab40f60 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -18,11 +18,11 @@ <link href="/System/Update/update.css" rel='stylesheet' type='text/css'/> <link href="/Content/overrides.css" rel='stylesheet' type='text/css'/> - <link rel="apple-touch-icon" href="/Content/Images/touch/57.png"/> - <link rel="apple-touch-icon" sizes="72x72" href="/Content/Images/touch/72.png"/> - <link rel="apple-touch-icon" sizes="114x114" href="/Content/Images/touch/114.png"/> - <link rel="apple-touch-icon" sizes="144x144" href="/Content/Images/touch/144.png"/> - <link rel="icon" type="image/ico" href="/Content/Images/favicon.ico"/> + <link rel="apple-touch-icon" href="/Content/Images/touch/57.png?v=2"/> + <link rel="apple-touch-icon" sizes="72x72" href="/Content/Images/touch/72.png?v=2"/> + <link rel="apple-touch-icon" sizes="114x114" href="/Content/Images/touch/114.png?v=2"/> + <link rel="apple-touch-icon" sizes="144x144" href="/Content/Images/touch/144.png?v=2"/> + <link rel="icon" type="image/ico" href="/Content/Images/favicon.ico?v=2"/> </head> <body> <div id="nav-region"></div>