From d727840fbf2fe2a771d4ae245a221a919d24fb4f Mon Sep 17 00:00:00 2001 From: Icer Addis Date: Tue, 7 Jan 2014 00:21:05 -0800 Subject: [PATCH 01/42] Indexer searching for special episodes using query string Added SpecialEpisodeSearchCriteria criteria to handle special episode search queries Added method NzbSearchService.SearchSpecial() for season0 episodes Added IIndexer GetSearchUrls() for doing text based queries --- .../Definitions/SearchCriteriaBase.cs | 2 +- .../SpecialEpisodeSearchCriteria.cs | 28 +++++++++++++++++++ .../IndexerSearch/NzbSearchService.cs | 23 +++++++++++++++ src/NzbDrone.Core/Indexers/Eztv/Eztv.cs | 5 ++++ src/NzbDrone.Core/Indexers/IIndexer.cs | 1 + src/NzbDrone.Core/Indexers/IndexerBase.cs | 1 + .../Indexers/IndexerFetchService.cs | 18 ++++++++++-- src/NzbDrone.Core/Indexers/Newznab/Newznab.cs | 9 ++++++ .../Indexers/Omgwtfnzbs/Omgwtfnzbs.cs | 6 ++++ src/NzbDrone.Core/Indexers/Wombles/Wombles.cs | 5 ++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + 11 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index c86256099..689d36ab3 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions } } - private static string GetQueryTitle(string title) + public static string GetQueryTitle(string title) { Ensure.That(title,() => title).IsNotNullOrWhiteSpace(); diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs new file mode 100644 index 000000000..d4f034e44 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class SpecialEpisodeSearchCriteria : SearchCriteriaBase + { + public string[] EpisodeQueryTitles { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + bool delimiter = false; + foreach (var title in EpisodeQueryTitles) + { + if (delimiter) + { + sb.Append(','); + } + sb.Append(title); + delimiter = true; + } + return string.Format("[{0} : {1}]", SceneTitle, sb.ToString()); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 0981c5eb9..cafc2cd32 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -64,6 +64,12 @@ namespace NzbDrone.Core.IndexerSearch return SearchDaily(series, episode); } + if (episode.SeasonNumber == 0) + { + // search for special episodes in season 0 + return SearchSpecial(series, new List{episode}); + } + return SearchSingle(series, episode); } @@ -103,11 +109,28 @@ namespace NzbDrone.Core.IndexerSearch return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); } + private List SearchSpecial(Series series, List episodes) + { + var searchSpec = Get(series, episodes); + // build list of queries for each episode in the form: " " + searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) + .Select(e => searchSpec.QueryTitle + "+" + SearchCriteriaBase.GetQueryTitle(e.Title)) + .ToArray(); + + return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); + } + public List SeasonSearch(int seriesId, int seasonNumber) { var series = _seriesService.GetSeries(seriesId); var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); + if (seasonNumber == 0) + { + // search for special episodes in season 0 + return SearchSpecial(series, episodes); + } + var searchSpec = Get(series, episodes); searchSpec.SeasonNumber = seasonNumber; diff --git a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs index 52c55df60..315178c54 100644 --- a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs +++ b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs @@ -46,5 +46,10 @@ namespace NzbDrone.Core.Indexers.Eztv //EZTV doesn't support searching based on actual episode airdate. they only support release date. return new string[0]; } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index 34daa6a26..4ec97efb2 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -13,5 +13,6 @@ namespace NzbDrone.Core.Indexers IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + IEnumerable GetSearchUrls(string query, int offset = 0); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 7d42847cd..b89b0538d 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers public abstract IEnumerable GetEpisodeSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int episodeNumber); public abstract IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date); public abstract IEnumerable GetSeasonSearchUrls(string seriesTitle, int tvRageId, int seasonNumber, int offset); + public abstract IEnumerable GetSearchUrls(string query, int offset); public override string ToString() { diff --git a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs index 2de0c51b0..924b6689a 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFetchService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFetchService.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Indexers IList Fetch(IIndexer indexer, SeasonSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, SingleEpisodeSearchCriteria searchCriteria); IList Fetch(IIndexer indexer, DailyEpisodeSearchCriteria searchCriteria); + IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria); } public class FetchFeedService : IFetchFeedFromIndexers @@ -77,9 +78,8 @@ namespace NzbDrone.Core.Indexers var searchUrls = indexer.GetEpisodeSearchUrls(searchCriteria.QueryTitle, searchCriteria.Series.TvRageId, searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber); var result = Fetch(indexer, searchUrls); - - _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); + return result; } @@ -94,6 +94,20 @@ namespace NzbDrone.Core.Indexers return result; } + public IList Fetch(IIndexer indexer, SpecialEpisodeSearchCriteria searchCriteria) + { + var queryUrls = new List(); + foreach (var episodeQueryTitle in searchCriteria.EpisodeQueryTitles) + { + _logger.Debug("Performing query of {0} for {1}", indexer, episodeQueryTitle); + queryUrls.AddRange(indexer.GetSearchUrls(episodeQueryTitle)); + } + + var result = Fetch(indexer, queryUrls); + _logger.Info("Finished searching {0} for {1}. Found {2}", indexer, searchCriteria, result.Count); + return result; + } + private List Fetch(IIndexer indexer, IEnumerable urls) { var result = new List(); diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 8a1be6f66..d14df1540 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -104,6 +104,15 @@ namespace NzbDrone.Core.Indexers.Newznab return RecentFeed.Select(url => String.Format("{0}&limit=100&q={1}&season={2}&ep={3}", url, NewsnabifyTitle(seriesTitle), seasonNumber, episodeNumber)); } + public override IEnumerable GetSearchUrls(string query, int offset) + { + // encode query (replace the + with spaces first) + query = query.Replace("+", " "); + query = System.Web.HttpUtility.UrlEncode(query); + return RecentFeed.Select(url => String.Format("{0}&offset={1}&limit=100&q={2}", url.Replace("t=tvsearch", "t=search"), offset, query)); + } + + public override IEnumerable GetDailyEpisodeSearchUrls(string seriesTitle, int tvRageId, DateTime date) { if (tvRageId > 0) diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs index c4daeab66..20f0953af 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs @@ -66,5 +66,11 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs return searchUrls; } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } + } } diff --git a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs index 5355d853d..f111db688 100644 --- a/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs +++ b/src/NzbDrone.Core/Indexers/Wombles/Wombles.cs @@ -41,5 +41,10 @@ namespace NzbDrone.Core.Indexers.Wombles { return new List(); } + + public override IEnumerable GetSearchUrls(string query, int offset) + { + return new List(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 599e2cdb1..2e2f74d42 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -259,6 +259,7 @@ + From 6ee08af1112dbd78ba60660035256aa4d729be5e Mon Sep 17 00:00:00 2001 From: Icer Addis Date: Tue, 7 Jan 2014 00:24:50 -0800 Subject: [PATCH 02/42] Special Episode parsing support in ParsingService Added ParsingService.ParseSpecialEpisodeTitle Added SeriesService.FindByNameInexact Added EpisodeService.FindSpecialEpisodeByName Added IsPossibleSpecialEpisode method to parse info DownloadDecisionMaker will try to find special episodes if a parse fails or is a possible special episode --- .../DecisionEngine/DownloadDecisionMaker.cs | 20 +++++++ .../Parser/Model/ParsedEpisodeInfo.cs | 6 ++ src/NzbDrone.Core/Parser/Parser.cs | 14 +++++ src/NzbDrone.Core/Parser/ParsingService.cs | 59 +++++++++++++++++++ src/NzbDrone.Core/Tv/EpisodeService.cs | 16 +++++ src/NzbDrone.Core/Tv/SeriesService.cs | 50 ++++++++++++++++ 6 files changed, 165 insertions(+) diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 32e7087c6..093e16a89 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -52,6 +52,13 @@ namespace NzbDrone.Core.DecisionEngine _logger.ProgressInfo("No reports found"); } + // get series from search criteria + Tv.Series series = null; + if (searchCriteria != null) + { + series = searchCriteria.Series; + } + var reportNumber = 1; foreach (var report in reports) @@ -61,8 +68,21 @@ namespace NzbDrone.Core.DecisionEngine try { + // use parsing service to parse episode info (this allows us to do episode title searches against the episode repository) var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); + // do we have a possible special episode? + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + { + // try to parse as a special episode + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, series); + if (specialEpisodeInfo != null) + { + // use special episode + parsedEpisodeInfo = specialEpisodeInfo; + } + } + if (parsedEpisodeInfo != null && !string.IsNullOrWhiteSpace(parsedEpisodeInfo.SeriesTitle)) { var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvRageId, searchCriteria); diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 7ae94f647..2b6a808af 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -33,6 +33,12 @@ namespace NzbDrone.Core.Parser.Model return AbsoluteEpisodeNumbers.Any(); } + public bool IsPossibleSpecialEpisode() + { + // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title + return string.IsNullOrEmpty(AirDate) && (EpisodeNumbers.Length == 0 || SeasonNumber == 0 || String.IsNullOrWhiteSpace(SeriesTitle)); + } + public override string ToString() { string episodeString = "[Unknown Episode]"; diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 7797a61cd..d59ce4e80 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -114,6 +114,11 @@ namespace NzbDrone.Core.Parser private static readonly Regex YearInTitleRegex = new Regex(@"^(?.+?)(?:\W|_)?(?<year>\d{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex NonWordRegex = new Regex(@"\W+", RegexOptions.Compiled); + private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of|part)\b\s?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static ParsedEpisodeInfo ParsePath(string path) { var fileInfo = new FileInfo(path); @@ -220,6 +225,15 @@ namespace NzbDrone.Core.Parser return MultiPartCleanupRegex.Replace(title, string.Empty).Trim(); } + public static string NormalizeEpisodeTitle(string title) + { + // convert any non-word characters to a single space + string normalizedSpaces = NonWordRegex.Replace(title, " ").ToLower(); + // remove common words + string normalized = CommonWordRegex.Replace(normalizedSpaces, String.Empty); + return normalized; + } + public static string ParseReleaseGroup(string title) { const string defaultReleaseGroup = "DRONE"; diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 0134bbe4a..a2dfee939 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource); Series GetSeries(string title); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvRageId, SearchCriteriaBase searchCriteria = null); @@ -39,10 +40,68 @@ namespace NzbDrone.Core.Parser _logger = logger; } + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + { + try + { + if (series == null) + { + // find series if we dont have it already + // we use an inexact match here since the series name is often mangled with the episode title + series = _seriesService.FindByTitleInexact(title); + if (series == null) + { + // no series matched + return null; + } + } + + // find special episode in series season 0 + Episode episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) + { + // created parsed info from tv episode that we found + var info = new ParsedEpisodeInfo(); + info.SeriesTitle = series.Title; + info.SeriesTitleInfo = new SeriesTitleInfo(); + info.SeriesTitleInfo.Title = info.SeriesTitle; + info.SeasonNumber = episode.SeasonNumber; + info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; + info.FullSeason = false; + info.Quality = QualityParser.ParseQuality(title); + info.ReleaseGroup = Parser.ParseReleaseGroup(title); + + _logger.Info("Found special episode {0} for title '{1}'", info, title); + return info; + } + } + catch (Exception e) + { + _logger.ErrorException("An error has occurred while trying to parse special episode " + title, e); + } + + return null; + } + + + public LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource) { var parsedEpisodeInfo = Parser.ParsePath(filename); + // do we have a possible special episode? + if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) + { + // try to parse as a special episode + var title = System.IO.Path.GetFileNameWithoutExtension(filename); + var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); + if (specialEpisodeInfo != null) + { + // use special episode + parsedEpisodeInfo = specialEpisodeInfo; + } + } + if (parsedEpisodeInfo == null) { return null; diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 96bfb1e52..5d8064ec5 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Tv Episode GetEpisode(int id); Episode FindEpisode(int seriesId, int seasonNumber, int episodeNumber, bool useScene = false); Episode FindEpisode(int seriesId, int absoluteEpisodeNumber); + Episode FindEpisodeByName(int seriesId, int seasonNumber, string episodeTitle); Episode GetEpisode(int seriesId, String date); Episode FindEpisode(int seriesId, String date); List<Episode> GetEpisodeBySeries(int seriesId); @@ -88,6 +89,21 @@ namespace NzbDrone.Core.Tv return _episodeRepository.GetEpisodes(seriesId, seasonNumber); } + public Episode FindEpisodeByName(int seriesId, int seasonNumber, string episodeTitle) + { + // TODO: can replace this search mechanism with something smarter/faster/better + var search = Parser.Parser.NormalizeEpisodeTitle(episodeTitle); + return _episodeRepository.GetEpisodes(seriesId, seasonNumber) + .FirstOrDefault(e => + { + // normalize episode title + string title = Parser.Parser.NormalizeEpisodeTitle(e.Title); + // find episode title within search string + return (title.Length > 0) && search.Contains(title); + }); + } + + public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec) { var episodeResult = _episodeRepository.EpisodesWithoutFiles(pagingSpec, false); diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 18b67a732..60937f7a9 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.Tv Series FindByTvRageId(int tvRageId); Series FindByTitle(string title); Series FindByTitle(string title, int year); + Series FindByTitleInexact(string title); void SetSeriesType(int seriesId, SeriesTypes seriesTypes); void DeleteSeries(int seriesId, bool deleteFiles); List<Series> GetAllSeries(); @@ -100,6 +101,55 @@ namespace NzbDrone.Core.Tv return _seriesRepository.FindByTitle(Parser.Parser.CleanSeriesTitle(title)); } + public Series FindByTitleInexact(string title) + { + // perform fuzzy matching of series name + // TODO: can replace this search mechanism with something smarter/faster/better + + // find any series clean title within the provided release title + string cleanTitle = Parser.Parser.CleanSeriesTitle(title); + var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); + if (!list.Any()) + { + // no series matched + return null; + } + else if (list.Count == 1) + { + // return the first series if there is only one + return list.Single(); + } + else + { + // build ordered list of series by position in the search string + var query = + list.Select(series => new + { + position = cleanTitle.IndexOf(series.CleanTitle), + length = series.CleanTitle.Length, + series = series + }) + .Where(s => (s.position>=0)) + .ToList() + .OrderBy(s => s.position) + .ThenByDescending(s => s.length) + .ToList(); + + // get the leftmost series that is the longest + // series are usually the first thing in release title, so we select the leftmost and longest match + // we could have multiple matches for series which have a common prefix like "Love it", "Love it Too" so we pick the longest one + var match = query.First().series; + + _logger.Trace("Multiple series matched {0} from title {1}", match.Title, title); + foreach (var entry in list) + { + _logger.Trace("Multiple series match candidate: {0} cleantitle: {1}", entry.Title, entry.CleanTitle); + } + + return match; + } + } + public Series FindByTitle(string title, int year) { return _seriesRepository.FindByTitle(title, year); From c459cdf168679a91c053bbd9b5614e739c15c0c6 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Tue, 7 Jan 2014 21:54:23 -0800 Subject: [PATCH 03/42] Fixes in response to code review ParseSpecialEpisode now follows similar pattern to Map() method and accepts TvRageId and SearchCriteria Fixed normalize episode title to handle punctuation separately from spaces and removed special episode words Removed comments --- .../DecisionEngine/DownloadDecisionMaker.cs | 13 +--- src/NzbDrone.Core/Parser/Parser.cs | 17 +++-- src/NzbDrone.Core/Parser/ParsingService.cs | 71 +++++++++++-------- src/NzbDrone.Core/Tv/SeriesService.cs | 4 -- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 093e16a89..b8b77c5a3 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -52,13 +52,6 @@ namespace NzbDrone.Core.DecisionEngine _logger.ProgressInfo("No reports found"); } - // get series from search criteria - Tv.Series series = null; - if (searchCriteria != null) - { - series = searchCriteria.Series; - } - var reportNumber = 1; foreach (var report in reports) @@ -68,17 +61,13 @@ namespace NzbDrone.Core.DecisionEngine try { - // use parsing service to parse episode info (this allows us to do episode title searches against the episode repository) var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); - // do we have a possible special episode? if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) { - // try to parse as a special episode - var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, series); + var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvRageId, searchCriteria); if (specialEpisodeInfo != null) { - // use special episode parsedEpisodeInfo = specialEpisodeInfo; } } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index d59ce4e80..f307e421e 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -114,8 +114,11 @@ namespace NzbDrone.Core.Parser private static readonly Regex YearInTitleRegex = new Regex(@"^(?<title>.+?)(?:\W|_)?(?<year>\d{4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex NonWordRegex = new Regex(@"\W+", RegexOptions.Compiled); - private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of|part)\b\s?", + private static readonly Regex WordDelimiterRegex = new Regex(@"(\s|\.|,|_|-|=|\|)+", RegexOptions.Compiled); + private static readonly Regex PunctuationRegex = new Regex(@"[^\w\s]", RegexOptions.Compiled); + private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of)\b\s?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SpecialEpisodeWordRegex = new Regex(@"\b(part|special|edition)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -227,11 +230,11 @@ namespace NzbDrone.Core.Parser public static string NormalizeEpisodeTitle(string title) { - // convert any non-word characters to a single space - string normalizedSpaces = NonWordRegex.Replace(title, " ").ToLower(); - // remove common words - string normalized = CommonWordRegex.Replace(normalizedSpaces, String.Empty); - return normalized; + string singleSpaces = WordDelimiterRegex.Replace(title, " "); + string noPunctuation = PunctuationRegex.Replace(singleSpaces, String.Empty); + string noCommonWords = CommonWordRegex.Replace(noPunctuation, String.Empty); + string normalized = SpecialEpisodeWordRegex.Replace(noCommonWords, String.Empty); + return normalized.Trim().ToLower(); } public static string ParseReleaseGroup(string title) diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index a2dfee939..a0682a138 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null); ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource); Series GetSeries(string title); @@ -40,51 +41,65 @@ namespace NzbDrone.Core.Parser _logger = logger; } - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null) { - try + if (searchCriteria != null) { - if (series == null) + var tvdbId = _sceneMappingService.GetTvDbId(title); + if (tvdbId.HasValue) { - // find series if we dont have it already - // we use an inexact match here since the series name is often mangled with the episode title - series = _seriesService.FindByTitleInexact(title); - if (series == null) + if (searchCriteria.Series.TvdbId == tvdbId) { - // no series matched - return null; + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); } } - // find special episode in series season 0 - Episode episode = _episodeService.FindEpisodeByName(series.Id, 0, title); - if (episode != null) + if (tvRageId == searchCriteria.Series.TvRageId) { - // created parsed info from tv episode that we found - var info = new ParsedEpisodeInfo(); - info.SeriesTitle = series.Title; - info.SeriesTitleInfo = new SeriesTitleInfo(); - info.SeriesTitleInfo.Title = info.SeriesTitle; - info.SeasonNumber = episode.SeasonNumber; - info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; - info.FullSeason = false; - info.Quality = QualityParser.ParseQuality(title); - info.ReleaseGroup = Parser.ParseReleaseGroup(title); - - _logger.Info("Found special episode {0} for title '{1}'", info, title); - return info; + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); } } - catch (Exception e) + + var series = _seriesService.FindByTitleInexact(title); + if (series == null && tvRageId > 0) { - _logger.ErrorException("An error has occurred while trying to parse special episode " + title, e); + series = _seriesService.FindByTvRageId(tvRageId); + } + + if (series == null) + { + _logger.Trace("No matching series {0}", title); + return null; + } + + return ParseSpecialEpisodeTitle(title, series); + } + + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + { + // find special episode in series season 0 + var episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) + { + // create parsed info from tv episode + var info = new ParsedEpisodeInfo(); + info.SeriesTitle = series.Title; + info.SeriesTitleInfo = new SeriesTitleInfo(); + info.SeriesTitleInfo.Title = info.SeriesTitle; + info.SeasonNumber = episode.SeasonNumber; + info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; + info.FullSeason = false; + info.Quality = QualityParser.ParseQuality(title); + info.ReleaseGroup = Parser.ParseReleaseGroup(title); + + _logger.Info("Found special episode {0} for title '{1}'", info, title); + return info; } return null; } - public LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource) { var parsedEpisodeInfo = Parser.ParsePath(filename); diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 60937f7a9..93405f8f5 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -103,9 +103,6 @@ namespace NzbDrone.Core.Tv public Series FindByTitleInexact(string title) { - // perform fuzzy matching of series name - // TODO: can replace this search mechanism with something smarter/faster/better - // find any series clean title within the provided release title string cleanTitle = Parser.Parser.CleanSeriesTitle(title); var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); @@ -137,7 +134,6 @@ namespace NzbDrone.Core.Tv // get the leftmost series that is the longest // series are usually the first thing in release title, so we select the leftmost and longest match - // we could have multiple matches for series which have a common prefix like "Love it", "Love it Too" so we pick the longest one var match = query.First().series; _logger.Trace("Multiple series matched {0} from title {1}", match.Title, title); From 2dbf0ecc8240c138ddc53a9d571fa4a911ba58e0 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Mon, 13 Jan 2014 21:20:29 -0800 Subject: [PATCH 04/42] Fixes for code review --- .../Definitions/SpecialEpisodeSearchCriteria.cs | 13 +------------ src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs | 7 ++++++- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs index d4f034e44..93bdfd0e0 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs @@ -11,18 +11,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public override string ToString() { - var sb = new StringBuilder(); - bool delimiter = false; - foreach (var title in EpisodeQueryTitles) - { - if (delimiter) - { - sb.Append(','); - } - sb.Append(title); - delimiter = true; - } - return string.Format("[{0} : {1}]", SceneTitle, sb.ToString()); + return string.Format("[{0} : {1}]", SceneTitle, String.Join(",", EpisodeQueryTitles)); } } } diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index 2b6a808af..db7d3abb5 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -36,7 +36,12 @@ namespace NzbDrone.Core.Parser.Model public bool IsPossibleSpecialEpisode() { // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title - return string.IsNullOrEmpty(AirDate) && (EpisodeNumbers.Length == 0 || SeasonNumber == 0 || String.IsNullOrWhiteSpace(SeriesTitle)); + return string.IsNullOrEmpty(AirDate) && + ( + EpisodeNumbers.Length == 0 || + SeasonNumber == 0 || + String.IsNullOrWhiteSpace(SeriesTitle) + ); } public override string ToString() From 502ddceea2fe06d59e4cdbd691a1640553016704 Mon Sep 17 00:00:00 2001 From: Icer Addis <iceraddis@gmail.com> Date: Tue, 14 Jan 2014 00:35:22 -0800 Subject: [PATCH 05/42] Replaced + with space in special episode query string builder --- src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index cafc2cd32..006f2b9a8 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -114,7 +114,7 @@ namespace NzbDrone.Core.IndexerSearch var searchSpec = Get<SpecialEpisodeSearchCriteria>(series, episodes); // build list of queries for each episode in the form: "<series> <episode-title>" searchSpec.EpisodeQueryTitles = episodes.Where(e => !String.IsNullOrWhiteSpace(e.Title)) - .Select(e => searchSpec.QueryTitle + "+" + SearchCriteriaBase.GetQueryTitle(e.Title)) + .Select(e => searchSpec.QueryTitle + " " + SearchCriteriaBase.GetQueryTitle(e.Title)) .ToArray(); return Dispatch(indexer => _feedFetcher.Fetch(indexer, searchSpec), searchSpec); From ff9887deaac37033182a6f7da95e2b5563843ab6 Mon Sep 17 00:00:00 2001 From: Taloth Saldono <Taloth@users.noreply.github.com> Date: Sat, 15 Feb 2014 11:51:52 +0100 Subject: [PATCH 06/42] Added MinSize check and revised tests. --- .../AcceptableSizeSpecificationFixture.cs | 291 +++++------------- .../AcceptableSizeSpecification.cs | 47 ++- 2 files changed, 109 insertions(+), 229 deletions(-) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs index b586622ea..8438f5a03 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs @@ -15,108 +15,62 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class AcceptableSizeSpecificationFixture : CoreTest<AcceptableSizeSpecification> { + private RemoteEpisode parseResultMultiSet; private RemoteEpisode parseResultMulti; private RemoteEpisode parseResultSingle; - private Series series30minutes; - private Series series60minutes; + private Series series; private QualityDefinition qualityType; [SetUp] public void Setup() { + series = Builder<Series>.CreateNew() + .Build(); + + parseResultMultiSet = new RemoteEpisode + { + Series = series, + Release = new ReleaseInfo(), + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, true) }, + Episodes = new List<Episode> { new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), new Episode() } + }; + parseResultMulti = new RemoteEpisode - { - Release = new ReleaseInfo(), - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, true) }, - Episodes = new List<Episode> { new Episode(), new Episode() } - }; + { + Series = series, + Release = new ReleaseInfo(), + ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, true) }, + Episodes = new List<Episode> { new Episode(), new Episode() } + }; parseResultSingle = new RemoteEpisode { + Series = series, Release = new ReleaseInfo(), ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, true) }, Episodes = new List<Episode> { new Episode() } }; - series30minutes = Builder<Series>.CreateNew() - .With(c => c.Runtime = 30) - .Build(); - - series60minutes = Builder<Series>.CreateNew() - .With(c => c.Runtime = 60) - .Build(); - qualityType = Builder<QualityDefinition>.CreateNew() - .With(q => q.MinSize = 0) + .With(q => q.MinSize = 2) .With(q => q.MaxSize = 10) .With(q => q.Quality = Quality.SDTV) .Build(); } - [Test] - public void IsAcceptableSize_true_single_episode_not_first_or_last_30_minute() + [TestCase(30, 50, false)] + [TestCase(30, 250, true)] + [TestCase(30, 500, false)] + [TestCase(60, 100, false)] + [TestCase(60, 500, true)] + [TestCase(60, 1000, false)] + public void IsAcceptableSize_single_episode(int runtime, int sizeInMegaBytes, bool expectedResult) { - parseResultSingle.Series = series30minutes; - parseResultSingle.Release.Size = 184572800; - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); - } - - [Test] - public void IsAcceptableSize_true_single_episode_not_first_or_last_60_minute() - { - parseResultSingle.Series = series60minutes; - parseResultSingle.Release.Size = 368572800; - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); - } - - [Test] - public void IsAcceptableSize_false_single_episode_not_first_or_last_30_minute() - { - parseResultSingle.Series = series30minutes; - parseResultSingle.Release.Size = 1.Gigabytes(); - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeFalse(); - } - - [Test] - public void IsAcceptableSize_false_single_episode_not_first_or_last_60_minute() - { - parseResultSingle.Series = series60minutes; - parseResultSingle.Release.Size = 1.Gigabytes(); + series.Runtime = runtime; + parseResultSingle.Series = series; + parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); @@ -125,91 +79,19 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Returns(false); bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - result.Should().BeFalse(); + + result.Should().Be(expectedResult); } - [Test] - public void IsAcceptableSize_true_multi_episode_not_first_or_last_30_minute() + [TestCase(30, 500, true)] + [TestCase(30, 1000, false)] + [TestCase(60, 1000, true)] + [TestCase(60, 2000, false)] + public void IsAcceptableSize_single_episode_first_or_last(int runtime, int sizeInMegaBytes, bool expectedResult) { - parseResultMulti.Series = series30minutes; - parseResultMulti.Release.Size = 184572800; - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - - - result.Should().BeTrue(); - } - - [Test] - public void IsAcceptableSize_true_multi_episode_not_first_or_last_60_minute() - { - parseResultMulti.Series = series60minutes; - parseResultMulti.Release.Size = 368572800; - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - - - result.Should().BeTrue(); - } - - [Test] - public void IsAcceptableSize_false_multi_episode_not_first_or_last_30_minute() - { - parseResultMulti.Series = series30minutes; - parseResultMulti.Release.Size = 1.Gigabytes(); - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - - - result.Should().BeFalse(); - } - - [Test] - public void IsAcceptableSize_false_multi_episode_not_first_or_last_60_minute() - { - parseResultMulti.Series = series60minutes; - parseResultMulti.Release.Size = 10.Gigabytes(); - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - - bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - - - result.Should().BeFalse(); - } - - [Test] - public void IsAcceptableSize_true_single_episode_first_30_minute() - { - parseResultSingle.Series = series30minutes; - parseResultSingle.Release.Size = 184572800; + series.Runtime = runtime; + parseResultSingle.Series = series; + parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); @@ -217,78 +99,62 @@ namespace NzbDrone.Core.Test.DecisionEngineTests s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) .Returns(true); - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - result.Should().BeTrue(); + result.Should().Be(expectedResult); } - [Test] - public void IsAcceptableSize_true_single_episode_first_60_minute() + [TestCase(30, 50 * 2, false)] + [TestCase(30, 250 * 2, true)] + [TestCase(30, 500 * 2, false)] + [TestCase(60, 100 * 2, false)] + [TestCase(60, 500 * 2, true)] + [TestCase(60, 1000 * 2, false)] + public void IsAcceptableSize_multi_episode(int runtime, int sizeInMegaBytes, bool expectedResult) { - parseResultSingle.Series = series60minutes; - parseResultSingle.Release.Size = 368572800; + series.Runtime = runtime; + parseResultMulti.Series = series; + parseResultMulti.Release.Size = sizeInMegaBytes.Megabytes(); Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); Mocker.GetMock<IEpisodeService>().Setup( s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); + .Returns(false); + bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); + result.Should().Be(expectedResult); } - [Test] - public void IsAcceptableSize_false_single_episode_first_30_minute() + [TestCase(30, 50 * 6, false)] + [TestCase(30, 250 * 6, true)] + [TestCase(30, 500 * 6, false)] + [TestCase(60, 100 * 6, false)] + [TestCase(60, 500 * 6, true)] + [TestCase(60, 1000 * 6, false)] + public void IsAcceptableSize_multiset_episode(int runtime, int sizeInMegaBytes, bool expectedResult) { - parseResultSingle.Series = series30minutes; - parseResultSingle.Release.Size = 1.Gigabytes(); + series.Runtime = runtime; + parseResultMultiSet.Series = series; + parseResultMultiSet.Release.Size = sizeInMegaBytes.Megabytes(); Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); Mocker.GetMock<IEpisodeService>().Setup( s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); + .Returns(false); + bool result = Subject.IsSatisfiedBy(parseResultMultiSet, null); - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeFalse(); + result.Should().Be(expectedResult); } [Test] - public void IsAcceptableSize_false_single_episode_first_60_minute() + public void IsAcceptableSize_return_true_if_unlimited_30_minute() { - - - parseResultSingle.Series = series60minutes; - parseResultSingle.Release.Size = 10.Gigabytes(); - - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeFalse(); - } - - [Test] - public void IsAcceptableSize_true_unlimited_30_minute() - { - - - parseResultSingle.Series = series30minutes; + series.Runtime = 30; + parseResultSingle.Series = series; parseResultSingle.Release.Size = 18457280000; qualityType.MaxSize = 0; @@ -306,11 +172,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } [Test] - public void IsAcceptableSize_true_unlimited_60_minute() + public void IsAcceptableSize_return_true_if_unlimited_60_minute() { - - - parseResultSingle.Series = series60minutes; + series.Runtime = 60; + parseResultSingle.Series = series; parseResultSingle.Release.Size = 36857280000; qualityType.MaxSize = 0; @@ -330,13 +195,13 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void IsAcceptableSize_should_treat_daily_series_as_single_episode() { - - parseResultSingle.Series = series60minutes; + series.Runtime = 60; + parseResultSingle.Series = series; parseResultSingle.Series.SeriesType = SeriesTypes.Daily; parseResultSingle.Release.Size = 300.Megabytes(); - qualityType.MaxSize = (int)600.Megabytes(); + qualityType.MaxSize = 10; Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); @@ -364,7 +229,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] - public void should_always_return_false_if_unknow() + public void should_always_return_false_if_unknown() { var parseResult = new RemoteEpisode { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index e63edaa8c..8069e8201 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -46,28 +46,43 @@ namespace NzbDrone.Core.DecisionEngine.Specifications var qualityDefinition = _qualityDefinitionService.Get(quality); + { + var minSize = qualityDefinition.MinSize.Megabytes(); + + //Multiply maxSize by Series.Runtime + minSize = minSize * subject.Series.Runtime * subject.Episodes.Count; + + //If the parsed size is smaller than minSize we don't want it + if (subject.Release.Size < minSize) + { + _logger.Trace("Item: {0}, Size: {1} is smaller than minimum allowed size ({2}), rejecting.", subject, subject.Release.Size, minSize); + return false; + } + } + if (qualityDefinition.MaxSize == 0) { _logger.Trace("Max size is 0 (unlimited) - skipping check."); - return true; } - - var maxSize = qualityDefinition.MaxSize.Megabytes(); - - //Multiply maxSize by Series.Runtime - maxSize = maxSize * subject.Series.Runtime * subject.Episodes.Count; - - //Check if there was only one episode parsed and it is the first - if (subject.Episodes.Count == 1 && subject.Episodes.First().EpisodeNumber == 1) + else { - maxSize = maxSize * 2; - } + var maxSize = qualityDefinition.MaxSize.Megabytes(); - //If the parsed size is greater than maxSize we don't want it - if (subject.Release.Size > maxSize) - { - _logger.Trace("Item: {0}, Size: {1} is greater than maximum allowed size ({2}), rejecting.", subject, subject.Release.Size, maxSize); - return false; + //Multiply maxSize by Series.Runtime + maxSize = maxSize * subject.Series.Runtime * subject.Episodes.Count; + + //Check if there was only one episode parsed and it is the first + if (subject.Episodes.Count == 1 && _episodeService.IsFirstOrLastEpisodeOfSeason(subject.Episodes.First().Id)) + { + maxSize = maxSize * 2; + } + + //If the parsed size is greater than maxSize we don't want it + if (subject.Release.Size > maxSize) + { + _logger.Trace("Item: {0}, Size: {1} is greater than maximum allowed size ({2}), rejecting.", subject, subject.Release.Size, maxSize); + return false; + } } _logger.Trace("Item: {0}, meets size constraints.", subject); From defa54f15ccacb513ee9fe71dcb2e4a377ab2b8f Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 16 Feb 2014 17:05:11 -0800 Subject: [PATCH 07/42] cleaned up tests and names --- .../AcceptableSizeSpecificationFixture.cs | 105 ++++++------------ 1 file changed, 32 insertions(+), 73 deletions(-) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs index 8438f5a03..117c81ec0 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs @@ -58,6 +58,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .With(q => q.Quality = Quality.SDTV) .Build(); + Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); + } + + private void GivenLastEpisode() + { + Mocker.GetMock<IEpisodeService>().Setup( + s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) + .Returns(true); } [TestCase(30, 50, false)] @@ -66,42 +74,28 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase(60, 100, false)] [TestCase(60, 500, true)] [TestCase(60, 1000, false)] - public void IsAcceptableSize_single_episode(int runtime, int sizeInMegaBytes, bool expectedResult) - { + public void single_episode(int runtime, int sizeInMegaBytes, bool expectedResult) + { series.Runtime = runtime; parseResultSingle.Series = series; parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(false); - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - result.Should().Be(expectedResult); + Subject.IsSatisfiedBy(parseResultSingle, null).Should().Be(expectedResult); } [TestCase(30, 500, true)] [TestCase(30, 1000, false)] [TestCase(60, 1000, true)] [TestCase(60, 2000, false)] - public void IsAcceptableSize_single_episode_first_or_last(int runtime, int sizeInMegaBytes, bool expectedResult) + public void single_episode_first_or_last(int runtime, int sizeInMegaBytes, bool expectedResult) { + GivenLastEpisode(); + series.Runtime = runtime; parseResultSingle.Series = series; parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - result.Should().Be(expectedResult); + Subject.IsSatisfiedBy(parseResultSingle, null).Should().Be(expectedResult); } [TestCase(30, 50 * 2, false)] @@ -110,21 +104,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase(60, 100 * 2, false)] [TestCase(60, 500 * 2, true)] [TestCase(60, 1000 * 2, false)] - public void IsAcceptableSize_multi_episode(int runtime, int sizeInMegaBytes, bool expectedResult) + public void multi_episode(int runtime, int sizeInMegaBytes, bool expectedResult) { series.Runtime = runtime; parseResultMulti.Series = series; parseResultMulti.Release.Size = sizeInMegaBytes.Megabytes(); - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - Mocker.GetMock<IEpisodeService>().Setup( s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) .Returns(false); - bool result = Subject.IsSatisfiedBy(parseResultMulti, null); - - result.Should().Be(expectedResult); + Subject.IsSatisfiedBy(parseResultMulti, null).Should().Be(expectedResult); } [TestCase(30, 50 * 6, false)] @@ -133,87 +123,58 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase(60, 100 * 6, false)] [TestCase(60, 500 * 6, true)] [TestCase(60, 1000 * 6, false)] - public void IsAcceptableSize_multiset_episode(int runtime, int sizeInMegaBytes, bool expectedResult) + public void multiset_episode(int runtime, int sizeInMegaBytes, bool expectedResult) { series.Runtime = runtime; parseResultMultiSet.Series = series; parseResultMultiSet.Release.Size = sizeInMegaBytes.Megabytes(); - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - Mocker.GetMock<IEpisodeService>().Setup( s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) .Returns(false); - bool result = Subject.IsSatisfiedBy(parseResultMultiSet, null); - - result.Should().Be(expectedResult); + Subject.IsSatisfiedBy(parseResultMultiSet, null).Should().Be(expectedResult); } [Test] - public void IsAcceptableSize_return_true_if_unlimited_30_minute() + public void should_return_true_if_unlimited_30_minute() { + GivenLastEpisode(); + series.Runtime = 30; parseResultSingle.Series = series; parseResultSingle.Release.Size = 18457280000; qualityType.MaxSize = 0; - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); + Subject.IsSatisfiedBy(parseResultSingle, null).Should().BeTrue(); } - + [Test] - public void IsAcceptableSize_return_true_if_unlimited_60_minute() + public void should_return_true_if_unlimited_60_minute() { + GivenLastEpisode(); + series.Runtime = 60; parseResultSingle.Series = series; parseResultSingle.Release.Size = 36857280000; qualityType.MaxSize = 0; - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); + Subject.IsSatisfiedBy(parseResultSingle, null).Should().BeTrue();; } [Test] - public void IsAcceptableSize_should_treat_daily_series_as_single_episode() + public void should_treat_daily_series_as_single_episode() { + GivenLastEpisode(); + series.Runtime = 60; parseResultSingle.Series = series; parseResultSingle.Series.SeriesType = SeriesTypes.Daily; - parseResultSingle.Release.Size = 300.Megabytes(); qualityType.MaxSize = 10; - Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); - - Mocker.GetMock<IEpisodeService>().Setup( - s => s.IsFirstOrLastEpisodeOfSeason(It.IsAny<int>())) - .Returns(true); - - - bool result = Subject.IsSatisfiedBy(parseResultSingle, null); - - - result.Should().BeTrue(); + Subject.IsSatisfiedBy(parseResultSingle, null).Should().BeTrue(); } [Test] @@ -227,7 +188,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(parseResult, null).Should().BeTrue(); } - [Test] public void should_always_return_false_if_unknown() { @@ -238,7 +198,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(parseResult, null).Should().BeFalse(); - Mocker.GetMock<IQualityDefinitionService>().Verify(c => c.Get(It.IsAny<Quality>()), Times.Never()); } } From ba226004123785b7f79c696d9f2afb27e7343bc0 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 16 Feb 2014 22:41:13 -0800 Subject: [PATCH 08/42] Couple XBMC Metadata fixes Fixed: Actor URL for XBMC Metadata Fixed: Incorrectly storing season images for XBMC metadata (cherry picked from commit 5b2c3b88c0fdf002bb46a45e85c2f5066cc84877) --- .../041_fix_xbmc_season_images_metadata.cs | 14 ++++++++++++++ .../MetaData/Consumers/Xbmc/XbmcMetadata.cs | 4 ++-- src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs diff --git a/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs b/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs new file mode 100644 index 000000000..25cbc8ed4 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(41)] + public class fix_xbmc_season_images_metadata : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE MetadataFiles SET Type = 4 WHERE Consumer = 'XbmcMetadata' AND SeasonNumber IS NOT NULL"); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index a4d5ac080..e9f58aa39 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -217,7 +217,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc tvShow.Add(new XElement("actor", new XElement("name", actor.Name), new XElement("role", actor.Character), - new XElement("thumb", actor.Images.First()) + new XElement("thumb", actor.Images.First().Url) )); } @@ -296,7 +296,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc SeriesId = series.Id, SeasonNumber = season.SeasonNumber, Consumer = GetType().Name, - Type = MetadataType.SeriesMetadata, + Type = MetadataType.SeasonImage, RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) }; diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 92c3429c2..89cb0b967 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -197,6 +197,7 @@ <Compile Include="Datastore\Migration\038_add_on_upgrade_to_notifications.cs" /> <Compile Include="Datastore\Migration\040_add_metadata_to_episodes_and_series.cs" /> <Compile Include="Datastore\Migration\039_add_metadata_tables.cs" /> + <Compile Include="Datastore\Migration\041_fix_xbmc_season_images_metadata.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationExtension.cs" /> From 606d78f5e14af5afd806471c3b46222d39fabf18 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 13 Feb 2014 21:31:49 -0800 Subject: [PATCH 09/42] Download clients now use thingy provider --- .../DownloadClient/DownloadClientModule.cs | 18 ++ .../DownloadClient/DownloadClientResource.cs | 10 + .../DownloadClientSchemaModule.cs | 37 +++ src/NzbDrone.Api/NzbDrone.Api.csproj | 5 +- .../Configuration/ConfigServiceFixture.cs | 10 - .../HistorySpecificationFixture.cs | 2 +- .../NotInQueueSpecificationFixture.cs | 3 - .../BlackholeProviderFixture.cs | 14 +- .../DownloadNzbFixture.cs | 29 +- .../QueueFixture.cs | 30 ++- .../PneumaticProviderFixture.cs | 11 +- .../SabProviderTests/SabProviderFixture.cs | 191 ------------- .../SabnzbdTests/SabnzbdFixture.cs | 104 ++++++++ .../Download/DownloadServiceFixture.cs | 7 +- .../Files/Categories_json.txt | 25 -- .../NzbDrone.Core.Test.csproj | 9 +- .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../Configuration/ConfigService.cs | 131 --------- .../Configuration/IConfigService.cs | 22 -- .../041_add_download_clients_table.cs | 20 ++ .../042_convert_config_to_download_clients.cs | 198 ++++++++++++++ src/NzbDrone.Core/Datastore/TableMapping.cs | 2 + .../Specifications/NotInQueueSpecification.cs | 5 +- .../RssSync/HistorySpecification.cs | 2 +- .../Download/Clients/Blackhole/Blackhole.cs | 69 +++++ .../Clients/Blackhole/TestBlackholeCommand.cs | 18 ++ .../Download/Clients/BlackholeProvider.cs | 68 ----- .../Download/Clients/FolderSettings.cs | 29 ++ .../Download/Clients/Nzbget/NzbGetQueue.cs | 4 +- .../Clients/Nzbget/NzbGetQueueItem.cs | 5 +- .../Download/Clients/Nzbget/Nzbget.cs | 96 +++++++ .../Download/Clients/Nzbget/NzbgetClient.cs | 136 ---------- .../{PriorityType.cs => NzbgetPriority.cs} | 2 +- ...etCommunicationProxy.cs => NzbgetProxy.cs} | 43 ++- .../Download/Clients/Nzbget/NzbgetSettings.cs | 59 +++++ .../Clients/Nzbget/TestNzbgetCommand.cs | 21 ++ .../{VersionModel.cs => VersionResponse.cs} | 2 +- .../Pneumatic.cs} | 29 +- .../Clients/Pneumatic/TestPneumaticCommand.cs | 18 ++ .../Clients/Sabnzbd/ConnectionInfoModel.cs | 8 - .../SabnzbdPriorityTypeConverter.cs | 6 +- .../Sabnzbd/Responses/SabnzbdAddResponse.cs | 18 ++ .../Responses/SabnzbdCategoryResponse.cs | 15 ++ .../Responses/SabnzbdVersionResponse.cs | 7 + .../Clients/Sabnzbd/SabAddResponse.cs | 19 -- .../Sabnzbd/SabAutoConfigureService.cs | 100 ------- .../Clients/Sabnzbd/SabCategoryModel.cs | 9 - .../Clients/Sabnzbd/SabCommunicationProxy.cs | 130 --------- .../Download/Clients/Sabnzbd/SabModel.cs | 9 - .../Clients/Sabnzbd/SabVersionModel.cs | 7 - .../Download/Clients/Sabnzbd/Sabnzbd.cs | 133 ++++++++++ .../Download/Clients/Sabnzbd/SabnzbdClient.cs | 250 ------------------ .../{SabQueue.cs => SabnzbdHistory.cs} | 4 +- ...abHistoryItem.cs => SabnzbdHistoryItem.cs} | 2 +- .../{SabJsonError.cs => SabnzbdJsonError.cs} | 2 +- ...{SabPriorityType.cs => SabnzbdPriority.cs} | 2 +- .../Download/Clients/Sabnzbd/SabnzbdProxy.cs | 182 +++++++++++++ .../{SabHistory.cs => SabnzbdQueue.cs} | 4 +- .../{SabQueueItem.cs => SabnzbdQueueItem.cs} | 4 +- .../Clients/Sabnzbd/SabnzbdSettings.cs | 66 +++++ .../Clients/Sabnzbd/TestSabnzbdCommand.cs | 23 ++ .../Download/DownloadClientBase.cs | 48 ++++ .../Download/DownloadClientDefinition.cs | 12 + .../Download/DownloadClientFactory.cs | 29 ++ .../Download/DownloadClientProvider.cs | 38 +-- .../Download/DownloadClientRepository.cs | 20 ++ src/NzbDrone.Core/Download/DownloadService.cs | 4 +- .../Download/Events/DownloadFailedEvent.cs | 18 ++ .../Download/Events/EpisodeGrabbedEvent.cs | 18 ++ src/NzbDrone.Core/Download/IDownloadClient.cs | 4 +- src/NzbDrone.Core/Indexers/IndexerBase.cs | 4 +- src/NzbDrone.Core/MetaData/MetadataService.cs | 9 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 56 ++-- src/NzbDrone.Core/Queue/QueueService.cs | 7 + src/NzbDrone.Core/Tv/SeriesService.cs | 2 + src/UI/.idea/jsLinters/jshint.xml | 4 +- src/UI/Form/FormBuilder.js | 7 + src/UI/Form/PathTemplate.html | 12 + .../Add/DownloadClientAddCollectionView.js | 23 ++ ...wnloadClientAddCollectionViewTemplate.html | 12 + .../Add/DownloadClientAddItemView.js | 38 +++ .../DownloadClientAddItemViewTemplate.html | 10 + .../DownloadClient/Add/SchemaModal.js | 20 ++ .../Settings/DownloadClient/BlackholeView.js | 24 -- .../DownloadClient/BlackholeViewTemplate.html | 13 - .../Delete/DownloadClientDeleteView.js | 23 ++ .../DownloadClientDeleteViewTemplate.html | 11 + .../DownloadClientCollection.js | 12 + .../DownloadClientCollectionView.js | 31 +++ .../DownloadClientCollectionViewTemplate.html | 16 ++ .../DownloadClient/DownloadClientItemView.js | 34 +++ .../DownloadClientItemViewTemplate.html | 17 ++ .../DownloadClient/DownloadClientLayout.js | 40 +++ .../DownloadClientLayoutTemplate.html | 16 ++ .../DownloadClient/DownloadClientModel.js | 10 + .../Edit/DownloadClientEditView.js | 93 +++++++ .../Edit/DownloadClientEditViewTemplate.html | 59 +++++ src/UI/Settings/DownloadClient/Layout.js | 78 ------ .../DownloadClient/LayoutTemplate.html | 29 -- src/UI/Settings/DownloadClient/NzbgetView.js | 15 -- .../DownloadClient/NzbgetViewTemplate.html | 86 ------ .../Settings/DownloadClient/PneumaticView.js | 24 -- .../DownloadClient/PneumaticViewTemplate.html | 13 - src/UI/Settings/DownloadClient/SabView.js | 15 -- .../DownloadClient/SabViewTemplate.html | 120 --------- .../DownloadClient/downloadclient.less | 27 ++ .../Settings/Indexers/CollectionTemplate.html | 2 +- src/UI/Settings/Indexers/CollectionView.js | 11 +- src/UI/Settings/Indexers/ItemTemplate.html | 2 +- src/UI/Settings/Indexers/indexers.less | 20 -- .../Notifications/AddItemTemplate.html | 2 +- .../Settings/Notifications/AddTemplate.html | 2 +- .../Notifications/CollectionTemplate.html | 4 +- .../Settings/Notifications/ItemTemplate.html | 2 +- .../Notifications/NotificationEditView.js | 4 +- src/UI/Settings/Notifications/SchemaModal.js | 10 +- .../Settings/Notifications/notifications.less | 55 +--- .../QualityProfileCollectionTemplate.html | 4 +- .../Profile/QualityProfileViewTemplate.html | 2 +- src/UI/Settings/Quality/quality.less | 19 -- src/UI/Settings/SettingsLayout.js | 2 +- src/UI/Settings/settings.less | 2 + src/UI/Settings/thingy.less | 65 +++++ 123 files changed, 2076 insertions(+), 1820 deletions(-) create mode 100644 src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs create mode 100644 src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs create mode 100644 src/NzbDrone.Api/DownloadClient/DownloadClientSchemaModule.cs rename src/NzbDrone.Core.Test/Download/DownloadClientTests/{NzbgetProviderTests => NzbgetTests}/DownloadNzbFixture.cs (63%) rename src/NzbDrone.Core.Test/Download/DownloadClientTests/{NzbgetProviderTests => NzbgetTests}/QueueFixture.cs (63%) delete mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/SabProviderTests/SabProviderFixture.cs create mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs delete mode 100644 src/NzbDrone.Core.Test/Files/Categories_json.txt create mode 100644 src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Blackhole/TestBlackholeCommand.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs create mode 100644 src/NzbDrone.Core/Download/Clients/FolderSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs rename src/NzbDrone.Core/Download/Clients/Nzbget/{PriorityType.cs => NzbgetPriority.cs} (84%) rename src/NzbDrone.Core/Download/Clients/Nzbget/{NzbGetCommunicationProxy.cs => NzbgetProxy.cs} (60%) create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs rename src/NzbDrone.Core/Download/Clients/Nzbget/{VersionModel.cs => VersionResponse.cs} (83%) rename src/NzbDrone.Core/Download/Clients/{PneumaticClient.cs => Pneumatic/Pneumatic.cs} (63%) create mode 100644 src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/ConnectionInfoModel.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAddResponse.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAutoConfigureService.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCategoryModel.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCommunicationProxy.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabModel.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabVersionModel.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs delete mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabQueue.cs => SabnzbdHistory.cs} (70%) rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabHistoryItem.cs => SabnzbdHistoryItem.cs} (95%) rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabJsonError.cs => SabnzbdJsonError.cs} (92%) rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabPriorityType.cs => SabnzbdPriority.cs} (84%) create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabHistory.cs => SabnzbdQueue.cs} (70%) rename src/NzbDrone.Core/Download/Clients/Sabnzbd/{SabQueueItem.cs => SabnzbdQueueItem.cs} (91%) create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientBase.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientDefinition.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientFactory.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientRepository.cs create mode 100644 src/NzbDrone.Core/Download/Events/DownloadFailedEvent.cs create mode 100644 src/NzbDrone.Core/Download/Events/EpisodeGrabbedEvent.cs create mode 100644 src/UI/Form/PathTemplate.html create mode 100644 src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js create mode 100644 src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js create mode 100644 src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/Add/SchemaModal.js delete mode 100644 src/UI/Settings/DownloadClient/BlackholeView.js delete mode 100644 src/UI/Settings/DownloadClient/BlackholeViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js create mode 100644 src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/DownloadClientCollection.js create mode 100644 src/UI/Settings/DownloadClient/DownloadClientCollectionView.js create mode 100644 src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/DownloadClientItemView.js create mode 100644 src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/DownloadClientLayout.js create mode 100644 src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html create mode 100644 src/UI/Settings/DownloadClient/DownloadClientModel.js create mode 100644 src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js create mode 100644 src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.html delete mode 100644 src/UI/Settings/DownloadClient/Layout.js delete mode 100644 src/UI/Settings/DownloadClient/LayoutTemplate.html delete mode 100644 src/UI/Settings/DownloadClient/NzbgetView.js delete mode 100644 src/UI/Settings/DownloadClient/NzbgetViewTemplate.html delete mode 100644 src/UI/Settings/DownloadClient/PneumaticView.js delete mode 100644 src/UI/Settings/DownloadClient/PneumaticViewTemplate.html delete mode 100644 src/UI/Settings/DownloadClient/SabView.js delete mode 100644 src/UI/Settings/DownloadClient/SabViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/downloadclient.less create mode 100644 src/UI/Settings/thingy.less diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs new file mode 100644 index 000000000..cdef47a78 --- /dev/null +++ b/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Download; + +namespace NzbDrone.Api.DownloadClient +{ + public class DownloadClientModule : ProviderModuleBase<DownloadClientResource, IDownloadClient, DownloadClientDefinition> + { + public DownloadClientModule(IDownloadClientFactory downloadClientFactory) + : base(downloadClientFactory, "downloadclient") + { + } + + protected override void Validate(DownloadClientDefinition definition) + { + if (!definition.Enable) return; + base.Validate(definition); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs new file mode 100644 index 000000000..cb1054168 --- /dev/null +++ b/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs @@ -0,0 +1,10 @@ +using System; + +namespace NzbDrone.Api.DownloadClient +{ + public class DownloadClientResource : ProviderResource + { + public Boolean Enable { get; set; } + public Int32 Protocol { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientSchemaModule.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientSchemaModule.cs new file mode 100644 index 000000000..58c1a2149 --- /dev/null +++ b/src/NzbDrone.Api/DownloadClient/DownloadClientSchemaModule.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using NzbDrone.Api.ClientSchema; +using NzbDrone.Core.Download; +using Omu.ValueInjecter; + +namespace NzbDrone.Api.DownloadClient +{ + public class DownloadClientSchemaModule : NzbDroneRestModule<DownloadClientResource> + { + private readonly IDownloadClientFactory _notificationFactory; + + public DownloadClientSchemaModule(IDownloadClientFactory notificationFactory) + : base("downloadclient/schema") + { + _notificationFactory = notificationFactory; + GetResourceAll = GetSchema; + } + + private List<DownloadClientResource> GetSchema() + { + var notifications = _notificationFactory.Templates(); + + var result = new List<DownloadClientResource>(notifications.Count); + + foreach (var notification in notifications) + { + var notificationResource = new DownloadClientResource(); + notificationResource.InjectFrom(notification); + notificationResource.Fields = SchemaBuilder.ToSchema(notification.Settings); + + result.Add(notificationResource); + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index bda4694cc..04dd817d4 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -94,6 +94,8 @@ <Compile Include="Commands\CommandResource.cs" /> <Compile Include="Config\NamingConfigResource.cs" /> <Compile Include="Config\NamingModule.cs" /> + <Compile Include="DownloadClient\DownloadClientModule.cs" /> + <Compile Include="DownloadClient\DownloadClientResource.cs" /> <Compile Include="DiskSpace\DiskSpaceModule.cs" /> <Compile Include="DiskSpace\DiskSpaceResource.cs" /> <Compile Include="EpisodeFiles\EpisodeFileModule.cs" /> @@ -122,6 +124,7 @@ <Compile Include="History\HistoryModule.cs" /> <Compile Include="Metadata\MetadataResource.cs" /> <Compile Include="Metadata\MetadataModule.cs" /> + <Compile Include="Notifications\NotificationSchemaModule.cs" /> <Compile Include="ProviderResource.cs" /> <Compile Include="ProviderModuleBase.cs" /> <Compile Include="Indexers\IndexerSchemaModule.cs" /> @@ -145,7 +148,7 @@ <Compile Include="Queue\QueueModule.cs" /> <Compile Include="Queue\QueueResource.cs" /> <Compile Include="ResourceChangeMessage.cs" /> - <Compile Include="Notifications\NotificationSchemaModule.cs" /> + <Compile Include="DownloadClient\DownloadClientSchemaModule.cs" /> <Compile Include="Notifications\NotificationModule.cs" /> <Compile Include="Notifications\NotificationResource.cs" /> <Compile Include="NzbDroneRestModule.cs" /> diff --git a/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs b/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs index aaf562c7f..35caa1216 100644 --- a/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs @@ -105,16 +105,6 @@ namespace NzbDrone.Core.Test.Configuration Subject.GetValue(key, value2).Should().Be(value2); } - [Test] - public void updating_a_vakye_should_update_its_value() - { - Subject.SabHost = "Test"; - Subject.SabHost.Should().Be("Test"); - - Subject.SabHost = "Test2"; - Subject.SabHost.Should().Be("Test2"); - } - [Test] [Description("This test will use reflection to ensure each config property read/writes to a unique key")] public void config_properties_should_write_and_read_using_same_key() diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs index 54c8ff6e0..48710e6f9 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs @@ -83,7 +83,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenSabnzbdDownloadClient() { Mocker.GetMock<IProvideDownloadClient>() - .Setup(c => c.GetDownloadClient()).Returns(Mocker.Resolve<SabnzbdClient>()); + .Setup(c => c.GetDownloadClient()).Returns(Mocker.Resolve<Sabnzbd>()); } private void GivenMostRecentForEpisode(HistoryEventType eventType) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs index baa24d5b4..4a3be3627 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/NotInQueueSpecificationFixture.cs @@ -56,9 +56,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Mocker.GetMock<IProvideDownloadClient>() .Setup(s => s.GetDownloadClient()) .Returns(_downloadClient.Object); - - _downloadClient.SetupGet(s => s.IsConfigured) - .Returns(true); } private void GivenEmptyQueue() diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/BlackholeProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/BlackholeProviderFixture.cs index 2b0639019..9f1c9d317 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/BlackholeProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/BlackholeProviderFixture.cs @@ -4,8 +4,9 @@ using Moq; using NUnit.Framework; using NzbDrone.Common; using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.Blackhole; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -13,7 +14,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download.DownloadClientTests { [TestFixture] - public class BlackholeProviderFixture : CoreTest<BlackholeProvider> + public class BlackholeProviderFixture : CoreTest<Blackhole> { private const string _nzbUrl = "http://www.nzbs.com/url"; private const string _title = "some_nzb_title"; @@ -27,13 +28,16 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests _blackHoleFolder = @"c:\nzb\blackhole\".AsOsAgnostic(); _nzbPath = @"c:\nzb\blackhole\some_nzb_title.nzb".AsOsAgnostic(); - - Mocker.GetMock<IConfigService>().SetupGet(c => c.BlackholeFolder).Returns(_blackHoleFolder); - _remoteEpisode = new RemoteEpisode(); _remoteEpisode.Release = new ReleaseInfo(); _remoteEpisode.Release.Title = _title; _remoteEpisode.Release.DownloadUrl = _nzbUrl; + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new FolderSettings + { + Folder = _blackHoleFolder + }; } private void WithExistingFile() diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/DownloadNzbFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/DownloadNzbFixture.cs similarity index 63% rename from src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/DownloadNzbFixture.cs rename to src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/DownloadNzbFixture.cs index 397eb531a..b0c8d2efb 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/DownloadNzbFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/DownloadNzbFixture.cs @@ -3,17 +3,15 @@ using System.Linq; using FizzWare.NBuilder; using Moq; using NUnit.Framework; -using NzbDrone.Common; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; -namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetProviderTests +namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { - public class DownloadNzbFixture : CoreTest + public class DownloadNzbFixture : CoreTest<Nzbget> { private const string _url = "http://www.nzbdrone.com"; private const string _title = "30.Rock.S01E01.Pilot.720p.hdtv"; @@ -32,6 +30,17 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetProviderTests .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) .Build() .ToList(); + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new NzbgetSettings + { + Host = "localhost", + Port = 6789, + Username = "nzbget", + Password = "pass", + TvCategory = "tv", + RecentTvPriority = (int)NzbgetPriority.High + }; } [Test] @@ -39,14 +48,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetProviderTests { var p = new object[] {"30.Rock.S01E01.Pilot.720p.hdtv.nzb", "TV", 50, false, "http://www.nzbdrone.com"}; - Mocker.GetMock<INzbGetCommunicationProxy>() - .Setup(s => s.AddNzb(p)) + Mocker.GetMock<INzbgetProxy>() + .Setup(s => s.AddNzb(It.IsAny<NzbgetSettings>(), p)) .Returns(true); - Mocker.Resolve<NzbgetClient>().DownloadNzb(_remoteEpisode); + Subject.DownloadNzb(_remoteEpisode); - Mocker.GetMock<INzbGetCommunicationProxy>() - .Verify(v => v.AddNzb(It.IsAny<object []>()), Times.Once()); + Mocker.GetMock<INzbgetProxy>() + .Verify(v => v.AddNzb(It.IsAny<NzbgetSettings>(), It.IsAny<object []>()), Times.Once()); } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/QueueFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/QueueFixture.cs similarity index 63% rename from src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/QueueFixture.cs rename to src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/QueueFixture.cs index a152e9cdf..4fbbbad74 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetProviderTests/QueueFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/QueueFixture.cs @@ -5,40 +5,52 @@ using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; -namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetProviderTests +namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { - public class QueueFixture : CoreTest<NzbgetClient> + public class QueueFixture : CoreTest<Nzbget> { - private List<NzbGetQueueItem> _queue; + private List<NzbgetQueueItem> _queue; [SetUp] public void Setup() { - _queue = Builder<NzbGetQueueItem>.CreateListOfSize(5) + _queue = Builder<NzbgetQueueItem>.CreateListOfSize(5) .All() .With(q => q.NzbName = "30.Rock.S01E01.Pilot.720p.hdtv.nzb") .Build() .ToList(); + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new NzbgetSettings + { + Host = "localhost", + Port = 6789, + Username = "nzbget", + Password = "pass", + TvCategory = "tv", + RecentTvPriority = (int)NzbgetPriority.High + }; } private void WithFullQueue() { - Mocker.GetMock<INzbGetCommunicationProxy>() - .Setup(s => s.GetQueue()) + Mocker.GetMock<INzbgetProxy>() + .Setup(s => s.GetQueue(It.IsAny<NzbgetSettings>())) .Returns(_queue); } private void WithEmptyQueue() { - Mocker.GetMock<INzbGetCommunicationProxy>() - .Setup(s => s.GetQueue()) - .Returns(new List<NzbGetQueueItem>()); + Mocker.GetMock<INzbgetProxy>() + .Setup(s => s.GetQueue(It.IsAny<NzbgetSettings>())) + .Returns(new List<NzbgetQueueItem>()); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs index dfb27a5f7..3d564a69f 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs @@ -6,7 +6,9 @@ using NUnit.Framework; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.Pneumatic; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -14,7 +16,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download.DownloadClientTests { [TestFixture] - public class PneumaticProviderFixture : CoreTest<PneumaticClient> + public class PneumaticProviderFixture : CoreTest<Pneumatic> { private const string _nzbUrl = "http://www.nzbs.com/url"; private const string _title = "30.Rock.S01E05.hdtv.xvid-LoL"; @@ -31,7 +33,6 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests _nzbPath = Path.Combine(_pneumaticFolder, _title + ".nzb").AsOsAgnostic(); _sabDrop = @"d:\unsorted tv\".AsOsAgnostic(); - Mocker.GetMock<IConfigService>().SetupGet(c => c.PneumaticFolder).Returns(_pneumaticFolder); Mocker.GetMock<IConfigService>().SetupGet(c => c.DownloadedEpisodesFolder).Returns(_sabDrop); _remoteEpisode = new RemoteEpisode(); @@ -41,6 +42,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests _remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); _remoteEpisode.ParsedEpisodeInfo.FullSeason = false; + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new FolderSettings + { + Folder = _pneumaticFolder + }; } private void WithExistingFile() diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabProviderTests/SabProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabProviderTests/SabProviderFixture.cs deleted file mode 100644 index 9c4943ace..000000000 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabProviderTests/SabProviderFixture.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.Clients.Sabnzbd; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabProviderTests -{ - [TestFixture] - - public class SabProviderFixture : CoreTest<SabnzbdClient> - { - private const string URL = "http://www.nzbclub.com/nzb_download.aspx?mid=1950232"; - private const string TITLE = "My Series Name - 5x2-5x3 - My title [Bluray720p] [Proper]"; - private RemoteEpisode _remoteEpisode; - - [SetUp] - public void Setup() - { - var fakeConfig = Mocker.GetMock<IConfigService>(); - - fakeConfig.SetupGet(c => c.SabHost).Returns("192.168.5.55"); - fakeConfig.SetupGet(c => c.SabPort).Returns(2222); - fakeConfig.SetupGet(c => c.SabApiKey).Returns("5c770e3197e4fe763423ee7c392c25d1"); - fakeConfig.SetupGet(c => c.SabUsername).Returns("admin"); - fakeConfig.SetupGet(c => c.SabPassword).Returns("pass"); - fakeConfig.SetupGet(c => c.SabTvCategory).Returns("tv"); - - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Release = new ReleaseInfo(); - _remoteEpisode.Release.Title = TITLE; - _remoteEpisode.Release.DownloadUrl = URL; - - _remoteEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) - .All() - .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) - .Build() - .ToList(); - } - - [Test] - public void should_be_able_to_get_categories_when_config_is_passed_in() - { - - const string host = "192.168.5.22"; - const int port = 1111; - const string apikey = "5c770e3197e4fe763423ee7c392c25d2"; - const string username = "admin2"; - const string password = "pass2"; - - Mocker.GetMock<IHttpProvider>(MockBehavior.Strict) - .Setup(s => s.DownloadString("http://192.168.5.22:1111/api?mode=get_cats&output=json&apikey=5c770e3197e4fe763423ee7c392c25d2&ma_username=admin2&ma_password=pass2")) - .Returns(ReadAllText("Files", "Categories_json.txt")); - - var result = Subject.GetCategories(host, port, apikey, username, password); - - - result.Should().NotBeNull(); - result.categories.Should().NotBeEmpty(); - } - - [Test] - public void should_be_able_to_get_categories_using_config() - { - Mocker.GetMock<IHttpProvider>(MockBehavior.Strict) - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=get_cats&output=json&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "Categories_json.txt")); - - - var result = Subject.GetCategories(); - - - result.Should().NotBeNull(); - result.categories.Should().NotBeEmpty(); - } - - [Test] - public void GetHistory_should_return_a_list_with_items_when_the_history_has_items() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "History.txt")); - - - var result = Subject.GetHistory(); - - - result.Should().HaveCount(1); - } - - [Test] - public void GetHistory_should_return_an_empty_list_when_the_queue_is_empty() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "HistoryEmpty.txt")); - - - var result = Subject.GetHistory(); - - - result.Should().BeEmpty(); - } - - [Test] - public void GetHistory_should_return_an_empty_list_when_there_is_an_error_getting_the_queue() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "JsonError.txt")); - - - Assert.Throws<ApplicationException>(() => Subject.GetHistory(), "API Key Incorrect"); - } - - [Test] - public void GetVersion_should_return_the_version_using_passed_in_values() - { - var response = "{ \"version\": \"0.6.9\" }"; - - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=version&output=json&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(response); - - - var result = Subject.GetVersion("192.168.5.55", 2222, "5c770e3197e4fe763423ee7c392c25d1", "admin", "pass"); - - - result.Should().NotBeNull(); - result.Version.Should().Be("0.6.9"); - } - - [Test] - public void GetVersion_should_return_the_version_using_saved_values() - { - var response = "{ \"version\": \"0.6.9\" }"; - - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=version&output=json&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(response); - - - var result = Subject.GetVersion(); - - - result.Should().NotBeNull(); - result.Version.Should().Be("0.6.9"); - } - - [Test] - public void Test_should_return_version_as_a_string() - { - const string response = "{ \"version\": \"0.6.9\" }"; - - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=version&output=json&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(response); - - - var result = Subject.Test("192.168.5.55", 2222, "5c770e3197e4fe763423ee7c392c25d1", "admin", "pass"); - - - result.Should().Be("0.6.9"); - } - - [Test] - public void downloadNzb_should_use_sabRecentTvPriority_when_recentEpisode_is_true() - { - Mocker.GetMock<IConfigService>() - .SetupGet(s => s.SabRecentTvPriority) - .Returns(SabPriorityType.High); - - Mocker.GetMock<ISabCommunicationProxy>() - .Setup(s => s.DownloadNzb(It.IsAny<Stream>(), It.IsAny<String>(), It.IsAny<String>(), (int)SabPriorityType.High)) - .Returns(new SabAddResponse()); - - Subject.DownloadNzb(_remoteEpisode); - - Mocker.GetMock<ISabCommunicationProxy>() - .Verify(v => v.DownloadNzb(It.IsAny<Stream>(), It.IsAny<String>(), It.IsAny<String>(), (int)SabPriorityType.High), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs new file mode 100644 index 000000000..8b05eac12 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.Sabnzbd; +using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests +{ + [TestFixture] + public class SabnzbdFixture : CoreTest<Sabnzbd> + { + private const string URL = "http://www.nzbclub.com/nzb_download.aspx?mid=1950232"; + private const string TITLE = "My Series Name - 5x2-5x3 - My title [Bluray720p] [Proper]"; + private RemoteEpisode _remoteEpisode; + + [SetUp] + public void Setup() + { + _remoteEpisode = new RemoteEpisode(); + _remoteEpisode.Release = new ReleaseInfo(); + _remoteEpisode.Release.Title = TITLE; + _remoteEpisode.Release.DownloadUrl = URL; + + _remoteEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) + .All() + .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) + .Build() + .ToList(); + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new SabnzbdSettings + { + Host = "192.168.5.55", + Port = 2222, + ApiKey = "5c770e3197e4fe763423ee7c392c25d1", + Username = "admin", + Password = "pass", + TvCategory = "tv", + RecentTvPriority = (int)SabnzbdPriority.High + }; + } + + [Test] + public void GetHistory_should_return_a_list_with_items_when_the_history_has_items() + { + Mocker.GetMock<IHttpProvider>() + .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) + .Returns(ReadAllText("Files", "History.txt")); + + + var result = Subject.GetHistory(); + + + result.Should().HaveCount(1); + } + + [Test] + public void GetHistory_should_return_an_empty_list_when_the_queue_is_empty() + { + Mocker.GetMock<IHttpProvider>() + .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) + .Returns(ReadAllText("Files", "HistoryEmpty.txt")); + + + var result = Subject.GetHistory(); + + + result.Should().BeEmpty(); + } + + [Test] + public void GetHistory_should_return_an_empty_list_when_there_is_an_error_getting_the_queue() + { + Mocker.GetMock<IHttpProvider>() + .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) + .Returns(ReadAllText("Files", "JsonError.txt")); + + + Assert.Throws<ApplicationException>(() => Subject.GetHistory(), "API Key Incorrect"); + } + + [Test] + public void downloadNzb_should_use_sabRecentTvPriority_when_recentEpisode_is_true() + { + Mocker.GetMock<ISabnzbdProxy>() + .Setup(s => s.DownloadNzb(It.IsAny<Stream>(), It.IsAny<String>(), It.IsAny<String>(), (int)SabnzbdPriority.High, It.IsAny<SabnzbdSettings>())) + .Returns(new SabnzbdAddResponse()); + + Subject.DownloadNzb(_remoteEpisode); + + Mocker.GetMock<ISabnzbdProxy>() + .Verify(v => v.DownloadNzb(It.IsAny<Stream>(), It.IsAny<String>(), It.IsAny<String>(), (int)SabnzbdPriority.High, It.IsAny<SabnzbdSettings>()), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs index f1a64dd7b..0d3755468 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using FizzWare.NBuilder; @@ -33,9 +34,6 @@ namespace NzbDrone.Core.Test.Download .With(c => c.Release = Builder<ReleaseInfo>.CreateNew().Build()) .With(c => c.Episodes = episodes) .Build(); - - - Mocker.GetMock<IDownloadClient>().Setup(c => c.IsConfigured).Returns(true); } private void WithSuccessfulAdd() @@ -85,7 +83,8 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_not_attempt_download_if_client_isnt_configure() { - Mocker.GetMock<IDownloadClient>().Setup(c => c.IsConfigured).Returns(false); + Mocker.GetMock<IProvideDownloadClient>() + .Setup(c => c.GetDownloadClient()).Returns((IDownloadClient)null); Subject.DownloadReport(_parseResult); diff --git a/src/NzbDrone.Core.Test/Files/Categories_json.txt b/src/NzbDrone.Core.Test/Files/Categories_json.txt deleted file mode 100644 index 5759a90e5..000000000 --- a/src/NzbDrone.Core.Test/Files/Categories_json.txt +++ /dev/null @@ -1,25 +0,0 @@ -{ - "categories":[ - "*", - "anime", - "apps", - "books", - "consoles", - "ds-games", - "emulation", - "games", - "misc", - "movies", - "music", - "pda", - "resources", - "test", - "tv", - "tv-dvd", - "unknown", - "wii-games", - "xbox-dlc", - "xbox-xbla", - "xxx" - ] -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index bb3fa1367..f50f25adf 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -122,10 +122,10 @@ <Compile Include="Download\DownloadApprovedReportsTests\DownloadApprovedFixture.cs" /> <Compile Include="Download\DownloadApprovedReportsTests\GetQualifiedReportsFixture.cs" /> <Compile Include="Download\DownloadClientTests\BlackholeProviderFixture.cs" /> - <Compile Include="Download\DownloadClientTests\NzbgetProviderTests\DownloadNzbFixture.cs" /> - <Compile Include="Download\DownloadClientTests\NzbgetProviderTests\QueueFixture.cs" /> + <Compile Include="Download\DownloadClientTests\NzbgetTests\DownloadNzbFixture.cs" /> + <Compile Include="Download\DownloadClientTests\NzbgetTests\QueueFixture.cs" /> <Compile Include="Download\DownloadClientTests\PneumaticProviderFixture.cs" /> - <Compile Include="Download\DownloadClientTests\SabProviderTests\SabProviderFixture.cs" /> + <Compile Include="Download\DownloadClientTests\SabnzbdTests\SabnzbdFixture.cs" /> <Compile Include="Download\DownloadServiceFixture.cs" /> <Compile Include="Download\FailedDownloadServiceFixture.cs" /> <Compile Include="Framework\CoreTest.cs" /> @@ -330,9 +330,6 @@ <Content Include="Files\RSS\newznab.xml"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> - <Content Include="Files\Categories_json.txt"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> <Content Include="Files\RSS\SizeParsing\newznab.xml"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 794a00c43..de22c722f 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Annotations Textbox, Password, Checkbox, - Select + Select, + Path } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 6ebe823ff..6e197460e 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -73,69 +73,6 @@ namespace NzbDrone.Core.Configuration _eventAggregator.PublishEvent(new ConfigSavedEvent()); } - public String SabHost - { - get { return GetValue("SabHost", "localhost"); } - - set { SetValue("SabHost", value); } - } - - public int SabPort - { - get { return GetValueInt("SabPort", 8080); } - - set { SetValue("SabPort", value); } - } - - public String SabApiKey - { - get { return GetValue("SabApiKey"); } - - set { SetValue("SabApiKey", value); } - } - - public String SabUsername - { - get { return GetValue("SabUsername"); } - - set { SetValue("SabUsername", value); } - } - - public String SabPassword - { - get { return GetValue("SabPassword"); } - - set { SetValue("SabPassword", value); } - } - - public String SabTvCategory - { - get { return GetValue("SabTvCategory", "tv"); } - - set { SetValue("SabTvCategory", value); } - } - - public SabPriorityType SabRecentTvPriority - { - get { return GetValueEnum("SabRecentTvPriority", SabPriorityType.Default); } - - set { SetValue("SabRecentTvPriority", value); } - } - - public SabPriorityType SabOlderTvPriority - { - get { return GetValueEnum("SabOlderTvPriority", SabPriorityType.Default); } - - set { SetValue("SabOlderTvPriority", value); } - } - - public bool SabUseSsl - { - get { return GetValueBoolean("SabUseSsl", false); } - - set { SetValue("SabUseSsl", value); } - } - public String DownloadedEpisodesFolder { get { return GetValue(ConfigKey.DownloadedEpisodesFolder.ToString()); } @@ -155,80 +92,12 @@ namespace NzbDrone.Core.Configuration set { SetValue("Retention", value); } } - public DownloadClientType DownloadClient - { - get { return GetValueEnum("DownloadClient", DownloadClientType.Blackhole); } - - set { SetValue("DownloadClient", value); } - } - - public string BlackholeFolder - { - get { return GetValue("BlackholeFolder", String.Empty); } - set { SetValue("BlackholeFolder", value); } - } - - public string PneumaticFolder - { - get { return GetValue("PneumaticFolder", String.Empty); } - set { SetValue("PneumaticFolder", value); } - } - public string RecycleBin { get { return GetValue("RecycleBin", String.Empty); } set { SetValue("RecycleBin", value); } } - public String NzbgetUsername - { - get { return GetValue("NzbgetUsername", "nzbget"); } - - set { SetValue("NzbgetUsername", value); } - } - - public String NzbgetPassword - { - get { return GetValue("NzbgetPassword", ""); } - - set { SetValue("NzbgetPassword", value); } - } - - public String NzbgetHost - { - get { return GetValue("NzbgetHost", "localhost"); } - - set { SetValue("NzbgetHost", value); } - } - - public Int32 NzbgetPort - { - get { return GetValueInt("NzbgetPort", 6789); } - - set { SetValue("NzbgetPort", value); } - } - - public String NzbgetTvCategory - { - get { return GetValue("NzbgetTvCategory", ""); } - - set { SetValue("NzbgetTvCategory", value); } - } - - public PriorityType NzbgetRecentTvPriority - { - get { return GetValueEnum("NzbgetRecentTvPriority", PriorityType.Normal); } - - set { SetValue("NzbgetRecentTvPriority", value); } - } - - public PriorityType NzbgetOlderTvPriority - { - get { return GetValueEnum("NzbgetOlderTvPriority", PriorityType.Normal); } - - set { SetValue("NzbgetOlderTvPriority", value); } - } - public string ReleaseRestrictions { get { return GetValue("ReleaseRestrictions", String.Empty).Trim('\r', '\n'); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 6c19d6c36..a9d121e2f 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Generic; -using NzbDrone.Core.Download; -using NzbDrone.Core.Download.Clients.Nzbget; -using NzbDrone.Core.Download.Clients.Sabnzbd; namespace NzbDrone.Core.Configuration { @@ -10,29 +7,10 @@ namespace NzbDrone.Core.Configuration { IEnumerable<Config> All(); Dictionary<String, Object> AllWithDefaults(); - String SabHost { get; set; } - int SabPort { get; set; } - String SabApiKey { get; set; } - String SabUsername { get; set; } - String SabPassword { get; set; } - String SabTvCategory { get; set; } - SabPriorityType SabRecentTvPriority { get; set; } - SabPriorityType SabOlderTvPriority { get; set; } - Boolean SabUseSsl { get; set; } String DownloadedEpisodesFolder { get; set; } bool AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } int Retention { get; set; } - DownloadClientType DownloadClient { get; set; } - string BlackholeFolder { get; set; } - string PneumaticFolder { get; set; } string RecycleBin { get; set; } - String NzbgetUsername { get; set; } - String NzbgetPassword { get; set; } - String NzbgetHost { get; set; } - Int32 NzbgetPort { get; set; } - String NzbgetTvCategory { get; set; } - PriorityType NzbgetRecentTvPriority { get; set; } - PriorityType NzbgetOlderTvPriority { get; set; } string ReleaseRestrictions { get; set; } Int32 RssSyncInterval { get; set; } Boolean AutoDownloadPropers { get; set; } diff --git a/src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs b/src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs new file mode 100644 index 000000000..7c11b9e21 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs @@ -0,0 +1,20 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(41)] + public class add_download_clients_table : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("DownloadClients") + .WithColumn("Enable").AsBoolean().NotNullable() + .WithColumn("Name").AsString().NotNullable() + .WithColumn("Implementation").AsString().NotNullable() + .WithColumn("Settings").AsString().NotNullable() + .WithColumn("ConfigContract").AsString().NotNullable() + .WithColumn("Protocol").AsInt32().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs b/src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs new file mode 100644 index 000000000..807dca491 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Runtime.Remoting.Messaging; +using FluentMigrator; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(42)] + public class convert_config_to_download_clients : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(ConvertToThingyProvder); + } + + private void ConvertToThingyProvder(IDbConnection conn, IDbTransaction tran) + { + var config = new Dictionary<string, string>(); + + using (IDbCommand configCmd = conn.CreateCommand()) + { + configCmd.Transaction = tran; + configCmd.CommandText = @"SELECT * FROM Config"; + using (IDataReader configReader = configCmd.ExecuteReader()) + { + var keyIndex = configReader.GetOrdinal("Key"); + var valueIndex = configReader.GetOrdinal("Value"); + + while (configReader.Read()) + { + var key = configReader.GetString(keyIndex); + var value = configReader.GetString(valueIndex); + + config.Add(key.ToLowerInvariant(), value); + } + } + } + + var client = GetConfigValue(config, "DownloadClient", ""); + + if (String.IsNullOrWhiteSpace(client)) + { + return; + } + + if (client.Equals("sabnzbd", StringComparison.InvariantCultureIgnoreCase)) + { + var settings = new ClientSettingsForMigration + { + Host = GetConfigValue(config, "SabHost", "localhost"), + Port = GetConfigValue(config, "SabPort", 8080), + ApiKey = GetConfigValue(config, "SabApiKey", ""), + Username = GetConfigValue(config, "SabUsername", ""), + Password = GetConfigValue(config, "SabPassword", ""), + TvCategory = GetConfigValue(config, "SabTvCategory", "tv"), + RecentTvPriority = GetSabnzbdPriority(GetConfigValue(config, "NzbgetRecentTvPriority", "Default")), + OlderTvPriority = GetSabnzbdPriority(GetConfigValue(config, "NzbgetOlderTvPriority", "Default")), + UseSsl = GetConfigValue(config, "SabUseSsl", false) + }; + + AddDownloadClient(conn, tran, "Sabnzbd", "Sabnzbd", settings.ToJson(), "SabnzbdSettings", 1); + } + + else if (client.Equals("nzbget", StringComparison.InvariantCultureIgnoreCase)) + { + var settings = new ClientSettingsForMigration + { + Host = GetConfigValue(config, "NzbGetHost", "localhost"), + Port = GetConfigValue(config, "NzbgetPort", 6789), + Username = GetConfigValue(config, "NzbgetUsername", "nzbget"), + Password = GetConfigValue(config, "NzbgetPassword", ""), + TvCategory = GetConfigValue(config, "NzbgetTvCategory", "tv"), + RecentTvPriority = GetNzbgetPriority(GetConfigValue(config, "NzbgetRecentTvPriority", "Normal")), + OlderTvPriority = GetNzbgetPriority(GetConfigValue(config, "NzbgetOlderTvPriority", "Normal")), + }; + + AddDownloadClient(conn, tran, "Nzbget", "Nzbget", settings.ToJson(), "NzbgetSettings", 1); + } + + else if (client.Equals("pneumatic", StringComparison.InvariantCultureIgnoreCase)) + { + var settings = new FolderSettingsForMigration + { + Folder = GetConfigValue(config, "PneumaticFolder", "") + }; + + AddDownloadClient(conn, tran, "Pneumatic", "Pneumatic", settings.ToJson(), "FolderSettings", 1); + } + + else if (client.Equals("blackhole", StringComparison.InvariantCultureIgnoreCase)) + { + var settings = new FolderSettingsForMigration + { + Folder = GetConfigValue(config, "BlackholeFolder", "") + }; + + AddDownloadClient(conn, tran, "Blackhole", "Blackhole", settings.ToJson(), "FolderSettings", 1); + } + + DeleteOldConfigValues(conn, tran); + } + + private T GetConfigValue<T>(Dictionary<string, string> config, string key, T defaultValue) + { + key = key.ToLowerInvariant(); + + if (config.ContainsKey(key)) + { + return (T) Convert.ChangeType(config[key], typeof (T)); + } + + return defaultValue; + } + + private void AddDownloadClient(IDbConnection conn, IDbTransaction tran, string name, string implementation, string settings, + string configContract, int protocol) + { + using (IDbCommand updateCmd = conn.CreateCommand()) + { + var text = String.Format("INSERT INTO DownloadClients (Enable, Name, Implementation, Settings, ConfigContract, Protocol) VALUES (1, ?, ?, ?, ?, ?)"); + updateCmd.AddParameter(name); + updateCmd.AddParameter(implementation); + updateCmd.AddParameter(settings); + updateCmd.AddParameter(configContract); + updateCmd.AddParameter(protocol); + + updateCmd.Transaction = tran; + updateCmd.CommandText = text; + updateCmd.ExecuteNonQuery(); + } + } + + private void DeleteOldConfigValues(IDbConnection conn, IDbTransaction tran) + { + using (IDbCommand updateCmd = conn.CreateCommand()) + { + var text = "DELETE FROM Config WHERE [KEY] IN ('nzbgetusername', 'nzbgetpassword', 'nzbgethost', 'nzbgetport', " + + "'nzbgettvcategory', 'nzbgetrecenttvpriority', 'nzbgetoldertvpriority', 'sabhost', 'sabport', " + + "'sabapikey', 'sabusername', 'sabpassword', 'sabtvcategory', 'sabrecenttvpriority', " + + "'saboldertvpriority', 'sabusessl', 'downloadclient', 'blackholefolder', 'pneumaticfolder')"; + + updateCmd.Transaction = tran; + updateCmd.CommandText = text; + updateCmd.ExecuteNonQuery(); + } + } + + private int GetSabnzbdPriority(string priority) + { + return (int)Enum.Parse(typeof(SabnzbdPriorityForMigration), priority, true); + } + + private int GetNzbgetPriority(string priority) + { + return (int)Enum.Parse(typeof(NzbGetPriorityForMigration), priority, true); + } + + private class ClientSettingsForMigration + { + public String Host { get; set; } + public Int32 Port { get; set; } + public String ApiKey { get; set; } + public String Username { get; set; } + public String Password { get; set; } + public String TvCategory { get; set; } + public Int32 RecentTvPriority { get; set; } + public Int32 OlderTvPriority { get; set; } + public Boolean UseSsl { get; set; } + } + + private class FolderSettingsForMigration + { + public String Folder { get; set; } + } + + private enum SabnzbdPriorityForMigration + { + Default = -100, + Paused = -2, + Low = -1, + Normal = 0, + High = 1, + Force = 2 + } + + private enum NzbGetPriorityForMigration + { + VeryLow = -100, + Low = -50, + Normal = 0, + High = 50, + VeryHigh = 100 + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index b5711cd18..78c6fa0fb 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Blacklisting; using NzbDrone.Core.Configuration; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Download; using NzbDrone.Core.Indexers; using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Jobs; @@ -39,6 +40,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity<ScheduledTask>().RegisterModel("ScheduledTasks"); Mapper.Entity<NotificationDefinition>().RegisterModel("Notifications"); Mapper.Entity<MetadataDefinition>().RegisterModel("Metadata"); + Mapper.Entity<DownloadClientDefinition>().RegisterModel("DownloadClients"); Mapper.Entity<SceneMapping>().RegisterModel("SceneMappings"); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs index fa6312258..7e4f34551 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/NotInQueueSpecification.cs @@ -5,7 +5,6 @@ using NzbDrone.Core.Download; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.DecisionEngine.Specifications { @@ -32,9 +31,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { var downloadClient = _downloadClientProvider.GetDownloadClient(); - if (!downloadClient.IsConfigured) + if (downloadClient == null) { - _logger.Warn("Download client {0} isn't configured yet.", downloadClient.GetType().Name); + _logger.Warn("Download client isn't configured yet."); return true; } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs index b2e034cac..1624c6296 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return true; } - if (_downloadClientProvider.GetDownloadClient().GetType() == typeof (SabnzbdClient)) + if (_downloadClientProvider.GetDownloadClient().GetType() == typeof (Sabnzbd)) { _logger.Trace("Performing history status check on report"); foreach (var episode in subject.Episodes) diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs new file mode 100644 index 000000000..4cfc0cf2d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Clients.Blackhole +{ + public class Blackhole : DownloadClientBase<FolderSettings>, IExecute<TestBlackholeCommand> + { + private readonly IDiskProvider _diskProvider; + private readonly IHttpProvider _httpProvider; + private readonly Logger _logger; + + public Blackhole(IDiskProvider diskProvider, IHttpProvider httpProvider, Logger logger) + { + _diskProvider = diskProvider; + _httpProvider = httpProvider; + _logger = logger; + } + + public override string DownloadNzb(RemoteEpisode remoteEpisode) + { + var url = remoteEpisode.Release.DownloadUrl; + var title = remoteEpisode.Release.Title; + + title = FileNameBuilder.CleanFilename(title); + + var filename = Path.Combine(Settings.Folder, title + ".nzb"); + + + _logger.Trace("Downloading NZB from: {0} to: {1}", url, filename); + _httpProvider.DownloadFile(url, filename); + _logger.Trace("NZB Download succeeded, saved to: {0}", filename); + + return null; + } + + public override IEnumerable<QueueItem> GetQueue() + { + return new QueueItem[0]; + } + + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + { + return new HistoryItem[0]; + } + + public override void RemoveFromQueue(string id) + { + } + + public override void RemoveFromHistory(string id) + { + } + + public void Execute(TestBlackholeCommand message) + { + var testPath = Path.Combine(message.Folder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/TestBlackholeCommand.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TestBlackholeCommand.cs new file mode 100644 index 000000000..10898f80a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TestBlackholeCommand.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download.Clients.Blackhole +{ + public class TestBlackholeCommand : Command + { + public override bool SendUpdatesToClient + { + get + { + return true; + } + } + + public String Folder { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs b/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs deleted file mode 100644 index 1f5a5c93d..000000000 --- a/src/NzbDrone.Core/Download/Clients/BlackholeProvider.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using NLog; -using NzbDrone.Common; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Download.Clients -{ - public class BlackholeProvider : IDownloadClient - { - private readonly IConfigService _configService; - private readonly IHttpProvider _httpProvider; - private readonly Logger _logger; - - - public BlackholeProvider(IConfigService configService, IHttpProvider httpProvider, Logger logger) - { - _configService = configService; - _httpProvider = httpProvider; - _logger = logger; - } - - public string DownloadNzb(RemoteEpisode remoteEpisode) - { - var url = remoteEpisode.Release.DownloadUrl; - var title = remoteEpisode.Release.Title; - - title = FileNameBuilder.CleanFilename(title); - - var filename = Path.Combine(_configService.BlackholeFolder, title + ".nzb"); - - - _logger.Trace("Downloading NZB from: {0} to: {1}", url, filename); - _httpProvider.DownloadFile(url, filename); - _logger.Trace("NZB Download succeeded, saved to: {0}", filename); - - return null; - } - - public bool IsConfigured - { - get - { - return !string.IsNullOrWhiteSpace(_configService.BlackholeFolder); - } - } - - public IEnumerable<QueueItem> GetQueue() - { - return new QueueItem[0]; - } - - public IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) - { - return new HistoryItem[0]; - } - - public void RemoveFromQueue(string id) - { - } - - public void RemoveFromHistory(string id) - { - } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/FolderSettings.cs b/src/NzbDrone.Core/Download/Clients/FolderSettings.cs new file mode 100644 index 000000000..f11169203 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/FolderSettings.cs @@ -0,0 +1,29 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients +{ + public class FolderSettingsValidator : AbstractValidator<FolderSettings> + { + public FolderSettingsValidator() + { + RuleFor(c => c.Folder).NotEmpty(); + } + } + + public class FolderSettings : IProviderConfig + { + private static readonly FolderSettingsValidator Validator = new FolderSettingsValidator(); + + [FieldDefinition(0, Label = "Folder", Type = FieldType.Path)] + public String Folder { get; set; } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueue.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueue.cs index 86cd09843..f7ec8a1be 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueue.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueue.cs @@ -4,11 +4,11 @@ using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Nzbget { - public class NzbGetQueue + public class NzbgetQueue { public String Version { get; set; } [JsonProperty(PropertyName = "result")] - public List<NzbGetQueueItem> QueueItems { get; set; } + public List<NzbgetQueueItem> QueueItems { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueueItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueueItem.cs index 8e2de535d..39bc8eb51 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueueItem.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetQueueItem.cs @@ -2,14 +2,11 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { - public class NzbGetQueueItem + public class NzbgetQueueItem { private string _nzbName; - public Int32 NzbId { get; set; } - public string NzbName { get; set; } - public String Category { get; set; } public Int32 FileSizeMb { get; set; } public Int32 RemainingSizeMb { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs new file mode 100644 index 000000000..6133a631f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using Omu.ValueInjecter; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class Nzbget : DownloadClientBase<NzbgetSettings>, IExecute<TestNzbgetCommand> + { + private readonly INzbgetProxy _proxy; + private readonly IParsingService _parsingService; + private readonly Logger _logger; + + public Nzbget(INzbgetProxy proxy, + IParsingService parsingService, + Logger logger) + { + _proxy = proxy; + _parsingService = parsingService; + _logger = logger; + } + + public override string DownloadNzb(RemoteEpisode remoteEpisode) + { + var url = remoteEpisode.Release.DownloadUrl; + var title = remoteEpisode.Release.Title + ".nzb"; + + string cat = Settings.TvCategory; + int priority = remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; + + _logger.Info("Adding report [{0}] to the queue.", title); + + var success = _proxy.AddNzb(Settings, title, cat, priority, false, url); + + _logger.Debug("Queue Response: [{0}]", success); + + return null; + } + + public override IEnumerable<QueueItem> GetQueue() + { + var items = _proxy.GetQueue(Settings); + + foreach (var nzbGetQueueItem in items) + { + var queueItem = new QueueItem(); + queueItem.Id = nzbGetQueueItem.NzbId.ToString(); + queueItem.Title = nzbGetQueueItem.NzbName; + queueItem.Size = nzbGetQueueItem.FileSizeMb; + queueItem.Sizeleft = nzbGetQueueItem.RemainingSizeMb; + queueItem.Status = nzbGetQueueItem.FileSizeMb == nzbGetQueueItem.PausedSizeMb ? "paused" : "queued"; + + var parsedEpisodeInfo = Parser.Parser.ParseTitle(queueItem.Title); + if (parsedEpisodeInfo == null) continue; + + var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0); + if (remoteEpisode.Series == null) continue; + + queueItem.RemoteEpisode = remoteEpisode; + + yield return queueItem; + } + } + + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + { + return new HistoryItem[0]; + } + + public override void RemoveFromQueue(string id) + { + throw new NotImplementedException(); + } + + public override void RemoveFromHistory(string id) + { + throw new NotImplementedException(); + } + + public VersionResponse GetVersion(string host = null, int port = 0, string username = null, string password = null) + { + return _proxy.GetVersion(Settings); + } + + public void Execute(TestNzbgetCommand message) + { + var settings = new NzbgetSettings(); + settings.InjectFrom(message); + + _proxy.GetVersion(settings); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs deleted file mode 100644 index 5431d0355..000000000 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetClient.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using NLog; -using NzbDrone.Common; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Download.Clients.Nzbget -{ - public class NzbgetClient : IDownloadClient - { - private readonly IConfigService _configService; - private readonly IHttpProvider _httpProvider; - private readonly INzbGetCommunicationProxy _proxy; - private readonly IParsingService _parsingService; - private readonly Logger _logger; - - public NzbgetClient(IConfigService configService, - IHttpProvider httpProvider, - INzbGetCommunicationProxy proxy, - IParsingService parsingService, - Logger logger) - { - _configService = configService; - _httpProvider = httpProvider; - _proxy = proxy; - _parsingService = parsingService; - _logger = logger; - } - - public string DownloadNzb(RemoteEpisode remoteEpisode) - { - var url = remoteEpisode.Release.DownloadUrl; - var title = remoteEpisode.Release.Title + ".nzb"; - - string cat = _configService.NzbgetTvCategory; - int priority = remoteEpisode.IsRecentEpisode() ? (int)_configService.NzbgetRecentTvPriority : (int)_configService.NzbgetOlderTvPriority; - - _logger.Info("Adding report [{0}] to the queue.", title); - - var success = _proxy.AddNzb(title, cat, priority, false, url); - - _logger.Debug("Queue Response: [{0}]", success); - - return null; - } - - public bool IsConfigured - { - get - { - return !string.IsNullOrWhiteSpace(_configService.NzbgetHost) && _configService.NzbgetPort != 0; - } - } - - public virtual IEnumerable<QueueItem> GetQueue() - { - var items = _proxy.GetQueue(); - - foreach (var nzbGetQueueItem in items) - { - var queueItem = new QueueItem(); - queueItem.Id = nzbGetQueueItem.NzbId.ToString(); - queueItem.Title = nzbGetQueueItem.NzbName; - queueItem.Size = nzbGetQueueItem.FileSizeMb; - queueItem.Sizeleft = nzbGetQueueItem.RemainingSizeMb; - queueItem.Status = nzbGetQueueItem.FileSizeMb == nzbGetQueueItem.PausedSizeMb ? "paused" : "queued"; - - var parsedEpisodeInfo = Parser.Parser.ParseTitle(queueItem.Title); - if (parsedEpisodeInfo == null) continue; - - var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0); - if (remoteEpisode.Series == null) continue; - - queueItem.RemoteEpisode = remoteEpisode; - - yield return queueItem; - } - } - - public IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) - { - return new HistoryItem[0]; - } - - public void RemoveFromQueue(string id) - { - throw new NotImplementedException(); - } - - public void RemoveFromHistory(string id) - { - throw new NotImplementedException(); - } - - public virtual VersionModel GetVersion(string host = null, int port = 0, string username = null, string password = null) - { - throw new NotImplementedException(); - - //Get saved values if any of these are defaults - if (host == null) - host = _configService.NzbgetHost; - - if (port == 0) - port = _configService.NzbgetPort; - - if (username == null) - username = _configService.NzbgetUsername; - - if (password == null) - password = _configService.NzbgetPassword; - - - var response = _proxy.GetVersion(); - - return Json.Deserialize<VersionModel>(response); - } - - public virtual string Test(string host, int port, string username, string password) - { - try - { - var version = GetVersion(host, port, username, password); - return version.Result; - } - catch (Exception ex) - { - _logger.DebugException("Failed to Test Nzbget", ex); - } - - return String.Empty; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/PriorityType.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs similarity index 84% rename from src/NzbDrone.Core/Download/Clients/Nzbget/PriorityType.cs rename to src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs index 7235f375a..c7e121805 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/PriorityType.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs @@ -1,6 +1,6 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { - public enum PriorityType + public enum NzbgetPriority { VeryLow = -100, Low = -50, diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetCommunicationProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs similarity index 60% rename from src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetCommunicationProxy.cs rename to src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 1b733ad69..0376dfad9 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbGetCommunicationProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -1,57 +1,52 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using NLog; using NzbDrone.Common.Serializer; -using NzbDrone.Core.Configuration; using NzbDrone.Core.Rest; using RestSharp; namespace NzbDrone.Core.Download.Clients.Nzbget { - public interface INzbGetCommunicationProxy + public interface INzbgetProxy { - bool AddNzb(params object[] parameters); - List<NzbGetQueueItem> GetQueue(); - string GetVersion(); + bool AddNzb(NzbgetSettings settings, params object[] parameters); + List<NzbgetQueueItem> GetQueue(NzbgetSettings settings); + VersionResponse GetVersion(NzbgetSettings settings); } - public class NzbGetCommunicationProxy : INzbGetCommunicationProxy + public class NzbgetProxy : INzbgetProxy { - private readonly IConfigService _configService; private readonly Logger _logger; - public NzbGetCommunicationProxy(IConfigService configService, Logger logger) + public NzbgetProxy(Logger logger) { - _configService = configService; _logger = logger; } - public bool AddNzb(params object[] parameters) + public bool AddNzb(NzbgetSettings settings, params object[] parameters) { var request = BuildRequest(new JsonRequest("appendurl", parameters)); - return Json.Deserialize<EnqueueResponse>(ProcessRequest(request)).Result; + return Json.Deserialize<EnqueueResponse>(ProcessRequest(request, settings)).Result; } - public List<NzbGetQueueItem> GetQueue() + public List<NzbgetQueueItem> GetQueue(NzbgetSettings settings) { - var request = BuildRequest(new JsonRequest("listgroups")); + var request = BuildRequest(new JsonRequest("listgroups")); - return Json.Deserialize<NzbGetQueue>(ProcessRequest(request)).QueueItems; + return Json.Deserialize<NzbgetQueue>(ProcessRequest(request, settings)).QueueItems; } - public string GetVersion() + public VersionResponse GetVersion(NzbgetSettings settings) { var request = BuildRequest(new JsonRequest("version")); - return ProcessRequest(request); + return Json.Deserialize<VersionResponse>(ProcessRequest(request, settings)); } - private string ProcessRequest(IRestRequest restRequest) + private string ProcessRequest(IRestRequest restRequest, NzbgetSettings settings) { - var client = BuildClient(); + var client = BuildClient(settings); var response = client.Execute(restRequest); _logger.Trace("Response: {0}", response.Content); @@ -60,14 +55,14 @@ namespace NzbDrone.Core.Download.Clients.Nzbget return response.Content; } - private IRestClient BuildClient() + private IRestClient BuildClient(NzbgetSettings settings) { var url = String.Format("http://{0}:{1}/jsonrpc", - _configService.NzbgetHost, - _configService.NzbgetPort); + settings.Host, + settings.Port); var client = new RestClient(url); - client.Authenticator = new HttpBasicAuthenticator(_configService.NzbgetUsername, _configService.NzbgetPassword); + client.Authenticator = new HttpBasicAuthenticator(settings.Username, settings.Password); return client; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs new file mode 100644 index 000000000..383622cef --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -0,0 +1,59 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetSettingsValidator : AbstractValidator<NzbgetSettings> + { + public NzbgetSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).GreaterThan(0); + RuleFor(c => c.Username).NotEmpty(); + RuleFor(c => c.Password).NotEmpty(); + } + } + + public class NzbgetSettings : IProviderConfig + { + private static readonly NzbgetSettingsValidator Validator = new NzbgetSettingsValidator(); + + public NzbgetSettings() + { + Host = "localhost"; + Port = 6789; + TvCategory = "tv"; + RecentTvPriority = (int)NzbgetPriority.Normal; + OlderTvPriority = (int)NzbgetPriority.Normal; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public String Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public Int32 Port { get; set; } + + [FieldDefinition(2, Label = "Username", Type = FieldType.Textbox)] + public String Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public String Password { get; set; } + + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox)] + public String TvCategory { get; set; } + + [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority))] + public Int32 RecentTvPriority { get; set; } + + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority))] + public Int32 OlderTvPriority { get; set; } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs new file mode 100644 index 000000000..805b4d19a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/TestNzbgetCommand.cs @@ -0,0 +1,21 @@ +using System; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class TestNzbgetCommand : Command + { + public override bool SendUpdatesToClient + { + get + { + return true; + } + } + + public String Host { get; set; } + public Int32 Port { get; set; } + public String Username { get; set; } + public String Password { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/VersionModel.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/VersionResponse.cs similarity index 83% rename from src/NzbDrone.Core/Download/Clients/Nzbget/VersionModel.cs rename to src/NzbDrone.Core/Download/Clients/Nzbget/VersionResponse.cs index 9e7d90064..780fd90ad 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/VersionModel.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/VersionResponse.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { - public class VersionModel + public class VersionResponse { public String Version { get; set; } public String Result { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs similarity index 63% rename from src/NzbDrone.Core/Download/Clients/PneumaticClient.cs rename to src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index 8ebc5b409..3b63d5383 100644 --- a/src/NzbDrone.Core/Download/Clients/PneumaticClient.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -6,12 +6,13 @@ using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Instrumentation; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.Download.Clients +namespace NzbDrone.Core.Download.Clients.Pneumatic { - public class PneumaticClient : IDownloadClient + public class Pneumatic : DownloadClientBase<FolderSettings>, IExecute<TestPneumaticCommand> { private readonly IConfigService _configService; private readonly IHttpProvider _httpProvider; @@ -19,7 +20,7 @@ namespace NzbDrone.Core.Download.Clients private static readonly Logger logger = NzbDroneLogger.GetLogger(); - public PneumaticClient(IConfigService configService, IHttpProvider httpProvider, + public Pneumatic(IConfigService configService, IHttpProvider httpProvider, IDiskProvider diskProvider) { _configService = configService; @@ -27,20 +28,20 @@ namespace NzbDrone.Core.Download.Clients _diskProvider = diskProvider; } - public string DownloadNzb(RemoteEpisode remoteEpisode) + public override string DownloadNzb(RemoteEpisode remoteEpisode) { var url = remoteEpisode.Release.DownloadUrl; var title = remoteEpisode.Release.Title; if (remoteEpisode.ParsedEpisodeInfo.FullSeason) { - throw new NotImplementedException("Full season Pneumatic releases are not supported."); + throw new NotImplementedException("Full season releases are not supported with Pneumatic."); } title = FileNameBuilder.CleanFilename(title); //Save to the Pneumatic directory (The user will need to ensure its accessible by XBMC) - var filename = Path.Combine(_configService.PneumaticFolder, title + ".nzb"); + var filename = Path.Combine(Settings.Folder, title + ".nzb"); logger.Trace("Downloading NZB from: {0} to: {1}", url, filename); _httpProvider.DownloadFile(url, filename); @@ -57,31 +58,33 @@ namespace NzbDrone.Core.Download.Clients { get { - return !string.IsNullOrWhiteSpace(_configService.PneumaticFolder); + return !string.IsNullOrWhiteSpace(Settings.Folder); } } - public IEnumerable<QueueItem> GetQueue() + public override IEnumerable<QueueItem> GetQueue() { return new QueueItem[0]; } - public IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) { return new HistoryItem[0]; } - public void RemoveFromQueue(string id) + public override void RemoveFromQueue(string id) { } - public void RemoveFromHistory(string id) + public override void RemoveFromHistory(string id) { } - public virtual bool IsInQueue(RemoteEpisode newEpisode) + public void Execute(TestPneumaticCommand message) { - return false; + var testPath = Path.Combine(message.Folder, "drone_test.txt"); + _diskProvider.WriteAllText(testPath, DateTime.Now.ToString()); + _diskProvider.DeleteFile(testPath); } } } diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs new file mode 100644 index 000000000..097a39aa5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/TestPneumaticCommand.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download.Clients.Pneumatic +{ + public class TestPneumaticCommand : Command + { + public override bool SendUpdatesToClient + { + get + { + return true; + } + } + + public String Folder { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/ConnectionInfoModel.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/ConnectionInfoModel.cs deleted file mode 100644 index cb03c1953..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/ConnectionInfoModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class ConnectionInfoModel - { - public string Address { get; set; } - public int Port { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs index e5c5f8b74..17557abcc 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters { public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - var priorityType = (SabPriorityType)value; + var priorityType = (SabnzbdPriority)value; writer.WriteValue(priorityType.ToString()); } @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters { var queuePriority = reader.Value.ToString(); - SabPriorityType output; + SabnzbdPriority output; Enum.TryParse(queuePriority, out output); return output; @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters public override bool CanConvert(Type objectType) { - return objectType == typeof(SabPriorityType); + return objectType == typeof(SabnzbdPriority); } } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs new file mode 100644 index 000000000..147bfce68 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdAddResponse + { + public SabnzbdAddResponse() + { + Ids = new List<string>(); + } + + public bool Status { get; set; } + + [JsonProperty(PropertyName = "nzo_ids")] + public List<string> Ids { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs new file mode 100644 index 000000000..03d71bee5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdCategoryResponse + { + public SabnzbdCategoryResponse() + { + Categories = new List<String>(); + } + + public List<String> Categories { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs new file mode 100644 index 000000000..fd281a58f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdVersionResponse + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAddResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAddResponse.cs deleted file mode 100644 index 040b2b2b4..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAddResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabAddResponse - { - public SabAddResponse() - { - Ids = new List<String>(); - } - - public bool Status { get; set; } - - [JsonProperty(PropertyName = "nzo_ids")] - public List<String> Ids { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAutoConfigureService.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAutoConfigureService.cs deleted file mode 100644 index 224cae5c5..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabAutoConfigureService.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Text.RegularExpressions; -using NLog; -using NzbDrone.Common.Instrumentation; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabAutoConfigureService - { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(); - - public SabModel AutoConfigureSab() - { - var info = GetConnectionList(); - return FindApiKey(info); - } - - private List<ConnectionInfoModel> GetConnectionList() - { - IPGlobalProperties ipProperties = IPGlobalProperties.GetIPGlobalProperties(); - var info = - ipProperties.GetActiveTcpListeners().Select( - p => - new ConnectionInfoModel { Address = p.Address.ToString().Replace("0.0.0.0", "127.0.0.1"), Port = p.Port }).Distinct(). - ToList(); - - info.RemoveAll(i => i.Port == 135); - info.RemoveAll(i => i.Port == 139); - info.RemoveAll(i => i.Port == 445); - info.RemoveAll(i => i.Port == 3389); - info.RemoveAll(i => i.Port == 5900); - info.RemoveAll(i => i.Address.Contains("::")); - - info.Reverse(); - - return info; - } - - private SabModel FindApiKey(List<ConnectionInfoModel> info) - { - foreach (var connection in info) - { - var apiKey = GetApiKey(connection.Address, connection.Port); - if (!String.IsNullOrEmpty(apiKey)) - return new SabModel - { - Host = connection.Address, - Port = connection.Port, - ApiKey = apiKey - }; - } - return null; - } - - private string GetApiKey(string ipAddress, int port) - { - var request = String.Format("http://{0}:{1}/config/general/", ipAddress, port); - var result = DownloadString(request); - - Regex regex = - new Regex("\\<input\\Wtype\\=\\\"text\\\"\\Wid\\=\\\"apikey\\\"\\Wvalue\\=\\\"(?<apikey>\\w+)\\W", - RegexOptions.IgnoreCase - | RegexOptions.Compiled); - var match = regex.Match(result); - - if (match.Success) - { - return match.Groups["apikey"].Value; - } - - return String.Empty; - } - - private string DownloadString(string url) - { - try - { - var request = WebRequest.Create(url); - request.Timeout = 2000; - - var response = request.GetResponse(); - - var reader = new StreamReader(response.GetResponseStream()); - return reader.ReadToEnd(); - } - catch (Exception ex) - { - Logger.Trace("Failed to get response from: {0}", url); - Logger.Trace(ex.Message, ex); - } - - return String.Empty; - } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCategoryModel.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCategoryModel.cs deleted file mode 100644 index 83d7b3e03..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCategoryModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabCategoryModel - { - public List<string> categories { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCommunicationProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCommunicationProxy.cs deleted file mode 100644 index 8e181a25e..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabCommunicationProxy.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.IO; -using NLog; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Configuration; -using RestSharp; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public interface ISabCommunicationProxy - { - SabAddResponse DownloadNzb(Stream nzb, string name, string category, int priority); - void RemoveFrom(string source, string id); - string ProcessRequest(IRestRequest restRequest, string action); - } - - public class SabCommunicationProxy : ISabCommunicationProxy - { - private readonly IConfigService _configService; - private readonly Logger _logger; - - public SabCommunicationProxy(IConfigService configService, Logger logger) - { - _configService = configService; - _logger = logger; - } - - public SabAddResponse DownloadNzb(Stream nzb, string title, string category, int priority) - { - var request = new RestRequest(Method.POST); - var action = String.Format("mode=addfile&cat={0}&priority={1}", category, priority); - - request.AddFile("name", ReadFully(nzb), title, "application/x-nzb"); - - SabAddResponse response; - - if (!Json.TryDeserialize<SabAddResponse>(ProcessRequest(request, action), out response)) - { - response = new SabAddResponse(); - response.Status = true; - } - - return response; - } - - public void RemoveFrom(string source, string id) - { - var request = new RestRequest(); - var action = String.Format("mode={0}&name=delete&del_files=1&value={1}", source, id); - - ProcessRequest(request, action); - } - - public string ProcessRequest(IRestRequest restRequest, string action) - { - var client = BuildClient(action); - var response = client.Execute(restRequest); - _logger.Trace("Response: {0}", response.Content); - - CheckForError(response); - - return response.Content; - } - - private IRestClient BuildClient(string action) - { - var protocol = _configService.SabUseSsl ? "https" : "http"; - - var url = string.Format(@"{0}://{1}:{2}/api?{3}&apikey={4}&ma_username={5}&ma_password={6}&output=json", - protocol, - _configService.SabHost, - _configService.SabPort, - action, - _configService.SabApiKey, - _configService.SabUsername, - _configService.SabPassword); - - _logger.Trace(url); - - return new RestClient(url); - } - - private void CheckForError(IRestResponse response) - { - if (response.ResponseStatus != ResponseStatus.Completed) - { - throw new ApplicationException("Unable to connect to SABnzbd, please check your settings"); - } - - SabJsonError result; - - if (!Json.TryDeserialize<SabJsonError>(response.Content, out result)) - { - //Handle plain text responses from SAB - result = new SabJsonError(); - - if (response.Content.StartsWith("error", StringComparison.InvariantCultureIgnoreCase)) - { - result.Status = "false"; - result.Error = response.Content.Replace("error: ", ""); - } - - else - { - result.Status = "true"; - } - - result.Error = response.Content.Replace("error: ", ""); - } - - if (result.Failed) - throw new ApplicationException(result.Error); - } - - //TODO: Find a better home for this - private byte[] ReadFully(Stream input) - { - byte[] buffer = new byte[16 * 1024]; - using (MemoryStream ms = new MemoryStream()) - { - int read; - while ((read = input.Read(buffer, 0, buffer.Length)) > 0) - { - ms.Write(buffer, 0, read); - } - return ms.ToArray(); - } - } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabModel.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabModel.cs deleted file mode 100644 index 158535065..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabModel - { - public string Host { get; set; } - public int Port { get; set; } - public string ApiKey { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabVersionModel.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabVersionModel.cs deleted file mode 100644 index 9d8cad8fd..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabVersionModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabVersionModel - { - public string Version { get; set; } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs new file mode 100644 index 000000000..c9c7ef395 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using Omu.ValueInjecter; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class Sabnzbd : DownloadClientBase<SabnzbdSettings>, IExecute<TestSabnzbdCommand> + { + private readonly IHttpProvider _httpProvider; + private readonly IParsingService _parsingService; + private readonly ISabnzbdProxy _sabnzbdProxy; + private readonly ICached<IEnumerable<QueueItem>> _queueCache; + private readonly Logger _logger; + + public Sabnzbd(IHttpProvider httpProvider, + ICacheManger cacheManger, + IParsingService parsingService, + ISabnzbdProxy sabnzbdProxy, + Logger logger) + { + _httpProvider = httpProvider; + _parsingService = parsingService; + _sabnzbdProxy = sabnzbdProxy; + _queueCache = cacheManger.GetCache<IEnumerable<QueueItem>>(GetType(), "queue"); + _logger = logger; + } + + public override string DownloadNzb(RemoteEpisode remoteEpisode) + { + var url = remoteEpisode.Release.DownloadUrl; + var title = remoteEpisode.Release.Title; + var category = Settings.TvCategory; + var priority = remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; + + using (var nzb = _httpProvider.DownloadStream(url)) + { + _logger.Info("Adding report [{0}] to the queue.", title); + var response = _sabnzbdProxy.DownloadNzb(nzb, title, category, priority, Settings); + + if (response != null && response.Ids.Any()) + { + return response.Ids.First(); + } + + return null; + } + } + + public override IEnumerable<QueueItem> GetQueue() + { + return _queueCache.Get("queue", () => + { + var sabQueue = _sabnzbdProxy.GetQueue(0, 0, Settings).Items; + + var queueItems = new List<QueueItem>(); + + foreach (var sabQueueItem in sabQueue) + { + var queueItem = new QueueItem(); + queueItem.Id = sabQueueItem.Id; + queueItem.Title = sabQueueItem.Title; + queueItem.Size = sabQueueItem.Size; + queueItem.Sizeleft = sabQueueItem.Sizeleft; + queueItem.Timeleft = sabQueueItem.Timeleft; + queueItem.Status = sabQueueItem.Status; + + var parsedEpisodeInfo = Parser.Parser.ParseTitle(queueItem.Title.Replace("ENCRYPTED / ", "")); + if (parsedEpisodeInfo == null) continue; + + var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0); + if (remoteEpisode.Series == null) continue; + + queueItem.RemoteEpisode = remoteEpisode; + + queueItems.Add(queueItem); + } + + return queueItems; + }, TimeSpan.FromSeconds(10)); + } + + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + { + var items = _sabnzbdProxy.GetHistory(start, limit, Settings).Items; + var historyItems = new List<HistoryItem>(); + + foreach (var sabHistoryItem in items) + { + var historyItem = new HistoryItem(); + historyItem.Id = sabHistoryItem.Id; + historyItem.Title = sabHistoryItem.Title; + historyItem.Size = sabHistoryItem.Size; + historyItem.DownloadTime = sabHistoryItem.DownloadTime; + historyItem.Storage = sabHistoryItem.Storage; + historyItem.Category = sabHistoryItem.Category; + historyItem.Message = sabHistoryItem.FailMessage; + historyItem.Status = sabHistoryItem.Status == "Failed" ? HistoryStatus.Failed : HistoryStatus.Completed; + + historyItems.Add(historyItem); + } + + return historyItems; + } + + public override void RemoveFromQueue(string id) + { + _sabnzbdProxy.RemoveFrom("queue", id, Settings); + } + + public override void RemoveFromHistory(string id) + { + _sabnzbdProxy.RemoveFrom("history", id, Settings); + } + + public void Execute(TestSabnzbdCommand message) + { + var settings = new SabnzbdSettings(); + settings.InjectFrom(message); + + _sabnzbdProxy.GetVersion(settings); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs deleted file mode 100644 index 5535eb00b..000000000 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdClient.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using NLog; -using NzbDrone.Common; -using NzbDrone.Common.Cache; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Download.Clients.Sabnzbd -{ - public class SabnzbdClient : IDownloadClient - { - private readonly IConfigService _configService; - private readonly IHttpProvider _httpProvider; - private readonly IParsingService _parsingService; - private readonly ISabCommunicationProxy _sabCommunicationProxy; - private readonly ICached<IEnumerable<QueueItem>> _queueCache; - private readonly Logger _logger; - - public SabnzbdClient(IConfigService configService, - IHttpProvider httpProvider, - ICacheManger cacheManger, - IParsingService parsingService, - ISabCommunicationProxy sabCommunicationProxy, - Logger logger) - { - _configService = configService; - _httpProvider = httpProvider; - _parsingService = parsingService; - _sabCommunicationProxy = sabCommunicationProxy; - _queueCache = cacheManger.GetCache<IEnumerable<QueueItem>>(GetType(), "queue"); - _logger = logger; - } - - public bool IsConfigured - { - get - { - return !string.IsNullOrWhiteSpace(_configService.SabHost) - && _configService.SabPort != 0; - } - } - - public string DownloadNzb(RemoteEpisode remoteEpisode) - { - var url = remoteEpisode.Release.DownloadUrl; - var title = remoteEpisode.Release.Title; - var category = _configService.SabTvCategory; - var priority = remoteEpisode.IsRecentEpisode() ? (int)_configService.SabRecentTvPriority : (int)_configService.SabOlderTvPriority; - - using (var nzb = _httpProvider.DownloadStream(url)) - { - _logger.Info("Adding report [{0}] to the queue.", title); - var response = _sabCommunicationProxy.DownloadNzb(nzb, title, category, priority); - - if (response != null && response.Ids.Any()) - { - return response.Ids.First(); - } - - return null; - } - } - - public IEnumerable<QueueItem> GetQueue() - { - return _queueCache.Get("queue", () => - { - string action = String.Format("mode=queue&output=json&start={0}&limit={1}", 0, 0); - string request = GetSabRequest(action); - string response = _httpProvider.DownloadString(request); - - CheckForError(response); - - var sabQueue = Json.Deserialize<SabQueue>(JObject.Parse(response).SelectToken("queue").ToString()).Items; - - var queueItems = new List<QueueItem>(); - - foreach (var sabQueueItem in sabQueue) - { - var queueItem = new QueueItem(); - queueItem.Id = sabQueueItem.Id; - queueItem.Title = sabQueueItem.Title; - queueItem.Size = sabQueueItem.Size; - queueItem.Sizeleft = sabQueueItem.Sizeleft; - queueItem.Timeleft = sabQueueItem.Timeleft; - queueItem.Status = sabQueueItem.Status; - - var parsedEpisodeInfo = Parser.Parser.ParseTitle(queueItem.Title.Replace("ENCRYPTED / ", "")); - if (parsedEpisodeInfo == null) continue; - - var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0); - if (remoteEpisode.Series == null) continue; - - queueItem.RemoteEpisode = remoteEpisode; - - queueItems.Add(queueItem); - } - - return queueItems; - }, TimeSpan.FromSeconds(10)); - } - - public IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) - { - string action = String.Format("mode=history&output=json&start={0}&limit={1}", start, limit); - string request = GetSabRequest(action); - string response = _httpProvider.DownloadString(request); - - CheckForError(response); - - var items = Json.Deserialize<SabHistory>(JObject.Parse(response).SelectToken("history").ToString()).Items; - var historyItems = new List<HistoryItem>(); - - foreach (var sabHistoryItem in items) - { - var historyItem = new HistoryItem(); - historyItem.Id = sabHistoryItem.Id; - historyItem.Title = sabHistoryItem.Title; - historyItem.Size = sabHistoryItem.Size; - historyItem.DownloadTime = sabHistoryItem.DownloadTime; - historyItem.Storage = sabHistoryItem.Storage; - historyItem.Category = sabHistoryItem.Category; - historyItem.Message = sabHistoryItem.FailMessage; - historyItem.Status = sabHistoryItem.Status == "Failed" ? HistoryStatus.Failed : HistoryStatus.Completed; - - historyItems.Add(historyItem); - } - - return historyItems; - } - - public void RemoveFromQueue(string id) - { - _sabCommunicationProxy.RemoveFrom("queue", id); - } - - public void RemoveFromHistory(string id) - { - _sabCommunicationProxy.RemoveFrom("history", id); - } - - public virtual SabCategoryModel GetCategories(string host = null, int port = 0, string apiKey = null, string username = null, string password = null) - { - //Get saved values if any of these are defaults - if (host == null) - host = _configService.SabHost; - - if (port == 0) - port = _configService.SabPort; - - if (apiKey == null) - apiKey = _configService.SabApiKey; - - if (username == null) - username = _configService.SabUsername; - - if (password == null) - password = _configService.SabPassword; - - const string action = "mode=get_cats&output=json"; - - var command = string.Format(@"http://{0}:{1}/api?{2}&apikey={3}&ma_username={4}&ma_password={5}", - host, port, action, apiKey, username, password); - - var response = _httpProvider.DownloadString(command); - - if (String.IsNullOrWhiteSpace(response)) - return new SabCategoryModel { categories = new List<string>() }; - - var categories = Json.Deserialize<SabCategoryModel>(response); - - return categories; - } - - public virtual SabVersionModel GetVersion(string host = null, int port = 0, string apiKey = null, string username = null, string password = null) - { - //Get saved values if any of these are defaults - if (host == null) - host = _configService.SabHost; - - if (port == 0) - port = _configService.SabPort; - - if (apiKey == null) - apiKey = _configService.SabApiKey; - - if (username == null) - username = _configService.SabUsername; - - if (password == null) - password = _configService.SabPassword; - - const string action = "mode=version&output=json"; - - var command = string.Format(@"http://{0}:{1}/api?{2}&apikey={3}&ma_username={4}&ma_password={5}", - host, port, action, apiKey, username, password); - - var response = _httpProvider.DownloadString(command); - - if (String.IsNullOrWhiteSpace(response)) - return null; - - var version = Json.Deserialize<SabVersionModel>(response); - - return version; - } - - public virtual string Test(string host, int port, string apiKey, string username, string password) - { - try - { - var version = GetVersion(host, port, apiKey, username, password); - return version.Version; - } - catch (Exception ex) - { - _logger.DebugException("Failed to Test SABnzbd", ex); - } - - return String.Empty; - } - - private string GetSabRequest(string action) - { - var protocol = _configService.SabUseSsl ? "https" : "http"; - - return string.Format(@"{0}://{1}:{2}/api?{3}&apikey={4}&ma_username={5}&ma_password={6}", - protocol, - _configService.SabHost, - _configService.SabPort, - action, - _configService.SabApiKey, - _configService.SabUsername, - _configService.SabPassword); - } - - private void CheckForError(string response) - { - var result = Json.Deserialize<SabJsonError>(response); - - if (result.Failed) - throw new ApplicationException(result.Error); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueue.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs similarity index 70% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueue.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs index d19fa608c..b19786739 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueue.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs @@ -3,11 +3,11 @@ using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class SabQueue + public class SabnzbdHistory { public bool Paused { get; set; } [JsonProperty(PropertyName = "slots")] - public List<SabQueueItem> Items { get; set; } + public List<SabnzbdHistoryItem> Items { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistoryItem.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs similarity index 95% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistoryItem.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs index fa94cbc2f..166b25c94 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistoryItem.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class SabHistoryItem + public class SabnzbdHistoryItem { [JsonProperty(PropertyName = "fail_message")] public string FailMessage { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabJsonError.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs similarity index 92% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabJsonError.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs index 8ad40b398..853c7e104 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabJsonError.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class SabJsonError + public class SabnzbdJsonError { public string Status { get; set; } public string Error { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabPriorityType.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs similarity index 84% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabPriorityType.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs index d16be5f2f..b769a78db 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabPriorityType.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs @@ -1,6 +1,6 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public enum SabPriorityType + public enum SabnzbdPriority { Default = -100, Paused = -2, diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs new file mode 100644 index 000000000..59fac17dd --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; +using RestSharp; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public interface ISabnzbdProxy + { + SabnzbdAddResponse DownloadNzb(Stream nzb, string name, string category, int priority, SabnzbdSettings settings); + void RemoveFrom(string source, string id, SabnzbdSettings settings); + string ProcessRequest(IRestRequest restRequest, string action, SabnzbdSettings settings); + SabnzbdVersionResponse GetVersion(SabnzbdSettings settings); + SabnzbdCategoryResponse GetCategories(SabnzbdSettings settings); + SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings); + SabnzbdHistory GetHistory(int start, int limit, SabnzbdSettings settings); + } + + public class SabnzbdProxy : ISabnzbdProxy + { + private readonly Logger _logger; + + public SabnzbdProxy(Logger logger) + { + _logger = logger; + } + + public SabnzbdAddResponse DownloadNzb(Stream nzb, string title, string category, int priority, SabnzbdSettings settings) + { + var request = new RestRequest(Method.POST); + var action = String.Format("mode=addfile&cat={0}&priority={1}", category, priority); + + request.AddFile("name", ReadFully(nzb), title, "application/x-nzb"); + + SabnzbdAddResponse response; + + if (!Json.TryDeserialize<SabnzbdAddResponse>(ProcessRequest(request, action, settings), out response)) + { + response = new SabnzbdAddResponse(); + response.Status = true; + } + + return response; + } + + public void RemoveFrom(string source, string id, SabnzbdSettings settings) + { + var request = new RestRequest(); + var action = String.Format("mode={0}&name=delete&del_files=1&value={1}", source, id); + + ProcessRequest(request, action, settings); + } + + public string ProcessRequest(IRestRequest restRequest, string action, SabnzbdSettings settings) + { + var client = BuildClient(action, settings); + var response = client.Execute(restRequest); + _logger.Trace("Response: {0}", response.Content); + + CheckForError(response); + + return response.Content; + } + + public SabnzbdVersionResponse GetVersion(SabnzbdSettings settings) + { + var request = new RestRequest(); + var action = "mode=version"; + + SabnzbdVersionResponse response; + + if (!Json.TryDeserialize<SabnzbdVersionResponse>(ProcessRequest(request, action, settings), out response)) + { + response = new SabnzbdVersionResponse(); + } + + return response; + } + + public SabnzbdCategoryResponse GetCategories(SabnzbdSettings settings) + { + var request = new RestRequest(); + var action = "mode=get_cats"; + + SabnzbdCategoryResponse response; + + if (!Json.TryDeserialize<SabnzbdCategoryResponse>(ProcessRequest(request, action, settings), out response)) + { + response = new SabnzbdCategoryResponse(); + } + + return response; + } + + public SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings) + { + var request = new RestRequest(); + var action = String.Format("mode=queue&start={0}&limit={1}", start, limit); + + var response = ProcessRequest(request, action, settings); + return Json.Deserialize<SabnzbdQueue>(JObject.Parse(response).SelectToken("queue").ToString()); + + } + + public SabnzbdHistory GetHistory(int start, int limit, SabnzbdSettings settings) + { + var request = new RestRequest(); + var action = String.Format("mode=queue&start={0}&limit={1}", start, limit); + + var response = ProcessRequest(request, action, settings); + return Json.Deserialize<SabnzbdHistory>(JObject.Parse(response).SelectToken("history").ToString()); + } + + private IRestClient BuildClient(string action, SabnzbdSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + + var url = string.Format(@"{0}://{1}:{2}/api?{3}&apikey={4}&ma_username={5}&ma_password={6}&output=json", + protocol, + settings.Host, + settings.Port, + action, + settings.ApiKey, + settings.Username, + settings.Password); + + _logger.Trace(url); + + return new RestClient(url); + } + + private void CheckForError(IRestResponse response) + { + if (response.ResponseStatus != ResponseStatus.Completed) + { + throw new ApplicationException("Unable to connect to SABnzbd, please check your settings"); + } + + SabnzbdJsonError result; + + if (!Json.TryDeserialize<SabnzbdJsonError>(response.Content, out result)) + { + //Handle plain text responses from SAB + result = new SabnzbdJsonError(); + + if (response.Content.StartsWith("error", StringComparison.InvariantCultureIgnoreCase)) + { + result.Status = "false"; + result.Error = response.Content.Replace("error: ", ""); + } + + else + { + result.Status = "true"; + } + + result.Error = response.Content.Replace("error: ", ""); + } + + if (result.Failed) + throw new ApplicationException(result.Error); + } + + //TODO: Find a better home for this + private byte[] ReadFully(Stream input) + { + byte[] buffer = new byte[16 * 1024]; + using (MemoryStream ms = new MemoryStream()) + { + int read; + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + { + ms.Write(buffer, 0, read); + } + return ms.ToArray(); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs similarity index 70% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistory.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs index f90a2d1ce..edbdab5da 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabHistory.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs @@ -3,11 +3,11 @@ using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class SabHistory + public class SabnzbdQueue { public bool Paused { get; set; } [JsonProperty(PropertyName = "slots")] - public List<SabHistoryItem> Items { get; set; } + public List<SabnzbdQueueItem> Items { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueueItem.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs similarity index 91% rename from src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueueItem.cs rename to src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs index bc233eb84..a3a74452f 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabQueueItem.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs @@ -4,7 +4,7 @@ using NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters; namespace NzbDrone.Core.Download.Clients.Sabnzbd { - public class SabQueueItem + public class SabnzbdQueueItem { public string Status { get; set; } public int Index { get; set; } @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd public string Title { get; set; } [JsonConverter(typeof(SabnzbdPriorityTypeConverter))] - public SabPriorityType Priority { get; set; } + public SabnzbdPriority Priority { get; set; } [JsonProperty(PropertyName = "cat")] public string Category { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs new file mode 100644 index 000000000..a6373b379 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -0,0 +1,66 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Download.Clients.Nzbget; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdSettingsValidator : AbstractValidator<SabnzbdSettings> + { + public SabnzbdSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).GreaterThan(0); + + //Todo: either API key or Username/Password needs to be valid + } + } + + public class SabnzbdSettings : IProviderConfig + { + private static readonly SabnzbdSettingsValidator Validator = new SabnzbdSettingsValidator(); + + public SabnzbdSettings() + { + Host = "localhost"; + Port = 8080; + TvCategory = "tv"; + RecentTvPriority = (int)SabnzbdPriority.Default; + OlderTvPriority = (int)SabnzbdPriority.Default; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public String Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public Int32 Port { get; set; } + + [FieldDefinition(2, Label = "API Key", Type = FieldType.Textbox)] + public String ApiKey { get; set; } + + [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox)] + public String Username { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] + public String Password { get; set; } + + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox)] + public String TvCategory { get; set; } + + [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority))] + public Int32 RecentTvPriority { get; set; } + + [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority))] + public Int32 OlderTvPriority { get; set; } + + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] + public Boolean UseSsl { get; set; } + + public ValidationResult Validate() + { + return Validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs new file mode 100644 index 000000000..2c1d2eb9d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/TestSabnzbdCommand.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class TestSabnzbdCommand : Command + { + public override bool SendUpdatesToClient + { + get + { + return true; + } + } + + public String Host { get; set; } + public Int32 Port { get; set; } + public String ApiKey { get; set; } + public String Username { get; set; } + public String Password { get; set; } + public Boolean UseSsl { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs new file mode 100644 index 000000000..b86eda48f --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public abstract class DownloadClientBase<TSettings> : IDownloadClient where TSettings : IProviderConfig, new() + { + public Type ConfigContract + { + get + { + return typeof(TSettings); + } + } + + public IEnumerable<ProviderDefinition> DefaultDefinitions + { + get + { + return new List<ProviderDefinition>(); + } + } + + public ProviderDefinition Definition { get; set; } + + protected TSettings Settings + { + get + { + return (TSettings)Definition.Settings; + } + } + + public override string ToString() + { + return GetType().Name; + } + + public abstract string DownloadNzb(RemoteEpisode remoteEpisode); + public abstract IEnumerable<QueueItem> GetQueue(); + public abstract IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0); + public abstract void RemoveFromQueue(string id); + public abstract void RemoveFromHistory(string id); + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs new file mode 100644 index 000000000..479d10925 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs @@ -0,0 +1,12 @@ +using System; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public class DownloadClientDefinition : ProviderDefinition + { + public Boolean Enable { get; set; } + public DownloadProtocol Protocol { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs new file mode 100644 index 000000000..07c56096e --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Composition; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientFactory : IProviderFactory<IDownloadClient, DownloadClientDefinition> + { + List<IDownloadClient> Enabled(); + } + + public class DownloadClientFactory : ProviderFactory<IDownloadClient, DownloadClientDefinition>, IDownloadClientFactory + { + private readonly IDownloadClientRepository _providerRepository; + + public DownloadClientFactory(IDownloadClientRepository providerRepository, IEnumerable<IDownloadClient> providers, IContainer container, Logger logger) + : base(providerRepository, providers, container, logger) + { + _providerRepository = providerRepository; + } + + public List<IDownloadClient> Enabled() + { + return GetAvailableProviders().Where(n => ((DownloadClientDefinition)n.Definition).Enable).ToList(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 12f9260b8..8a220d8b0 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -1,4 +1,6 @@ -using NzbDrone.Core.Configuration; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.Download.Clients.Sabnzbd; @@ -12,42 +14,16 @@ namespace NzbDrone.Core.Download public class DownloadClientProvider : IProvideDownloadClient { + private readonly IDownloadClientFactory _downloadClientFactory; - private readonly SabnzbdClient _sabnzbdClient; - private readonly IConfigService _configService; - private readonly BlackholeProvider _blackholeProvider; - private readonly PneumaticClient _pneumaticClient; - private readonly NzbgetClient _nzbgetClient; - - - public DownloadClientProvider(SabnzbdClient sabnzbdClient, IConfigService configService, - BlackholeProvider blackholeProvider, - PneumaticClient pneumaticClient, - NzbgetClient nzbgetClient) + public DownloadClientProvider(IDownloadClientFactory downloadClientFactory) { - _sabnzbdClient = sabnzbdClient; - _configService = configService; - _blackholeProvider = blackholeProvider; - _pneumaticClient = pneumaticClient; - _nzbgetClient = nzbgetClient; + _downloadClientFactory = downloadClientFactory; } public IDownloadClient GetDownloadClient() { - switch (_configService.DownloadClient) - { - case DownloadClientType.Blackhole: - return _blackholeProvider; - - case DownloadClientType.Pneumatic: - return _pneumaticClient; - - case DownloadClientType.Nzbget: - return _nzbgetClient; - - default: - return _sabnzbdClient; - } + return _downloadClientFactory.Enabled().FirstOrDefault(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/DownloadClientRepository.cs b/src/NzbDrone.Core/Download/DownloadClientRepository.cs new file mode 100644 index 000000000..25c1ea15c --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientRepository.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientRepository : IProviderRepository<DownloadClientDefinition> + { + + } + + public class DownloadClientRepository : ProviderRepository<DownloadClientDefinition>, IDownloadClientRepository + { + public DownloadClientRepository(IDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 15acf13b2..71e633d47 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -36,9 +36,9 @@ namespace NzbDrone.Core.Download var downloadTitle = remoteEpisode.Release.Title; var downloadClient = _downloadClientProvider.GetDownloadClient(); - if (!downloadClient.IsConfigured) + if (downloadClient == null) { - _logger.Warn("Download client {0} isn't configured yet.", downloadClient.GetType().Name); + _logger.Warn("Download client isn't configured yet."); return; } diff --git a/src/NzbDrone.Core/Download/Events/DownloadFailedEvent.cs b/src/NzbDrone.Core/Download/Events/DownloadFailedEvent.cs new file mode 100644 index 000000000..0475ceaf2 --- /dev/null +++ b/src/NzbDrone.Core/Download/Events/DownloadFailedEvent.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Download.Events +{ + public class DownloadFailedEvent : IEvent + { + public Int32 SeriesId { get; set; } + public List<Int32> EpisodeIds { get; set; } + public QualityModel Quality { get; set; } + public String SourceTitle { get; set; } + public String DownloadClient { get; set; } + public String DownloadClientId { get; set; } + public String Message { get; set; } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Events/EpisodeGrabbedEvent.cs b/src/NzbDrone.Core/Download/Events/EpisodeGrabbedEvent.cs new file mode 100644 index 000000000..887e42362 --- /dev/null +++ b/src/NzbDrone.Core/Download/Events/EpisodeGrabbedEvent.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Events +{ + public class EpisodeGrabbedEvent : IEvent + { + public RemoteEpisode Episode { get; private set; } + public String DownloadClient { get; set; } + public String DownloadClientId { get; set; } + + public EpisodeGrabbedEvent(RemoteEpisode episode) + { + Episode = episode; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index 42107372b..d13fe243e 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -1,12 +1,12 @@ using System.Collections.Generic; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Download { - public interface IDownloadClient + public interface IDownloadClient : IProvider { string DownloadNzb(RemoteEpisode remoteEpisode); - bool IsConfigured { get; } IEnumerable<QueueItem> GetQueue(); IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0); void RemoveFromQueue(string id); diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index e59bcf4c9..0aead1b89 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -59,7 +59,7 @@ namespace NzbDrone.Core.Indexers public enum DownloadProtocol { - Usenet, - Torrent + Usenet = 1, + Torrent = 2 } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetaData/MetadataService.cs b/src/NzbDrone.Core/MetaData/MetadataService.cs index 08708ab3e..461b992d2 100644 --- a/src/NzbDrone.Core/MetaData/MetadataService.cs +++ b/src/NzbDrone.Core/MetaData/MetadataService.cs @@ -1,12 +1,9 @@ -using System.IO; -using System.Linq; +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 { @@ -16,10 +13,10 @@ namespace NzbDrone.Core.Metadata IHandle<SeriesRenamedEvent> { private readonly IMetadataFactory _metadataFactory; - private readonly MetadataFileService _metadataFileService; + private readonly IMetadataFileService _metadataFileService; private readonly Logger _logger; - public NotificationService(IMetadataFactory metadataFactory, MetadataFileService metadataFileService, Logger logger) + public NotificationService(IMetadataFactory metadataFactory, IMetadataFileService metadataFileService, Logger logger) { _metadataFactory = metadataFactory; _metadataFileService = metadataFileService; diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 89cb0b967..4391b60eb 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -198,6 +198,8 @@ <Compile Include="Datastore\Migration\040_add_metadata_to_episodes_and_series.cs" /> <Compile Include="Datastore\Migration\039_add_metadata_tables.cs" /> <Compile Include="Datastore\Migration\041_fix_xbmc_season_images_metadata.cs" /> + <Compile Include="Datastore\Migration\041_add_download_clients_table.cs" /> + <Compile Include="Datastore\Migration\042_convert_config_to_download_clients.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationExtension.cs" /> @@ -240,13 +242,27 @@ <Compile Include="DecisionEngine\Specifications\RssSync\HistorySpecification.cs" /> <Compile Include="DiskSpace\DiskSpace.cs" /> <Compile Include="DiskSpace\DiskSpaceService.cs" /> + <Compile Include="Download\Clients\Blackhole\Blackhole.cs" /> + <Compile Include="Download\Clients\Blackhole\TestBlackholeCommand.cs" /> + <Compile Include="Download\Clients\FolderSettings.cs" /> + <Compile Include="Download\Clients\Nzbget\NzbgetSettings.cs" /> + <Compile Include="Download\Clients\Nzbget\TestNzbgetCommand.cs" /> + <Compile Include="Download\Clients\Pneumatic\Pneumatic.cs" /> + <Compile Include="Download\Clients\Pneumatic\TestPneumaticCommand.cs" /> + <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdAddResponse.cs" /> + <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdCategoryResponse.cs" /> + <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdVersionResponse.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdSettings.cs" /> + <Compile Include="Download\Clients\Sabnzbd\TestSabnzbdCommand.cs" /> + <Compile Include="Download\DownloadClientBase.cs" /> + <Compile Include="Download\DownloadClientDefinition.cs" /> + <Compile Include="Download\DownloadClientFactory.cs" /> + <Compile Include="Download\DownloadClientRepository.cs" /> <Compile Include="Download\Clients\Nzbget\JsonRequest.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbGetCommunicationProxy.cs" /> - <Compile Include="Download\Clients\Sabnzbd\ConnectionInfoModel.cs" /> + <Compile Include="Download\Clients\Nzbget\NzbgetProxy.cs" /> <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdPriorityTypeConverter.cs" /> <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdQueueTimeConverter.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabAutoConfigureService.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabCommunicationProxy.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdProxy.cs" /> <Compile Include="Download\CheckForFailedDownloadCommand.cs" /> <Compile Include="Download\HistoryItem.cs" /> <Compile Include="Download\DownloadFailedEvent.cs" /> @@ -476,10 +492,10 @@ <Compile Include="Download\Clients\Nzbget\EnqueueResponse.cs" /> <Compile Include="Download\Clients\Nzbget\ErrorModel.cs" /> <Compile Include="Download\Clients\Nzbget\JsonError.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbGetQueue.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbGetQueueItem.cs" /> - <Compile Include="Download\Clients\Nzbget\PriorityType.cs" /> - <Compile Include="Download\Clients\Nzbget\VersionModel.cs" /> + <Compile Include="Download\Clients\Nzbget\NzbgetQueue.cs" /> + <Compile Include="Download\Clients\Nzbget\NzbgetQueueItem.cs" /> + <Compile Include="Download\Clients\Nzbget\NzbgetPriority.cs" /> + <Compile Include="Download\Clients\Nzbget\VersionResponse.cs" /> <Compile Include="Organizer\NamingConfig.cs" /> <Compile Include="Parser\Language.cs" /> <Compile Include="Parser\Model\LocalEpisode.cs" /> @@ -529,18 +545,13 @@ <Compile Include="Tv\RefreshEpisodeService.cs" /> <Compile Include="Tv\SeriesRepository.cs" /> <Compile Include="Qualities\QualityModel.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabAddResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabHistoryItem.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabHistory.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabJsonError.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabQueue.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabCategoryModel.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabModel.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabQueueItem.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabVersionModel.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdHistoryItem.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdHistory.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdJsonError.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdQueue.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdQueueItem.cs" /> <Compile Include="MediaCover\MediaCoverService.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetClient.cs" /> - <Compile Include="Download\Clients\PneumaticClient.cs" /> + <Compile Include="Download\Clients\Nzbget\Nzbget.cs" /> <Compile Include="MediaFiles\RecycleBinProvider.cs" /> <Compile Include="SeriesStats\SeriesStatistics.cs" /> <Compile Include="SeriesStats\SeriesStatisticsRepository.cs" /> @@ -556,13 +567,10 @@ <Compile Include="MediaFiles\DiskScanService.cs"> <SubType>Code</SubType> </Compile> - <Compile Include="Download\Clients\BlackholeProvider.cs"> - <SubType>Code</SubType> - </Compile> <Compile Include="Download\IDownloadClient.cs"> <SubType>Code</SubType> </Compile> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdClient.cs"> + <Compile Include="Download\Clients\Sabnzbd\Sabnzbd.cs"> <SubType>Code</SubType> </Compile> <Compile Include="Download\DownloadService.cs"> @@ -631,7 +639,7 @@ <SubType>Code</SubType> </Compile> <Compile Include="Notifications\NotificationDefinition.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabPriorityType.cs" /> + <Compile Include="Download\Clients\Sabnzbd\SabnzbdPriority.cs" /> <Compile Include="MediaFiles\EpisodeFile.cs" /> <Compile Include="Tv\Episode.cs" /> <Compile Include="Instrumentation\Log.cs" /> diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index 070c90fb6..ce81a1bfd 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -23,6 +23,13 @@ namespace NzbDrone.Core.Queue public List<Queue> GetQueue() { var downloadClient = _downloadClientProvider.GetDownloadClient(); + + if (downloadClient == null) + { + _logger.Trace("Download client is not configured."); + return new List<Queue>(); + } + var queueItems = downloadClient.GetQueue(); return MapQueue(queueItems); diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index ee7af6841..47e5fedf9 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -97,6 +97,8 @@ namespace NzbDrone.Core.Tv return FindByTvdbId(tvdbId.Value); } + var clean = Parser.Parser.CleanSeriesTitle(title); + return _seriesRepository.FindByTitle(Parser.Parser.CleanSeriesTitle(title)); } diff --git a/src/UI/.idea/jsLinters/jshint.xml b/src/UI/.idea/jsLinters/jshint.xml index e85398a55..4e0df49ad 100644 --- a/src/UI/.idea/jsLinters/jshint.xml +++ b/src/UI/.idea/jsLinters/jshint.xml @@ -8,16 +8,16 @@ <option es3="false" /> <option forin="true" /> <option immed="true" /> + <option latedef="true" /> <option newcap="true" /> <option noarg="true" /> <option noempty="false" /> <option nonew="true" /> <option plusplus="false" /> <option undef="true" /> + <option unused="true" /> <option strict="true" /> <option trailing="false" /> - <option latedef="true" /> - <option unused="true" /> <option quotmark="single" /> <option maxdepth="3" /> <option asi="false" /> diff --git a/src/UI/Form/FormBuilder.js b/src/UI/Form/FormBuilder.js index bb391f162..3eade017f 100644 --- a/src/UI/Form/FormBuilder.js +++ b/src/UI/Form/FormBuilder.js @@ -35,6 +35,13 @@ define( ]); } + if (field.type === 'path') { + return _templateRenderer.apply(field, + [ + 'Form/PathTemplate' + ]); + } + return _templateRenderer.apply(field, [ 'Form/TextboxTemplate' diff --git a/src/UI/Form/PathTemplate.html b/src/UI/Form/PathTemplate.html new file mode 100644 index 000000000..6b5d16bc6 --- /dev/null +++ b/src/UI/Form/PathTemplate.html @@ -0,0 +1,12 @@ +<div class="control-group"> + <label class="control-label">{{label}}</label> + + <div class="controls"> + <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" class="x-path"/> + {{#if helpText}} + <span class="help-inline"> + <i class="icon-nd-form-info" title="{{helpText}}"/> + </span> + {{/if}} + </div> +</div> diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js new file mode 100644 index 000000000..e6f557dc1 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js @@ -0,0 +1,23 @@ +'use strict'; + +define([ + 'marionette', + 'Settings/DownloadClient/Add/DownloadClientAddItemView' +], function (Marionette, AddItemView) { + + return Marionette.CompositeView.extend({ + itemView : AddItemView, + itemViewContainer: '.add-download-client .items', + template : 'Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate', + + itemViewOptions: function () { + return { + downloadClientCollection: this.downloadClientCollection + }; + }, + + initialize: function (options) { + this.downloadClientCollection = options.downloadClientCollection; + } + }); +}); diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.html b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.html new file mode 100644 index 000000000..0dc1bb250 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.html @@ -0,0 +1,12 @@ +<div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h3>Add Download Client</h3> +</div> +<div class="modal-body"> + <div class="add-download-client add-thingies"> + <ul class="items"></ul> + </div> +</div> +<div class="modal-footer"> + <button class="btn" data-dismiss="modal">close</button> +</div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js new file mode 100644 index 000000000..9ce89a4ee --- /dev/null +++ b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js @@ -0,0 +1,38 @@ +'use strict'; + +define([ + 'AppLayout', + 'marionette', + 'Settings/DownloadClient/Edit/DownloadClientEditView' +], function (AppLayout, Marionette, EditView) { + + return Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate', + tagName : 'li', + + events: { + 'click': '_add' + }, + + initialize: function (options) { + this.downloadClientCollection = options.downloadClientCollection; + }, + + _add: function (e) { + if (this.$(e.target).hasClass('icon-info-sign')) { + return; + } + + this.model.set({ + id : undefined, + name : this.model.get('implementationName'), + onGrab : true, + onDownload : true, + onUpgrade : true + }); + + var editView = new EditView({ model: this.model, downloadClientCollection: this.downloadClientCollection }); + AppLayout.modalRegion.show(editView); + } + }); +}); diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.html b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.html new file mode 100644 index 000000000..dfaee211e --- /dev/null +++ b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.html @@ -0,0 +1,10 @@ +<div class="add-thingy span3"> + <div class="row"> + <div class="span3"> + {{implementation}} + {{#if link}} + <a href="{{link}}"><i class="icon-info-sign"/></a> + {{/if}} + </div> + </div> +</div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Add/SchemaModal.js b/src/UI/Settings/DownloadClient/Add/SchemaModal.js new file mode 100644 index 000000000..dac0dca63 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Add/SchemaModal.js @@ -0,0 +1,20 @@ +'use strict'; +define([ + 'AppLayout', + 'Settings/DownloadClient/DownloadClientCollection', + 'Settings/DownloadClient/Add/DownloadClientAddCollectionView' +], function (AppLayout, DownloadClientCollection, DownloadClientAddCollectionView) { + return ({ + + open: function (collection) { + var schemaCollection = new DownloadClientCollection(); + var originalUrl = schemaCollection.url; + schemaCollection.url = schemaCollection.url + '/schema'; + schemaCollection.fetch(); + schemaCollection.url = originalUrl; + + var view = new DownloadClientAddCollectionView({ collection: schemaCollection, downloadClientCollection: collection}); + AppLayout.modalRegion.show(view); + } + }); +}); diff --git a/src/UI/Settings/DownloadClient/BlackholeView.js b/src/UI/Settings/DownloadClient/BlackholeView.js deleted file mode 100644 index c9d91001b..000000000 --- a/src/UI/Settings/DownloadClient/BlackholeView.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -define( - [ - 'marionette', - 'Mixins/AsModelBoundView', - 'Mixins/AutoComplete', - 'bootstrap' - ], function (Marionette, AsModelBoundView) { - - var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/BlackholeViewTemplate', - - ui: { - 'blackholeFolder': '.x-path' - }, - - onShow: function () { - this.ui.blackholeFolder.autoComplete('/directories'); - } - }); - - return AsModelBoundView.call(view); - }); diff --git a/src/UI/Settings/DownloadClient/BlackholeViewTemplate.html b/src/UI/Settings/DownloadClient/BlackholeViewTemplate.html deleted file mode 100644 index e41520b2f..000000000 --- a/src/UI/Settings/DownloadClient/BlackholeViewTemplate.html +++ /dev/null @@ -1,13 +0,0 @@ -<fieldset> - <legend>Blackhole</legend> - <div class="control-group"> - <label class="control-label">Blackhole Folder</label> - - <div class="controls"> - <input type="text" name="blackholeFolder" class="x-path"/> - <span class="help-inline"> - <i class="icon-nd-form-info" title="The folder where your download client will pickup .nzb files"/> - </span> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js new file mode 100644 index 000000000..502d57e7f --- /dev/null +++ b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js @@ -0,0 +1,23 @@ +'use strict'; +define( + [ + 'vent', + 'marionette' + ], function (vent, Marionette) { + return Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate', + + events: { + 'click .x-confirm-delete': '_delete' + }, + + _delete: function () { + this.model.destroy({ + wait : true, + success: function () { + vent.trigger(vent.Commands.CloseModalCommand); + } + }); + } + }); + }); diff --git a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.html b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.html new file mode 100644 index 000000000..b4fff099b --- /dev/null +++ b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.html @@ -0,0 +1,11 @@ +<div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h3>Delete Download Client</h3> +</div> +<div class="modal-body"> + <p>Are you sure you want to delete '{{name}}'?</p> +</div> +<div class="modal-footer"> + <button class="btn" data-dismiss="modal">cancel</button> + <button class="btn btn-danger x-confirm-delete">delete</button> +</div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollection.js b/src/UI/Settings/DownloadClient/DownloadClientCollection.js new file mode 100644 index 000000000..6166da3e4 --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientCollection.js @@ -0,0 +1,12 @@ +'use strict'; +define( + [ + 'backbone', + 'Settings/DownloadClient/DownloadClientModel' + ], function (Backbone, DownloadClientModel) { + + return Backbone.Collection.extend({ + model: DownloadClientModel, + url : window.NzbDrone.ApiRoot + '/downloadclient' + }); + }); diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js b/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js new file mode 100644 index 000000000..4a11cb167 --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js @@ -0,0 +1,31 @@ +'use strict'; +define( + [ + 'underscore', + 'AppLayout', + 'marionette', + 'Settings/DownloadClient/DownloadClientItemView', + 'Settings/DownloadClient/Add/SchemaModal' + ], function (_, AppLayout, Marionette, DownloadClientItemView, SchemaModal) { + return Marionette.CompositeView.extend({ + itemView : DownloadClientItemView, + itemViewContainer: '#x-download-clients', + template : 'Settings/DownloadClient/DownloadClientCollectionViewTemplate', + + ui: { + 'addCard': '.x-add-card' + }, + + events: { + 'click .x-add-card': '_openSchemaModal' + }, + + appendHtml: function (collectionView, itemView, index) { + collectionView.ui.addCard.parent('li').before(itemView.el); + }, + + _openSchemaModal: function () { + SchemaModal.open(this.collection); + } + }); + }); diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.html b/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.html new file mode 100644 index 000000000..9b9692658 --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.html @@ -0,0 +1,16 @@ +<fieldset> + <legend>Download Clients</legend> + <div class="row"> + <div class="span12"> + <ul id="x-download-clients" class="download-client-list"> + <li> + <div class="download-client-item thingy add-card x-add-card"> + <span class="center well"> + <i class="icon-plus" title="Add Download Client"/> + </span> + </div> + </li> + </ul> + </div> + </div> +</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientItemView.js b/src/UI/Settings/DownloadClient/DownloadClientItemView.js new file mode 100644 index 000000000..67ff8344e --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientItemView.js @@ -0,0 +1,34 @@ +'use strict'; + +define( + [ + 'AppLayout', + 'marionette', + 'Settings/DownloadClient/Edit/DownloadClientEditView', + 'Settings/DownloadClient/Delete/DownloadClientDeleteView' + ], function (AppLayout, Marionette, EditView, DeleteView) { + + return Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/DownloadClientItemViewTemplate', + tagName : 'li', + + events: { + 'click .x-edit' : '_edit', + 'click .x-delete' : '_delete' + }, + + initialize: function () { + this.listenTo(this.model, 'sync', this.render); + }, + + _edit: function () { + var view = new EditView({ model: this.model}); + AppLayout.modalRegion.show(view); + }, + + _delete: function () { + var view = new DeleteView({ model: this.model}); + AppLayout.modalRegion.show(view); + } + }); + }); diff --git a/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.html b/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.html new file mode 100644 index 000000000..e8550d7f5 --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.html @@ -0,0 +1,17 @@ +<div class="download-client-item thingy"> + <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> + <button class="btn btn-mini btn-icon-only x-delete"><i class="icon-nd-delete"/></button> + </span> + </div> + + <div class="settings"> + {{#if enable}} + <span class="label label-success">Enabled</span> + {{else}} + <span class="label">Not Enabled</span> + {{/if}} + </div> +</div> diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayout.js b/src/UI/Settings/DownloadClient/DownloadClientLayout.js new file mode 100644 index 000000000..dee8340aa --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientLayout.js @@ -0,0 +1,40 @@ +'use strict'; + +define( + [ + 'marionette', + 'Settings/DownloadClient/DownloadClientCollection', + 'Settings/DownloadClient/DownloadClientCollectionView', + 'Mixins/AsModelBoundView', + 'Mixins/AutoComplete', + 'bootstrap' + ], function (Marionette, DownloadClientCollection, DownloadClientCollectionView, AsModelBoundView) { + + var view = Marionette.Layout.extend({ + template : 'Settings/DownloadClient/DownloadClientLayoutTemplate', + + regions: { + downloadClients: '#x-download-clients-region' + }, + + ui: { + droneFactory: '.x-path' + }, + + events: { + 'change .x-download-client': 'downloadClientChanged' + }, + + initialize: function () { + this.downloadClientCollection = new DownloadClientCollection(); + this.downloadClientCollection.fetch(); + }, + + onShow: function () { + this.downloadClients.show(new DownloadClientCollectionView({ collection: this.downloadClientCollection })); + this.ui.droneFactory.autoComplete('/directories'); + } + }); + + return AsModelBoundView.call(view); + }); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html b/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html new file mode 100644 index 000000000..9bc372e3a --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html @@ -0,0 +1,16 @@ +<div id="x-download-clients-region"></div> + +<fieldset class="form-horizontal"> + <legend>Options</legend> + <div class="control-group"> + <label class="control-label">Drone Factory</label> + + <div class="controls"> + <input type="text" name="downloadedEpisodesFolder" class="x-path"/> + <span class="help-inline"> + <i class="icon-nd-form-info" title="The folder where your download client downloads TV shows to (Completed Download Directory)"/> + <i class="icon-nd-form-warning" title="Do not use the folder that contains some or all of your sorted and named TV shows - doing so could cause data loss"></i> + </span> + </div> + </div> +</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientModel.js b/src/UI/Settings/DownloadClient/DownloadClientModel.js new file mode 100644 index 000000000..5e08858af --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientModel.js @@ -0,0 +1,10 @@ +'use strict'; +define( + [ + 'backbone.deepmodel' + ], function (DeepModel) { + return DeepModel.DeepModel.extend({ + + }); + }); + diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js new file mode 100644 index 000000000..2cab9b5e1 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js @@ -0,0 +1,93 @@ +'use strict'; + +define( + [ + 'vent', + 'AppLayout', + 'marionette', + 'Settings/DownloadClient/Delete/DownloadClientDeleteView', + 'Commands/CommandController', + 'Mixins/AsModelBoundView', + 'underscore', + 'Form/FormBuilder', + 'Mixins/AutoComplete', + 'bootstrap' + ], function (vent, AppLayout, Marionette, DeleteView, CommandController, AsModelBoundView, _) { + + var model = Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/Edit/DownloadClientEditViewTemplate', + + ui: { + path : '.x-path', + modalBody : '.modal-body' + }, + + events: { + 'click .x-save' : '_save', + 'click .x-save-and-add': '_saveAndAdd', + 'click .x-delete' : '_delete', + 'click .x-back' : '_back', + 'click .x-test' : '_test' + }, + + initialize: function (options) { + this.downloadClientCollection = options.downloadClientCollection; + }, + + onShow: function () { + //Hack to deal with modals not overflowing + if (this.ui.path.length > 0) { + this.ui.modalBody.addClass('modal-overflow'); + } + + this.ui.path.autoComplete('/directories'); + }, + + _save: function () { + var self = this; + var promise = this.model.save(); + + if (promise) { + promise.done(function () { + self.downloadClientCollection.add(self.model, { merge: true }); + vent.trigger(vent.Commands.CloseModalCommand); + }); + } + }, + + _saveAndAdd: function () { + var self = this; + var promise = this.model.save(); + + if (promise) { + promise.done(function () { + self.notificationCollection.add(self.model, { merge: true }); + + require('Settings/DownloadClient/Add/SchemaModal').open(self.downloadClientCollection); + }); + } + }, + + _delete: function () { + var view = new DeleteView({ model: this.model }); + AppLayout.modalRegion.show(view); + }, + + _back: function () { + require('Settings/DownloadClient/Add/SchemaModal').open(this.downloadClientCollection); + }, + + _test: function () { + var testCommand = 'test{0}'.format(this.model.get('implementation')); + var properties = {}; + + _.each(this.model.get('fields'), function (field) { + properties[field.name] = field.value; + }); + + CommandController.Execute(testCommand, properties); + } + }); + + return AsModelBoundView.call(model); + }); diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.html b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.html new file mode 100644 index 000000000..53aa004dd --- /dev/null +++ b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.html @@ -0,0 +1,59 @@ +<div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + {{#if id}} + <h3>Edit - {{implementation}}</h3> + {{else}} + <h3>Add - {{implementation}}</h3> + {{/if}} +</div> +<div class="modal-body download-client-modal"> + <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"> + {{#if id}} + <button class="btn btn-danger pull-left x-delete">delete</button> + {{else}} + <button class="btn pull-left x-back">back</button> + {{/if}} + + <button class="btn x-test">test <i class="x-test-icon icon-nd-test"/></button> + <button class="btn" data-dismiss="modal">cancel</button> + + <div class="btn-group"> + <button class="btn btn-primary x-save">save</button> + <button class="btn btn-icon-only btn-primary dropdown-toggle" data-toggle="dropdown"> + <span class="caret"></span> + </button> + <ul class="dropdown-menu"> + <li class="save-and-add x-save-and-add"> + save and add + </li> + </ul> + </div> +</div> diff --git a/src/UI/Settings/DownloadClient/Layout.js b/src/UI/Settings/DownloadClient/Layout.js deleted file mode 100644 index 730ec1cd7..000000000 --- a/src/UI/Settings/DownloadClient/Layout.js +++ /dev/null @@ -1,78 +0,0 @@ -'use strict'; - -define( - [ - 'marionette', - 'Settings/DownloadClient/SabView', - 'Settings/DownloadClient/BlackholeView', - 'Settings/DownloadClient/PneumaticView', - 'Settings/DownloadClient/NzbgetView', - 'Mixins/AsModelBoundView', - 'Mixins/AutoComplete', - 'bootstrap' - ], function (Marionette, SabView, BlackholeView, PneumaticView, NzbgetView, AsModelBoundView) { - - var view = Marionette.Layout.extend({ - template : 'Settings/DownloadClient/LayoutTemplate', - - regions: { - downloadClient: '#download-client-settings-region' - }, - - ui: { - downloadClientSelect: '.x-download-client', - downloadedEpisodesFolder: '.x-path' - }, - - events: { - 'change .x-download-client': 'downloadClientChanged' - }, - - onShow: function () { - this.sabView = new SabView({ model: this.model}); - this.blackholeView = new BlackholeView({ model: this.model}); - this.pneumaticView = new PneumaticView({ model: this.model}); - this.nzbgetView = new NzbgetView({ model: this.model}); - - this.ui.downloadedEpisodesFolder.autoComplete('/directories'); - - var client = this.model.get('downloadClient'); - this.refreshUIVisibility(client); - }, - - downloadClientChanged: function () { - var clientId = this.ui.downloadClientSelect.val(); - this.refreshUIVisibility(clientId); - }, - - refreshUIVisibility: function (clientId) { - - if (!clientId) { - clientId = 'sabnzbd'; - } - - switch (clientId.toString()) { - case 'sabnzbd': - this.downloadClient.show(this.sabView); - break; - - case 'blackhole': - this.downloadClient.show(this.blackholeView); - break; - - case 'pneumatic': - this.downloadClient.show(this.pneumaticView); - break; - - case 'nzbget': - this.downloadClient.show(this.nzbgetView); - break; - - default : - throw 'unknown download client id' + clientId; - } - } - }); - - return AsModelBoundView.call(view); - }); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/LayoutTemplate.html b/src/UI/Settings/DownloadClient/LayoutTemplate.html deleted file mode 100644 index 54c0c81f6..000000000 --- a/src/UI/Settings/DownloadClient/LayoutTemplate.html +++ /dev/null @@ -1,29 +0,0 @@ -<fieldset class="form-horizontal"> - <legend>General</legend> - - <div class="control-group"> - <label class="control-label">Download Client</label> - - <div class="controls"> - <select class="inputClass x-download-client" name="downloadClient"> - <option value="sabnzbd">SABnzbd</option> - <option value="blackhole">Blackhole</option> - <option value="pneumatic">Pneumatic</option> - <option value="nzbget">NZBGet</option> - </select> - </div> - </div> - <div class="control-group"> - <label class="control-label">Drone Factory</label> - - <div class="controls"> - <input type="text" name="downloadedEpisodesFolder" class="x-path"/> - <span class="help-inline"> - <i class="icon-nd-form-info" title="The folder where your download client downloads TV shows to (Completed Download Directory)"/> - <i class="icon-nd-form-warning" title="Do not use the folder that contains some or all of your sorted and named TV shows - doing so could cause data loss"></i> - </span> - </div> - </div> -</fieldset> - -<div id="download-client-settings-region" class="form-horizontal"></div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/NzbgetView.js b/src/UI/Settings/DownloadClient/NzbgetView.js deleted file mode 100644 index 601df8457..000000000 --- a/src/UI/Settings/DownloadClient/NzbgetView.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -define( - [ - 'marionette', - 'Mixins/AsModelBoundView', - 'bootstrap' - ], function (Marionette, AsModelBoundView) { - - var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/NzbgetViewTemplate' - }); - - return AsModelBoundView.call(view); - }); diff --git a/src/UI/Settings/DownloadClient/NzbgetViewTemplate.html b/src/UI/Settings/DownloadClient/NzbgetViewTemplate.html deleted file mode 100644 index cae6ec715..000000000 --- a/src/UI/Settings/DownloadClient/NzbgetViewTemplate.html +++ /dev/null @@ -1,86 +0,0 @@ -<fieldset> - <legend>NZBGet</legend> - <div class="control-group"> - <label class="control-label">Host</label> - - <div class="controls"> - <input type="text" name="nzbgetHost"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Port</label> - - <div class="controls"> - <input type="text" name="nzbgetPort"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">API Key</label> - - <div class="controls"> - <input type="text" name="nzbgetApiKey"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Username</label> - - <div class="controls"> - <input type="text" name="nzbgetUsername"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Password</label> - - <div class="controls"> - <input type="password" name="nzbgetPassword"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">TV Category</label> - - <div class="controls"> - <input type="text" name="nzbgetTvCategory"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Download Priority</label> - - <div class="controls"> - <select name="nzbgetRecentTvPriority"> - <option value="default">Default</option> - <option value="pasued">Paused</option> - <option value="low">Low</option> - <option value="normal">Normal</option> - <option value="high">High</option> - <option value="force">Force</option> - </select> - <span class="help-inline"> - <i class="icon-nd-form-info" title="Priority to use when sending episodes that aired within the last 14 days"/> - </span> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Older Download Priority</label> - - <div class="controls"> - <select name="nzbgetOlderTvPriority"> - <option value="default">Default</option> - <option value="pasued">Paused</option> - <option value="low">Low</option> - <option value="normal">Normal</option> - <option value="high">High</option> - <option value="force">Force</option> - </select> - <span class="help-inline"> - <i class="icon-nd-form-info" title="Priority to use when sending episodes that aired over 14 days ago"/> - </span> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/DownloadClient/PneumaticView.js b/src/UI/Settings/DownloadClient/PneumaticView.js deleted file mode 100644 index 524c19eae..000000000 --- a/src/UI/Settings/DownloadClient/PneumaticView.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -define( - [ - 'marionette', - 'Mixins/AsModelBoundView', - 'Mixins/AutoComplete', - 'bootstrap' - ], function (Marionette, AsModelBoundView) { - - var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/PneumaticViewTemplate', - - ui: { - 'pneumaticFolder': '.x-path' - }, - - onShow: function () { - this.ui.pneumaticFolder.autoComplete('/directories'); - } - }); - - return AsModelBoundView.call(view); - }); diff --git a/src/UI/Settings/DownloadClient/PneumaticViewTemplate.html b/src/UI/Settings/DownloadClient/PneumaticViewTemplate.html deleted file mode 100644 index 8fc612fc3..000000000 --- a/src/UI/Settings/DownloadClient/PneumaticViewTemplate.html +++ /dev/null @@ -1,13 +0,0 @@ -<fieldset> - <legend>Pneumatic</legend> - <div class="control-group"> - <label class="control-label">Nzb Folder</label> - - <div class="controls"> - <input type="text" name="pneumaticFolder" class="x-path"/> - <span class="help-inline"> - <i class="icon-nd-form-info" title="Folder to save NZBs for Pneumatic<br/>must be accessible from XBMC"></i> - </span> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/DownloadClient/SabView.js b/src/UI/Settings/DownloadClient/SabView.js deleted file mode 100644 index ce6da37e5..000000000 --- a/src/UI/Settings/DownloadClient/SabView.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -define( - [ - 'marionette', - 'Mixins/AsModelBoundView', - 'bootstrap' - ], function (Marionette, AsModelBoundView) { - - var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/SabViewTemplate' - }); - - return AsModelBoundView.call(view); - }); diff --git a/src/UI/Settings/DownloadClient/SabViewTemplate.html b/src/UI/Settings/DownloadClient/SabViewTemplate.html deleted file mode 100644 index 9b997ae07..000000000 --- a/src/UI/Settings/DownloadClient/SabViewTemplate.html +++ /dev/null @@ -1,120 +0,0 @@ -<fieldset> - <legend>SABnzbd</legend> - - {{!<div class="control-group"> - <label class="control-label">Auto-Configure</label> - - <div class="controls"> - <input type="button" value="Auto-Configure" class="btn btn-inverse"/> - <span class="help-inline"> - <i class="icon-nd-form-info" - title="(Windows only) If access to SABnzbd doesn't require a username & password and it is on the same system as NzbDrone, you can auto-configure it"/> - </span> - </div> - </div>}} - - <div class="control-group"> - <label class="control-label">Host</label> - - <div class="controls"> - <input type="text" name="sabHost"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Port</label> - - <div class="controls"> - <input type="text" name="sabPort"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">API Key</label> - - <div class="controls"> - <input type="text" name="sabApiKey"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Username</label> - - <div class="controls"> - <input type="text" name="sabUsername"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Password</label> - - <div class="controls"> - <input type="password" name="sabPassword"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">TV Category</label> - - <div class="controls"> - <input type="text" name="sabTvCategory" placeholder="This is not the dropdownlist you're looking for"/> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Download Priority</label> - - <div class="controls"> - <select name="sabRecentTvPriority"> - <option value="default">Default</option> - <option value="paused">Paused</option> - <option value="low">Low</option> - <option value="normal">Normal</option> - </option> <option value="high">High</option> - <option value="force">Force</option> - </select> - <span class="help-inline"> - <i class="icon-nd-form-info" title="Priority to use when sending episodes that aired within the last 14 days"/> - </span> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Older Download Priority</label> - - <div class="controls"> - <select name="sabOlderTvPriority"> - <option value="default">Default</option> - <option value="paused">Paused</option> - <option value="low">Low</option> - <option value="normal">Normal</option> - <option value="high">High</option> - <option value="force">Force</option> - </select> - <span class="help-inline"> - <i class="icon-nd-form-info" title="Priority to use when sending episodes that aired over 14 days ago"/> - </span> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Use SSL</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="sabUseSsl" class="x-ssl"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-nd-form-info" title="Connect to SABnzbd over SSL"/> - </span> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/DownloadClient/downloadclient.less b/src/UI/Settings/DownloadClient/downloadclient.less new file mode 100644 index 000000000..a41bec135 --- /dev/null +++ b/src/UI/Settings/DownloadClient/downloadclient.less @@ -0,0 +1,27 @@ +.download-client-list { + li { + display: inline-block; + vertical-align: top; + } +} + +.download-client-item { + + width: 290px; + height: 90px; + padding: 10px 15px; + + h3 { + width: 230px; + } + + &.add-card { + .center { + margin-top: 15px; + } + } +} + +.modal-overflow { + overflow-y: visible; +} \ No newline at end of file diff --git a/src/UI/Settings/Indexers/CollectionTemplate.html b/src/UI/Settings/Indexers/CollectionTemplate.html index 7713572fe..f2f4aff77 100644 --- a/src/UI/Settings/Indexers/CollectionTemplate.html +++ b/src/UI/Settings/Indexers/CollectionTemplate.html @@ -2,7 +2,7 @@ <legend>Indexers</legend> <div class="row"> <div class="span12"> - <ul id="x-indexers" class="indexer-list"> + <ul id="x-indexers" class="indexer-list thingies"> <li> <div class="indexer-settings-item add-card x-add-card"> <span class="center well"> diff --git a/src/UI/Settings/Indexers/CollectionView.js b/src/UI/Settings/Indexers/CollectionView.js index 0fd21e6b4..662dd5298 100644 --- a/src/UI/Settings/Indexers/CollectionView.js +++ b/src/UI/Settings/Indexers/CollectionView.js @@ -6,9 +6,8 @@ define( 'Settings/Indexers/ItemView', 'Settings/Indexers/EditView', 'Settings/Indexers/Collection', - 'System/StatusModel', 'underscore' - ], function (AppLayout, Marionette, IndexerItemView, IndexerEditView, IndexerCollection, StatusModel, _) { + ], function (AppLayout, Marionette, IndexerItemView, IndexerEditView, IndexerCollection, _) { return Marionette.CompositeView.extend({ itemView : IndexerItemView, itemViewContainer: '#x-indexers', @@ -28,12 +27,14 @@ define( _openSchemaModal: function () { var self = this; - //TODO: Is there a better way to deal with changing URLs? var schemaCollection = new IndexerCollection(); - schemaCollection.url = StatusModel.get('urlBase') + '/api/indexer/schema'; + var originalUrl = schemaCollection.url; + + schemaCollection.url = schemaCollection.url + '/schema'; + schemaCollection.fetch({ success: function (collection) { - collection.url = StatusModel.get('urlBase') + '/api/indexer'; + collection.url = originalUrl; var model = _.first(collection.models); model.set({ diff --git a/src/UI/Settings/Indexers/ItemTemplate.html b/src/UI/Settings/Indexers/ItemTemplate.html index 87acb9e0b..6ab71d071 100644 --- a/src/UI/Settings/Indexers/ItemTemplate.html +++ b/src/UI/Settings/Indexers/ItemTemplate.html @@ -1,4 +1,4 @@ -<div class="indexer-settings-item"> +<div class="indexer-settings-item thingy"> <div> <h3>{{name}}</h3> {{#if_eq implementation compare="Newznab"}} diff --git a/src/UI/Settings/Indexers/indexers.less b/src/UI/Settings/Indexers/indexers.less index d037d3b43..79923b909 100644 --- a/src/UI/Settings/Indexers/indexers.less +++ b/src/UI/Settings/Indexers/indexers.less @@ -1,31 +1,11 @@ -@import "../../Shared/Styles/card"; - -.indexer-list { - li { - display: inline-block; - vertical-align: top; - } -} - .indexer-settings-item { - .card; - width: 220px; height: 260px; padding: 10px 15px; h3 { - margin-top: 0px; - display: inline-block; width: 190px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .btn-group { - margin-top: 8px; } &.add-card { diff --git a/src/UI/Settings/Notifications/AddItemTemplate.html b/src/UI/Settings/Notifications/AddItemTemplate.html index 31fb15419..dfaee211e 100644 --- a/src/UI/Settings/Notifications/AddItemTemplate.html +++ b/src/UI/Settings/Notifications/AddItemTemplate.html @@ -1,4 +1,4 @@ -<div class="add-notification-item span3"> +<div class="add-thingy span3"> <div class="row"> <div class="span3"> {{implementation}} diff --git a/src/UI/Settings/Notifications/AddTemplate.html b/src/UI/Settings/Notifications/AddTemplate.html index 2f3ba9f31..06a241428 100644 --- a/src/UI/Settings/Notifications/AddTemplate.html +++ b/src/UI/Settings/Notifications/AddTemplate.html @@ -3,7 +3,7 @@ <h3>Add Notification</h3> </div> <div class="modal-body"> - <div class="add-notifications"> + <div class="add-notifications add-thingies"> <ul class="items"></ul> </div> </div> diff --git a/src/UI/Settings/Notifications/CollectionTemplate.html b/src/UI/Settings/Notifications/CollectionTemplate.html index 510190305..2d4cc25f4 100644 --- a/src/UI/Settings/Notifications/CollectionTemplate.html +++ b/src/UI/Settings/Notifications/CollectionTemplate.html @@ -1,8 +1,8 @@ <div class="row"> <div class="span12"> - <ul class="notifications"> + <ul class="notifications thingies"> <li> - <div class="notification-item add-card x-add-card"> + <div class="notification-item thingy add-card x-add-card"> <span class="center well"> <i class="icon-plus" title="Add Connection"/> </span> diff --git a/src/UI/Settings/Notifications/ItemTemplate.html b/src/UI/Settings/Notifications/ItemTemplate.html index 0b9c83d49..64125cea6 100644 --- a/src/UI/Settings/Notifications/ItemTemplate.html +++ b/src/UI/Settings/Notifications/ItemTemplate.html @@ -1,4 +1,4 @@ -<div class="notification-item"> +<div class="notification-item thingy"> <div> <h3>{{name}}</h3> <span class="btn-group pull-right"> diff --git a/src/UI/Settings/Notifications/NotificationEditView.js b/src/UI/Settings/Notifications/NotificationEditView.js index 03de47197..161ebde96 100644 --- a/src/UI/Settings/Notifications/NotificationEditView.js +++ b/src/UI/Settings/Notifications/NotificationEditView.js @@ -22,7 +22,7 @@ define( }, events: { - 'click .x-save' : '_saveNotification', + 'click .x-save' : '_saveClient', 'click .x-save-and-add': '_saveAndAddNotification', 'click .x-delete' : '_deleteNotification', 'click .x-back' : '_back', @@ -38,7 +38,7 @@ define( this._onDownloadChanged(); }, - _saveNotification: function () { + _saveClient: function () { var self = this; var promise = this.model.saveSettings(); diff --git a/src/UI/Settings/Notifications/SchemaModal.js b/src/UI/Settings/Notifications/SchemaModal.js index c8f6979da..923072ec4 100644 --- a/src/UI/Settings/Notifications/SchemaModal.js +++ b/src/UI/Settings/Notifications/SchemaModal.js @@ -2,16 +2,16 @@ define([ 'AppLayout', 'Settings/Notifications/Collection', - 'Settings/Notifications/AddView', - 'System/StatusModel' -], function (AppLayout, NotificationCollection, AddSelectionNotificationView, StatusModel) { + 'Settings/Notifications/AddView' +], function (AppLayout, NotificationCollection, AddSelectionNotificationView) { return ({ open: function (collection) { var schemaCollection = new NotificationCollection(); - schemaCollection.url = StatusModel.get('urlBase') + '/api/notification/schema'; + var orginalUrl = schemaCollection.url; + schemaCollection.url = schemaCollection.url + '/schema'; schemaCollection.fetch(); - schemaCollection.url = StatusModel.get('urlBase') + '/api/notification'; + schemaCollection.url = orginalUrl; var view = new AddSelectionNotificationView({ collection: schemaCollection, notificationCollection: collection}); AppLayout.modalRegion.show(view); diff --git a/src/UI/Settings/Notifications/notifications.less b/src/UI/Settings/Notifications/notifications.less index 24e54738f..c8e059594 100644 --- a/src/UI/Settings/Notifications/notifications.less +++ b/src/UI/Settings/Notifications/notifications.less @@ -1,70 +1,17 @@ -@import "../../Shared/Styles/card.less"; -@import "../../Shared/Styles/clickable.less"; - -.add-notification-item { - .card; - cursor: pointer; - font-size: 24px; - font-weight: lighter; - text-align: center; - - a { - font-size: 16px; - color: #595959; - - i { - .clickable; - } - } - - a:hover { - text-decoration: none; - } -} - -.add-notifications { - text-align: center; - - .items { - list-style-type: none; - margin: 0px; - - li { - display: inline-block; - vertical-align: top; - } - } -} - .notifications { width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; - - li { - display: inline-block; - vertical-align: top; - } } -.notification-item { - .card; +.notification-item { width: 290px; height: 90px; padding: 20px 20px; h3 { - margin-top: 0px; - display: inline-block; width: 230px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .btn-group { - margin-top: 8px; } .settings { diff --git a/src/UI/Settings/Quality/Profile/QualityProfileCollectionTemplate.html b/src/UI/Settings/Quality/Profile/QualityProfileCollectionTemplate.html index 0c403edcf..7a00ff46b 100644 --- a/src/UI/Settings/Quality/Profile/QualityProfileCollectionTemplate.html +++ b/src/UI/Settings/Quality/Profile/QualityProfileCollectionTemplate.html @@ -2,9 +2,9 @@ <legend>Quality Profiles</legend> <div class="row"> <div class="span12"> - <ul class="quality-profiles"> + <ul class="quality-profiles thingies"> <li> - <div class="quality-profile-item add-card x-add-card"> + <div class="quality-profile-item thingy add-card x-add-card"> <span class="center well"> <i class="icon-plus" title="Add Profile"/> </span> diff --git a/src/UI/Settings/Quality/Profile/QualityProfileViewTemplate.html b/src/UI/Settings/Quality/Profile/QualityProfileViewTemplate.html index 23acb6942..6d5247760 100644 --- a/src/UI/Settings/Quality/Profile/QualityProfileViewTemplate.html +++ b/src/UI/Settings/Quality/Profile/QualityProfileViewTemplate.html @@ -1,4 +1,4 @@ -<div class="quality-profile-item"> +<div class="quality-profile-item thingy"> <div> <h3 name="name"></h3> <span class="btn-group pull-right"> diff --git a/src/UI/Settings/Quality/quality.less b/src/UI/Settings/Quality/quality.less index 199f87201..c10c990f1 100644 --- a/src/UI/Settings/Quality/quality.less +++ b/src/UI/Settings/Quality/quality.less @@ -1,33 +1,14 @@ -@import "../../Shared/Styles/card"; @import "../../Content/Bootstrap/mixins"; @import "../../Content/FontAwesome/font-awesome"; -.quality-profiles { - li { - display: inline-block; - vertical-align: top; - } -} - .quality-profile-item { - .card; - width: 300px; height: 120px; padding: 10px 15px; h3 { - margin-top: 0px; - display: inline-block; width: 240px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .btn-group { - margin-top: 8px; } &.add-card { diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js index 47973005e..6711658cb 100644 --- a/src/UI/Settings/SettingsLayout.js +++ b/src/UI/Settings/SettingsLayout.js @@ -12,7 +12,7 @@ define( 'Settings/Quality/QualityLayout', 'Settings/Indexers/IndexerLayout', 'Settings/Indexers/Collection', - 'Settings/DownloadClient/Layout', + 'Settings/DownloadClient/DownloadClientLayout', 'Settings/Notifications/CollectionView', 'Settings/Notifications/Collection', 'Settings/Metadata/MetadataLayout', diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less index 69a44d8ca..9f0ce6ac2 100644 --- a/src/UI/Settings/settings.less +++ b/src/UI/Settings/settings.less @@ -4,6 +4,8 @@ @import "Quality/quality"; @import "Notifications/notifications"; @import "Metadata/metadata"; +@import "DownloadClient/downloadclient"; +@import "thingy"; li.save-and-add { .clickable; diff --git a/src/UI/Settings/thingy.less b/src/UI/Settings/thingy.less new file mode 100644 index 000000000..6e8dad971 --- /dev/null +++ b/src/UI/Settings/thingy.less @@ -0,0 +1,65 @@ +@import "../Shared/Styles/card"; +@import "../Shared/Styles/clickable"; + +.add-thingy { + .card; + cursor: pointer; + font-size: 24px; + font-weight: lighter; + text-align: center; + + a { + font-size: 16px; + color: #595959; + + i { + .clickable; + } + } + + a:hover { + text-decoration: none; + } +} + +.add-thingies { + text-align: center; + + .items { + list-style-type: none; + margin: 0px; + + li { + display: inline-block; + vertical-align: top; + } + } +} + +.thingy { + + .card; + + h3 { + margin-top: 0px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .btn-group { + margin-top: 8px; + } + + .settings { + margin-top: 5px; + } +} + +.thingies { + li { + display: inline-block; + vertical-align: top; + } +} \ No newline at end of file From 77b83b521e40ead9e0eaa136883d596947e47c8d Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 16 Feb 2014 01:56:12 -0800 Subject: [PATCH 10/42] Validation, settings UI cleanup and different settings models, oh my New: Download client UI matches other settings Fixed: Prevent drone factory folder from being set to invalid paths/root path for series Fixed: Switching pages in settings will not hide changes Fixed: Test download clients Fixed: Settings are validated before saving --- .../Config/DownloadClientConfigModule.cs | 19 ++++ .../Config/DownloadClientConfigResource.cs | 15 +++ src/NzbDrone.Api/Config/HostConfigModule.cs | 55 +++++++++++ src/NzbDrone.Api/Config/HostConfigResource.cs | 22 +++++ .../Config/IndexerConfigModule.cs | 15 +++ .../Config/IndexerConfigResource.cs | 12 +++ .../Config/MediaManagementConfigModule.cs | 17 ++++ .../Config/MediaManagementConfigResource.cs | 19 ++++ ...{NamingModule.cs => NamingConfigModule.cs} | 4 +- .../Config/NzbDroneConfigModule.cs | 51 ++++++++++ src/NzbDrone.Api/Config/SettingsModule.cs | 71 ------------- src/NzbDrone.Api/NzbDrone.Api.csproj | 13 ++- .../RootFolders/RootFolderModule.cs | 23 +++-- src/NzbDrone.Api/Series/SeriesModule.cs | 1 + .../Validation/RuleBuilderExtensions.cs | 6 +- src/NzbDrone.Common/PathExtensions.cs | 1 - .../SabnzbdTests/SabnzbdFixture.cs | 39 -------- src/NzbDrone.Core.Test/Files/History.txt | 99 ------------------- src/NzbDrone.Core.Test/Files/HistoryEmpty.txt | 50 ---------- src/NzbDrone.Core.Test/Files/JsonError.txt | 4 - .../NzbDrone.Core.Test.csproj | 9 -- .../Configuration/ConfigService.cs | 2 +- .../Configuration/IConfigService.cs | 24 +++-- .../Download/Clients/Blackhole/Blackhole.cs | 1 - .../Download/Clients/FolderSettings.cs | 5 +- .../Clients/Sabnzbd/SabnzbdSettings.cs | 14 ++- src/NzbDrone.Core/NzbDrone.Core.csproj | 5 + .../Validation/FolderValidator.cs} | 6 +- .../Validation/Paths/DroneFactoryValidator.cs | 29 ++++++ .../Validation/Paths/PathExistsValidator.cs | 23 +++++ .../Validation/Paths/PathValidator.cs | 28 ++++++ .../Validation/Paths/RootFolderValidator.cs | 24 +++++ src/UI/.idea/jsLinters/jshint.xml | 4 +- .../DownloadClient/DownloadClientItemView.js | 2 +- .../DownloadClient/DownloadClientLayout.js | 26 ++--- .../DownloadClientLayoutTemplate.html | 18 +--- .../DownloadClientSettingsModel.js | 11 +++ .../Edit/DownloadClientEditView.js | 10 +- .../FailedDownloadHandlingView.js | 37 +++++++ .../FailedDownloadHandlingViewTemplate.html | 65 ++++++++++++ .../Options/DownloadClientOptionsView.js | 26 +++++ .../DownloadClientOptionsViewTemplate.html | 14 +++ .../Settings/General/GeneralSettingsModel.js | 3 +- src/UI/Settings/General/GeneralView.js | 12 ++- ...Template.html => GeneralViewTemplate.html} | 0 .../Settings/Indexers/IndexerSettingsModel.js | 11 +++ .../Indexers/Options/IndexerOptionsView.js | 10 +- .../FileManagement/FileManagementView.js | 27 ++--- .../FileManagementViewTemplate.html | 66 ------------- .../MediaManagement/MediaManagementLayout.js | 2 +- .../MediaManagementSettingsModel.js | 11 +++ .../Permissions/PermissionsView.js | 8 +- .../MediaManagement/Sorting/SortingView.js | 17 ++++ ...Template.html => SortingViewTemplate.html} | 0 .../Settings/MediaManagement/Sorting/View.js | 13 --- src/UI/Settings/SettingsLayout.js | 41 +++++--- src/UI/Settings/SettingsModel.js | 11 --- 57 files changed, 667 insertions(+), 484 deletions(-) create mode 100644 src/NzbDrone.Api/Config/DownloadClientConfigModule.cs create mode 100644 src/NzbDrone.Api/Config/DownloadClientConfigResource.cs create mode 100644 src/NzbDrone.Api/Config/HostConfigModule.cs create mode 100644 src/NzbDrone.Api/Config/HostConfigResource.cs create mode 100644 src/NzbDrone.Api/Config/IndexerConfigModule.cs create mode 100644 src/NzbDrone.Api/Config/IndexerConfigResource.cs create mode 100644 src/NzbDrone.Api/Config/MediaManagementConfigModule.cs create mode 100644 src/NzbDrone.Api/Config/MediaManagementConfigResource.cs rename src/NzbDrone.Api/Config/{NamingModule.cs => NamingConfigModule.cs} (97%) create mode 100644 src/NzbDrone.Api/Config/NzbDroneConfigModule.cs delete mode 100644 src/NzbDrone.Api/Config/SettingsModule.cs delete mode 100644 src/NzbDrone.Core.Test/Files/History.txt delete mode 100644 src/NzbDrone.Core.Test/Files/HistoryEmpty.txt delete mode 100644 src/NzbDrone.Core.Test/Files/JsonError.txt rename src/{NzbDrone.Api/Validation/PathValidator.cs => NzbDrone.Core/Validation/FolderValidator.cs} (74%) create mode 100644 src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/PathExistsValidator.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/PathValidator.cs create mode 100644 src/NzbDrone.Core/Validation/Paths/RootFolderValidator.cs create mode 100644 src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js create mode 100644 src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingView.js create mode 100644 src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingViewTemplate.html create mode 100644 src/UI/Settings/DownloadClient/Options/DownloadClientOptionsView.js create mode 100644 src/UI/Settings/DownloadClient/Options/DownloadClientOptionsViewTemplate.html rename src/UI/Settings/General/{GeneralTemplate.html => GeneralViewTemplate.html} (100%) create mode 100644 src/UI/Settings/Indexers/IndexerSettingsModel.js create mode 100644 src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js create mode 100644 src/UI/Settings/MediaManagement/Sorting/SortingView.js rename src/UI/Settings/MediaManagement/Sorting/{ViewTemplate.html => SortingViewTemplate.html} (100%) delete mode 100644 src/UI/Settings/MediaManagement/Sorting/View.js delete mode 100644 src/UI/Settings/SettingsModel.js diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs b/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs new file mode 100644 index 000000000..16c4dfd3b --- /dev/null +++ b/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs @@ -0,0 +1,19 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Api.Config +{ + public class DownloadClientConfigModule : NzbDroneConfigModule<DownloadClientConfigResource> + { + public DownloadClientConfigModule(IConfigService configService, RootFolderValidator rootFolderValidator, PathExistsValidator pathExistsValidator) + : base(configService) + { + SharedValidator.RuleFor(c => c.DownloadedEpisodesFolder) + .SetValidator(rootFolderValidator) + .SetValidator(pathExistsValidator) + .When(c => !String.IsNullOrWhiteSpace(c.DownloadedEpisodesFolder)); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs b/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs new file mode 100644 index 000000000..f3fa14e8b --- /dev/null +++ b/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs @@ -0,0 +1,15 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Config +{ + public class DownloadClientConfigResource : RestResource + { + public String DownloadedEpisodesFolder { get; set; } + public String DownloadClientWorkingFolders { get; set; } + + public Boolean AutoRedownloadFailed { get; set; } + public Boolean RemoveFailedDownloads { get; set; } + public Boolean EnableFailedDownloadHandling { get; set; } + } +} diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/NzbDrone.Api/Config/HostConfigModule.cs new file mode 100644 index 000000000..6d818db6a --- /dev/null +++ b/src/NzbDrone.Api/Config/HostConfigModule.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Reflection; +using FluentValidation; +using NzbDrone.Core.Configuration; +using Omu.ValueInjecter; + +namespace NzbDrone.Api.Config +{ + public class HostConfigModule : NzbDroneRestModule<HostConfigResource> + { + private readonly IConfigFileProvider _configFileProvider; + + public HostConfigModule(ConfigFileProvider configFileProvider) + : base("/config/host") + { + _configFileProvider = configFileProvider; + + GetResourceSingle = GetHostConfig; + GetResourceById = GetHostConfig; + UpdateResource = SaveHostConfig; + + SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); + SharedValidator.RuleFor(c => c.Port).InclusiveBetween(1, 65535); + + SharedValidator.RuleFor(c => c.Username).NotEmpty().When(c => c.AuthenticationEnabled); + SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationEnabled); + + SharedValidator.RuleFor(c => c.SslPort).InclusiveBetween(1, 65535).When(c => c.EnableSsl); + SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl); + } + + private HostConfigResource GetHostConfig() + { + var resource = new HostConfigResource(); + resource.InjectFrom(_configFileProvider); + resource.Id = 1; + + return resource; + } + + private HostConfigResource GetHostConfig(int id) + { + return GetHostConfig(); + } + + private void SaveHostConfig(HostConfigResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configFileProvider.SaveConfigDictionary(dictionary); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/NzbDrone.Api/Config/HostConfigResource.cs new file mode 100644 index 000000000..8fc4151d9 --- /dev/null +++ b/src/NzbDrone.Api/Config/HostConfigResource.cs @@ -0,0 +1,22 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Config +{ + public class HostConfigResource : RestResource + { + public Int32 Port { get; set; } + public Int32 SslPort { get; set; } + public Boolean EnableSsl { get; set; } + public Boolean LaunchBrowser { get; set; } + public Boolean AuthenticationEnabled { get; set; } + public String Username { get; set; } + public String Password { get; set; } + public String LogLevel { get; set; } + public String Branch { get; set; } + public String ApiKey { get; set; } + public Boolean Torrent { get; set; } + public String SslCertHash { get; set; } + public String UrlBase { get; set; } + } +} diff --git a/src/NzbDrone.Api/Config/IndexerConfigModule.cs b/src/NzbDrone.Api/Config/IndexerConfigModule.cs new file mode 100644 index 000000000..10df7f2ae --- /dev/null +++ b/src/NzbDrone.Api/Config/IndexerConfigModule.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Api.Config +{ + public class IndexerConfigModule : NzbDroneConfigModule<IndexerConfigResource> + { + + public IndexerConfigModule(IConfigService configService) + : base(configService) + { + SharedValidator.RuleFor(c => c.RssSyncInterval).InclusiveBetween(10, 120); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/IndexerConfigResource.cs b/src/NzbDrone.Api/Config/IndexerConfigResource.cs new file mode 100644 index 000000000..aeb9706e3 --- /dev/null +++ b/src/NzbDrone.Api/Config/IndexerConfigResource.cs @@ -0,0 +1,12 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Config +{ + public class IndexerConfigResource : RestResource + { + public Int32 Retention { get; set; } + public Int32 RssSyncInterval { get; set; } + public String ReleaseRestrictions { get; set; } + } +} diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs new file mode 100644 index 000000000..6c8b8c65e --- /dev/null +++ b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Api.Config +{ + public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource> + { + public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator) + : base(configService) + { + SharedValidator.RuleFor(c => c.FileChmod).NotEmpty(); + SharedValidator.RuleFor(c => c.FolderChmod).NotEmpty(); + SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs new file mode 100644 index 000000000..9ff4efc66 --- /dev/null +++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs @@ -0,0 +1,19 @@ +using System; +using NzbDrone.Api.REST; + +namespace NzbDrone.Api.Config +{ + public class MediaManagementConfigResource : RestResource + { + public Boolean AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } + public String RecycleBin { get; set; } + public Boolean AutoDownloadPropers { get; set; } + public Boolean CreateEmptySeriesFolders { get; set; } + + public Boolean SetPermissionsLinux { get; set; } + public String FileChmod { get; set; } + public String FolderChmod { get; set; } + public String ChownUser { get; set; } + public String ChownGroup { get; set; } + } +} diff --git a/src/NzbDrone.Api/Config/NamingModule.cs b/src/NzbDrone.Api/Config/NamingConfigModule.cs similarity index 97% rename from src/NzbDrone.Api/Config/NamingModule.cs rename to src/NzbDrone.Api/Config/NamingConfigModule.cs index f340f5ab9..eb41ef9e9 100644 --- a/src/NzbDrone.Api/Config/NamingModule.cs +++ b/src/NzbDrone.Api/Config/NamingConfigModule.cs @@ -12,14 +12,14 @@ using Omu.ValueInjecter; namespace NzbDrone.Api.Config { - public class NamingModule : NzbDroneRestModule<NamingConfigResource> + public class NamingConfigModule : NzbDroneRestModule<NamingConfigResource> { private readonly INamingConfigService _namingConfigService; private readonly IFilenameSampleService _filenameSampleService; private readonly IFilenameValidationService _filenameValidationService; private readonly IBuildFileNames _filenameBuilder; - public NamingModule(INamingConfigService namingConfigService, + public NamingConfigModule(INamingConfigService namingConfigService, IFilenameSampleService filenameSampleService, IFilenameValidationService filenameValidationService, IBuildFileNames filenameBuilder) diff --git a/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs b/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs new file mode 100644 index 000000000..64b31014d --- /dev/null +++ b/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Reflection; +using NzbDrone.Api.REST; +using NzbDrone.Core.Configuration; +using Omu.ValueInjecter; + +namespace NzbDrone.Api.Config +{ + public abstract class NzbDroneConfigModule<TResource> : NzbDroneRestModule<TResource> where TResource : RestResource, new() + { + private readonly IConfigService _configService; + + protected NzbDroneConfigModule(IConfigService configService) + : this(new TResource().ResourceName.Replace("config", ""), configService) + { + } + + protected NzbDroneConfigModule(string resource, IConfigService configService) : + base("config/" + resource.Trim('/')) + { + _configService = configService; + + GetResourceSingle = GetConfig; + GetResourceById = GetConfig; + UpdateResource = SaveConfig; + } + + private TResource GetConfig() + { + var resource = new TResource(); + resource.InjectFrom(_configService); + resource.Id = 1; + + return resource; + } + + private TResource GetConfig(int id) + { + return GetConfig(); + } + + private void SaveConfig(TResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configService.SaveConfigDictionary(dictionary); + } + } +} diff --git a/src/NzbDrone.Api/Config/SettingsModule.cs b/src/NzbDrone.Api/Config/SettingsModule.cs deleted file mode 100644 index d4135393f..000000000 --- a/src/NzbDrone.Api/Config/SettingsModule.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public class SettingsModule : NzbDroneApiModule - { - private readonly IConfigService _configService; - private readonly IConfigFileProvider _configFileProvider; - - public SettingsModule(IConfigService configService, IConfigFileProvider configFileProvider) - : base("/settings") - { - _configService = configService; - _configFileProvider = configFileProvider; - Get["/"] = x => GetGeneralSettings(); - Post["/"] = x => SaveGeneralSettings(); - - Get["/host"] = x => GetHostSettings(); - Post["/host"] = x => SaveHostSettings(); - - Get["/log"] = x => GetLogSettings(); - Post["/log"] = x => SaveLogSettings(); - } - - private Response SaveLogSettings() - { - throw new NotImplementedException(); - } - - private Response GetLogSettings() - { - throw new NotImplementedException(); - } - - private Response SaveHostSettings() - { - var request = Request.Body.FromJson<Dictionary<string, object>>(); - _configFileProvider.SaveConfigDictionary(request); - - return GetHostSettings(); - } - - private Response GetHostSettings() - { - return _configFileProvider.GetConfigDictionary().AsResponse(); - } - - private Response GetGeneralSettings() - { - var collection = Request.Query.Collection; - - if (collection.HasValue && Boolean.Parse(collection.Value)) - return _configService.All().AsResponse(); - - return _configService.AllWithDefaults().AsResponse(); - } - - private Response SaveGeneralSettings() - { - var request = Request.Body.FromJson<Dictionary<string, object>>(); - _configService.SaveValues(request); - - - return request.AsResponse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 04dd817d4..9ca8abe50 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -92,8 +92,17 @@ <Compile Include="ClientSchema\SelectOption.cs" /> <Compile Include="Commands\CommandModule.cs" /> <Compile Include="Commands\CommandResource.cs" /> + <Compile Include="Config\IndexerConfigModule.cs" /> + <Compile Include="Config\MediaManagementConfigModule.cs" /> + <Compile Include="Config\MediaManagementConfigResource.cs" /> + <Compile Include="Config\NzbDroneConfigModule.cs" /> + <Compile Include="Config\DownloadClientConfigModule.cs" /> + <Compile Include="Config\DownloadClientConfigResource.cs" /> + <Compile Include="Config\HostConfigModule.cs" /> + <Compile Include="Config\HostConfigResource.cs" /> <Compile Include="Config\NamingConfigResource.cs" /> - <Compile Include="Config\NamingModule.cs" /> + <Compile Include="Config\NamingConfigModule.cs" /> + <Compile Include="Config\IndexerConfigResource.cs" /> <Compile Include="DownloadClient\DownloadClientModule.cs" /> <Compile Include="DownloadClient\DownloadClientResource.cs" /> <Compile Include="DiskSpace\DiskSpaceModule.cs" /> @@ -177,11 +186,9 @@ <Compile Include="Qualities\QualityDefinitionResource.cs" /> <Compile Include="Qualities\QualityDefinitionModule.cs" /> <Compile Include="Extensions\ReqResExtensions.cs" /> - <Compile Include="Config\SettingsModule.cs" /> <Compile Include="System\SystemModule.cs" /> <Compile Include="TinyIoCNancyBootstrapper.cs" /> <Compile Include="Update\UpdateModule.cs" /> - <Compile Include="Validation\PathValidator.cs" /> <Compile Include="Validation\RuleBuilderExtensions.cs" /> </ItemGroup> <ItemGroup> diff --git a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs index 5d0298698..edcf69fe5 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs +++ b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs @@ -5,7 +5,7 @@ using FluentValidation.Results; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.RootFolders; using NzbDrone.Api.Mapping; -using NzbDrone.Api.Validation; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Api.RootFolders { @@ -13,7 +13,11 @@ namespace NzbDrone.Api.RootFolders { private readonly IRootFolderService _rootFolderService; - public RootFolderModule(IRootFolderService rootFolderService, ICommandExecutor commandExecutor) + public RootFolderModule(IRootFolderService rootFolderService, + ICommandExecutor commandExecutor, + RootFolderValidator rootFolderValidator, + PathExistsValidator pathExistsValidator, + DroneFactoryValidator droneFactoryValidator) : base(commandExecutor) { _rootFolderService = rootFolderService; @@ -23,7 +27,10 @@ namespace NzbDrone.Api.RootFolders CreateResource = CreateRootFolder; DeleteResource = DeleteFolder; - SharedValidator.RuleFor(c => c.Path).IsValidPath(); + SharedValidator.RuleFor(c => c.Path).IsValidPath() + .SetValidator(rootFolderValidator) + .SetValidator(pathExistsValidator) + .SetValidator(droneFactoryValidator); } private RootFolderResource GetRootFolder(int id) @@ -33,15 +40,7 @@ namespace NzbDrone.Api.RootFolders private int CreateRootFolder(RootFolderResource rootFolderResource) { - try - { - return GetNewId<RootFolder>(_rootFolderService.Add, rootFolderResource); - } - catch (Exception ex) - { - throw new ValidationException(new [] { new ValidationFailure("Path", ex.Message) }); - } - + return GetNewId<RootFolder>(_rootFolderService.Add, rootFolderResource); } private List<RootFolderResource> GetRootFolders() diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs index d87a0f4ec..97255f50c 100644 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ b/src/NzbDrone.Api/Series/SeriesModule.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.Tv; using NzbDrone.Api.Validation; using NzbDrone.Api.Mapping; using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Api.Series { diff --git a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs b/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs index a13cbf52a..42f0d8db0 100644 --- a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs +++ b/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Validators; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Api.Validation { @@ -21,11 +22,6 @@ namespace NzbDrone.Api.Validation return ruleBuilder.SetValidator(new RegularExpressionValidator("^http(s)?://", RegexOptions.IgnoreCase)).WithMessage("must start with http:// or https://"); } - public static IRuleBuilderOptions<T, string> IsValidPath<T>(this IRuleBuilder<T, string> ruleBuilder) - { - return ruleBuilder.SetValidator(new PathValidator()); - } - public static IRuleBuilderOptions<T, string> NotBlank<T>(this IRuleBuilder<T, string> ruleBuilder) { return ruleBuilder.SetValidator(new NotNullValidator()).SetValidator(new NotEmptyValidator("")); diff --git a/src/NzbDrone.Common/PathExtensions.cs b/src/NzbDrone.Common/PathExtensions.cs index 309fee9cb..c08770fc8 100644 --- a/src/NzbDrone.Common/PathExtensions.cs +++ b/src/NzbDrone.Common/PathExtensions.cs @@ -71,7 +71,6 @@ namespace NzbDrone.Common return false; } - public static bool ContainsInvalidPathChars(this string text) { return text.IndexOfAny(Path.GetInvalidPathChars()) >= 0; diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index 8b05eac12..fe9529ef1 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -49,45 +49,6 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests }; } - [Test] - public void GetHistory_should_return_a_list_with_items_when_the_history_has_items() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "History.txt")); - - - var result = Subject.GetHistory(); - - - result.Should().HaveCount(1); - } - - [Test] - public void GetHistory_should_return_an_empty_list_when_the_queue_is_empty() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "HistoryEmpty.txt")); - - - var result = Subject.GetHistory(); - - - result.Should().BeEmpty(); - } - - [Test] - public void GetHistory_should_return_an_empty_list_when_there_is_an_error_getting_the_queue() - { - Mocker.GetMock<IHttpProvider>() - .Setup(s => s.DownloadString("http://192.168.5.55:2222/api?mode=history&output=json&start=0&limit=0&apikey=5c770e3197e4fe763423ee7c392c25d1&ma_username=admin&ma_password=pass")) - .Returns(ReadAllText("Files", "JsonError.txt")); - - - Assert.Throws<ApplicationException>(() => Subject.GetHistory(), "API Key Incorrect"); - } - [Test] public void downloadNzb_should_use_sabRecentTvPriority_when_recentEpisode_is_true() { diff --git a/src/NzbDrone.Core.Test/Files/History.txt b/src/NzbDrone.Core.Test/Files/History.txt deleted file mode 100644 index 6d13ffe42..000000000 --- a/src/NzbDrone.Core.Test/Files/History.txt +++ /dev/null @@ -1,99 +0,0 @@ -{ - "history":{ - "active_lang":"en", - "paused":false, - "session":"5c770e3197e4fe763423ee7c392c25d1", - "restart_req":false, - "power_options":true, - "slots":[ - { - "action_line":"", - "show_details":"True", - "script_log":"", - "meta":null, - "fail_message":"", - "loaded":false, - "id":9858, - "size":"970 MB", - "category":"tv", - "pp":"D", - "retry":0, - "completeness":0, - "script":"None", - "nzb_name":"The.Mentalist.S04E12.720p.HDTV.x264-IMMERSE.nzb", - "download_time":524, - "storage":"C:\\ServerPool\\ServerFolders\\Unsorted TV\\The Mentalist - 4x12 - My Bloody Valentine [HDTV-720p]", - "status":"Completed", - "script_line":"", - "completed":1327033479, - "nzo_id":"SABnzbd_nzo_0crgis", - "downloaded":1016942445, - "report":"", - "path":"D:\\SABnzbd\\downloading\\The Mentalist - 4x12 - My Bloody Valentine [HDTV-720p]", - "postproc_time":24, - "name":"The Mentalist - 4x12 - My Bloody Valentine [HDTV-720p]", - "url":"", - "bytes":1016942445, - "url_info":"", - "stage_log":[ - { - "name":"Download", - "actions":[ - "Downloaded in 8 minutes 44 seconds at an average of 1.8 MB/s" - ] - }, - { - "name":"Repair", - "actions":[ - "[the.mentalist.s04e12.720p.hdtv.x264-immerse] Quick Check OK" - ] - }, - { - "name":"Unpack", - "actions":[ - "[the.mentalist.s04e12.720p.hdtv.x264-immerse] Unpacked 1 files/folders in 23 seconds" - ] - } - ] - } - ], - "speed":"0 ", - "helpuri":"http://wiki.sabnzbd.org/", - "size":"0 B", - "uptime":"1d", - "total_size":"10.2 T", - "month_size":"445.7 G", - "week_size":"46.6 G", - "version":"0.6.9", - "new_rel_url":"http://sourceforge.net/projects/sabnzbdplus/files/sabnzbdplus/sabnzbd-0.6.14", - "diskspacetotal2":"9314.57", - "color_scheme":"gold", - "diskspacetotal1":"871.41", - "nt":true, - "status":"Idle", - "last_warning":"2012-01-19 23:58:01,736\nWARNING:\nAPI Key incorrect, Use the api key from Config->General in your 3rd party program:", - "have_warnings":"3", - "cache_art":"0", - "sizeleft":"0 B", - "finishaction":null, - "paused_all":false, - "cache_size":"0 B", - "new_release":"0.6.14", - "pause_int":"0", - "mbleft":"0.00", - "diskspace1":"869.82", - "darwin":false, - "timeleft":"0:00:00", - "mb":"0.00", - "noofslots":9724, - "day_size":"0 ", - "eta":"unknown", - "nzb_quota":"", - "loadavg":"", - "cache_max":"-1", - "kbpersec":"0.00", - "speedlimit":"", - "webdir":"D:\\SABnzbd\\SABnzbd\\interfaces\\Plush\\templates", - "diskspace2":"1084.96" - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/HistoryEmpty.txt b/src/NzbDrone.Core.Test/Files/HistoryEmpty.txt deleted file mode 100644 index 1c5cb9d95..000000000 --- a/src/NzbDrone.Core.Test/Files/HistoryEmpty.txt +++ /dev/null @@ -1,50 +0,0 @@ -{ - "history":{ - "active_lang":"en", - "paused":false, - "session":"5c770e3197e4fe763423ee7c392c25d1", - "restart_req":false, - "power_options":true, - "slots":[ - - ], - "speed":"0 ", - "helpuri":"http://wiki.sabnzbd.org/", - "size":"0 B", - "uptime":"1d", - "total_size":"10.2 T", - "month_size":"445.7 G", - "week_size":"46.6 G", - "version":"0.6.9", - "new_rel_url":"http://sourceforge.net/projects/sabnzbdplus/files/sabnzbdplus/sabnzbd-0.6.14", - "diskspacetotal2":"9314.57", - "color_scheme":"gold", - "diskspacetotal1":"871.41", - "nt":true, - "status":"Idle", - "last_warning":"2012-01-19 23:58:01,736\nWARNING:\nAPI Key incorrect, Use the api key from Config->General in your 3rd party program:", - "have_warnings":"3", - "cache_art":"0", - "sizeleft":"0 B", - "finishaction":null, - "paused_all":false, - "cache_size":"0 B", - "new_release":"0.6.14", - "pause_int":"0", - "mbleft":"0.00", - "diskspace1":"869.82", - "darwin":false, - "timeleft":"0:00:00", - "mb":"0.00", - "noofslots":9724, - "day_size":"0 ", - "eta":"unknown", - "nzb_quota":"", - "loadavg":"", - "cache_max":"-1", - "kbpersec":"0.00", - "speedlimit":"", - "webdir":"D:\\SABnzbd\\SABnzbd\\interfaces\\Plush\\templates", - "diskspace2":"1084.96" - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/JsonError.txt b/src/NzbDrone.Core.Test/Files/JsonError.txt deleted file mode 100644 index aa32a0bdd..000000000 --- a/src/NzbDrone.Core.Test/Files/JsonError.txt +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": false, - "error": "API Key Incorrect" -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index f50f25adf..3ee136d83 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -287,18 +287,9 @@ <Content Include="Files\LongOverview.txt"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> - <Content Include="Files\HistoryEmpty.txt"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> <Content Include="Files\Queue.txt"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> - <Content Include="Files\History.txt"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> - <Content Include="Files\JsonError.txt"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> <None Include="..\NzbDrone.Test.Common\App.config"> <Link>App.config</Link> </None> diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 6e197460e..a0aa00bc8 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Configuration return dict; } - public void SaveValues(Dictionary<string, object> configValues) + public void SaveConfigDictionary(Dictionary<string, object> configValues) { var allWithDefaults = AllWithDefaults(); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index a9d121e2f..ca44cc046 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -7,23 +7,33 @@ namespace NzbDrone.Core.Configuration { IEnumerable<Config> All(); Dictionary<String, Object> AllWithDefaults(); + void SaveConfigDictionary(Dictionary<string, object> configValues); + + //Download Client String DownloadedEpisodesFolder { get; set; } - bool AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } - int Retention { get; set; } - string RecycleBin { get; set; } - string ReleaseRestrictions { get; set; } - Int32 RssSyncInterval { get; set; } - Boolean AutoDownloadPropers { get; set; } String DownloadClientWorkingFolders { get; set; } + + //Failed Download Handling (Download client) Boolean AutoRedownloadFailed { get; set; } Boolean RemoveFailedDownloads { get; set; } Boolean EnableFailedDownloadHandling { get; set; } + + //Media Management + Boolean AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } + String RecycleBin { get; set; } + Boolean AutoDownloadPropers { get; set; } Boolean CreateEmptySeriesFolders { get; set; } - void SaveValues(Dictionary<string, object> configValues); + + //Permissions (Media Management) Boolean SetPermissionsLinux { get; set; } String FileChmod { get; set; } String FolderChmod { get; set; } String ChownUser { get; set; } String ChownGroup { get; set; } + + //Indexers + Int32 Retention { get; set; } + Int32 RssSyncInterval { get; set; } + String ReleaseRestrictions { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs index 4cfc0cf2d..28cc9eb9e 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs @@ -4,7 +4,6 @@ using System.IO; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; diff --git a/src/NzbDrone.Core/Download/Clients/FolderSettings.cs b/src/NzbDrone.Core/Download/Clients/FolderSettings.cs index f11169203..cacb847ea 100644 --- a/src/NzbDrone.Core/Download/Clients/FolderSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/FolderSettings.cs @@ -1,8 +1,10 @@ using System; using FluentValidation; using FluentValidation.Results; +using NzbDrone.Common.Disk; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation.Paths; namespace NzbDrone.Core.Download.Clients { @@ -10,7 +12,8 @@ namespace NzbDrone.Core.Download.Clients { public FolderSettingsValidator() { - RuleFor(c => c.Folder).NotEmpty(); + //Todo: Validate that the path actually exists + RuleFor(c => c.Folder).IsValidPath(); } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs index a6373b379..de5e87fb8 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -2,7 +2,6 @@ using FluentValidation; using FluentValidation.Results; using NzbDrone.Core.Annotations; -using NzbDrone.Core.Download.Clients.Nzbget; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Download.Clients.Sabnzbd @@ -14,7 +13,18 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd RuleFor(c => c.Host).NotEmpty(); RuleFor(c => c.Port).GreaterThan(0); - //Todo: either API key or Username/Password needs to be valid + RuleFor(c => c.ApiKey).NotEmpty() + .WithMessage("API Key is required when username/password are not configured") + .When(c => String.IsNullOrWhiteSpace(c.Username)); + + RuleFor(c => c.Username).NotEmpty() + .WithMessage("Username is required when API key is not configured") + .When(c => String.IsNullOrWhiteSpace(c.ApiKey)); + + + RuleFor(c => c.Password).NotEmpty() + .WithMessage("Password is required when API key is not configured") + .When(c => String.IsNullOrWhiteSpace(c.ApiKey)); } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 4391b60eb..efa52abe7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -660,6 +660,11 @@ <Compile Include="Update\UpdatePackageProvider.cs" /> <Compile Include="Update\UpdatePackage.cs" /> <Compile Include="Update\UpdateCheckService.cs" /> + <Compile Include="Validation\Paths\DroneFactoryValidator.cs" /> + <Compile Include="Validation\Paths\PathValidator.cs" /> + <Compile Include="Validation\Paths\RootFolderValidator.cs" /> + <Compile Include="Validation\Paths\PathExistsValidator.cs" /> + <Compile Include="Validation\FolderValidator.cs" /> <Compile Include="Validation\RuleBuilderExtensions.cs" /> </ItemGroup> <ItemGroup> diff --git a/src/NzbDrone.Api/Validation/PathValidator.cs b/src/NzbDrone.Core/Validation/FolderValidator.cs similarity index 74% rename from src/NzbDrone.Api/Validation/PathValidator.cs rename to src/NzbDrone.Core/Validation/FolderValidator.cs index f7cf37eab..daa3645bf 100644 --- a/src/NzbDrone.Api/Validation/PathValidator.cs +++ b/src/NzbDrone.Core/Validation/FolderValidator.cs @@ -1,11 +1,11 @@ using FluentValidation.Validators; using NzbDrone.Common; -namespace NzbDrone.Api.Validation +namespace NzbDrone.Core.Validation { - public class PathValidator : PropertyValidator + public class FolderValidator : PropertyValidator { - public PathValidator() + public FolderValidator() : base("Invalid Path") { } diff --git a/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs b/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs new file mode 100644 index 000000000..ec5447774 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs @@ -0,0 +1,29 @@ +using System; +using FluentValidation.Validators; +using NzbDrone.Common; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Validation.Paths +{ + public class DroneFactoryValidator : PropertyValidator + { + private readonly IConfigService _configService; + + public DroneFactoryValidator(IConfigService configService) + : base("Path is already used for drone factory") + { + _configService = configService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return false; + + var droneFactory = _configService.DownloadedEpisodesFolder; + + if (String.IsNullOrWhiteSpace(droneFactory)) return true; + + return !droneFactory.PathEquals(context.PropertyValue.ToString()); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/PathExistsValidator.cs b/src/NzbDrone.Core/Validation/Paths/PathExistsValidator.cs new file mode 100644 index 000000000..8e3e39aed --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/PathExistsValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation.Validators; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Validation.Paths +{ + public class PathExistsValidator : PropertyValidator + { + private readonly IDiskProvider _diskProvider; + + public PathExistsValidator(IDiskProvider diskProvider) + : base("Path does not exist") + { + _diskProvider = diskProvider; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return false; + + return (_diskProvider.FolderExists(context.PropertyValue.ToString())); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/PathValidator.cs b/src/NzbDrone.Core/Validation/Paths/PathValidator.cs new file mode 100644 index 000000000..a77c76ae5 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/PathValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using FluentValidation.Validators; +using NzbDrone.Common; + +namespace NzbDrone.Core.Validation.Paths +{ + public static class PathValidation + { + public static IRuleBuilderOptions<T, string> IsValidPath<T>(this IRuleBuilder<T, string> ruleBuilder) + { + return ruleBuilder.SetValidator(new PathValidator()); + } + } + + public class PathValidator : PropertyValidator + { + public PathValidator() + : base("Invalid Path") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return false; + return context.PropertyValue.ToString().IsPathValid(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/RootFolderValidator.cs b/src/NzbDrone.Core/Validation/Paths/RootFolderValidator.cs new file mode 100644 index 000000000..382056e24 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/RootFolderValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation.Validators; +using NzbDrone.Common; +using NzbDrone.Core.RootFolders; + +namespace NzbDrone.Core.Validation.Paths +{ + public class RootFolderValidator : PropertyValidator + { + private readonly IRootFolderService _rootFolderService; + + public RootFolderValidator(IRootFolderService rootFolderService) + : base("Path is already configured as a root folder") + { + _rootFolderService = rootFolderService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return (!_rootFolderService.All().Exists(r => r.Path.PathEquals(context.PropertyValue.ToString()))); + } + } +} \ No newline at end of file diff --git a/src/UI/.idea/jsLinters/jshint.xml b/src/UI/.idea/jsLinters/jshint.xml index 4e0df49ad..e85398a55 100644 --- a/src/UI/.idea/jsLinters/jshint.xml +++ b/src/UI/.idea/jsLinters/jshint.xml @@ -8,16 +8,16 @@ <option es3="false" /> <option forin="true" /> <option immed="true" /> - <option latedef="true" /> <option newcap="true" /> <option noarg="true" /> <option noempty="false" /> <option nonew="true" /> <option plusplus="false" /> <option undef="true" /> - <option unused="true" /> <option strict="true" /> <option trailing="false" /> + <option latedef="true" /> + <option unused="true" /> <option quotmark="single" /> <option maxdepth="3" /> <option asi="false" /> diff --git a/src/UI/Settings/DownloadClient/DownloadClientItemView.js b/src/UI/Settings/DownloadClient/DownloadClientItemView.js index 67ff8344e..0d021c059 100644 --- a/src/UI/Settings/DownloadClient/DownloadClientItemView.js +++ b/src/UI/Settings/DownloadClient/DownloadClientItemView.js @@ -22,7 +22,7 @@ define( }, _edit: function () { - var view = new EditView({ model: this.model}); + var view = new EditView({ model: this.model, downloadClientCollection: this.model.collection }); AppLayout.modalRegion.show(view); }, diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayout.js b/src/UI/Settings/DownloadClient/DownloadClientLayout.js index dee8340aa..e632371dc 100644 --- a/src/UI/Settings/DownloadClient/DownloadClientLayout.js +++ b/src/UI/Settings/DownloadClient/DownloadClientLayout.js @@ -5,24 +5,17 @@ define( 'marionette', 'Settings/DownloadClient/DownloadClientCollection', 'Settings/DownloadClient/DownloadClientCollectionView', - 'Mixins/AsModelBoundView', - 'Mixins/AutoComplete', - 'bootstrap' - ], function (Marionette, DownloadClientCollection, DownloadClientCollectionView, AsModelBoundView) { + 'Settings/DownloadClient/Options/DownloadClientOptionsView', + 'Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingView' + ], function (Marionette, DownloadClientCollection, DownloadClientCollectionView, DownloadClientOptionsView, FailedDownloadHandlingView) { - var view = Marionette.Layout.extend({ + return Marionette.Layout.extend({ template : 'Settings/DownloadClient/DownloadClientLayoutTemplate', regions: { - downloadClients: '#x-download-clients-region' - }, - - ui: { - droneFactory: '.x-path' - }, - - events: { - 'change .x-download-client': 'downloadClientChanged' + downloadClients : '#x-download-clients-region', + downloadClientOptions : '#x-download-client-options-region', + failedDownloadHandling : '#x-failed-download-handling-region' }, initialize: function () { @@ -32,9 +25,8 @@ define( onShow: function () { this.downloadClients.show(new DownloadClientCollectionView({ collection: this.downloadClientCollection })); - this.ui.droneFactory.autoComplete('/directories'); + this.downloadClientOptions.show(new DownloadClientOptionsView({ model: this.model })); + this.failedDownloadHandling.show(new FailedDownloadHandlingView({ model: this.model })); } }); - - return AsModelBoundView.call(view); }); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html b/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html index 9bc372e3a..365590417 100644 --- a/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html +++ b/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.html @@ -1,16 +1,6 @@ <div id="x-download-clients-region"></div> +<div class="form-horizontal"> + <div id="x-download-client-options-region"></div> + <div id="x-failed-download-handling-region"></div> +</div> -<fieldset class="form-horizontal"> - <legend>Options</legend> - <div class="control-group"> - <label class="control-label">Drone Factory</label> - - <div class="controls"> - <input type="text" name="downloadedEpisodesFolder" class="x-path"/> - <span class="help-inline"> - <i class="icon-nd-form-info" title="The folder where your download client downloads TV shows to (Completed Download Directory)"/> - <i class="icon-nd-form-warning" title="Do not use the folder that contains some or all of your sorted and named TV shows - doing so could cause data loss"></i> - </span> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js b/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js new file mode 100644 index 000000000..8a3b066b3 --- /dev/null +++ b/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js @@ -0,0 +1,11 @@ +'use strict'; +define( + [ + 'Settings/SettingsModelBase' + ], function (SettingsModelBase) { + return SettingsModelBase.extend({ + url : window.NzbDrone.ApiRoot + '/config/downloadclient', + successMessage: 'Download client settings saved', + errorMessage : 'Failed to save download client settings' + }); + }); diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js index 2cab9b5e1..6f75aaf8f 100644 --- a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js +++ b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js @@ -8,13 +8,14 @@ define( 'Settings/DownloadClient/Delete/DownloadClientDeleteView', 'Commands/CommandController', 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView', 'underscore', 'Form/FormBuilder', 'Mixins/AutoComplete', 'bootstrap' - ], function (vent, AppLayout, Marionette, DeleteView, CommandController, AsModelBoundView, _) { + ], function (vent, AppLayout, Marionette, DeleteView, CommandController, AsModelBoundView, AsValidatedView, _) { - var model = Marionette.ItemView.extend({ + var view = Marionette.ItemView.extend({ template: 'Settings/DownloadClient/Edit/DownloadClientEditViewTemplate', ui: { @@ -89,5 +90,8 @@ define( } }); - return AsModelBoundView.call(model); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingView.js b/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingView.js new file mode 100644 index 000000000..9af62d5dc --- /dev/null +++ b/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingView.js @@ -0,0 +1,37 @@ +'use strict'; +define( + [ + 'marionette', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (Marionette, AsModelBoundView, AsValidatedView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingViewTemplate', + + ui: { + failedDownloadHandlingCheckbox: '.x-failed-download-handling', + failedDownloadOptions : '.x-failed-download-options' + }, + + events: { + 'change .x-failed-download-handling': '_setFailedDownloadOptionsVisibility' + }, + + _setFailedDownloadOptionsVisibility: function () { + var checked = this.ui.failedDownloadHandlingCheckbox.prop('checked'); + if (checked) { + this.ui.failedDownloadOptions.slideDown(); + } + + else { + this.ui.failedDownloadOptions.slideUp(); + } + } + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; + }); diff --git a/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingViewTemplate.html b/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingViewTemplate.html new file mode 100644 index 000000000..90c7764e0 --- /dev/null +++ b/src/UI/Settings/DownloadClient/FailedDownloadHandling/FailedDownloadHandlingViewTemplate.html @@ -0,0 +1,65 @@ +<fieldset class="advanced-setting"> + <legend>Failed Download Handling</legend> + + <div class="control-group"> + <label class="control-label">Enable</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="enableFailedDownloadHandling" class="x-failed-download-handling"/> + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + + <span class="help-inline-checkbox"> + <i class="icon-nd-form-info" title="Process failed downloads and blacklist the release"/> + </span> + </div> + </div> + + <div class="x-failed-download-options"> + <div class="control-group"> + <label class="control-label">Redownload</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="autoRedownloadFailed"/> + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + + <span class="help-inline-checkbox"> + <i class="icon-nd-form-info" title="Automatically search for and attempt to download another release when a download fails?"/> + </span> + </div> + </div> + + <div class="control-group"> + <label class="control-label">Remove</label> + + <div class="controls"> + <label class="checkbox toggle well"> + <input type="checkbox" name="removeFailedDownloads"/> + <p> + <span>Yes</span> + <span>No</span> + </p> + + <div class="btn btn-primary slide-button"/> + </label> + + <span class="help-inline-checkbox"> + <i class="icon-nd-form-info" title="Automatically remove failed downloads from history and encrypted downloads from queue?"/> + </span> + </div> + </div> + </div> +</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsView.js b/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsView.js new file mode 100644 index 000000000..444bed1d8 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsView.js @@ -0,0 +1,26 @@ +'use strict'; +define( + [ + 'marionette', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView', + 'Mixins/AutoComplete' + ], function (Marionette, AsModelBoundView, AsValidatedView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/DownloadClient/Options/DownloadClientOptionsViewTemplate', + + ui: { + droneFactory : '.x-path' + }, + + onShow: function () { + this.ui.droneFactory.autoComplete('/directories'); + } + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; + }); diff --git a/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsViewTemplate.html b/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsViewTemplate.html new file mode 100644 index 000000000..888161027 --- /dev/null +++ b/src/UI/Settings/DownloadClient/Options/DownloadClientOptionsViewTemplate.html @@ -0,0 +1,14 @@ +<fieldset"> + <legend>Options</legend> + <div class="control-group"> + <label class="control-label">Drone Factory</label> + + <div class="controls"> + <input type="text" name="downloadedEpisodesFolder" class="x-path"/> + <span class="help-inline"> + <i class="icon-nd-form-info" title="The folder where your download client downloads TV shows to (Completed Download Directory)"/> + <i class="icon-nd-form-warning" title="Do not use the folder that contains some or all of your sorted and named TV shows - doing so could cause data loss"></i> + </span> + </div> + </div> +</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/General/GeneralSettingsModel.js b/src/UI/Settings/General/GeneralSettingsModel.js index c458cec87..5274cfbd8 100644 --- a/src/UI/Settings/General/GeneralSettingsModel.js +++ b/src/UI/Settings/General/GeneralSettingsModel.js @@ -5,9 +5,8 @@ define( ], function (SettingsModelBase) { return SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/settings/host', + url : window.NzbDrone.ApiRoot + '/config/host', successMessage: 'General settings saved', errorMessage : 'Failed to save general settings' - }); }); diff --git a/src/UI/Settings/General/GeneralView.js b/src/UI/Settings/General/GeneralView.js index 6971dc5f5..df61a4c71 100644 --- a/src/UI/Settings/General/GeneralView.js +++ b/src/UI/Settings/General/GeneralView.js @@ -2,10 +2,11 @@ define( [ 'marionette', - 'Mixins/AsModelBoundView' - ], function (Marionette, AsModelBoundView) { + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (Marionette, AsModelBoundView, AsValidatedView) { var view = Marionette.ItemView.extend({ - template: 'Settings/General/GeneralTemplate', + template: 'Settings/General/GeneralViewTemplate', events: { 'change .x-auth': '_setAuthOptionsVisibility', @@ -56,6 +57,9 @@ define( } }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/General/GeneralTemplate.html b/src/UI/Settings/General/GeneralViewTemplate.html similarity index 100% rename from src/UI/Settings/General/GeneralTemplate.html rename to src/UI/Settings/General/GeneralViewTemplate.html diff --git a/src/UI/Settings/Indexers/IndexerSettingsModel.js b/src/UI/Settings/Indexers/IndexerSettingsModel.js new file mode 100644 index 000000000..34ede06ee --- /dev/null +++ b/src/UI/Settings/Indexers/IndexerSettingsModel.js @@ -0,0 +1,11 @@ +'use strict'; +define( + [ + 'Settings/SettingsModelBase' + ], function (SettingsModelBase) { + return SettingsModelBase.extend({ + url : window.NzbDrone.ApiRoot + '/config/indexer', + successMessage: 'Indexer settings saved', + errorMessage : 'Failed to save indexer settings' + }); + }); diff --git a/src/UI/Settings/Indexers/Options/IndexerOptionsView.js b/src/UI/Settings/Indexers/Options/IndexerOptionsView.js index 2fa8f3a59..92b7ab9d9 100644 --- a/src/UI/Settings/Indexers/Options/IndexerOptionsView.js +++ b/src/UI/Settings/Indexers/Options/IndexerOptionsView.js @@ -2,12 +2,16 @@ define( [ 'marionette', - 'Mixins/AsModelBoundView' - ], function (Marionette, AsModelBoundView) { + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (Marionette, AsModelBoundView, AsValidatedView) { var view = Marionette.ItemView.extend({ template: 'Settings/Indexers/Options/IndexerOptionsViewTemplate' }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js b/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js index 8cd2c120e..c458724f3 100644 --- a/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js +++ b/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js @@ -3,37 +3,24 @@ define( [ 'marionette', 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView', 'Mixins/AutoComplete' - ], function (Marionette, AsModelBoundView) { + ], function (Marionette, AsModelBoundView, AsValidatedView) { var view = Marionette.ItemView.extend({ template: 'Settings/MediaManagement/FileManagement/FileManagementViewTemplate', ui: { - recyclingBin : '.x-path', - failedDownloadHandlingCheckbox: '.x-failed-download-handling', - failedDownloadOptions : '.x-failed-download-options' - }, - - events: { - 'change .x-failed-download-handling': '_setFailedDownloadOptionsVisibility' + recyclingBin : '.x-path' }, onShow: function () { this.ui.recyclingBin.autoComplete('/directories'); - }, - - _setFailedDownloadOptionsVisibility: function () { - var checked = this.ui.failedDownloadHandlingCheckbox.prop('checked'); - if (checked) { - this.ui.failedDownloadOptions.slideDown(); - } - - else { - this.ui.failedDownloadOptions.slideUp(); - } } }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.html b/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.html index a150cee6e..c984fbef1 100644 --- a/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.html +++ b/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.html @@ -52,69 +52,3 @@ </div> </div> </fieldset> - -<fieldset class="advanced-setting"> - <legend>Failed Download Handling</legend> - - <div class="control-group"> - <label class="control-label">Enable</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableFailedDownloadHandling" class="x-failed-download-handling"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-nd-form-info" title="Process failed downloads and blacklist the release"/> - </span> - </div> - </div> - - <div class="x-failed-download-options"> - <div class="control-group"> - <label class="control-label">Redownload</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="autoRedownloadFailed"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-nd-form-info" title="Automatically search for and attempt to download another release when a download fails?"/> - </span> - </div> - </div> - - <div class="control-group"> - <label class="control-label">Remove</label> - - <div class="controls"> - <label class="checkbox toggle well"> - <input type="checkbox" name="removeFailedDownloads"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-nd-form-info" title="Automatically remove failed downloads from history and encrypted downloads from queue?"/> - </span> - </div> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/MediaManagementLayout.js b/src/UI/Settings/MediaManagement/MediaManagementLayout.js index 3b5ac123e..e184c7b2f 100644 --- a/src/UI/Settings/MediaManagement/MediaManagementLayout.js +++ b/src/UI/Settings/MediaManagement/MediaManagementLayout.js @@ -4,7 +4,7 @@ define( [ 'marionette', 'Settings/MediaManagement/Naming/NamingView', - 'Settings/MediaManagement/Sorting/View', + 'Settings/MediaManagement/Sorting/SortingView', 'Settings/MediaManagement/FileManagement/FileManagementView', 'Settings/MediaManagement/Permissions/PermissionsView' ], function (Marionette, NamingView, SortingView, FileManagementView, PermissionsView) { diff --git a/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js b/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js new file mode 100644 index 000000000..1f27e23c3 --- /dev/null +++ b/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js @@ -0,0 +1,11 @@ +'use strict'; +define( + [ + 'Settings/SettingsModelBase' + ], function (SettingsModelBase) { + return SettingsModelBase.extend({ + url : window.NzbDrone.ApiRoot + '/config/mediamanagement', + successMessage: 'Media management settings saved', + errorMessage : 'Failed to save media managemnent settings' + }); + }); diff --git a/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js b/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js index e1a098106..f4ef2d225 100644 --- a/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js +++ b/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js @@ -3,8 +3,9 @@ define( [ 'marionette', 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView', 'Mixins/AutoComplete' - ], function (Marionette, AsModelBoundView) { + ], function (Marionette, AsModelBoundView, AsValidatedView) { var view = Marionette.ItemView.extend({ template: 'Settings/MediaManagement/Permissions/PermissionsViewTemplate', @@ -35,5 +36,8 @@ define( } }); - return AsModelBoundView.call(view); + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; }); diff --git a/src/UI/Settings/MediaManagement/Sorting/SortingView.js b/src/UI/Settings/MediaManagement/Sorting/SortingView.js new file mode 100644 index 000000000..fdb95b98c --- /dev/null +++ b/src/UI/Settings/MediaManagement/Sorting/SortingView.js @@ -0,0 +1,17 @@ +'use strict'; +define( + [ + 'marionette', + 'Mixins/AsModelBoundView', + 'Mixins/AsValidatedView' + ], function (Marionette, AsModelBoundView, AsValidatedView) { + + var view = Marionette.ItemView.extend({ + template: 'Settings/MediaManagement/Sorting/SortingViewTemplate' + }); + + AsModelBoundView.call(view); + AsValidatedView.call(view); + + return view; + }); diff --git a/src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.html similarity index 100% rename from src/UI/Settings/MediaManagement/Sorting/ViewTemplate.html rename to src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.html diff --git a/src/UI/Settings/MediaManagement/Sorting/View.js b/src/UI/Settings/MediaManagement/Sorting/View.js deleted file mode 100644 index 18e7d3d37..000000000 --- a/src/UI/Settings/MediaManagement/Sorting/View.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; -define( - [ - 'marionette', - 'Mixins/AsModelBoundView' - ], function (Marionette, AsModelBoundView) { - - var view = Marionette.ItemView.extend({ - template: 'Settings/MediaManagement/Sorting/ViewTemplate' - }); - - return AsModelBoundView.call(view); - }); diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js index 6711658cb..122d84307 100644 --- a/src/UI/Settings/SettingsLayout.js +++ b/src/UI/Settings/SettingsLayout.js @@ -2,17 +2,20 @@ define( [ 'jquery', + 'underscore', 'vent', 'marionette', 'backbone', - 'Settings/SettingsModel', 'Settings/General/GeneralSettingsModel', 'Settings/MediaManagement/Naming/NamingModel', 'Settings/MediaManagement/MediaManagementLayout', + 'Settings/MediaManagement/MediaManagementSettingsModel', 'Settings/Quality/QualityLayout', 'Settings/Indexers/IndexerLayout', 'Settings/Indexers/Collection', + 'Settings/Indexers/IndexerSettingsModel', 'Settings/DownloadClient/DownloadClientLayout', + 'Settings/DownloadClient/DownloadClientSettingsModel', 'Settings/Notifications/CollectionView', 'Settings/Notifications/Collection', 'Settings/Metadata/MetadataLayout', @@ -20,17 +23,20 @@ define( 'Shared/LoadingView', 'Config' ], function ($, + _, vent, Marionette, Backbone, - SettingsModel, GeneralSettingsModel, NamingModel, MediaManagementLayout, + MediaManagementSettingsModel, QualityLayout, IndexerLayout, IndexerCollection, + IndexerSettingsModel, DownloadClientLayout, + DownloadClientSettingsModel, NotificationCollectionView, NotificationCollection, MetadataLayout, @@ -84,26 +90,31 @@ define( this.loading.show(new LoadingView()); var self = this; - this.settings = new SettingsModel(); - this.generalSettings = new GeneralSettingsModel(); + this.mediaManagementSettings = new MediaManagementSettingsModel(); this.namingSettings = new NamingModel(); - this.indexerSettings = new IndexerCollection(); - this.notificationSettings = new NotificationCollection(); + this.indexerSettings = new IndexerSettingsModel(); + this.indexerCollection = new IndexerCollection(); + this.downloadClientSettings = new DownloadClientSettingsModel(); + this.notificationCollection = new NotificationCollection(); + this.generalSettings = new GeneralSettingsModel(); - Backbone.$.when(this.settings.fetch(), - this.generalSettings.fetch(), + Backbone.$.when( + this.mediaManagementSettings.fetch(), this.namingSettings.fetch(), this.indexerSettings.fetch(), - this.notificationSettings.fetch() + this.indexerCollection.fetch(), + this.downloadClientSettings.fetch(), + this.notificationCollection.fetch(), + this.generalSettings.fetch() ).done(function () { if(!self.isClosed) { self.loading.$el.hide(); - self.mediaManagement.show(new MediaManagementLayout({ settings: self.settings, namingSettings: self.namingSettings })); - self.quality.show(new QualityLayout({ settings: self.settings })); - 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.mediaManagement.show(new MediaManagementLayout({ settings: self.mediaManagementSettings, namingSettings: self.namingSettings })); + self.quality.show(new QualityLayout()); + self.indexers.show(new IndexerLayout({ settings: self.indexerSettings, indexersCollection: self.indexerCollection })); + self.downloadClient.show(new DownloadClientLayout({ model: self.downloadClientSettings })); + self.notifications.show(new NotificationCollectionView({ collection: self.notificationCollection })); self.metadata.show(new MetadataLayout()); self.general.show(new GeneralView({ model: self.generalSettings })); } @@ -204,7 +215,7 @@ define( }, _navigate:function(route){ - Backbone.history.navigate(route, { trigger: true, replace: true }); + Backbone.history.navigate(route, { trigger: false, replace: true }); }, _save: function () { diff --git a/src/UI/Settings/SettingsModel.js b/src/UI/Settings/SettingsModel.js deleted file mode 100644 index 0b5da8dd2..000000000 --- a/src/UI/Settings/SettingsModel.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; -define( - [ - 'Settings/SettingsModelBase' - ], function (SettingsModelBase) { - return SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/settings', - successMessage: 'Settings saved', - errorMessage : 'Failed to save settings' - }); - }); From 207ffd1e5a3c4281ba8f73ba681ccc7b0e539336 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 16 Feb 2014 09:05:19 -0800 Subject: [PATCH 11/42] Fixed root folder integration test --- src/NzbDrone.Api/Config/DownloadClientConfigModule.cs | 4 +++- src/NzbDrone.Api/RootFolders/RootFolderModule.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs b/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs index 16c4dfd3b..31e56685e 100644 --- a/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs +++ b/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs @@ -9,8 +9,10 @@ namespace NzbDrone.Api.Config { public DownloadClientConfigModule(IConfigService configService, RootFolderValidator rootFolderValidator, PathExistsValidator pathExistsValidator) : base(configService) - { + { SharedValidator.RuleFor(c => c.DownloadedEpisodesFolder) + .Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() .SetValidator(rootFolderValidator) .SetValidator(pathExistsValidator) .When(c => !String.IsNullOrWhiteSpace(c.DownloadedEpisodesFolder)); diff --git a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs index edcf69fe5..be1c43674 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs +++ b/src/NzbDrone.Api/RootFolders/RootFolderModule.cs @@ -27,7 +27,9 @@ namespace NzbDrone.Api.RootFolders CreateResource = CreateRootFolder; DeleteResource = DeleteFolder; - SharedValidator.RuleFor(c => c.Path).IsValidPath() + SharedValidator.RuleFor(c => c.Path) + .Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() .SetValidator(rootFolderValidator) .SetValidator(pathExistsValidator) .SetValidator(droneFactoryValidator); From 6b389d26431f5b0a486d79f736235a275fff4a0d Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 16 Feb 2014 23:02:45 -0800 Subject: [PATCH 12/42] Reordered migrations after rebase --- ...oad_clients_table.cs => 042_add_download_clients_table.cs} | 2 +- ...d_clients.cs => 043_convert_config_to_download_clients.cs} | 3 +-- src/NzbDrone.Core/NzbDrone.Core.csproj | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) rename src/NzbDrone.Core/Datastore/Migration/{041_add_download_clients_table.cs => 042_add_download_clients_table.cs} (97%) rename src/NzbDrone.Core/Datastore/Migration/{042_convert_config_to_download_clients.cs => 043_convert_config_to_download_clients.cs} (99%) diff --git a/src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs b/src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs similarity index 97% rename from src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs rename to src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs index 7c11b9e21..08cf7622b 100644 --- a/src/NzbDrone.Core/Datastore/Migration/041_add_download_clients_table.cs +++ b/src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs @@ -3,7 +3,7 @@ using NzbDrone.Core.Datastore.Migration.Framework; namespace NzbDrone.Core.Datastore.Migration { - [Migration(41)] + [Migration(42)] public class add_download_clients_table : NzbDroneMigrationBase { protected override void MainDbUpgrade() diff --git a/src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs b/src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs similarity index 99% rename from src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs rename to src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs index 807dca491..c83e3e20b 100644 --- a/src/NzbDrone.Core/Datastore/Migration/042_convert_config_to_download_clients.cs +++ b/src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; using System.Data; -using System.Runtime.Remoting.Messaging; using FluentMigrator; using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore.Migration.Framework; namespace NzbDrone.Core.Datastore.Migration { - [Migration(42)] + [Migration(43)] public class convert_config_to_download_clients : NzbDroneMigrationBase { protected override void MainDbUpgrade() diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index efa52abe7..a87eaeb6c 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -198,8 +198,8 @@ <Compile Include="Datastore\Migration\040_add_metadata_to_episodes_and_series.cs" /> <Compile Include="Datastore\Migration\039_add_metadata_tables.cs" /> <Compile Include="Datastore\Migration\041_fix_xbmc_season_images_metadata.cs" /> - <Compile Include="Datastore\Migration\041_add_download_clients_table.cs" /> - <Compile Include="Datastore\Migration\042_convert_config_to_download_clients.cs" /> + <Compile Include="Datastore\Migration\042_add_download_clients_table.cs" /> + <Compile Include="Datastore\Migration\043_convert_config_to_download_clients.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationExtension.cs" /> From f9312eb3e564cf692b47f7201ba653325e560a33 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Mon, 17 Feb 2014 17:57:20 -0800 Subject: [PATCH 13/42] Fixed a copy pasta error for SAB history --- src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs | 2 +- src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs | 2 +- src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs | 2 +- src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs | 2 +- src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs | 2 +- src/NzbDrone.Core/Download/DownloadClientBase.cs | 2 +- src/UI/.idea/jsLinters/jshint.xml | 4 ++-- .../Settings/DownloadClient/Add/DownloadClientAddItemView.js | 4 +--- 8 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs index 28cc9eb9e..8b5ff0242 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/Blackhole.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole return new QueueItem[0]; } - public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 10) { return new HistoryItem[0]; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index 6133a631f..aa172e449 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -65,7 +65,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget } } - public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 10) { return new HistoryItem[0]; } diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index 3b63d5383..3c875b9c8 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic return new QueueItem[0]; } - public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 10) { return new HistoryItem[0]; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index c9c7ef395..79777d009 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -89,7 +89,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd }, TimeSpan.FromSeconds(10)); } - public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0) + public override IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 10) { var items = _sabnzbdProxy.GetHistory(start, limit, Settings).Items; var historyItems = new List<HistoryItem>(); diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index 59fac17dd..be2c499d4 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -108,7 +108,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd public SabnzbdHistory GetHistory(int start, int limit, SabnzbdSettings settings) { var request = new RestRequest(); - var action = String.Format("mode=queue&start={0}&limit={1}", start, limit); + var action = String.Format("mode=history&start={0}&limit={1}", start, limit); var response = ProcessRequest(request, action, settings); return Json.Deserialize<SabnzbdHistory>(JObject.Parse(response).SelectToken("history").ToString()); diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index b86eda48f..157b1e855 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Download public abstract string DownloadNzb(RemoteEpisode remoteEpisode); public abstract IEnumerable<QueueItem> GetQueue(); - public abstract IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 0); + public abstract IEnumerable<HistoryItem> GetHistory(int start = 0, int limit = 10); public abstract void RemoveFromQueue(string id); public abstract void RemoveFromHistory(string id); } diff --git a/src/UI/.idea/jsLinters/jshint.xml b/src/UI/.idea/jsLinters/jshint.xml index e85398a55..4e0df49ad 100644 --- a/src/UI/.idea/jsLinters/jshint.xml +++ b/src/UI/.idea/jsLinters/jshint.xml @@ -8,16 +8,16 @@ <option es3="false" /> <option forin="true" /> <option immed="true" /> + <option latedef="true" /> <option newcap="true" /> <option noarg="true" /> <option noempty="false" /> <option nonew="true" /> <option plusplus="false" /> <option undef="true" /> + <option unused="true" /> <option strict="true" /> <option trailing="false" /> - <option latedef="true" /> - <option unused="true" /> <option quotmark="single" /> <option maxdepth="3" /> <option asi="false" /> diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js index 9ce89a4ee..beab52273 100644 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js +++ b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js @@ -26,9 +26,7 @@ define([ this.model.set({ id : undefined, name : this.model.get('implementationName'), - onGrab : true, - onDownload : true, - onUpgrade : true + enable : true }); var editView = new EditView({ model: this.model, downloadClientCollection: this.downloadClientCollection }); From 55383502ca7511a96e757480a995bc6b1a19a5a1 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Mon, 17 Feb 2014 20:15:25 -0800 Subject: [PATCH 14/42] Fixed: Do not set display season/episode for XBMC metadata --- src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index e9f58aa39..2579511e7 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -329,8 +329,11 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc 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)); + + //If trakt ever gets airs before information for specials we should add set it + details.Add(new XElement("displayseason")); + details.Add(new XElement("displayepisode")); + 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)); From 1dec725941feed28e7f979ab0131f8618a4e0e80 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Mon, 17 Feb 2014 20:50:13 -0800 Subject: [PATCH 15/42] Fixed: Getting root folders with invalid paths --- src/NzbDrone.Core/RootFolders/RootFolderService.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/RootFolders/RootFolderService.cs b/src/NzbDrone.Core/RootFolders/RootFolderService.cs index b659f809d..ea5285327 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderService.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderService.cs @@ -29,6 +29,7 @@ namespace NzbDrone.Core.RootFolders private readonly IDiskProvider _diskProvider; private readonly ISeriesRepository _seriesRepository; private readonly IConfigService _configService; + private readonly Logger _logger; private static readonly HashSet<string> SpecialFolders = new HashSet<string> { "$recycle.bin", "system volume information", "recycler", "lost+found" }; @@ -36,12 +37,14 @@ namespace NzbDrone.Core.RootFolders public RootFolderService(IRootFolderRepository rootFolderRepository, IDiskProvider diskProvider, ISeriesRepository seriesRepository, - IConfigService configService) + IConfigService configService, + Logger logger) { _rootFolderRepository = rootFolderRepository; _diskProvider = diskProvider; _seriesRepository = seriesRepository; _configService = configService; + _logger = logger; } public List<RootFolder> All() @@ -57,7 +60,7 @@ namespace NzbDrone.Core.RootFolders rootFolders.ForEach(folder => { - if (_diskProvider.FolderExists(folder.Path)) + if (folder.Path.IsPathValid() && _diskProvider.FolderExists(folder.Path)) { folder.FreeSpace = _diskProvider.GetAvailableSpace(folder.Path); folder.UnmappedFolders = GetUnmappedFolders(folder.Path); From cbd8e986773edac5025079707d2b8d03c02d9d82 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Tue, 18 Feb 2014 20:51:37 -0800 Subject: [PATCH 16/42] More xbmc metadata improvements New: Create/update episode metadata when series is refreshed Fixed: Episode Metadata when screenshot is not available Fixed: Episode metadata being stored in database incorrectly Fixed: Do not create metadata when series folder does not exist --- .../044_fix_xbmc_episode_metadata.cs | 27 ++++++ .../MetaData/Consumers/Xbmc/XbmcMetadata.cs | 91 +++++++++++++++---- .../MetaData/Files/CleanMetadataService.cs | 45 +++++++++ src/NzbDrone.Core/MetaData/MetadataService.cs | 10 +- .../Metadata/Files/MetadataFileService.cs | 6 ++ src/NzbDrone.Core/Metadata/MetadataBase.cs | 5 - src/NzbDrone.Core/NzbDrone.Core.csproj | 4 + .../RootFolders/RootFolderLayoutTemplate.html | 1 + 8 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs create mode 100644 src/NzbDrone.Core/MetaData/Files/CleanMetadataService.cs diff --git a/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs b/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs new file mode 100644 index 000000000..0c645259b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs @@ -0,0 +1,27 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(44)] + public class fix_xbmc_episode_metadata : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + //Convert Episode Metadata to proper type + Execute.Sql("UPDATE MetadataFiles " + + "SET Type = 2 " + + "WHERE Consumer = 'XbmcMetadata' " + + "AND EpisodeFileId IS NOT NULL " + + "AND Type = 4 " + + "AND RelativePath LIKE '%.nfo'"); + + //Convert Episode Images to proper type + Execute.Sql("UPDATE MetadataFiles " + + "SET Type = 5 " + + "WHERE Consumer = 'XbmcMetadata' " + + "AND EpisodeFileId IS NOT NULL " + + "AND Type = 4"); + } + } +} diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index 2579511e7..d95eaf648 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -9,6 +9,7 @@ using System.Xml.Linq; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; +using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; @@ -25,6 +26,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc private readonly IMetadataFileService _metadataFileService; private readonly IDiskProvider _diskProvider; private readonly IHttpProvider _httpProvider; + private readonly IEpisodeService _episodeService; private readonly Logger _logger; public XbmcMetadata(IEventAggregator eventAggregator, @@ -33,6 +35,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc IMetadataFileService metadataFileService, IDiskProvider diskProvider, IHttpProvider httpProvider, + IEpisodeService episodeService, Logger logger) : base(diskProvider, httpProvider, logger) { @@ -42,6 +45,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc _metadataFileService = metadataFileService; _diskProvider = diskProvider; _httpProvider = httpProvider; + _episodeService = episodeService; _logger = logger; } @@ -51,40 +55,63 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc public override void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles) { + if (!_diskProvider.FolderExists(series.Path)) + { + _logger.Info("Series folder does not exist, skipping metadata creation"); + return; + } + 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); } + + var episodeFiles = GetEpisodeFiles(series.Id); + + foreach (var episodeFile in episodeFiles) + { + if (Settings.EpisodeMetadata) + { + WriteEpisodeNfo(series, episodeFile, existingMetadataFiles); + } + } + + foreach (var episodeFile in episodeFiles) + { + if (Settings.EpisodeImages) + { + WriteEpisodeImages(series, episodeFile, existingMetadataFiles); + } + } } public override void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload) { if (Settings.EpisodeMetadata) { - WriteEpisodeNfo(series, episodeFile); + WriteEpisodeNfo(series, episodeFile, new List<MetadataFile>()); } if (Settings.EpisodeImages) { - WriteEpisodeImages(series, episodeFile); + WriteEpisodeImages(series, episodeFile, new List<MetadataFile>()); } } public override void AfterRename(Series series) { + //TODO: This should be part of the base class, but could be overwritten if the logic needs to be different + //or it could be done in MetadataService instead of having each metadata consumer do it var episodeFiles = _mediaFileService.GetFilesBySeries(series.Id); var episodeFilesMetadata = _metadataFileService.GetFilesBySeries(series.Id).Where(c => c.EpisodeFileId > 0).ToList(); @@ -305,7 +332,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc } } - private void WriteEpisodeNfo(Series series, EpisodeFile episodeFile) + private void WriteEpisodeNfo(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) { var filename = episodeFile.Path.Replace(Path.GetExtension(episodeFile.Path), ".nfo"); @@ -322,6 +349,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc using (var xw = XmlWriter.Create(sb, xws)) { var doc = new XDocument(); + var image = episode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); var details = new XElement("episodedetails"); details.Add(new XElement("title", episode.Title)); @@ -333,8 +361,17 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc //If trakt ever gets airs before information for specials we should add set it details.Add(new XElement("displayseason")); details.Add(new XElement("displayepisode")); + + if (image == null) + { + details.Add(new XElement("thumb")); + } + + else + { + details.Add(new XElement("thumb", image.Url)); + } - 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)); @@ -353,19 +390,21 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc _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) - }; + var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeMetadata && + c.EpisodeFileId == episodeFile.Id) ?? + new MetadataFile + { + SeriesId = series.Id, + EpisodeFileId = episodeFile.Id, + Consumer = GetType().Name, + Type = MetadataType.EpisodeMetadata, + RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) + }; _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); } - private void WriteEpisodeImages(Series series, EpisodeFile episodeFile) + private void WriteEpisodeImages(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) { var screenshot = episodeFile.Episodes.Value.First().Images.Single(i => i.CoverType == MediaCoverTypes.Screenshot); @@ -373,16 +412,32 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc DownloadImage(series, screenshot.Url, filename); - var metadata = new MetadataFile + var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeImage && + c.EpisodeFileId == episodeFile.Id) ?? + new MetadataFile { SeriesId = series.Id, EpisodeFileId = episodeFile.Id, Consumer = GetType().Name, - Type = MetadataType.SeasonImage, + Type = MetadataType.EpisodeImage, RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) }; _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); } + + private List<EpisodeFile> GetEpisodeFiles(int seriesId) + { + var episodeFiles = _mediaFileService.GetFilesBySeries(seriesId); + var episodes = _episodeService.GetEpisodeBySeries(seriesId); + + foreach (var episodeFile in episodeFiles) + { + var localEpisodeFile = episodeFile; + episodeFile.Episodes = new LazyList<Episode>(episodes.Where(e => e.EpisodeFileId == localEpisodeFile.Id)); + } + + return episodeFiles; + } } } diff --git a/src/NzbDrone.Core/MetaData/Files/CleanMetadataService.cs b/src/NzbDrone.Core/MetaData/Files/CleanMetadataService.cs new file mode 100644 index 000000000..cdcbb0088 --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Files/CleanMetadataService.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Metadata.Files +{ + public interface ICleanMetadataService + { + void Clean(Series series); + } + + public class CleanMetadataService : ICleanMetadataService + { + private readonly IMetadataFileService _metadataFileService; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public CleanMetadataService(IMetadataFileService metadataFileService, + IDiskProvider diskProvider, + Logger logger) + { + _metadataFileService = metadataFileService; + _diskProvider = diskProvider; + _logger = logger; + } + + public void Clean(Series series) + { + _logger.Trace("Cleaning missing metadata files for series: {0}", series.Title); + + var metadataFiles = _metadataFileService.GetFilesBySeries(series.Id); + + foreach (var metadataFile in metadataFiles) + { + if (!_diskProvider.FileExists(Path.Combine(series.Path, metadataFile.RelativePath))) + { + _logger.Trace("Deleting metadata file from database: {0}", metadataFile.RelativePath); + _metadataFileService.Delete(metadataFile.Id); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MetaData/MetadataService.cs b/src/NzbDrone.Core/MetaData/MetadataService.cs index 461b992d2..008354c75 100644 --- a/src/NzbDrone.Core/MetaData/MetadataService.cs +++ b/src/NzbDrone.Core/MetaData/MetadataService.cs @@ -7,24 +7,30 @@ using NzbDrone.Core.Metadata.Files; namespace NzbDrone.Core.Metadata { - public class NotificationService + public class MetadataService : IHandle<MediaCoversUpdatedEvent>, IHandle<EpisodeImportedEvent>, IHandle<SeriesRenamedEvent> { private readonly IMetadataFactory _metadataFactory; private readonly IMetadataFileService _metadataFileService; + private readonly ICleanMetadataService _cleanMetadataService; private readonly Logger _logger; - public NotificationService(IMetadataFactory metadataFactory, IMetadataFileService metadataFileService, Logger logger) + public MetadataService(IMetadataFactory metadataFactory, + IMetadataFileService metadataFileService, + ICleanMetadataService cleanMetadataService, + Logger logger) { _metadataFactory = metadataFactory; _metadataFileService = metadataFileService; + _cleanMetadataService = cleanMetadataService; _logger = logger; } public void Handle(MediaCoversUpdatedEvent message) { + _cleanMetadataService.Clean(message.Series); var seriesMetadata = _metadataFileService.GetFilesBySeries(message.Series.Id); foreach (var consumer in _metadataFactory.Enabled()) diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs index f888bfdb8..56471a0b1 100644 --- a/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Metadata.Files MetadataFile FindByPath(string path); List<string> FilterExistingFiles(List<string> files, Series series); MetadataFile Upsert(MetadataFile metadataFile); + void Delete(int id); } public class MetadataFileService : IMetadataFileService, @@ -72,6 +73,11 @@ namespace NzbDrone.Core.Metadata.Files return _repository.Upsert(metadataFile); } + public void Delete(int id) + { + _repository.Delete(id); + } + public void HandleAsync(SeriesDeletedEvent message) { _logger.Trace("Deleting Metadata from database for series: {0}", message.Series); diff --git a/src/NzbDrone.Core/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Metadata/MetadataBase.cs index dbd613f1d..29143a424 100644 --- a/src/NzbDrone.Core/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -55,11 +55,6 @@ namespace NzbDrone.Core.Metadata } } - protected virtual void EnsureFolder(string path) - { - _diskProvider.CreateFolder(path); - } - protected virtual void DownloadImage(Series series, string url, string path) { try diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index a87eaeb6c..6de48e0c3 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -200,6 +200,9 @@ <Compile Include="Datastore\Migration\041_fix_xbmc_season_images_metadata.cs" /> <Compile Include="Datastore\Migration\042_add_download_clients_table.cs" /> <Compile Include="Datastore\Migration\043_convert_config_to_download_clients.cs" /> + <Compile Include="Datastore\Migration\044_fix_xbmc_episode_metadata.cs"> + <SubType>Code</SubType> + </Compile> <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationExtension.cs" /> @@ -334,6 +337,7 @@ <Compile Include="MetadataSource\Trakt\Actor.cs" /> <Compile Include="MetadataSource\Trakt\People.cs" /> <Compile Include="MetadataSource\Trakt\Ratings.cs" /> + <Compile Include="Metadata\Files\CleanMetadataService.cs" /> <Compile Include="Metadata\Consumers\Fake\Fake.cs" /> <Compile Include="Metadata\Consumers\Fake\FakeSettings.cs" /> <Compile Include="Metadata\Consumers\Xbmc\XbmcMetadata.cs" /> diff --git a/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.html b/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.html index 390b3c056..c8ba616c5 100644 --- a/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.html +++ b/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.html @@ -4,6 +4,7 @@ </div> <div class="modal-body root-folders-modal"> <div class="validation-errors"></div> + <div class="alert alert-info">Enter the path that contains some or all of your TV series, you will be able to choose which series you want to import<button type="button" class="close" data-dismiss="alert">×</button></div> <div class="input-prepend input-append x-path control-group"> <span class="add-on"> <i class="icon-folder-open"></i></span> <input class="span9" type="text" validation-name="path" placeholder="Enter path to folder that contains your shows"> From c5a3b714e6a8ab930583acece35c8ff754eec00b Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Tue, 18 Feb 2014 22:25:44 -0800 Subject: [PATCH 17/42] Fixed XBMC notification logo --- Logo/64.png | Bin 1494 -> 3582 bytes Logo/twitter.banner.fw.png | Bin 80832 -> 0 bytes Logo/twitter.fw.png | Bin 66162 -> 0 bytes Logo/twitter.wall.fw.png | Bin 94674 -> 0 bytes .../Notifications/Xbmc/JsonApiProvider.cs | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 Logo/twitter.banner.fw.png delete mode 100644 Logo/twitter.fw.png delete mode 100644 Logo/twitter.wall.fw.png diff --git a/Logo/64.png b/Logo/64.png index 8ef72896e811d3436fb8a7021cb1deec6ccfc31f..33387d7f9372b0951d676da70a366cf594312de4 100644 GIT binary patch literal 3582 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 Ef+h&)O8@`> literal 1494 zcmd5+`BPJ86#eoD$&)|>MIp$PL=&(rQ6W*8I%q<r5G)|z76u#=qAY@fB?6-5y+|Pm z1{Y*)u>=>iEh^}Y#g_3AK!^jC;8Ibs3J3~{0f7R6<ZJBokLVBQp1b^Z?zvYT5gzPd zx6}>*;1D7TjIy%u15m^oJ!!*gD=kVFMP~p&`}hM$_nzf0SexX;fUp1n8jc^Ck34Oy zSu#;n7yvn*0N{B5%t0%U0C3O;fKdqm>ni|oO)HMO9Rz@FXh>kdw(KYI^6?_e!jN~X zf!yTZ2S{!2ErS=0&oTQ*`oi42D+?EkD=I(pdo5s?^G<3UDeQ<Ur%i5b)SMVEk(j!h zH8uD)Aomx7d#@O_LPnu3{cPsRqpBIf**sRssxjl%apX5L;&v+(Gm!S}xF1&8V|O#< zT;kblT2A(ibmjZxC5M?u{G-PrIkyBi$&R!`3bT7^jX^*eAz&Lc?_8Ltt2odsqkOUS zB{Lxcuw&^|%#ESQwb-ufU&^1{<5H{a3eBpl%;n>2iu)aK5Wnosh%iRM>A)yO0?5VS z!-YxL*EuJTK?pnbv((zR^ao0DUn_cj31ifSfJjePP(cQ_>uGQFejeX9?}7*cq`(pr zA)z+FL<8mbn0H3O1p?ygmws%vs8G@G%4Sd0fE9{J9h%uNW+Y4){ABl@zwWlbuTq~n zQ*sHQtQL=1ot`yoBqpw6E|_Jnd9HIBLMA0hi{0Y$qP%Bp*or}j5!IL+F6?U8WggF0 z=O+n(RRNuWXEysybudc_$O?JwzP7JuZ871?(=W`&O^v<s55KV?m75Bov&>ltoW?<Y z)8zbgu-QnYY3yNg+!aW<c&Q|fz{|#DGA}O=fT+~zdr0fbvmfzW9GxlgoR$FL8~i}k z)L{8UHAbjZh*h)Yyr)j`HT*{TfQA0~gJlJl7NO%jezV%9mQv2drOfHU@-_lu=p4!F zh)>lAv}|$n(1!ZOIo<l~^867u{(bkLg4LlKf+5`g5Zkr|Nm;+H0LDX$&DT=NAy8+` zRs6EAE;*#%h@Gg$T+*)%WGxqhhKK=Ew?@+~W-xtycS)r=U2Rj}t0z{l+2<RY(fwSl z0+XyZ%Oo!)^H)tX)vvk*L70V?m*L|DOb+5)@J9J-sD|5knj{3Lj3kMU%Js%QDZS(H z#n0u6HxOH1%og<=bgBKJE43E0v}16keO*+&C#MI4iXUU!@0gfb!z7gDwYk{9^g+t9 zn%5uCFL|PLI!{}y434h!ORu-+YFv9)$>*knM~#qZEJ9FLp?@>#9itEbuDP$NymXF2 z<GbIeut}gxF4v74pQrU@T9@eDiX&d>L)m4eZYgUgnXw|h<@U~VB(eb<2F0{3NGC|} zs(b9jSX78v5cVkZqcK2QGwuO~Em@%&I_}Y2R)|mIeFSWR2V6e-cP1g1nlx7{@O{Z| ze?oh`2TUifZ?r|ViW$&EQ`}d=fHTxs4}fX|`bk>$M&8i@g$8k8;gozTgP~6wMaTxp z1b7TsctH~tAW8be|LYXpBIgf?&+9}J#T(-7IY~6DSqyiRqP8L)G2>#gqmVoM%z>Qp zI6AAr+)*{OJ3*5DhhG18wOyyGmtl#MN#FG&m#J-X8^h6=P)(zY5+OTE>9Mi|Q-{ja zo^{>8eSqc;L;LOu29w7hv`gO6*O_5Gm3-9;skSwKrG9f5&-#QN=AzL56jP0KKPNUl unih80kR6Y)1kCM!Vs!MyP(KCr0Y;5-?>EkzZ>>KVgnShqc;!pU(SHCcJdV5o diff --git a/Logo/twitter.banner.fw.png b/Logo/twitter.banner.fw.png deleted file mode 100644 index 83c839d77bbaf5b60eb1f3f816137f65dfa9c074..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80832 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 diff --git a/Logo/twitter.fw.png b/Logo/twitter.fw.png deleted file mode 100644 index c722627ce1844dec807053eed9b2e7a6c3913e12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66162 zcmbrkb8se4^yr;zY}?%E2{!h|ww;Y_+qP}nwzF}vv2B~r&G&cTs`vi+)_r?wPWPO1 zy65y%P0yL?Pq@6S7y>LVEC>h)f`quRA_xfRcM}u@8uGi+b|^FXuD~4yC6uARix;#} z*!MMzow$Z02nZbNe<$etrq9Q>64OaU-ATy7_>YshtqrlVxwSC}BRc~l8v_%w57^Q8 zw+H@z9!j<*POg6(j6np9Yz>TwMa><IU2PpK9f^gM*iO0{qP~ZuuyasVbIZEavX&!T z#>g?zG966|`bQZVdD{KxB~cW>L@8`}iK?P;s8w8{QLfmFQrAiq8SMvsr}V=73K|cT z0t&3RGaPR?UQM_WU(h6Do21oyd*^y?<$9Wa-23{xxw_<J1J$hIK1;s<5B2IB^r8#H zW=+_}kyagJqOE7u>ssC$vJ>7O+%(T~)@<EVqDRr?YodqSPAs^mmGOKHr%hG~$!hDP zorC#294k0izV>#EAB`L91Xl<y6QBB?ZTw$*`d!b(O<KS@jW1t&`(FgW&qemTql?Q6 z2kjn@Rp9dC;;S2DTTjncJK^Od!xntVOfJ9L=EbFTDxihIH%o_L(`tquZUN@u-onA% z%ihDsnJZV~@$IDZ6-kZZ{j-ET6SRYqTP$z&aC&b1qjP-v_QT4~(TNZH>H@LObkTj) z<KuDo;Kah`)`QR2^95^B+sDy^sXa%}%a^+M>*=@8Q|!X(_{ZDbJr-E6dsXRp<sApz zBlFtUr-SG7&g0gWn=8xKHt^?bkNJW3XYGCB!fEWq`C#{Q=llNWadF7yvIN=eY@gDc zw~DJnR0cP2cdwdlcKlZV&c%T(T}LNn4bR~+8$Y)6KC-c}a&V!skUg>g^<pa0-?P!d z>u7Uc05P*z|F4y4Vr8KaTh(8H=gc~g-^Z|!Hdxv7S!4fmuQsJj1ZJbP6KCYiv)^|f zqoUhC<)Oo?{&w9pvbUHV@ON_zY1sl>l}ONL6RcMQsyDx>hL|-cq$Oh+ivxt(vTZQ? z5U4LDL<>&ajVIhMDxWhXv|r0)&sv8g<lnNKaGppwx(2q#UKNRw%1{f@k+%qab#%}5 zGR5^`sk*NEet=uloB{H_K2xbIulE%T5PT&HsM$dc6wUg~7w`aYz71~jPf5bQ_p{3l zOi74QNApz?oDC0vB#2iZ!Z2Wsj}YnoHKOGljFKTq7l!0^jq-l36MO#roVv}ynTU;U zbJwE+wGdJoCj6@`aE5g9-s2tT2($uWHlRD@^L<yydS90zkONB_j~sAn>AV5et0jM0 zv6poSY09)w3~f2D=#_A&edUb0#({;Yn#Z=|sVmt<%NLJ$?<qh~fxzYxNjVXhRs z4S&<0butXG_OI5Bw2Ai_l)IyIY#T#ByKrA9H#3{ZH{;mdod)Pk`s$}5)viF)OQN1F zhBajGgkHC%d1Q9V^xzh=bC5T~{D-2)$vn!eAHLAgj~@{0>{-?3I9eX@8r5PDwjH&H z=85W9x#iKGh&4oMA+I%4o5B_WI}J^9pr;gVZtd?VW=OL*SZ@&9;$7V|Yr+}uIyu^W z6I`CFJAHk?8IK8J<1Y4EyFg~{Z?3r1uUFVIs&2#Q{A6?dfm>u7DC)sx>&{t797Pr0 z3+q4O;1escT2OJrFAudF&ST1wvWFHU=HN5BnziOV{1V8w_TV@VHH9<%gZhJYyo<1C z#R|ft!~&Xt%*J;VTIpq{g>YfczcnY+rS!Tw?paH{qf1&d8c5#n#MWMS2zO|Rde-TH zT#@3iEU=L(>mVgnc?<FEvEeBzn0zQ5EP|-tW7q`pnm?+6vSgtVye-a@O^AY64#MDd z=z9c7C^q!!K-OnV>XUFv1q8bu;mn<JgWdJg=e?F!1uxRpQ5z#N<kD;)w72;k)D}Ra z(KB%uI1zvxFa)CT0Wf8dYlyjjY8iD?Z<?h$@s61^qYcm1=PLd|{1t6#XvcBC<|w-s zT($B>yvGjqfxV!hxA@g+aFMIubp(GY2{;k;!F%MQ0AAzIk?2-#uLzfGrmgXeSbq35 zoyW69ak8Vn=#;8nHFvxPm>$E7Sc+aY>`R8W+E2QZ`O8tVjcHbuK8rUOMy0Jb+dd-k z`EA}tRkXXo>*=%ISEhvfW^O{0A$E6eI-nrC@tM)Y^hP+k(ETu5CM<VB9#xbFy~KGe zLWP**>teGgW(i<mdhdS#y{vdZQml5&#N0rl`U&q1K5g$Mww_Os(JmIj-^dObxrkFd zlt)|vy1)`6x3~DbHpL?rbF7|x40Q%ir~djkw=Nsb$UM>xd|P5yLjAWpJ5-mRTfXh( zby~aztVp1s+j!dtLaNt^W)0{GN6JD%Q69L@&dQDCw3RHG0OLnVzweRUYWW-+%<Jxi z&%mp{hYTliWgf($ru3H$qa^C_8nDWA|CnjU=j?;rmFLF=(xt%*qu?(Ccqa<}wd9oq zQJ4|_1dstTW#)Ol5C({;eJg5(W8b(lr2!v<C%w`3-+eyy-Tk%>Ub?l}WM?08d%0CG zH#|F|y`u=???E53xcE%bz?OYqfvwV~(DXT?V9U|<pBLB5mKnh8A4O$Ny^+3ZLfDlR zak<Nu3CPeuTZVf6Nk3Hw{=|+7+~Ls-tUCS98-Mf7M*jRZy01WKN$B5U+tp3{L;MH6 z26B(6mBiT;QYdfY@l8O@fiGfySW(5IebD-sSmvUn4+NanZ?_#_yH;uy8ZhTLvgngp z)U>P{d>1I`bDX|-E5{;nlt}&BXePbhPIw2zxak!~_xm*e@cdFn0jAR){c9uv?my&& zJQD2dp_4ZGq8*mL=AP(^j{voDh938~MO2i(kTkwrr;lfV^;e@8*R`N?UICTFL=;Cv z5}xTVp&twi+^280`}|9K!yjaPdO}}Fueq|HG-tkMzD~W;cXx32y<y!CSGPd%B=Ym# z<&xYSAS?7AQ_VQ?F91;T(3j!Mn`ll?YRb2<%}CWp`#6;}3^fNR@gc^VtPLclyz5FC z0VaIR?Dd8{s<nTiMXC`18Eb9lxG?I0^DlC>KqLG2R0gw;z($5>-|1KKtVR}}`Nq>X zBIR*%U?7>QTv0a))~GExPiMzh>E3qkV_M7ZTj`)0A@aSII#7*owq5BXk;P_hO{*W~ zgKi9XwC?oD5C;7FHgg=|M#L7bCw$Le0~Gp-U1!q;7F(j#0NFXeH0glPt8cmJ$xp7g zu?hj+fL>sJWL_$eq6T=*xr6%N$xVh#VIz`#!Wo<^a>uU+tegZ|eIX?THjnANu=w0G zC8EawTi00c`0*inBk5m0drf}u-)q$E1KXJ`3BIn?<~*MH7ZK!rb!r*sz?cgnK4K@+ zW{9FN&o+TmLD8lrdi$f4A+zshDv`w=v7p9}H_WDL+dVs@!VZ5tpVb5v9J;<_bUq*_ zYhgpxz&(k^I8E4dn8BA(efe5Y`PX+Id-F_Bd5Cy3%_J$C<h}MP+kWuxi-JoqMnxxq zgwLaeLf;Fi25(P(fVvT0kq;?{Br=<V*FdBYeSB3Ova<_ik=sm9b!X`Oyn++|ItaY< z`Ni$N+I!{eoaXJk;$@GvGkC8@75)=t4<kS2!|Vh|a<U2J6$_j3>hr}cvvYK>Aee^Q zc>i<!b<IioG6~g&IflwwZlkN><b!;={_P3RA6bXr!gW@Yol;gyUaa;t>N|rnJbK*6 zv!3w#fV1xz!}&_o^XXeTI>Yy1nEoP6vcXyjA*~U9td}}Mv_@>9g{=@pTep#W*0Ocq zY7r<lW6pQ-9te$-=<^9O!*7~`!jy@M6l)K$exxCi>)OBE%M&I}MIQbr50|X71*6BG zf*$Ed{M^Zg^!m}e#<vnN6$N)3T(t+dyqbRTaM0|3HB0J>-1a@OmJ)U3p+$Rs`ILJ3 zu=enrk3Mp{@5tmWU!D3qXk8ck6T>j%`S!U0X5EYU@z#s<0kKXt_J1C==m)l|3Qv@o zM!)bxSH{8oKL##Q4mSNikmmi-<Ygb{+rATPacfIK(YLL?U*B!;@ALmxp)O+12LX|J zv$j-L`cA^V%^mT?64*>e2(?;c!&i~sMNm5N@UBInuijT|!GFi$-}iy!Nz+gc_%G~f zrgWhX;~-%HKc^-u&T42_W>sWv;x-GSMfDap<#gptR9F#vtaWs|FOa5dL?cr_0~={w z=Pp}|MS!)TrfLElv74_fI$0O3$7<7iNr0a9^U2ySM$L;Wgw@oqCw(6b&&aQx7kEUo z%inm8Z>=Yn*Lu3m9yfDaxIMiVJZ4dNi6hU^x`s<`aNE<kwwRuwP<6*77w&ML8zXQl zo=Y!K__ro=*Z9%(<JCeI?r=7%xfGGArbxDp&LGcxp%A<hmUS{v2pvtfbyAT_eAZUp zQjt$2w)&k?=bt15|E%XUTje5!TI^@JJ0%N6EXQ0BHO>b?JI6pRV*yVsH}puiHCZCZ zW4xV``n!Lav&nKXbyiOL2Mpneu;9pyzKWsKNA5>3t-Adlv2HFA=)|I*>r6c|)7qDZ zCcCL83GF9caF6{aW}=ttt_zs7B4zsO(yRMNsE15`Db(37=`%}Uv-Oxp$KWx#iNfcg z1pfSULk^!~#B|afP#J|42&(rK1JY~<j`xDa4AOf5(t>yWyL}5ZDo-=d=`Q|P+AL%K zY4G_hK2Bb_`5G4ss|=p?5*N$>6^)<WtYtkfSW6PgXO_?!sV9%6G)LYHM;;i6BVxx( z*IcMAk^XwqKMS~er{jK;%cWf9ruF#h&6<G|rcpDjmfXaS%fYQycH>q|`@SD|$0l{| zEkRSPkEVn1cULGLT}FSBo5flIc%SWt>!|eILML66oBA(2hcDuu4&IuP@vaPjNu5CX zF=SXA!F&?I8C7DIz{iOZ2BP9v7nLXEUulAn3t{~%28<YvU`lBYj!mk??_dj%Q<sr~ zNg?`HroY9vliG^kBNBb3IGm~4qWLwmx}x_VhzCyzz*{4=$fGT(U&za_SMedE2lMQ) zSr7w9{>*Z~vcz(-XzBkxM&8uAc?A`icjzJceUAik0w$%o6mWobhIFYLo{;H~)pgr3 z6sy=(QLaZKa)+%<7)YZp4*_4{XY5mxT2W5K2YU#p>xZ12;Y>5pCAbs{V1kvlLtje) z$E1?2m0*@(1jl0<xS<}cL%{CNN1a5{Xy3((*(c(R+4iT=rNSOjf;6C$WFCg>j0xK- zkLdR}gw#q8_Xx)c0O<OfsSHTDwewvN3~qOhtPB22fDr?1BC~f3O<FObw!Es<tuXUQ z3Q$4g2wtfx(J4GQVv8m^w{Q}r=Lwh4?xNqS`<ZS>Idl(1-pxl{%R0VSRhXV^`;hW_ zw)WWl6>#$2Ctq{^;i&>g07(L2F=W?MQ_+lrTzRHvZ#4^WYS935hDG?-$qMG31p1Ki z>lmhNnu<2lr(}sZN;!f|=t*62PTO{1x4+M<OnEMf79%GLL}B+IoeeIH3X#)z=G2aq z<tp*Ap=xj|29y*HUu}rZ-s-yH$eLiRz$hcj;B+=lsPqWaP9%G)pR8Lwp~jwwdulo? zo>KEKb~Yp$v#c=i4N{nqXc}!fVL$)8Zfsa=Ach{g{;5=Mr>bYzdQmbq-O{qZQ<#Dd zY*<!^Jx{&bl;5*Y6vxOHOB+huqT4#EHwlC|-&ovG+$~%@`y#{BpZ8b_YVQFMX&k>H zCUOGjn#?vxcwUpy7Zw;(+N5O?pOw^L5D!Zbc=tEhkHq39>OjUlY*GB7u);CML5BVI zrNSVq_Fk#@E%?b9=FsrXjKq;xHdqM>%k-(txn6_BpGsoR__jP_aa;+!aK!Hox6H1@ zBo(jo4#HCno8i297zv2}#|=KXAZMN;exlm!?G`ojlat{!eF_|t^a#RzF)1WVEN~q- z)BP*C?1(R|8v7eVH`aE}gXt0&`78D|VE|Dnog09Hu==$Bvf*>O!Ky9$dbz23s{<Qz z+Tagb#V!F$cejA_f=k7OyNsW-wc(K2SctO3km7uE3I_1|(Nat$i~tfYqm0RD8+0DW zGU3=MAILe2%75pIiKM)PL6*ipQje)HRwkpVGw>Z8X~>jkWK3Xld=K0RF`~lzHK`Sa zFM^2V^`^tr4wm0r75?;?BC|2pYbph&M`0%rnzEtENj;bF82|C=C3o^0X68XdG#fz9 zzR4Fwi#a8q+lTTwjbxYzG<jRa>L2m+CbW9<?w^60ST&5NA~bP-f`D+uhq&D$Zwq^i zrc&rETy>WEryVO4tyU2c<>U0544_6vbjzoCR{6M!8vpzg-d*#pi9E|59F_EVx{*-^ z#A3Gtx>pRxr!}wV>z}xHa}@W&Rx?H&M%4zgKuK_Lh9oFHhH@Sc(Q2h>q=zRHGsF2P z{;H^>KcL~_*Wv@~B09Q`(c!^VDk|Y}UW&g~=Z1z;dLyxZIHwKMEVAAY`_wKT|C5x; zljUJ!k}Wmk5DRS-6|#^awDDK*$`yx5q!HLM0hF?VJ!d_<JD=G&*PsE#%Pm7rx!l!{ zycy8rbLb{+2Jkl+s5kD0SDadxS&$i6bf;<WGwsLn`?-*Zrw*11b<^&GrUVpFPB-J# zB3)93(OG(d=Y#=G%3^+FDg%}IU%3w=Bxp*dmWRhaC~+)wE{lDu7K|APee)f6%(Av@ z2mL?9<57)4BlgytB?p2F+ZxYIa?+F(se2w`$zgXG8<!!;A|u3dm5N-WbDMll>E9Mf zwMYF?(}JScgFc)0_ayj49@`AIhPuo2f=<b)p9-kuekkhRh?8L^jj2W4iPAo27i{Dg zUsMWwRFNQ!Ys~O}l9DaI>BFJPZZpM-fS>&e+t2IDOOKbU&I@-SL0`qn_5L#l?Ldhc zeFGQ&I`(HbY)ny)iU#h(B`rtMql53OEM}pMeNdcTMSe7xEUW287u#1A<xNGzp!wgl zO8h`uz2s5&95BE*HjKGiZL-b+SQR{Bv>|SggGmqv;*@W&_e8(FWxUROPHBqcsE5P6 ziLNSJ(LO<ygi}JQG0@Ivi|2?x9*9;OA@3}7#YVPh9YdF<pd41I6h@mIR0$u7ilUP( zb8s6cE0vov8AV2@=R{8)5>EieXiFXEo9Cq+_rmXZqKo<`cQ$YOnZ*^M(wQUqgwt7f zkK^;P`!Lv;YN^vi)9HCasVMx6Kb#iuQ`}$WYW%fiX4G>aAu8L|@Fpm{U7j#030dxq zj_FgF;*ECXu^M%3UpiG!$&^wR&Qz+f%IT)CVzt~-mu|`|nP~Od6<Sk;jnhUAd4AJa zP!lqn3TXUhq47maUsOXt+Wrqot!%(2qYR?Y{m&51^j))0p+r3@mIAeR$sgUf{VmmL zDT!VMz5O^5mgw?~fNh92`+u3%m(4$3I^ONz>T@Asp9%-G7qipVfKHg_6vGslA!e!q zI!EfpaXx=Z9`Uka4Xq9EPL|SpYfDOT?d+XLwV*fXrAn>rP?OG}TlC~NXfl<KFcZAF zt>LC<<#3J6Ihimv2Q?qI8TIqj^^3Wa7J#24ptLM<7<CeVT*#x8WSBO!7VAqjhEH_% ziIkZ=WP{_MiyN18nlA(bo9K=hjcy<gts3tpkmF==g-t7*rS}2^p#K%h;tgR5_<Qh3 z42T)=iYqP^rJhX`-3TZAFnOpY=~IsDA30vL!%9bwaTvbKFY3vWnVF|~G=4qyyZ;n% z?R9itOg7z_h8YFLxR@@YmUJArj?=}yK_l_(azGMmnjkEn_<hnKc50Kj=KM~;RkUsT z?y|ZEjulyx)me|Xhvvg{y=Wt`<=EvQ*Q|B70_ZpR*;&Z2>qEH;M^(bJ+8(!6dc&vG z0dq|&NY8Bk0Ug4it2Vdk4%0`}qOtFJ<6Rnht$*5}R>kpPOLFNjO#k(TvEdr=o^{^3 zuWBWD5#StPlOJ}4f~Av5Nh;Gvx{V%IJ0qaIMCC5WdiyHv<pbz}-ShlAybR?>c{8id z{VIUAw;-)7GK!CfuOw4PM!BK@Sb+`jPFtnG?2d~c1GlBe7#ix?XML}LVbuQtZvm=1 z_$!^5zU;w;UVnkE@g>bL(6HvvL8mpv3S#`|!BUt3WI~K-#{E*yieI<BJL8`qWpf?o z8f(xJSqEo4rRI#gio*Z4%o<QOwf&EULcauuPLSjEX&+;~5(e-`P|6}-1$hmPzR8-; z|IyUJ<Cm}dz_7B@ko_sjqHxBq_>yLE+OP*^`gzPpv)-8y*vUo>?&EZXGp)*S%Yh-* z@?jMLZKQ@GWI$;5h4{?zJ+B8}?sYk32Q}Je6Xqr>>6R<Yo=pq-?cL=lZHpS(S`>`6 zD%>S*y9dKH{8U0fC{AqBGePaDnv9<#Wna64VJx$C2J1*PqQn8~<6^`dw4mq1=P1Q{ z<z~@D<!IYQ8*1HVOlj5YU(LKKgme&>GAdmWEh2mC;V(<o%K=x0PjYh$<s!lhOruA; zjGQhsXX!1RgSGwWhiru&G@zO*Wy4v`zcXnHjPsYVD{n_k4GAnl2c5XB9gufPR+0$2 zkDvgPr;YGLx<Q<XZiS&N1(F;$gR09YAsqR~2Gl&J57R36=a;8zbs|DL?>=7sL|=~7 zCK9TSau!5V*CM{=T;5f;3~9kB!G`X+q0IK-xXGi1TcZxTxz6@rPlICb4F7)xOyb3p z2CK)1t;t-|E6hfiCYKv%-2}6E<MgcE04K2W+=0(p2QdFo-^z_X4*YjR*I>M`0%Kr8 zr~fHD5VrpJa$;IYlc)E)YZXGT0G}>QX=y?$k)eG!WHSAGsvv|DKL4E1pBlfu%z1?_ zNzi_4VN3PJyYkx%C7sWhsEcFW?Jt7!VP=Z0i&;8E9v2N}ug0)l777s6c>|tGaUXW) zc+b7!U)xFVm3J#O3$q28%;A@m-yvELN#I|b_4JAChxNMbhC0Mv6;D%1tKD9OGpT-0 zfk{<QJRXTIS?h-S(Af&#D*O^wQq4@&vW)nHHt4Q|-eHu(w|tk5MEi&$^R+vQz?MZ5 zV$cb(b1}Qz=SZ`O-7DX-V0E3vGAv|jo-T@x3n2Ltar}~4j4O|MvuTgpxvv7coegpS zdxFGqfG0x+KU(G()VKYQS}&PS1kv`TZCy>*=B2i-wQKR!KOFNhLWB&_@z$ODge_K@ zw_TIfjVy(IgZfQ8ZC&TJ^_N?~DRwT{@7nWz-FCQ&!=B^)4@&*Y>?bLXJ6D7@h|@{2 z2ZvgA9xhX^@z}4bTia;&uhQ(*f>T^97K?D5XM;m$LP^_^wSuxIO715lgw|bl!OItT zFnuHrywif)3?iT>hpuk~=u}H4Dx`?q?t$-G`uc-am892i+w3(<r<Zi#T${yE6MKN= zfC`@OK7<z(&nccw=KKsKWgQ@d_X3xM0B5ii&eP61U;9yjlHqgJX@jsYu{&RKv5Lv+ zBVZyKnhbK~2*rpCIG7Vg^iItK=8^bp*RtjjbN9BCD=rpcZ*g2zkRd^_Wb^`Fmrd%M zzw9*2W8NWjV?kS+a4Pylreu5C^}LGH<1oT3;^S#Yo(b(a;=3A@{Rndnnr41{v_d#5 zz5@3(!DOnegQDh?S~Ip~is%Y~$w_KG>wM*3;V9+t9CUFNKuW##)KmvC{?sRUqzTt9 zg0cQaByLD>d9_MTJ3N7|If-7Q;dCr_Y7Fmt+&+KinVPH9`#kz!)Abxpq$a7+edPON zkN?M!&YcSLU#jW$8Lj}wZNICAXY5b#J5R9;cf@t0O^%R#q}3>GpJcIwQE&QJZHGCG zF2`KRgim?T<2CNVcrJrx*P3p}%WT_iZvLIESB&v}4DG_F+2&B)Cza;V8eXT%{Oft4 zXHbnU!ZV%Ad`qOyPElpglQp4b!nbMOhe2b+%^M3Mop&>iUuE7-mxY!@-p72Jbtj-r zQ+hT3<H+D1^PhLe)2!=mey`@!?1vlfl$<MPwYSS<<F0t^(`xffjrSAa_2S3IS-fkr ztL|yGt;<2S)_)NK|8_gZ3{!SF^4`@vO}DcCC;yAMG2FFAY-^G5jb8LyevjQ8+HyPl zIM%r)w0-oS{GS_A5_S-}tG%Ce;`}Gq#FqKiLPVCyt}T$fGyju1|4k&*?Zo3i=dw}8 z`)MkstMb37EOWEpFA(vAJ4oGet*xMZQz)*xwzyB#MKnbkH-w`5?&d{7e3kmBR>ML& zVsv;_a$42d=R&bw8SGQWIoRy!JV#A!YR5BH>hL|i+7fS6n`F#p9YsQ|nlEvdTRyzc zwrRz5<laRylzp<-o;yXoggUUihB}^;&U5g$oqXOnm-&o}`Y3(w_oJLKpVo~E*t!3% zEW61~3D*t^>s+p}qUp>CcWd3u#5(%I5^5%#vGO<()!2!;sQx#f?W)kH@2M@r`g}hg zi7Pq0g=W6D^gX>CwCRghlr1Wa4L>`MLhm~qnGdbI)!YH^>eTj_&(i4lBTio=PEE7Z zxskYa&N_CA85Xiw%($*#vB4-?a9_t81Jcl;LNxW<z-+l)x5dIwTJ3U(UulU!X+GXa z4IDoSggoE4tMrWSgy23+pU`vXlXLe==g*F#%l{Pbny?f}7pOEH<o{w<zF0TnVMy#1 z>lI1y!v__qo5CVjOKOVax4}z<lxD`0rGlK6P-3h}7i;R{N)XPF)ti~f^ypIQ>DbVM zXw8cp_D~W5BccUZH%uFyhYL=p9StSNDGc9W_1!MA*4Hp^3KJ?70V=L|Lp-y7Tz~vd z<4tV}Hs2&VB%JvnL%WbneIf?^ODs1T^;k4JJ%Ij%hiCi&#TmBQ92Q6hCt*uru^EN0 z{BcnSWac?^AHi&Yw(u%^-^So6)@I|&i#mxJ=SqJp@$0T!VY<o{q(!Vp44ptR#!i^D z5n|ID;H4wG<t6z<yz$HwA=z}vMbS!}`^9-~;?ysj<I-_QVMO^7X}RrJZO8}o*A&XC zznn4~V_87H-ZCE(>U#hq(4*gE$e%KflAituP%rMr<O^ZmnyuHk%HvhBFY#seFe{*S zS$hU)(Wgxf8aHXOPBG8)MQXmpZ_<|rbX#V{KlbQdJb=_?Z{6IT8TCf@KDhkFjP%OH z3bCT~(J-6*v*9-A?B-a_SM)R6rfF*Q=3=kamVNbV%FHv~X3hMWYz63lRy>3Eu0em- zgNo<yd(_kpene{|MB8mI&kw=f^=}AaKorVw?#{huembp%Wo(im^QOA}1K6S2yzC%Z z|DGy-IN>LOguCF(ONQs^VdpE@?VSAm+1V-`8NCN8FK$&wMTFWMYi>1<nrP9<<j;do z?8Nw)zjNOk^lqX$4IUeRYbmDX5AzzU7v-A2z9MQ_v@{%jH0`|7yK*o{O$$im)bd)d z5OdKtc0HQZ^o&{eDx3avBk}@xDzz<bEu@9(eVY%7=i8Gx!_M-%Cxt@)pGE9@;mUlN z73o===H{Pl?bCH}wS|3KQjIpEs<`omlzU))JlMPcOvrE8@U_@`E>$kYw`YjqH7ATN zRkmD9i;YRT{qb*9ij3Y=s+-nbk5(1Bg!b`_Yw<estd%MumF8Y9-#ZI~d7|h8qSiPZ z=huw8@^(O@?+C6_a9Gr9H&QNDE{ezj3?10nWfGZ#_$QnR87E%}tOI9>v7)=U>qE+O zN@G2NO2aZ|bc($=p`)O;?B|4gZ>&(lZ>csd8z&_;g`mQSub&z~<Uu~Y+JIKL_ak)6 zfGSO58pG%C$SwFSPM32gpAEOgJ{%AJxeb&xrF3Aa*8|U!yog5k9NDD3(P!F-mVP!< zff-ZCVbljO$ty|D=h)><_-i~@^X7-uqwgP1i!KGyy=w(-7F}(pD+hx2=Nq2ZIpzk< zfO869k`Wcn@^Y#V^h~DwzcBFzh7-3XEu6T^sc@V&7Sx|bD4fjmemEC{WTH}ZJ0?ZI zp9v~kuJKa#k1KfHSVk&v6NSn1T5|!9^K=VAeD<x(^Izwd14&-Ba-WZ&^@y=+a^h%p zu`J{=zmd3q>ZB=Z!;j(3jK9m;h@kb5It?eTuiY=x&amB+vlqjh7q#o6^_X4sX%Lm! zUPU>%qpcCE1!q;!9Bg-rfBdW%8<S(0P#${|VuL-SZ5ySu?^yJmtFGJlRbHL?7I<-T zOx}MVF!$6FHSs>hR6~lcvcbP^7(Q+O@wjfaRo$^j_zJTdz6u`WXw(jYm)V=8HPkD3 z;0!J!?^|WlopRL4V&Nu>LBS?VOF@XwVBUkh@O(bt%bG%&;06ikDRxJ-nz^^SFkcKS z^eEUN^eo^<8yw61^gKgwlWWXpfH#fv>Mn^U#YEH0^7S>;O<y{3&KANo_csg*`m|Np zdga+x(%3dB;zW#|Ay5)A734ZZ+s?MD3Cty#dP6mwVOq~tn2zFkVSBiQOZXXM=AOyh zT%LlS{p!!~s6o)mO=;Oo=L{S?BB(GN99};_JoPAhpJ;yEUeX@ad(I3Oyd3Myuy*_8 zB&BRbczh;Go?;uKb53MdIC~9wcI?@;p8EN2G~AVKphveQ&?L3=aHZ*^?&~Xi8G4;c zgve*WU*(yxhP3R{cGh*hjvd(W7Y>M$bBtgQq~3^6Yo@fVOz@s5=MPokqU^e6NFFh7 zY+piriSEp{^j2zIGWt+v2=cg*66guiOjE=REBv82<tW$iq?((qh!kN<yv;BkvVxYC zs9LDP-&^vp^xq7gl9jl`8Oq~HQN+K|f5)jbile||r;E#nug!m5c@E0gs#rY*@mRe# zod)+szW&#ssgN;5Thk7EU>+6`bjv5N5@QTAV+|TQT=`pn+UW`APRMdS{B9Gg_Z=kH z`E;|X1nWDbjZ6YQx#?)Olda}sH*}ZtHFQL0nsi@CHW9X-H&^9f6vP&O39D#$%aFZK z<5poAfIK^n^pY$OcgdtI9lwe<;ibsID~6l12ul}|-Y^%a<DhcCxg9n}<<rEA1${=1 z;24@viVj9-yz%}`q96OxWnby!jla}O=pJ9zHKE9rpUf6dUv=owrsTiC$WRdFE&afF zzcO(OiQ5iav5;OmxM2slWv!RgL5+5(xD$<DE{$Erjrc-=A@A*Bct5p-q^eaveXTBf zUCs82q*lxPV0ii&X~U5Ca{TZ9$@_~iKT2sb_3UDl_W6NZY@fF6Z=G{U<Rv`-63SGK zunJ+OW<#WPJ74I{yTvvYAZu5Tozp~LyAsSFd=SbR-FhRxt*h(39B7DYT0fCG*iUPQ zlw;loCC9XJo~1K<Z_en79I`9n%8USLk@HAZ+YBV(6ZFir>1dUcn_mwY<ji}C^*`^> zJqQ&TTjG(^z4vwpKp!+SCM_3;>qZ*710)WbS(2AU$=t1ZQkE&v+^xBimra=60cyxC zkeB3NELx7;EP8muTVuK5%}y0nuUjj*k<H|fl&_jR&m{sSarP%ucg<!OQ(){`j^00S zhBVV&Y<e&!^`~Z*er!)Hf5cxy3A7ybNw$pSVhX!M3kt?Rr6WFs)b)ot%Nx1YFRq(L zf}UM0sb(&<a5Z83VNULhA)XB(o&`0&NL|+wOKKyooS|qv+<atT5z>GC>N6Cr+c2pV zvbtgj#9STriG|-oMsPE&i(P+$8f`nN8~*ZxFXQFtTWDDa`!_te0J<(_**OSiW#l^M z<sNE){FdCikT9~yAlm`<@3cE`+LH}CfU^c@<0a&KIRfl2b#rD;N&Esm=RTP40WAdn zv@-OpXSqY*sO3E!CS-p5fNaS()E^vqma{pR{0T*#_$Qb62Mcm$;xs1c5-&;VjUMuo z&xiw|mAQiYK+#3$2zLqT^xh3|TUP)Q;|K)c$ZEGCxDcSfI=tV17!0TRo0uP;W7Y{Q zg<j9Y>vX~4y)Jqbq4gwUaLcugL*TSM(&aoY#=Ldxv`I^=sku?aGCOdh^7G2h=a6M^ z_@->M=i&J!WeEk?V3r%F3qVBNeL_U!{zoaE&C<}?I)mBvx*fju!qS$C@Zci=@go${ z2Qul+QyKBbtZx|3LqJj(ow}rHndC7e5O-h`@br}aloLR6ZJRAf_2vS_j{iJf_T-NK ziYSY#q>P86$FFyBjfM{e#&wk^Gv)LI*|xMb8rK{x)6zS9x&H1}J-l~F$g?fwGHX!p z#2c!2-T1*}C!^M$GFWq1)4OzIG?owLDmc~)%^T8nTmtXTaEJN63GjiY+hhqAYPp$0 zt|rrj?>u1a5YuvUy!hcfwDo()=F!vbxtpZ(y^v(f8;FePP})CqOUWEJ4ih#dw>v@Y z<?q+Radxd>W$UW&&<)C;$Bqp3&@x<dX{DrQbrvD>Q^%ouuo}=}M{>0{ALxcOL3YzV zZeZCagJL8ucuh!PVjL=!1O7O>4Or88+B@_@vJQR3A=<e|<2lmzIpi)=D;n?=(!?i9 zn00}&mhTyE{S^Aw{?*!Kc8O$H-@EDWEi?NMar0gevM9crm#d83tR7-nmH75Tl--V7 z^f{l#=T_$tsT>=KS@vQFr!(CzYf|-7Ovi%=PHQ$(eIu<q>=5D%k$RENNb<0RtVAVT zz9Ntf2HBa`K48wMYsZ5PrZoPA?{OoJh#BTn!Nu$BC7**5A>m>pJ#~7xn;w(<Q`e=H z+uOtJj<I{!rbXuonsa;-I?A8_MS0DktLd=S1ZcOyf%+te8J=?P--bQ`<&@cz3SK_J z`+KKUW*KgScrWHRpMe&CK_$`LVyszz^s^>0up2b~?~;BldMA_xR-esfuwYFpovh~z zg-nLR+54Xk4iQ7x{gP{8l)6I~AO)K+67D1!kvQZ^@^PEYPeeuEIS+_bt8UVPxit?F z+%&yV`aXKMd8IX8oU+u#n*H)+P^`8uL0xR~*d)C(r_W;T2`S^5pk<!9S#FKU9K!%Y z?aF8aVY~wa^4I9!=@0CJyWJ0OU=KbFtqTA0V0H@}rP-1vE>7Yy;%98R{4+W7%_#k# z!gy-C>VI1gM;9<!4p~FSDffGr)8?;55WVNjXxS&0;C1BpR|{K^DIAddn~|H9*oj*B z$<np{aPZG!nZ6o^P&XJuo5+;-XA;2S=as>}Pd<hGK=SadSu~kvaaOWtN5uBo5%uri zj5;vsGCAN7ND4FQDmi}9wep)%w;8eZu*Zk^$e(XIEiwZwj+Q<U{Q<%eEik7}rm)~Y z>Hx2qUQtc{2iqUYsM>9cMOynOy{$22D=M2Iw2Q!BO^JDvGqD~RI>+|pHXJd9GrK9N z50eaXb9o!E`ob_=_+Qcn{x<NU$YO*JeGMk)jV=O9gyN~&G8g%KuQu+qIrEY$@B!qQ zZ+Id%()E`5()t_@%y~~eXruT$&{5-&^LZN$hQs52S`wtA7!I(7CLlFzOlG3DzX*Af zQisaTAhT6YL?HtdKcZ6Ecp1V`ZIT53!5iA4Ku%G`j6hk!DhzeuFgo);@PfJ1#*D;f z3M53B{#}y9Q`nI9xm9{xlFa;WAr6<rDw{J$WR62PRzy@c;SA3mSU6PBD17Tm$CICw zRZizk(A5$%XUi@0i>JzZL|5+1EiR0!$s=mVvnR^WJidqVlDRsQ#o$+8i`cVtX!Wdk zIha@d?gN#eMDaJN1+`3;11`E$a)-{EQaN*c@3VXW^1BmH^bnschK=G{l%%->y%P;u z@w}?CXyROhQhGx&Tftp=7D>+Ehx(@E__YY)%2L=wfVFW|OM)?F@$D8Z@odfYkMZaI zl_xe*R?#;BX6b^-2x$@p_cpj3ewdvZSuPp`R}w^y9Vq-6Sp{XnD@f3tz2POzN|aj= z1Dqmd0V{JMToj~FlPpo3U6vNJ=&;b8;sh4-S?qVL<gh@q%$*)G6!YlpY{?-VXt0=b zt{-@YGnI<=?2yH`9~=(W`+kxmI?&+66;Y_^p%lq6oyf5Gj8dQt?s3X6?U|5eKpY-S zCavOwxV@mb;mAA#0TOh%T~4#Tye@y%!rOl?iX3ydf+*ROFe!TuJkZdH2lfh9Q_n2A zh<zO~`)dhu$tj+?XaKDnb4Svuc?SlI5hjkq7mu!udjgKRsk97{jJUE(aG7N}G=UpH zLyEFBlzz0L+>T+qa5<|VNDeLdIc&9KNBvq1(g?&D?2>8LAK8B<117kA3Zx3GGp?l; z+!71--eUj6>E>;RpWX5of;?ftjhPuB%fJcX%VAJvUD4L(gCytbvspqIX(|9C!KlfF zo+Rp6RB}|36eTKn$wi*XqMs}XpH&6M+Cqx%T+m&o9I{7n01HhTmC+DEC#`|;%;l8? zd0VRJ%`uGCIQJ>J;&WZ-*uByk;vh^`@#H!~A^rW;L>w$(m64b!^51zEm5ns!qb~BH z3v}l!)fkmcI_0yeqE4-T4h)+Tvi%5DAGl@?h&Ezd&JxCS8!_Ds=CZOwm)IW(^vBv8 zF+&U*YSSZ^W*-UNfw|EGL+s^vKP`FXWiKwvx~@=k!Y0en*0B~@kpRWeE>^loI5r1{ zH0fN2Oe%->9`K0`2;73TP%6~hb%qZfNvXdOxg~2G5vdRB_8+(q5*!ev6l-sPQJ>Zs zJ>n`PH6Ti9)-tJ4U)CKw;wGou#aJqMQeW2{JXsXP*J>(y0<OnxXA;)V-PL1KDO#7& z2DJlR4t%JUrkwxAY@U}l79F~L=*}EcbT*Q`c-RZvfxE!#)w+oHjK}PqFJg^rt=W5Z z<C4GHawpWPl@`q!iQT7G7`x9l7ruHt%I=}1V7OOjP%+_8WOS)+Bypq~tLOrnkI-iu zXlfU|9VIO^QkVbEqKdUtPe~PON)|RMDWj;J-(k9<nqPinoJX@|bb99vjHyYg`O9x| zbSz@D^Om2Sm>u}lmwF9#^wp}h&#vj~os##>SL|?5MGaon%(DYz736y78BKO`G~>%2 zk^qB{zqgA%T9Mc#zr3okR!Pf5&$*fb1zl`@qUVO}r35tJ(EkU(Ee=)T5d|7^Nc#5o znjbV=K^X_Jvt0;;waP~<o8nN2NM(~QL7QUsV&%S>)jWLkBJH4u)g2*gLPcSe6)ID8 zr994qQ6i3ozcNmMabiV9kuCv>Nj!IDmM%fAN&JN6zIjr=17>ru+te6uq-N;fqs2c# zn9W$p6O}@^Q&Iw&^|}Ol=DO+gW?fus3tfsO@ovr+<?e^Fg*Rv7>i3cc1<}YUp@$h* zY>BL7LD3Uc;cSBHgjH6$_bTC?Y<6p1;MI3e7W=JOk%dbnGVJRk;r9<~^jW{AOMI+| z=9B}gv%y^I(vS;g474sDxKq`*IW+n;E_S3L)OS9>cf7ed0HCP`E!b0t8W&EGcA}w_ zn-@+!$u5^pF7}e<#}v9}X20)wV<1^%%;(OVb{jtXR~OB08+`jryqo@qZo3I03EKXK z+s67hBCnsg=x|nxIqhzWdwGA;G@l^F)W0sta%c9;4{|zJdoBG1^LV~`jwbYdpVoLv z@ei$oGOgTQ$MLB9BzjpwV$v&ab`}TN_5<D<jpoiS=Egn(!LmwMx$=IRBeb06fy#_n z?G9n6H<=CSRXzd$YoT4BZ1=^Br4nS-^!*EG32s`UR{MFbkl-G329Cy>LQ8LUee@1} zYle}$8H=tf`JBetWLT!&&M`bzC5{IR1?PKo$+FA;FUE%R=3UkL;1iDZ;f+h?QF`^j z#^hKVp6;oX#=DnRpDx0`zyQDZPQ3gFjnT@)vd$j1K3y8!DvNbR8Z{*PTBC3u@;`jd zbZAEcn+GQFrv|)7?GDZUuzp`E2gN3wSe?Hw6WEOuqW%||JF|y-qgN3;C5yBfsjW`* z(HMw~%)(OsH^FAsxFmrrXwU<{o40J>328+w^u`(5B=Y38lxAd0VBi<Q4$o)%fOp}& zwV+rD7@E(9%52v1Rh+XCYjNp>EgZ!!)bY^zo7DS$a4EA|ErMUyH`0z{l>_+WDD$~$ zSJdShM983_ZhSdsJt69QwbBwKb&bqbE5Sa*5XR(H#M{nSJv`Q#ig{EcKxG6S44*%X zoNr2tY+$SKdRX`{SM!%p5K?JOAD1aj6u!j3MjuwmCT6L^$ya|ZuiaZAY(7pG?TNYD z;KFcdyq6-23w>jR2q3#PV($^R`w*k+<m*{v4}i!8#~{o6KcF>uTrvM6rce_&_>-(H zC=c0Q>wC?K*PCijAc!AdU6C<B+$aBQ_9~gGoqBW}uU6`VYPd4v4MUMUR^X7S><6Su zUmmRbQRsD%f+D{c`d=;mK!mhhN5-k|S1J_T5It>&stEu?C!eL8Xp*51&1Mj^s}%Dm zQy1kmMAw($FG8;ogM}%_o@FgPI{ZF07!zi3x<R0hnDDLguzt5gVD+SM_YizPW-Wgc z%`QckcCPMFxNWGXR+#3m-Lhi5y%N{3bb#mp8xR(ALiZmH8F~;riE9;g3e_7YeBn6P zdRDBoJn2HRU4$E5U!$#PyY8-tyZMM~X}h=TVk2fZ70Tnq=Gy=@)ZAlqqS-Rcg$lq4 zI|#iDW$?A5V#(xpRfX?xy+G^istLkBY8LtHCHMygNaS~wL748^7n!GZaY=*$@nVi& zMzO`{Q;(GRgz!-zk{DBMio4^`$QZIydnNgbj{>P(tMENdDF_a{CMs@P_H{*j_ZBCV zIo-S_Re%R<CwL*|@3h~3%m^Q>$ITJ$Bna!go`|33`j^rl!wS`-ks|?!Xta1gE%N?0 zh2ZmWXh!5FEFsYS-o3~lbl#_q%N9uX+@6(JZ(MPROchd~@e7PGKc6aaZnk1kMptqn z*?%<P+G-Ur6oHIG;UpYCEG@duUz@^dvRizpC(QqZ?@Iy|#}}GcIPM6y?3*=oiIPjS zYY}uO*bDX6xJVAG1pWVpuDxdHRs##81r_HtP42&0ij{-$e+mSncu2pBVvJ^fjP+Q3 zOcIEPS@bmFD>PcU8X%bk7YXgOSLH(bz3HOlp)AYPWK^;+I~LH|p(8d*fuYc!K=DuG z@?$wMX9d>tAM@AUlXYLL_Wj?%Yj@iOUWuPyc{OGRc0ZVY--XAZ8e-dOM=%AO!_1K^ z7}+=#?xYJ*e1BbX&9tqPJ#;J-i%iq(s-<`C@QZ(Bwlj$&3fagS2c5N#OZVT$;?EQY zFfjo<5T3%gcJGM*nyTEJ24calIRYzvLl!a^Ccg~CJ+fA%c>tT4cS*swp9!;|m@_xu z3@nV0(?BS$3`a?#a4gYFF+&d>QpTyN9ys3hqkCPys|>RbpZYXmr0^}{MB^$$Z%^^| z6*Dw_fe)GFzbiABO;pz9efj<53erL%f?4s2#WAt2*pxR5?-t>MPZ*(`6g~iqDZYQu zSo=MlG@74g8~wnwLdZ6ttd^fuQ4g>`-Q;QwVFf|UE*xk6C%;4yL<|ChYDVnoY_(uZ zcXD##jY+I!PFp{cu2Y)TTZR=v1WSX{{k}M8l(o!wXNlewQ|E{dTHPQ<3!Y^t_4ft^ zgB)?kA<dh!y++uxN_sx7n($&6$*r&h-#ksr#$I6k4-0wliaNK=<&Z8SmIH$PI_1u1 z_2~suHvSXGzE`dvm(y_mJ_G(=F`s-~wJvqrKbuSQ9Iup)E9A0c*@{}1ixq~s&M}IU zqoBw7X(uQjQQbL1Twpp<(U*Qve^LvfR3@an7BRr4r*Y}f@;^uV75%BNgq&6loU;N+ zSxU~e_h=+R!u%bPL<J|0zFnh&N{>IBt-^jkl$9WPTkK1&01}$s&4#U^%=4B-+HLt! z^eE*zSBtsJg_zVwZnZ&qQA>a&cSl_@ig8bQCGtrs{ZpbIOU4~F?k+$Kw?0p}M=^<A zv;7+V5D{Rkhp=ag;4VJx@pX!Y)4l|ym1g%A<4#8^pDKV4bqFyP<z)dOlLVJP*7I@! z7bzOYTyGYn7d9z|2qz|-@+PRDvC}&EkTk#Wcq`_dCEAT6+EE_BMUTH*6#P8!(ikyY zOM_Zj>{1hfJ`%0+OD^gQi_TLx%e>nlMCIKSpiQ|iihAF4;s>4whk@4W`uaq_o}<Ce zIg$4MH|?~f&OvcXM>Kzu5n~+n9YOE|R8=~4`u~z$;{jgkLlV4OZ^9s)dC560-|6{X z)o=UIoB|l=gzC3g7YaJ{a!6&AffCy0oC2^_4sz4%-h5kVDMlAvc!58qCxh?uih|tc zO~X%r(#+{G)z~KgCN%B%$oPC<{A4TT`zr~>Uy+gBaWp(1rTQB0?Hih>Vw|||0ztB= zLs?`z8-Ax(Qk!zvyC-<IMv@@)X%|d$Q5voZl7%H&wfCL4vS*;v+M^`To8lf7^IusX zb|pRFJ+dNd2rr~nP`~K-;fZyxF6q+x)pjDaBEOsG>GRV}s%)maKgyZb>nds+{0TF` z_-7oW0(n}Buh=_R&bUO`;%}4v`flM-0g9#O+7;Y)+2HgS@~1#~jQ%^saCHNxu?@*; z>q5(H*Iki7N(!3N9B-8f5CIG_0z$Pzi1TyX7QasLUKunK-9&7FV-bC!TZO<gLnZ3w zxUSyhAq$r5TFSJ#(_%Fc(p+Qm&)5#!-&K>tR1)HBmau8%^Ryv|K;$y9Y_>3lfFF-; z2|=-A9&trg5)6x}61(B#5R)f*LVo4MfzdO~yY#=P@ed;og(SRrGjobn&L-}@5z+U^ zE1%=Lv#n1|XSM0g6@*)u-n3_nR`P33-S*Nofcr&?etqKRT&f*^;spe{QqG08__dN- zF7;;6GX_a&I*SkZAXaVV$yFEl&aK*h{VnfHPayv%;`aKyZHQE-c70^8FT`{99^diX zWgA8&a;JWFo)%Ss=a9&9N%;59(-=F%-nk=kDy!s{l8hH0TCQGXZNN*8!|fwz9t;o5 zlB~~c&<hKi${f=u<ft;*MHq~WGF(f5K9Pw_xCnz0`LF&=DF~+eYqzN$iy!c5Ac2~G z%XQL}VQ@6v*>Ec?6Lfq>D?4eH?b^%L6j}leoS(RJaD8_Qla9LYSnIKBR<)GPaT0*4 zU>Ah^$1oe&qVOe0T^02IZD~q_0o43wafV4Vhm1s)?fG;YezFjhN}Em4G{Xpb3W5Xp zVjKsL=;hckVG!7BdkC|}mq-_x1Lk2#Kcga>M0nS(8}onMTP;nyn*_csS?pyn+RIqj zs~|52(oqXf{9)jpzkl_L&6zp&tO&RHE6iRk;u;<HOc33c2@vV>dcTdhTnu6}6wF){ z>=dopn&1{{EZX@qLU!mqYWbvswvz>8SF^FQFP&);ZC@ya>MrBmY`_vQOXv->AOEso zKfOC+qUWvww`$#|w(M!Zq_PyE^dq48-UnV1)7Gj7W~g-DY)bJ<WZ+_yLv=!E@Me|# zycQ-%bcJkxawGFLO}ZWeE@p>Ub<j3*DM<`-@ilmEXNjoJ2Zw576|r;l^(e}Ok>c~` z=cV(y?vYQ}g&5ePf5SInKXb4xx%cHEmAk9J&ESDJw1d1opWI8KP})RxGcyN7(-hXw z5jHf@fnx;aa+juZY&n#P@{Ms;wk)?MHcaG=uTDJa^imHtHoH0M=D8-s&1Ue5)Q##- z?yuIxG0ac!>A@P@&%(A)526iHGPZD=zt2kTfR!7+cXqdF*clN=zgBd1Aq&h_dk<{% zxdwX$>1H3vOGqyN!^kWLZR29q22RTlE=Se13j~TMzKZ2R2}b1}<kM)0Z%UldYZZUn z;mzTyHhWog9VvgqT4e9-%dNhn9=For?A$DoV6i$&@Y{3-95OI|$xYZZGzr@>ItMv! z<&*4(Ullwp=PXSpN74qJ65k=>*`tnfXE4~!aqQLVyyR~bdX_W8E~s>Q8bl>)aJxn= zVksVgNgZN(NxBo@L1`Iu2Fzn*EaK8Y17Y*K$|E<Jru8g1q@YvX$4z_~3`g;zRr?vU z2W^~9D{|X0FA?-ka?GCCrf>`XG_t^vMao8^_Uv(f8I?mOL3p`lja<2@^tqb$^UOMo zWiiH@potkSxcBMVtSnxOr|ylqzT;KV+PZ%Kc&_XGFZSL$s)=rI^aoK<QNRW&RZ#&E z5Rpy@Hc$`|>CzMgr1u(<h)S^lA_CHaiipx8y%P{2^hgc8Lx2PV1k#had7g95`<(Z; z)_vEx>v#URcde5(*?Z6IGBbPjch7uhf9AWtYX#|@D%F4f)nrSaTF(KsU%{%aRn=Sv ztF1gKds8j5$f~diL(MiE*Kd6I56ksu?lnZ3I%+@rz9ZwDD6MYq#IwL9sqh-Ch<KKA z)`C8Km|E4Q4Gi>p_65fAovQd%3@O>t6v$O`@-i`lB#AL%qQ^HxJ_ScabN}GYj0@}w z?MVPYqDXM-Q<rh0{m(z=`EG&Ny1l|xEzaqDth&3VqRH$Ap4h55j)^p@%;q0LZtiO2 zL2MtYGFoxvIXWWLA|4#~HvY}*KoqFD@?J4rGAWhqsp%~+(=l}~*M0beDy(_Y;KgAc zPtv~2S->OXUkR*Z2=0dcY{#$v0nq*fp#29x`wxKj9{}w?0NQ^5wEqBT|4##G=JHgh z9HS5#cRVyZt}nUDd{ZT$(>~21d&5BgLY%=PWkb=oJCnT^YrZShUWU2vkto_s-L=*P zd-*<4+~ec-;qglyV>VD11KQ_5pfI;PT99V}ukM~Wj&LwtHO+T#{Z(@RUHA=}NB%A7 zo#XLliJEM1o|8}Bu%Bs;WS2>UjUDyXYWE*dSlKn$-=MIRk>0Ir4ivVkSgVJ~xaZ}e zm(pVudadG8T9p1EcFwec4?L==kXODoKlJj<v4XA}2<52Jiy4q`%hKCme~4X-OVCNs zfP4J)T9JBUUD>CNocOQKvH7L+bh;8Iv{&URLt}p0SC0})*9vfhA}Do}KO!988r-Rv z{@~t3J!w*S@TC~Ee#^S;UUIwCPncfLg}1<~M!1sncm0})J);sZM%vjxy%d^l4f(Ph z?S<p|jk)D6V#38FiKQlG$E|?XQM5tpn_j^!k;67|9qFPv$nN<G)hN(NnWZy%cFkpX z@GZRg0dnw>puOKiaoeXSd0g7Nq<mpEK4)n`DAbif4%!~_&K7>YrbFxYcGZ`ZhNRt$ zQdJFy2AEt2D?+Zrt08DE>3A9AHMAtubVb2>pw5H#h$Wv<A<^gnQ>VtVeyqE>gKZhJ z`93kVafz$1gzdNgcjy^uAluYuoI<O6etB5i!LjH696i&2^qn-`+B`GfRX_~)y)$<) znC7n@x@WCf`4DABHX4#uRuHOlXo+8kzVW3lEm~+?x}mFJy;)G=Q-iVE<anO+idXZj z=shO7DacN7t3~p$2SOT2YpfUAz4hQkr-#W|WNS%%%Gs?4>75=qiS?@DT^>m<>s7Nl zJ<8JRQ{?v9JP$qsPcN#mPe;Z%`Yd36J<v2Cj_@I1S|7X%Pl}L7Kn5gD+ts&SXb-NC zmm)KYY8sD*I?CUPP;=mO`LX2c5lLM1V7F7AOWFiHJZCd+%=cM)_vuf!7<YKXuaC^M zXv&n<jjXPfwQ>aodlVRX*oZwisLSW#`<OS#kvHh4H8YoDDR@qS*K;RUK0K6tH$*@f zesV@h%F4TJ=(H*&_3CSaBT9d--@e+4k&}e^N>;Oyf~IE)SdzzGfPbTWjm#Caxe?ez z?i*X*;1U@XD}ALG<9M9@@(=i||DWJ9u4fvb{|P>`Q{Ec`Z=&<peDi5q2KJAS*arqq zKUE$}vR_il!T3BB(cG(Q76B|&76<z4UOq|Qe@2(5L}SJC@WaWY{s(}eCqIX#<^()L zY!)>8{McNN-<$oi78T!}YPrGPR{gOxQ{J}Hw|vjVx=`)vdf%&j`$}VYc+<U``WFoo z6rm5iB`P<oXNBziMi~6U(lVfsK5wxBr8OaqtYDAVmBE!yiUNGYDyGGHk(n;lC&YI~ zQrY6u{hD(QLTg9+3tF2`PfRsGiW#%r4(6YB3b9FWB8$p2PjS1s`S@hZZ~TV)youd9 zcV*!ypONB|4^>^fdlH`fpz@7@=Xq2{er&wmt6Xw-o2wnm=2{OefgcEOKm{G}TNxLt z@Ak|7u5Qdno~1gRB2*ZyJHoSlH6FV7{yy(SARO}Tn?jFN#Pw95S|`UDzLB#XZ|8Sa z-u+R(5486+hcgrU|8QpJhyLWu9`{V<$k%EFM`~tzOJ-KmLG_HOx09?sL1H6(A8*aT z!@1C;lkE$;e=X&0EFNprEw>ikp~!4h*2&PPo=W@{iRB3&cN_?={4S>&)cQ3^fz~=Z zEM!wHc+79*>ZRz#!|J~tm#9pfr|6vh6q3L{{TfkXXrD*?r2L?~j$qVI6CdNpE9=ZH zNr!efGdn}>A#X)dqRZ7esn__&K1RIuVZ?k3VEMT9jY-Tn!s~b#=^wv~)#6YGZ%IKQ zf%klVJMtA5y#<wtOg62Zn2gD%wuTIW&rUIfLW#b7o^awrY31Rh9EAqSDEZ|f8Sn0? zH(R@cV&1z8UC#9%I6RPlLuTk|#_rhKWcB7Jm+gH)87~HIMP!G2w<n~%V9RLf1TpW4 zChff)yxwhyo3j^wodr{@8_&`KT?zXz<*PQX0C|zg?Lzq3BoT4JOT&I_%ccyj?kT#T zA^rMB*N(33t3{q#qiMrugNx#l8v|eQd%Okx^d%)qivGaNmRI%5XWAh0Z-J?&CmcL} z7{8k2so;B9MUIxh8VcosnD5T_$n3B#*=2rKit-x>`MmF&XB}`v<TLM~r9^>)T!hYm ziLXa_zoVWsJTt7cKP>XZtKrO{Zsc|0hr-$`vTKNggI(^}%|ZNAK3n){#W&^por*!V z?EA%wxuvc@JrGZpvkGrwl&%UC59uj=KIE_Tj;Z(Y4qYyN!#<qF9ga#JmvR67!naT3 zd`72paNw<;cAQ#PlTqfqD@U7nZXhhDtUgxG>6v^`nN#Iy4}I8Bo_x=%H35pj6;<CJ zI5hct#^OvxqKyAL-780~n5s*u=t`&9aM$SmJmE^2+jUXttlwhKS$E~DqPnGB8Gi3v z_R$6Di#=c5mEF>7zgLU!9t8X5G|hPH`77Hi`)5iT+)%b32#8QrZF0G&Hm}y89>0ry z&uuL5e)-dWz4QK3++3M$AD<K^ep_jB(%4&cXUO8+r8iAHr55!rr_L82O|80|WV1g* zX?L9@2exuk-jSN#ol-MlF!3fwCa8Mp98B5h-4&Ut$PtqphxVR4U!tqFS1MLYK;xmc z@+<2)Ne_W9UBvU`dz%qe^~a6f&Ux;=dW4S^cP8}fh{~n&mqcq$-h)iPbmI5j-L`O( zL#&OK%sp-@I-59uKqJaTnaeWc#Fd=TlLF$MhI3|Dp7&k|l;XK63u`mXK&0N-uhuE) z9<5Zp@43q{{rTrAQ8JEG2S3zG&=HaK$MxsYJ(9Oum7P6u<7B+gqp07*J#u?Ep_n<d zJ^38ItY_hwhIy;S*qga0{wf;g$=^8J1{Mq;`T2ny=B%K@XxU8VN56}igA{>gra~^Y zIMRw;=v_1m0&$9IJRcI2_e$cT*Ue3pnsJbMguj~{Cl61@_an{*Hv7$_Go#u}&-tdP z9?kE0yva6u#KXyi&~uc?JD?`(rI&j}Ls&EU<$X7~3ppK6M!(+~<=}9%JH3L&&UCA> z&RbqL-wv5Zb)-~Ywy^2AoYWsmJTCYI8xpy|@biR-B%YFDeLQ*I#O3DKx8JlU_F%Gi zZ@5Z}B}dl@@``To#JlFnTo4F(&gHx5=utEj8_HF3@P58zW%+3iT?SLWoAbu^lufzb zuj?E4Z=X8w<PGb!!LO_3M!wUDvE^;OchAi!yx>L$iABbh;LJJg>3M%BOthxbPxsus z!tPyy=6Wx?TizL<?e^{A|B%Zys(mZ}#e@;hAaGFp^@pPS{<r+Dwa?wWSMpKtRo;f^ z6~B8^P3e81?pm^$%b}lQ?=I~&^wSXEy_<KdTBdRda!Qf+p&i=Kt0T{#3xs@`B2O*X zIBeloBdjuiBGW$tWWP@1&s=u7({sk?)?o0nulT%|l2jpU!~9B~A9~-VH%e}O9z3PX zg*pB#C~8<^(#i|XJHPAk9=vkzC24o%N6Lq0WD>un<LXQ+vc9a)8l~LN3awPlLF#|I z{RHiKR+=WvWA=<cdho@*<+_(%Zr5^`NA8}we&YC$(EPQRoqY|0g?#U&kL4a2$d5DV z2kpy&rANao`Ysor{;n;ydn!;=niM6ZBA=Y^bp3R#{Om%Hhv)5YYVX^;7hZX`*DvMu z2PwYL-!!!py!QIk7lV<K(?2!|9dGzPUvNUZ9Fe?79<oj@chdUS;+7JWKK#mjtk`VX zP^#8Ow<dM{Y~IY?W3WS4G<d6|h_zoX3wfibQm;r$&jlZZ-A)Zq@YbJ8EtGSe555BH zm}<u8J(|lHj7zEzmysq1o7q!h6>cmIzFd2~njVvKf47z@GUlM#mQwPatgR3ETVbH+ z+oRE9pRmvK-}{Uxg@ZZ`PsWJlVy$D|`;YjAXYIYM5p(W4HZ<*hpjByjoQ#2W%()`$ zhl2OPR$Jk528Lniif;|?L#+HEDt+yi^4}X$C<|4tx7T*GFQU8NjpJWFQn;$+k9z-o zNz)m<zB6du@2>?z;<nyRjvkI^sFn=dr4@~|)H*x;SbuY8_4R`MntG%Cp?AzGu?}p3 z>HR0AIW)teQtjojzKDi!>COFJuj(y~-k*qfHnNxVHpuMa5N0V40uDT}PR6b_YMiSm z?DBAa@=|Jicq!LJKDTdFHb1r?DD1V?%ktns`&|J~%u<j+)*lQF3VwUAq=nLRj;~a( zMfvRFhshjHCe@+<%c$#Kv&*GDpkzex+_!EVKKW$!u=zV=kkNzcC&>#Qpj;uP>x)!( zRME|YrY9SRUwa-l%L&RF6Ed+E(aqT@$r#{Ip2FL)g|u(I{6^omo6qrUsHB(fi#;Z0 zqHi_TTb@;3VC##>I<|Z^e}Lq-{ocDe<ab5YExK(r`4LDpEaBAcArn2-mmh}Bza0!O z?KoJWn}4vm^tQSAePsN;vi2<VyU2Lsvi8M_%`2S`HC~Th;}FD%n?={1NvoHxy&ikr zxj1w>ZYfu?$O;*cyn?*rc7<cl$<1DN;8wjRd9dC;&m=VHY+=|rr9QoVT`m4Sd>I|- zhkv#?58n_ueNn(d6-mqgWhbKUW&g|~3{tT<{<_r!|MqK!*L+>plW6V6+fR*HBRZeI z@3qn5B|OaOkQ?=3^?UBiWc`q9{HAO<5*T|n?VRISN2utXUm=o~Wu6xdFY|A$$ZQZ7 z?I~NU=Q3E8Ru_#v2hh&e^2QNQm9>4CO8F%{AbWJhRyDEL8l33868<`?cV^2yuk1DH zzLw(VKGjboKxHfr@MaoiG||*_B1t}mN7Vs6&i|{?3^PO3yxcI!h5EkRGk>Y?smo8% zLr->L=KvlXXS`v4^Rghvj9eqfvK+?>oZa0n{%4m22|d#rN4cKQ9PVt1E;{w?aD8OS zeoLeD4?XNUc}ZT}<)pN%p7skWR)Y~YvLDQx0<F5#zP#fkxV|FuUJrcWTdzo5d<OoB zK!1Pb_O56A2e0*?yi?%+W3ytfzVADq$0B<RT_wnm6)T-}I$REDj2yw(C0X{iUp;lK z)HaT6%NbXr+l0EUd17*Mj<TMc1xP57Z|!H8y!8BaOXAr`-p!c4-$0-Qfn2~p|5nEI z<<$opd-A?ISWB+$w&LRAdB-^uGkPz_MjreU;{TU{yuWAW*v89j_8Y$>c+>QnL3;<z z)}PWEl00+7_L;%0{R++Js&}W(9GQsB1YhsIbjs$8?7O-V;0kG7u3qe630G(>gXFvG zZzVnLuRkBly&*u2VTIil<oV@yN04Vm!m--;Pmh`=xi@wmy)vzMRa6j{E&fz-3wS5V zA4|fB{}!f!&&Kl1F@d{+yuWC!0yBo3txZWw*#Wx7=>U3niEbv`ofG`SX=8uqW}RT< z2`r;Wz#-gBPH^wW`~=uF(ms;`HxNAfi<T!a(^>=HL`W*ck^r@<ww&EDBy}Pd9U{)g z9-3nYh!dJa*eiR&H?rVlZgJZ!jLt`$ROSF*KIR-SCx7(~Cq3!um@Nsz*x<OviXT-? zIkDfmjsb&_L20Yml+!947xvI-iHd`Tf|jUQ`?mqJ54txoqW2PN*l9fON?34!u6zpB zmH%7I5ulqp$Z9oP<KtdKmNM_)j1ce)@o;)#HM{BzG@t<99O6WGJ_Jl0T}xGA@~yw3 zbUa{JNPH0@Bvdd>`N$pV>^;*0tFJ3q1HuH3%Qy9AyG4j?%}akI2Nd!^e<Z`3>k^rs zD-OSfJ9~!NdwgQHN@D<rkilKnqb;b9Y9EpsIK^_T0UHC)8g&)OWMm`P&O>oBSuxPz zMffG&!)zP=8$d`bNs;>XhIS%*k89KvXP3exvN8WmwrCSr<0<?qynP%@;$nAjGn06z z(!3jr%SXc*!kNOM97hbo@X^psXu_g?gU+nKqtShQ^9ILJzbF0OcR2vpeC~YPAHh-! z^;uqXSe{hEM<U_P?51(}b*R%4C%$_R)#pieyV?aPtE%QH=ou1-?iHkZ*KmABx*&*c z{afSNyw2(H3FS{?P4WVt(b`gX!vsOY9C+>dveJ5F3h#0JujP`pLz7pF0((V4h=$L~ zZ3l=)KBG%Q%2l<4w54w(IGh_aHS?*-(>xvCo!>tkLx99A`}xnj4YiLI7f}l|Hs86H z!r0e0KN~;%a{jHrVUL^JKn!4Vh^AgB49PUs2OO&7hu^e3L0Bm(eg;G?>NmtNBIlb( zZzYgNNef4<r9aqKgXHVExDHhOC7qVF|0e0oic1_<Kd5JV>(uSjvS;JIygl|#@BS_C zw;3TMj$P6YYoyqCs4`+`HRj8{BcHk6(rE!Fq96U&5_-E44zS0G=mZHstWMfI9+Emc zO52!i@j)RSQ0#olSfeY;QX~Ih3nQ`~Ge_P?Vb|1E#t->O!;AGT4?%Qb%#q<C1NJ)L z38!zbV5fbi8(GY)#V$DgGg-I-xg~o+v3KzVF7YnQws;IvGUh`|3eK`|d*Cos@(5$l zo5fbtq`}!t8di137?7`dfAFowWk)d1jNN`L4a@cv-uCD{2d+9gwrf#@OjvNZyhTMW zFJP1wuE8q~CI(a-r&uqbRc3S=HKTR7I-NjDgwYP5kZnv0As$=~Td;>0$V+0!Dlf9S ztvaT|xt1Bph@h6=0Y)?_>^pNiyv{8L%7>;fs`Wc`aP8^wmpgA39lU<I9R%(N&9MW@ zCG2{vGQA}799$W)*49(90(7q>J8am+)Y3w)G0E^~rH<9~zt?5x%@gS9B>f-QJb>h* zNVMLXq>J0Gdeo}!6o<a(T3&PK#Vonr%GyXFH`b+4NLftG$Yw%BbQppX)c8xGA(;t8 zvq?g>?Cr8gn3k{cM(0@}E8{J~3mz*{S(vx<v*2Dk8jlgq=gS-01Xg?F#c{>8>9O3@ zttyE*lX2b7X_%@+09Y3KlJlNbQpB^7fXm$G8*#Oglp+N0!I|L71(L|FRbpcYDc2Bb zLR;&E(KP$c>7eaz^O3~>PtcQwnMN;gJQQO0=C=Z@9r`=ifdUw4;X`pg-L3{?{I<2- zyQwlfT(@$CM}bfvn>Op?2fv=eL?wZt(>l6nhE;Y`@AQW)X?Z5s!axTg(_onh#*pb5 zr6u*Rw_C@BarCK`z1uIx_t&=8o(N{Qy+>j23&xq9Y1{YNnOIs2JD6Z&2)4Nj%>~P| zLda{26&ypPLy0V?wtl<QqT!2VloB+yJ}+o-Gj!=r_|#W%adURdrGtMg_nd498BA>* zl&dX>{Pd&~N}>|FS!UGuny=wFyoK4~>gqbM%A6VAzs!$*ftzUBmcM3?CwVxyg1fgq zBK5$TIb{WsK=LFu_)<i~Nc?Y+5jY5)cMEl|ZGGGo7eJ_*S42ON7)ji`eFz=Mnt%yo zA&FZ!y0?8EG_sQTV2LVF8+HSMPbFapRV0<=*cAY0)d9~1EQ2?;bMuVFL}b^_FiM;* z^@?@{ThAnowDKJUa1XYK5wMvWhjWUUJr{eo;l8#{yE<yZdVg?Y6n}TDXeow93lGos z2JA2$R`ynDfB~_Bd)*UAmB~8WHJgU{u!yPAT_;*_JHO)8vj#Df)NEI-1qQ0Cv*ZqB z0sLpZTsnw5;V|Q&+}MPR)+v8CPu-O*GCt8JObuT+RnS4*<?uX<i58|ZZB5zpd=c8k zvVdbmWI}5vPOY)&AC1~up&~gDA1(R_egy3Ki(-F$K6(0Zut!nhNP|@I45u=j8E@BR zfpTSSWqT~|xJYHqPmhhl$DTANc=P!xcSE_1%tasg8Rsp!w!(ZT;$cSfs=bVIc=r~D zyc~2OyZNi<xSVPoeLA>&JwrP!8Y|d>j5*Zkp=JjtdJZ^=*gRwzkCAD=y`c?Q+*bOF z0wQ0ce&;kkzGQDY_E&h|+r!oN!P5(uW#M2gORqNn9hEGmn9qU}S)wePeLI{0rZ;{_ zNHkujgojezKx)p9pieWRoNK}r*x#9TtSsh$@L=niK4jn87x?y}nn@+*v0>6e^QOKl z7FdJG^(XLDy@O-LJsBa|+$<1Of3krvrq}lEtvy{<VkaxIk`R7d%y&T_mCWQr&KnZA z3Wqa$Aw7`Mj%nw1A|E4NKN+5=ba@3mUWCN>A@kDQg1lUWQ<HR13aBCWB($)&2fvkB z3`0C3{NsxKNVf?pM`g%us8LOW{R&%%gr(ska#(K^?toYWc<NaqJOO&5^3!7GDJvQ- zWUaL_aV)qSJiYj1`uhgu#s(0XKzlHrv?1@Om;#<I+XSIjwGVf7!z$lo#7MEGN0*9# z(WN?_%7IZm58%!C89z2P{0Fyx5z1uQXTL_<FaNI>sj=%tdEPzNHd>-<c|J>;+_G?8 zfol1O*gW|Wwbx7;NUuoAG%~}qe=Tp4UZ;!vS+O|1lWn*_)R0(SXAXZvbyO$8bp$lC zy_=O+j^hqE*Z>X};CQdVA5LC`mg_nTeqR7nc6r>LB&r~KpLQu2cSPI!b3z}|`!w)V z2SmY5YH!u6qTyP!d33>c0F$!Jp7^iZ6=Wu~>rI+%D}3tJBxBWAEEHD>zJz^UFDwP9 zAf{)i;Y;YggtX^QD7&v^rJq9i?dhwYF<bQ?lLdaGWsYK9XeEFqOd|Vh*T86wJ-6pa zH-T1foXRN;WZ8FNge4jStFqNPHVzJEd&j4))sb=URGqv1bEo@QD3ZCODJ+<l$Ih{@ zp_m^uRx_4aiO`axnPOJ-x0zQWz#}ee66L^eO63JG+CM&(VhO`s#`v_}Wv7g0cXE0I zQ?>?xg*}dcRDk<{+gcxvndq4II(F-|Dw*KbWChp&lLqW4)>~L_fWrKdF`lr@KH#m_ z^DCp3#uIDA;}O@yfuhGu#1@6Bh@+13eqc8{s<6j9Q0-ytNh;C&zeN7;ed6DG#!K}I zzwQa8!UT3purCbvD(ULm&Sj$(CJZ|_rUa+`4XuC=6#<vwb0sp<xm&o}cmq74N~l)- zef%3~ow;{bx^pS@{JkcH3=`HV$DEllzwP*}t&q@{d;~{|Y!V8;UhU9n7?GTi7~Cr? zIW?h#iytzoR~7Ww=bY^<e;J)Yq^r=fHBw1pU_<-(o3&qHFEzF>B__m@V(Vh8UX6MR z9)4KhE_e~9r3o_7P9=^{vp>lb0=YP*h~I_Xy*ed=UU98sUuVbfx1D9&WzAW28m^`W z$V#u+8v?GjusPgiXol(<bg&FnHA|GO5ufn3Gp=L!Pl?%!bW_UQV_?x|s@5SAzDqT8 zw96VHPgUQG4k`Xt5X}yk4_?z`$g$8s4yn`#GG&=6&PreySl1vQP>MQND?UFwSM}!5 zTK^YMjfANBBe>)>L}i-j5E3J0j4=<FTW&T7z*_69qqKl{+{EK_n0WB!%-yPtm>XF* zwF?T?xB?NmV9kLrXRU3jdKdI7Yz3H%h^i`;Ye}4ny35vp=gdqtL6afZ+7MEKdyy+2 zu%YX3=ZA^{60AB<9>_PQg%TEP8BE0?TwRa>$Y=GSJ`1&>{d%bGjx>-{O1e|X!P*vX z*p;eIxEH!)rER(1`GDUYo~4MC8)>kC#HUS*)%tHtUf(t<w#NE>h0QM8)QLh8!d}<s z6mRu}o!alTr#<{CDsO7LZaZ;GYhXQu_@bcXDO!Uwc|v$cK)5C9J>yn!CW%0@h<OyM z3~+sXMwgsHoy*~<mvVnSz{W2Bn;u{%O8i8W1a(a~N~NfV%be}YfRv^qX3q@n?Z41` z?%SX0C3laQgzJ+yqetypgk1oyqBM<Hoht+#Grvjwcp6II+8Ljp3)>9e3ZA7#5{MrY zpgKWgicpkVuvao`a=U8sd&lg8^I|U<xuP9{u4WH?s$vDS+Oo^H*C)r=8sx4BcA2gZ zgL0Te_vp{`21M(A&QGw%V?m*|82GU#ZUC&81PKN+C_yJe3&#OfB{W(b`UPLh!s6|r zhkW35<J0S8lvq(63y3WnJ&6q>qZE2q!zUby6zZ<~_{G6xH|i`CCv&9fw#I7`PK})` zvU};cr~G=4Aa-Y)9UtZmRIA}WxROJ>Fa!yVd2gV@Fni6>0T+QG0Jbw~qSnDEo0>)( zIiU=K-05cbO?iOIfKN?Cl?=kyBE$ivg>vWmm}4}KP|)e^0bz_!u`8>9Gzf$<5e%pb zwN}IO!mRml)-XHOED#2hIs~(vsguh?QGD^slRAj;&4|4CDHhbFAO*1owJ1Z_uTl0i z7WmWAbtj7{?yG&%JN9-qRTOCbSfwXmHN%k2gA+&%OV}!NIECgk^0|Ss-8Nf~S*piW zx<D}0wz8#qB3v%swTO)%y>W{p>vSm>tI!$Xv3dU<bg&N`8{t%!*MK3tAtG1^T*Q0; zp-h83D$4MThpM7=GMut|N-#^-5;h(Xpy1KnA{I2p^{)IHG{MPUp2dO}hgA@0rQn<` zIzs_HVM{5o^B@Lfk?Mx0U2Ac;Fo_HZOqIg)1n@+teKfp+lEu%DWqB4eMzu+gNcGuk zP&j~!S%d-vOin!;LEE$?Q)u3ZE;c)XF9FNR%JRgq8>gEWSoW$|m}fC-#FkM)tLM$Y z!91r(P62C}rLpWDO5JEZ4&wq>rNBJNuB1lJUg4^YvQdY3XK%kd>*kH(HuwEtTXEru zBNrDx_Ajw{7x`}z8;;&5Dtj<WEcU3MB>%6M>Vmv;H$>a*S5|efb*{H{F&MTL(%s#) z&Q0*hzTeLgsj1BmFP%8J_xV4(88DN+gI~m9vf&YPnv{IpTtIFimot^p44ACYUa^%U z05)rJAkX;E=*tnbt;K1KLL-zx)*}8;bKbqp(AOJelp~T6#yhB8s(tKYvcrxEJvjMm z5_WBxmStzQRk*O*?o@+)ZdbAET0KO^*Bw_7jweVgdQ1n^l>0!$(*?rz<g<}s&O7mp zY%rWnfh^1Sv#6W%l!nmg+HZ<{H#8f>SCntC5$CsJ>uZFa3+!(RV|T(<Ke|aN8taQ~ zhcn|a`2c$Q4^<Vmen68EVC3AlC1vy;qimfm1)^ML)EzWIjPJ`pPybH)`jYs@#x?+S z^Hdf|y&QT|=Zn5@`}Y;Ib?QgHvC>X4teHf<t9<q*6w9V}A1`FWEnJ3y6?j&CWH)=& z=bNcT2thR50@|lA_gwL}&No=3wuOIit*%bzX)xuuc@~TI7SlC-5+EuGSGTg4wdqnj z>kGN;WENCxuBmba#MZNs<eVZbx^UZM?DaL$r@Lti8&wK&Yl$`X&hW>BxCZ7OA2}P_ zR(W!sm`5FP;m~8UB|L`MLzjV0vJzHR*Zn40XB(x;z~R4DQ&z4GeD84+ZYPq_;H^8% z#gjd1#i!?$3C0d4(BIkIK|91%pOe#~>EdZCHgpgoS0K<MxBWGPYSaWIwh>QDLlt>; z+F~Bp{`S92>u^9{r^f)q(r--7)bHCXwPOEhCYtJ$)Jb^HmQORN`h}djJn0IwbsX{2 zY4KOC73Q&DB*f92y*he4d)V(o!q4x$#mm)mUc}P{ZIuPC7kcxzB+PBYtC%*la{a4| z!=R(~d2;?in@mUr{*J}s5R<(hXPyg|1uS%m`jbe}y|Q%iJ-E=fQ4yIu)+>emk!{E8 zT*cks+w+;Wtzm8x_D)OY^nop(*?|k7!#R9SWj{%$NuBIpYi2^pWdqi^JQZMnmn~N| z&?%mNlwp~f8Zg}1U?Do{O&i>~kZmz4v2I0^T08~<y7k&8SnzxvOu;*Ns+`TIQw1}6 z+pyNKJ+}&M@1(;1Jm6mA{+OIMPXxAA5R(<C_>%~-8E+mLb$r!l=G6NTWQ3`Ua4}<| zc_8~eTrPWfMYgP}SL>v3IiXvRdr_#YTuU?b5CcEbfu_vKD#jX{_VR_5;?3zve;obg zsa|KEPFV|QW$BRO_qaq?eb=;j5Yd2LZ2w=fdEi%LEG_d{kfg^4^Nab0M+3&i*f{Z# zM3txB;?_&W7b$yjj#eR%gYZ3b5{0Oc;q#O9oixTt<zf@;`$+2D!VMGWV64Rc1Uueu z$hN9lGSb!Q7LwkDz@1!oYsjL!f~==*cVGRgj9A&e4JWBbqo0&*g=}>`?(Pr5hIF-W zS3frwy-iuL#QD8{zM)>UK7_^H&e7q`l1t6m)LkUJ7w}En7By+jo+Y*GJFy?6<}<Ul zY}@j?SLyNfzeLR2jusN8v#NI6b@$U0shVrRawkUlF;1(aK-2Y_M*ID&xb|&gguW## zIdvTya<brZ0bXm%C0zR|Iw5iTl`DbZumZ<<9;VUrx`e}iv*I!RY((o^*%k<yRrbO> zZD>a<Oc{ib6akWLwpAV07&T*nvYk20p=+H$6y44TJf`db<PzKtz?S~4>?wEPX}r8n z^lWxu#0pg|)lG$6FyRe6Ma&lTX1mrt=&_E_-ch<6)>;^GQ;qFG|J#{Ret>t1hk<ZK zkBkYd&VqJ21^~tNCPMFK_bk?(i0tKjT*-Zssk0cgXc<=YxUjRZ7}@_gT=XBpb76VH z7Hp~Bq@VMy3o9YPjhWkJUB?jb9@2cq>c`wT?pi>0YIN0p&DYJLz)Gv1u8$6}0BO8s z#qko=285sYNHzq)>%Sa?I}=i^-EZcx-K&r$zHR%_|4VYC_o9gL41+kNV@^o;a1`bg zBV`88*_12BBf`i;NWBT|=f#AJ_E~;<Vv1I+j9~Rzh2ih6M`y9h(6#pJxn0KhXmCQp zL74Zs`6uSQQ~Fo<^Keud2S!wYcZ4O>%=LVzy%T;r>7w2mE2<JrJN~1kuc(@Tt6;lA zZvGXYc5^2nB?ncUHxE(2)_ebn1E!O>&=*mstbAI)E5+Yd&0OP+*Xpzkq_r>0H!H0% z*m+`cSIF-b8%7~L6p828|3<9nrlaDg?SSbLu&dV2MV$9^e*@2$Pl$T0U2$1IHkpmY zOgq9UK>PYUn2t8KSg8lmsvnFxkb2tyF-DFQD%hC`o?Q;E>AmY<*#=~l#UIb#LHSQv z-+783^U;bIr2WY5RVs5W)$p~jPu8!1>7>~>aMD-bHLfzHh@H$nuKbUCVLiMFw>(BP zseV`0XfrQPY{ZT<?GJW_8`~c9uvh0AexLA-Cs>;s|G+W47FjyZYs(!!d+d}k!8bP^ zxyMnc(fce+Xaic<$dC3}4o_A-vDx?GkeA^+-*Ulxuz6cA+s?kut^97Sz|>;3rPx(+ zom4p`G#>xHU}ab0lKXUL;IAEmD4!o+T$v_s4hnM<^!rfwx#6g3Kbq`yr_CX2?uwhO zoc9xcY30S8VfXb}@}J~_4$24^+6pk96y6&+ES)=X!G7dZDasy6KyLbtfxM1@!XgYw zTh!pz0%zvSaa-00|Mpi|SGX&((GsBej7^B;YBhaQsh`2vouvhutXkX(6&=GL^<`-J z=<3ij-nV~P*=uGy7|)Uuzh08G7j%J`)=?1g0j0lTAZA>By>pSr(vFsLJTzWOp)8G9 z@lim-;b!*llJ9ee8dw%AKQx<r2i`d^8{990`!@fvGZBg*pltLPcW=_fLcV2YucK>s ziH&d1L*(b4`)oIePMC^4#wI&-A&9-$=bdco<;6iUEjP3C*&!FEdqe0kRcT3@dk6Q{ z=AqH9*rLiLQ?~J^dES6-!jyZ$<Z3PP+=Dn9m_rxx@yc*<)tAjuxvb3L&Fz}*5W7)8 z?e7<vTK5ru!VWXxAwIfx`%Tc>MmguaJ9Y&}h`qzlh}=F`?Gtc6*0n(T_dk_^Gr3lR z3CI$mVJ&Tn(016fDoG!qgtK?ol|*pmu^`Rq+<WU5^T)XnLD@Ol<!DLm^94-Qs<L50 z*wO3Xt4}Bj)2^iCi29>t-9Q!W3yKlF@%C@+x=d>B{?1bJi`TE&7nd8N(^3OjOrGV_ z*2~`^k>YxrE!f%qrI#DLN2fe}fRVV7Jaj~fSEWxz<u2(tKSx5*+UJ4SUF-f?51rwJ zRcDtH;CRBa>VZqV-#o+LWd~fHawx4YIPqg$pg&ZtWSCHub79>a8n<3_zxW*Kq+CNa z>U#0fLMCe50k^+uEE{&)mSsZ5!b&{g%)MdXF;WgyfV&Sb>_(XRqCiuzeLfxWPzA_A z_`a#-T@+$?)U3z&RDd<Vhz@lXvpTM$`73JHLq<Dz;l<4CF1!wSz%UrIsCl)LO@iLk ztZMIdDDW1QgY>aEhABY=Bnh=T;s3ELgW}hd)Yx5Sr=tUOJ??{^kZYhaF^i`n?$k%^ z%+2gvA_H|wO!gm-sOwRltg2VePph_zY4NU_QN`HW&B|9<h=-O~%0=Za<9TpFLn60b zYU3VT4aU7lDs~;#WAh`&!x-4du%x*W^qr2w+GOYViT#q-M(~Gxtyf?rDEoColLh7x zaOvRuppB5T7A5Zry-y|e1<!8Dl=_#}{sgLvRpN=4zc<91YFSpl?gTvEIA9F>Fz-o4 z>em!`C!4dL(3WpvhCWK9O25`v4FSUqwfaLfw?aiWYWHZT5F&Id1;(AqeqkDlkH_TE zEa)>~b8W|Y>zp{9nC^H=ys3CdkNP!U(c~?^;+A#5j*%k_N?5tfyX~29ZNOR80_ser z)Wjl+3byXEWU5A1TDoSZ_FJ&0ojb`7%tSxoUtF1FNDv>vAAc@#zRWxIjwR>4X-&o~ z!Kv4FQfm`-Wt;iEC^lunu>emUn}7QKigrc|kqR%eIgbG`;VwYDO+++65!j&w{0Lva zhm$?2zr46S+MExCJitcvDi@Tcd8@U$vxR*bmLybfh|6LinOJ=j6@sBn+n^h;TF)s_ zCBQ>Z@IQLZf86B$&?h!1vxgylqm1puX4p}nklav)?w@u${LtqPIqZc_kk9v)X*XNH zmjpvn1Qggu<mwk-s3AH`gM&1B^eJnb-w8;R)6YgJ^5}|A)kTcE&qmQ!2xMm{nl3IF zGb}P$8gqxSn+7WIvRq!>YQG%h@P!;#2&j*6KDl1WcK1Pm>&8S%J>XMaH|W&q>5@-m zxL18Nixt8<sI%R|Pmn792>m|ZC4}9nx~W-6FdqM8tfdVU0s&dfu+)t8PlM15?y-zU zkC#vw{mpvGP(+2%yJ(`c`pN5N!tCNtg+6lMY%CG<XWisC4b*7!w1w|PqDk5g%U_yI zX;`<#!HcAE?*cQ;tgA60+poIA6>gAnLEk1UBL+HPejtZWja*^*jYf47o4C<Y?jAHH z1-XDu@L+QX<`ErDL6?^p5xM;(n6UD@V-8Sb<hnDK_I@d`VLX96!`cYEGm~kmn(g=S zI<VI5F6zQ6S5A@YTABF4Q38gP_<)tQTWd(R9VI;p_mZ_^duGkDt9#0BkyO~pW*TcX zUY6EvB+To=0`*0DnVPi)%zPYVaWW{flfA0O?WOek_;Q+w-Sj=nEC~yo$Yq9Uar|Pq z9VRzCyFWl8i<Q_hj#d8MY8Q+XN?gyMm<$c+M{G}Oj-~31g}vfj^-r4uv1i>s<3k#R zI4W)4Un=hV*Z(FJx25C|7uUgEk^p=R_DECr+TTfnzm;A;WMTe){Ie7|wAW|lOXu_F zoGApu{yOB(s{f`#PJiizza+TF?!S%3#)4I;PTHQgMoS)3(|HdDzMU)Sx_+i&S}ynf zeFND)92S;Bp|J6I4}+-b=cVGQcNj-j98Ar#C%x4cNu&UPPNxUJx`Oh=&4=a98{uJL z8Y7$sG-S~>z+f;G4T1{8!8w2%12Y4p>FDUd#<&8wy>g1>-UK%XMGYlVkM}lMrw4Vb zmzC4Yg6^K|2Y<1=r_-rq5b))drJDtSV<xg3f($oqN&qi0)v=2HnccLUpRo>e>8^&! zmUh?@L|V^HYw;~7&rZ@so3Ghsnbsn=>dYEByW_9SpaE-ZYa8*g(9o|G^c^Ya)eOn{ z{<%!DJ`-Uf)}>aTnrKgf>Lj`KBWg?KaiY{^cDCCRK+u9B?if&i5UkeDqs=<+QIuI` zQ&Pz7>2G4LK=Fowgv2n-lV4KYd4VNO1ic!jf}?sq_^jY)-cQ#`9+B~cjO~U9wG(m2 zX*%gFF#7GbdCIvitpehagwI9H1jS7+as}cpTyc5+c6`Ms#f4-t{G1sMY2VZyc-+0B zy7D;9o2Ipl9P0#fOHCSiQoI-t2t{S>EUSsJ+pX_w=+ZI3RlDu%)|npr#&e)mCi2fK zg)mJW!}SCbofT=r@9~nUKOR9vdKx|GltG6Ia##hpP-8H?!?Pzmt{;GUNufyk(@aB; z(}fw^BoCP`ja7Z-yiy(N{nT=J3ie`=3F`d1XOEl366zJJbb=w}Fs!|mN&cx^(~Iyr zOR?8*J(LR3IR7K0?0o&WW>*^iWl+M7%Wyiq_I?yabGYG-oR=kf`{Y|o`yBDSU*YE{ zw^JL|b1!G-tfzYV<ghUhL@HgtjUHFXmh}^8`DLj>Qqg{}g(4x<$sVXWO*t(HIT_Sl z(OGW(b(C~!v<f(SHM5VE2<SWO=tJ4yR^TL@%zhg@m)USKo~jIVSRIAAH_$UvA-kyI zW_jCJtBmb2Tb)1pbdFNWzyVod37rwel^gJBec9}LU$qsLhLvEKQ~D6t=<NH7KToJp z6SzbM8U+KqC~%1Y|Aeg%01bYIB(uYq1j;UF7dWvoHDX%Zuy;Z7JDal5=cw2&?Mid1 zQl0CG$_17t?opfXoFlGW1(HQorpP2DKZkp4p7xUV>O_pT138zoNeb;Sn_*d~{&a7x zV?v+R&51im9F{f8XioS2nZiNm2A|h_VW{D@`qWNfC^)^aol9f7)35J{90ijQ$u*P; z!zXSHx}e5w)ZYd2PpPys(mMQa|0QZINW`SZyN_zcXL50IoBq`a<wpIRI-%iNx91O! zMVs*Y9p?2D6!sJRBT!Nw$yfFDH1KLaF7kSYF#~Vp*=zde%Sz7s%D)zgK0i@hBheVA z(Nz&bLDd>leC)6RqUeD1uV@V61nVMYupvmOt5n+_ed!>)F(!~$>XZyEdlX#M!vIyl zhtyE)xOIn0Jj6l+pS>8K1!c62qH662SX}{)L<azi3xkb;u>=6>JRZJ|C5~6J=-m_? ziiJYb#j%tEif-{x2qW|6P_`InqUe7d7oD>AU#=ReF8Bbgu=@Vk1sBeM1?I2nt-JFt zm+?O&<nVA44Sw^N1a)WSn$OuTP~UZBsfYZXg!{<B;tYNFD_j*@eo>;1PCWMt-o#U_ zNZo2(=ij$WQh?L=1$cDAgY}Wp0@5M+DE^q|V^_==viITj+2Qjm%T>_67Zt1EL17<# zj6R(3a&@5?sDb(*LVrm3=HlVES7U^;Pd>s=Q-Z?nwMY3*<KgVW5nARx8^DnC%%lCo z4v|q0*eh031k2b^9SKJ)3M{QX&dbSh8~>ubYNqV83rh>akPKw4@!)+VtN;(FF0Mf+ z5G3Q4wJaVmK@aJt1ulSYY62CvAYF&CdjqFDj%=ps;_CHQbgsRxdD<IP)DQV-_*DEO z$+oTtvnLQ{^LFW35kjfI#;ap|FXU;jn@7I}DP{e8Kh4u5T-<XJl-TRsRp}eAi~A8& zgp&A88-G@Q{>je4$lgf{g2)=TrjO0Qn3$QSgUw)nqf%Bu(U?j<PavYQFiQMmKzUFF zg)1w2pZhwurkY8F#TZYZ{h9B6oR}j<ULakZ)aUf@r@c4LDz>?@V%ptDq`w3Rib3sl z6C4ONr$NCbuITNN9r-nZ!<5)%KRhY~FJZr;mAf6fW7<)-xNWs#`amFs-u6PgKtV3Z z{#Jr1Hkms0Ry-$qk-h#wJUcqKLbn&n9Uf6Dk89)tsm3MY3*U;Twsq`?enjVP`fKwp zE4igtQ1-5DK1ttB=v2w(*$%xjna;SjZFL3nfO2!$?+HQxFEMq><7)W9bYd4rncZ#_ z6hoz0*DBsZBNtP;xdRCgygNj7(ZxaCSv<?t74bCzTw+zj%C7t$orc4gI0?ERU<Wmx z##`dkYo%|Y3wDfXdAr35hSe6wPitB%Dv|3BYu@E}fMZ~0r)zheq*%_@Nm9?gKfE&j zFHUCmRqHSNH)WAFA+0Oa_t+wquBOhSTDh<;`YSV<ylZ*n{qz()CS=X#u%<$?-x}y? zyrHu#MBwB6eT`J|v*izFy}2YV&GX3tE5OyjI~Tg>=-@`-Tj-q9USiC^=C?u;(Dr-t zn>Jp;1}^b5{^sHiO5l@|Nf?XtP*W%w>%h}r68nhdgdctyDC!tUxw@?WYnjZw!f|_d z{ozh==Cl7*!Y-LK7ksWRaQv4fUa`$%k5W7RbxDEbUO$}+u~&y3hW|FIvPcp*vBrVv zbfh>i-7ffFqTM?DKS=Dq9K{js@n)lzODA+^<2huBUk{h4<}Rtg-FnOW-uym%;uG)Q z<$YqGM077|oVj-G_Z+>7G$qZ#8tk-q^MsZq{-dw@v(rjeZ3OFhk+hA=05m}expT>A z8<Hd9|JrUn;KP!7k6U_zK>!)|@zo&BIUPGey<f8FbF@L#LwN}V{Z^mK86)h5Fp5f` zUI62S5Tgz~MMh24qao^;H3vO+L?bdw<jG?|vp--{{PzUgVF^Q0L9CGh>+tj7D?iwM zM<+j>r`zX|jkED9brYu$oZdDBvDwZ3&M1kjSbOx@Ci%*k*d%<qDV(g@*JDtMV&!1V zs$P0qyko6NG$0y`0?XbJe1PR0g4gGa<i~J^KGN0a=w#tp#yB_}<dd~s)v;ZN--iDp zWn~58TpP=jCq~?@>f0!F=gCMg?&kbph^_VWTSYsliw}I(B0~CDxT%%Qop@*HVU}?i z91@Q%7zGx@eeU+K^~crFY|i*u890MAO88?qjWdLn-SdC{CU}GKVc6c+d1dh?oV(2J zznWru`M=!W{)dEr++})<T66`YA8|12O!a`--YAH{$K9Nj_ivBLoIdyE#d}@e$ivo; zZU)Z*S!u+$?J0TC*iLvuFKuOU?aqzA|75k)tM~rQ-pJb-*Q(x|kL_(%5oDX?S@L3a zsis+~{Kb*0JHBj|Mm7Hf6c4)}I;%Lz4_~RI8dBb%0$mbNF3xKl2g1ySpdD&U$t@0L zjtMCHo(c(ecrp~sRDrZQ)P4oAA0&sss2=1<RA3Jd#;nk+7@0>7NVEHsN#a!e&g7#6 zlwJ>|fr<}mz{Q~M+TmeLmDLB7d8ElWLV$frj5so%U02D1F@wft6vvBPVN6m8K1~9c z*s-R}W>+my#Hb!YJ}3cT!Y-TG<{%BhjN{S-fXqW;3@Xm8djt(Oc52gMYn;*|Z({mJ zN)q7AuWz8tc{OLkyhPn62$YJ;$7k?!N@al+I7Ja{rP*P1Azwv+`q6ZsPLepgB*UpE z2Hri!M)AVqzOIvICF*ph{b#bv&MiTiPUVC33Gg>`7&;Q(xHN%l23uTjoNWeM24mpA z94c!l)QyCJL6SBV?bU&cU_xE-4d=2+o0N<q%917~5?=2<I`4ow4GcYn7pH{h#h?H> z8!XJGZE@<d62u-OQ<2yQMA8N&744S*CR07e=aF6sz~!(JYzyjc1%<SUH0=qgrefDl z42lCQqiIE_aKRhM=`mN8O-kY<9;=-bhsq|{qlT+aQW@@X#7$07JrqND%xe_pGm^L| zks3$4#i*uYEXhg<a0q$&l{EstA(2wFX5OaF21bD=`jd5Nc*Ls!5F3uCA7qfJ2?3V% zXgS7w@k&D7S73k0Z8$_xWQ~v;<6dXf(>v-4Rf~aBtg1qkyEir2!46fR2a64wDsY<? zWt0ak)vtG|Fdo(5Q^g4T8jx&~uauWTi4U8+X$X-}Ak}~}%7RJQ%wA_(pR}%)L3Bkq zikeb0K6g?(7%ckbgDo*}i}OZ-N9Zr1>OcNJ$jZMQ^+*5S<2QEi*i(~K-ThO}_+6#G z``>duWw>U*@pboKlzDnYCXzo^LCX1rqqN_}ZP+@^WnDaAY8iB!I(u~}NGDrZWJnm8 z*u;qaOOE_9Y8=jBt9IirVOk2KWm&j9pQ`|gV`mxtNOe9vv{XC=`2*0$Kk|~lXzI+y z29ye|JG8Q^JWluWCk+3rSDAx@GB%l3^aA|ny{x=Z4P{tEGz*F3B}Z}CeqbeHqvaGe zB8zGq@IyMHxR;q1R<V~|beH8+3Bh~-9-!Gu`i$mm`~-OE@u3LYU4WZ?VdS*og3qCP z)5~y=nXfO{N>dVV&h3Pp7d2d!I7*3L$rd<EP8i+fP%26EhQmPD7ZBeDsyh<y4wN&v z0EeTL7c1+$LfZ)-)u}EYG_kgv0x6q?qiWnhu<44TC^OW#i_74OQ<SL(!&mb?boa1L z2Q+2YvR6;tO9ad^kH$x_1}6B-mL*y%X72q4exbg>&B70Tp=Rl}vis&6Cu)4VP}T5q zh0sIG?67-0?7m@f-|SPH4#&rMYo~cmZ6+9Ki!8HsKAr~dW1kkoMSj19Z(B5>@W45* zZlL4j!>LoGgcO)+#DYUI{3YmNF~;8R5FISMK_mlFr?ycMgeX=Wlz;FW&`*r2g-T3t z0+{D@4ZKeV;VZ6@lk9A(9JV48_uT|ILg5O4vEAn|e{cy*kuCTF*f@^vprZ*O(SL5D zkvQbUA94Q_AS(X1)qhR^hj0G#>VM0~c>Uj2|EWOSw8KA_kAuh1_9GrQM2uONrE+my z5dKR-1~)ij!2K_a!v7{AM?#=-)dZPg^1E9XkG%YKCh^u;p_3e6D$(lfk&`du-n@Ae zcQFla!pK}lAXaFDz+miyfE68c2zLJu1!A3o*%A(_ir^Y6OO$WJ>OG9fY1!p#*Sg1p zYdQ+nLUI(Fi98_4U3oy{H{xt_$Yj!J$&2RAo`?YE=5-&g<c!IyazlQ~m5rXp$B)f~ zX6eq#tX$f7RD<LgQr&;#<AmA8FAi}$NVUl70NbqbDa;4^Yo1E7%<G3q$^8NXa)Tb@ zGV6L_ch9Krq&&WNz1u-UsJ&muc<-h2o&{H`6K;FSvDfk5Z$s9{^@S7sTYpgF(q_1| zP4b`n?HA}fwqFjJS{ijL33`zJskqtsQ<WIpFQKLw276{BtB|AO^!%mj#bJ7NYg6+x zFMfpB?jYljRN^-cUt8(`OIuMyKoMNetg62IOY;aK$>?Xs9$(qY<C4e8zcS@Thr}=Y zHc1P_1R450+||gLE3;~5Y^h0NNU=JNp~}1>rVZnY;wu+dQ!`x^iA2xdogBJmf}I^+ zBjNMDy$>>)p+FMi87l4P6s~=}+wqF;gooyd{T>;2o9ewHHAV!OxLp_Wf+EdZBQAf7 z9zCbkM?4&vQ6N@rr#Jfu`%V_amw*4}xm%V_myBF_+H~ruvWkIjPm)|d><sPitG0^P z>FgB!cBJcOSL@ci?D3RAZGnp`s^owxt+P$=VU>*V8OIIqm(K<g8#82E5$%)4qDJS8 zrDW6=RA)5vgh2bV@>f{{uX(Mn=1_h<zjdgxVBgz29|Ke~dU=eVTdlU!t~ps{okxE# zXgC_U+qq7mlYE5v4YOZa;e@-h%zF%YP#c^jue5U3H_MnC#KnS#9y@j94IePZ+Am?J zZ4mhLQ<a5lM*72u+ZVMLs*|BJdL(`mce~#{=WOmh>SJ-os<KX49kE9BXwM4gg9X1? z`#j-#s)rC}UQ$(?jVzK-HPG8k)wkJ*+7l8in~qTfUld)?4T(Pxx7XWf2K{yNs8{!% zP7fXM&T_|{F?XUs*>TX*NrAEd)!ti1#Sv}$zSX$9CAdRyclY2#aCdiihv06(A&{WK z-2)_O2*KUm-THO*-glpK&l%^v$A>${dt;;rA6BpauQ}KH%?VXS&s9b39;_8%=_UQv z11=EbB7_Pvu*zEHiZ@HDG2Zs<2GL4PIpcJ48ggrfvK%5|zo*2o&4rbju!I)4U2(p< z`ZdHb<f$Sk-ggyc@<eD7y>4|@M*HXr$UB?c5^Ye&0iIQ``N-QOF#<)l_*Td&%#*NW zX*cV~lGdlVJ$vp?`R~9|Jv{*&7Yn7`$q6ZmydL}Sx<X(7cxZOUiYvs5%+U#{ML^s6 zo*T;`0Ot!2<6J=?jj>LwN=G6uS8;UxNn<f9q`wrYnGJd9?ukJ@?yhvlL?fD1SKxJD zVeGLnLWBoZc8TQA>Bc2=jGO{&4Fk(*60(ER{ht`3Hp6lEPIlF{RDq@adeeNPA;(_g zGGjS;szSK^c4inG)*+I6SLLPxNP?x0H+Y7>9)ua|*9vm7too0w!&$|1wH@d#*Qh#) zftK)ryoC9yVTVUf6uY4pLyiW?8vRMPh<9#d^;bItv&<fsvUbt;G4@WmuR8+9xXMqv zSHQ9>M6RDBPxto2)8dfLR!Z<t4yUIL4+v7iBDJ;fU+@bH4k(JgF629hdW0%pqx$8j ziwnvJe1&8~@ldQ`#-BL)e-N~+SKaQ`w77JtCJFfmffx~a`Ul|Zms=kX_O_i-`-J1y z3pj2Xj+Yj=wLkqdPbp19QB#PSiTP4`i&-Ip-$;iNL*u^4%p*bRC+>|9q+9h1X?iW~ z-K08H4GK@j1e?E=wO^2Fvaw8SrxV`Lz%A+cy5tNqA(qg^kbK?3(LTaA(M9#efv<{K z(D$sH0rAl5_lG+hZLFg@aC2uK{_*FPZexziN?HZT!jv%msKFu8E8<dVh#AH7DM2kX zo-<;;E}J6a_j|~i2ex!Q>T(XNf#1KL1)L%``LwgAG3-39=&(0lksU74$J;FMHcq+N z5Wo_3i)t~8w+9p+6TqaHo`y!n1gpJSz6k6OwiPx>mkZo|vU$2Vqg6sR3^VB~kl|>i zjtOg{ETb3MX&a-01v!%vEh9qfa|vPB9BL8`h^tXDSyMR5s4$J2Vm(o8n^klp^sG`( zeP$X<;vnJK8{Zfn<egOH%Xkqu_Tj|`M#A!)4gFVk0<uBfcl}a$;{9L4h^LHgI{j`& zkG^iB4rOZx$FevZ=9)Lj;o?gnux~PYK~J#F;kyJ9@+Vp=^#9r(?S{)AIV2j)KtSIt zjFO?>BAWEZ;`w2JGW5;Pr1Yj}r}1Pp#aq<rQYBp&qu;v6J=%J@y(1r)vI*T<1K-Ru z0c%QX;5u_q?DL3t4_}X~B1ZN65~qq#vp&o(o5c8jbM?5&neauW6rA6bsrq&q;WEcX z4$uzBj9Q^2Sm1ZAS=@t3*S+sQW&Nsd+GLfIPvCjw)A&fBTjiboz{4f2vpd<)dgrA& zgPZ3?z8W+DkLZdk8z6iHKL6zEkXbW#b@_I-u+^SJg+Ah@PJ2b~H~0F~-1)iz8B?YS z4c6)_p$6){t{-NaiGrPaiNQ0C2KwCDrOSgp7Q{`%`>-4{`N9Dq>|`%gS}^V$!P7kO zxMQ)k$dA9#9vGFJJFHrzGlazJKRg&9mmUVzrbv{WUBOt!zDuMBsqMzci7I?shMTAi zi2_FwLp2VR*cu74V@t~>sX12H5cM`ox07tH-&|`DwOGb%#MWFY+m_3SJuYH2UQGD% zH@te;F@)`k(q{%c!%r;wqfAXUgBkj4_V`0Jch70t*Iw>etN2Y&iSd?<vV$@cD#l#} zH?b`K#N*w<4}baW@2|J!-*t^1;PJP-9tiHp{_yzSP0-vcc0GCr=9KIdjKC)S%F#B1 zNgxST?kole;6W9EULsjjKA_WdLY*}+9Y7lUE)DksA9XVWapLZ0F*w!f+l*`~2I)|@ z;laSbeEv?cFhM^Cz?h%XRK)>~B9uJPLWpXXI{ndK37T+pJUN)*aUcv7X$l^UUXm|| zi@*LxjW$?96)1qd)+R#os9Nqs5W`*TpaRWf&}Nts*0if96apXe_+4$gK_5TtGNYE> zwlHKsxJ#0QlBA~)rA?m}bzAPKhxmJiEBZ*3ME4ISwHP}L)Ag#5+M2O*QfKFst|Nu9 zVZp%)7VP>JLN$6H1s8p`*4?viLDx%PDJ023FPolIDjeBgm}m^gN(jXIxa<d=&hW;d z`6{L}C)tGe%y}<riz6;xf=4H(;%EaBj-xX|>3oq_>=A1Y`E_^7OXj&tq%CaRzlODm zMo)P4dvmv_=x2zafErMx_6a8z3C1mI3$B)%3xj*z3by3SP+0E8cPSJno@y)vx~5N0 z@9?)_#=qv<cka`ht3lb4^-`5{buyAAO9gv8(20EE$8Iq1dbeL6iZ9*9nUk^CMTWSu zf`@Qkzuep_#Tgw4N1j^iD1u?Lkq#e!DOP!j7Lkjm3E7wB%x|kk@`X+Y9$c-p>^Udn z7{cJrib=2Jb$_+J>KdVW!SszLr6g{ri=sdi15t=pF6?oXIGFdOafiUWHN!T7P=UoS zq&e<Ia1Z%Y+`v7Ud0qUta%JY{u$WM3&dqk)SIvwe6dx~`ixYmcNJ7e<a({b^W_As$ zz?*?KIUlyE8A}dqF*={y5;qJpwr|kb#Z#zC@&OT<3SA>*FXxzZ_kZn+OgPQz_Bx8c z$<w2>$2)sswh5qy@q$`g3yeao>busQSogDx*hmgmsRSY%F-Z1ZX?W;DV2N(u35Y}G zC)$vCN$-11$HLVH7DOp#T@RfhGy15Qp}fDG%Db`-N}$bBYPm2PPG-rcl2S@({(>Y6 zEjDerbgzmm6y(Z8%JOzgRZNmEsjzgy{P%(S;mHcMrq*$#PC)LQ;bd{qI#;}Mg_Tn& z`q0nQ&o{0VeCt!<GhPnC!{t+MHpLY-Ldd}waU{6kn=}u020U|d7dp6HpdUj&O<*EV z+e!rodgU-Yd?7woB>dVDC0we|X+0nm<>Z9I83TQLuvhYc!(#r3o+;Jkg!R<rrxHf+ z$xeJ(JFim5TpQOrzbrh_{AJ;~<~+2UTCO@RVOThlx3p=sE!*)DDB2qtYSFy|9$EqX zTOgj8r<8^&Vf)GB&Uo&ocbqjBIWRWIZt*Je{E!CoZqD_aD3`Ou4vxg{ueKjgNqJ=n zg4a#WPLG@WT~HE71yx>afiGgKMT6sx<8KESz&phGt$3W-@;;Sulm=+{zW9c@)|hHa zYv4TEG(Wj_<UQ7B>Y#u?h3G7;=}XCU7$y0X?qb;M;rw@yXXX@s9P7J&TokCwG#|z~ zR`4^V)7`9EA{V!E+oAhub~bQHTG-iqq0@LSz6tA_DCR1At(ISuhY2?d1^hPOzfM1T zsYrT+Tq?*Dj-njZ$Uk`aL;Kp6+x5`xDt2)|fFpLRUTE;)$enn%#68-MWaqvLd~+9< z22)hT;UNDss#s9eP#j!Z=6%i#xFA<j<U^m^xn#Z+!Cu*qF|j<YHX4@Tu00p3{vm}} z`y!8|g5)<^C}Th|;m=CtL8O|Gm#obePj2BukSmNt0TQ8><4eW&)Wz6W*I7TlL^wT^ z09_kBoY}GjsKmTZ8?%#e@&_>i-#uCm#fUEWf3Ty@VVZU^e=Y}2FXpl;37Ti`Zv@|D z&_;avaWukRxEoI0-77Q9HZ|2~>wA>1XH{u;IMM#=%_A#{gcm4k;7~y$jN|d+;Je_f z5Jdq!*tI7;Tt5xt>h{<GkRj;hjvKU$EUYWV>NYCdhjVMCY5B*uK_ZM1v)fL=LPra+ zVH&>N65<%@-1DtKy!0}Dsm>8cs;xICxh@}gwY6e;;Wek-F3t=#*Xv)-A?`s7dMIBc zbFVE*(Su?xqP?zyS{n+NA9=wkD?}>$$+~G?+4v{tEo=>h9>r^Z_`pN`YhFs@4(h<M z4(!TCV`*=b^EAT6WoY7-c6;LM2vPTMtdXkDDpl*vSeg2`!-1n@A_yFw;_Q9#-aZ{* zSgzWVrf}aOVGnOdI>XMI`JX|C`693ZvExswNz`X0!#3UJSDY6?MPor8w(4@zVF5CS zRi7kwUQS#buwm_CB_hmYSx>7@Jw|g_zP2D-U>pDk+^ye5zLamtRExO&)^}2X+t)LA zHK+_SxEtIK{ZZ2z7AHu49;1zs)1>4+d(mLnw{X)?pcN3?<q@f)=HiwKn$XjFeYj|- zLkIdnGN_fIB9$7q4?ZGe`;fU*2%B#ayK<d<;0eci{RW6vARqKZ8&QQ;K_AY+IHXh) z{q>cGv&t}+4+Dyp{W~b9FKVSh4!2xtfLG)Djhxf(J|xak?9^ADIgdu9WByS?ZNFZ! zP)4G9R4@Kq5a?%DgYf(k1-e{&F}149Gj>>Dah&u!{ax(29zuyV%mr#|X7PFn-9|?q zV{t?-J3;uvH2s(a{53z_Y`j1kkt&C9`xECX{T+40?W7U+5xzJt=usyRhqM-OsK3no zeo$sAxYu&{X15t5K4|7(j^US;^GTmWbVw7zGPNMh3m0z=k?2B%avvo)dIug%ql`%? z|D*L?kx9cM%MD5FdUYEF|3o}ce)~y;Mp`aZm)4xHfa^^*eUY_2G(DyI>fp$mOuI^B zwRVq-WN+;*XAL*DqH;d2NKF0mm*uSTy9Afr1RpRPerNE^3FiQB9rC%tqnc*xifmqT z6u)_(n91H#IBJj3h~Ep{&Mwnl<+8oXzd=K3=~M-TC|rH768as+(qDPR7+w92Y&lv_ zTc@;CWT?FrZPg-=M|^4=tMs#4nb1sLEXO5C$gi8AA`#*GkdkK$=d$M-p-UD{9V;4p zy%@@m#%aMjAKa9l-|FIefX~q-aECOSpj^Zz#^My!3Dc1Qi$vIuIi~iCs|#{S8#=ID zps}Ka(IjE#Z=upcpfjv6*%DnAru9O~!GQ~zR=_B)sx6;~!ru>X_}tE|75}q~*T;ac zpL>x4YBO2`#I~#-fbIoLi-CG1&Gh-Y^=I6@NcEkD9@H7K7{3S`LIJ$M%&-NEJVDNT zaoS6Glm|CuJ1o){V=&Th&Vt&$qg=xE7wuIa4=<^D3t3z6WOU&xZdP@<YpC8jJm|O` zk0gyMvw$0zzg^9pHX~icC~-87$jYmoe7E@^pGuMCNQ{1#OcJT5z5r8e5!lA&?s7}A zo`-B93J+CM{*&Xx6pNJmNy#TQ5x=4PbCjHb=TY3b2q`qLi#A-a_AYq>@?CK~6(i|r zr9wrcX(~k=tk<VaYPqE_u|4aBVSG%r4I6R}*E`;oxj;|s<$V!s{T%ggyY2MBVnz}Z zm%EqBx8zP<ogsz=MO<&bj@V7571l;zQug2oK5}w+#w~DDe8J6|P<!0_d9fTU5g4Qj zv)M@_+@u}XMu{X2$c_xOFMGS?o36l4TPycYzx{6GNEi1`7W!nQ-XabirFAix7o&wy z)TO%qYOwcrkixvT147-4ln2sCmYG*W3KTIoCi<Qzq{sfb){LA-$TGvJk3Wqhg&DiY znC-2xC>|vR!hCpAwV82=>LsTn4)Mi4=3;J8Mp_PR?b?7Jsv^g7^W~<>$MMb(*3Oqm zB|9D4bs3fjOr;Q0&QkTun3(c1f<Gb8RU3aM%BtPj4$!;lELOO{N_YIq=7+hDArZeU zkpxb@$&pCn%a27cSBk;hIDZ7+zf8K69faW($FsKB+b(<E9ZRWI#AI;yKG41-LvhHE zjL(Sx6Q2seKKk8)1Fgl>W^rz9aYl+nDk70Y)rfenmwM{b7kAysXF`|2i>I;}syo+^ zSvc*kja2}0w;|Xa6R3|iNtv`un-06MAC)iLi7~lFk$w4TDTGe)t}3(&vL?F+xnVNJ z<Mju9I^Z_24eiCr%a4f!M<-nL+*q<KSC_+@BW&SKWM5U)wxX>`8SRFq?lolFE8u=a zo}eWJ{}>X%5e5@vnwTZQfVOG4YyBS{+V!OTabKs}Mq%N6*>zbO^e&c2tIuj5mMDiZ zHfTSZW&IKAM#4qh+v6d)5qz%0rwP@#2sU25pB~@vWt(H=rkJiHpNc!r-RIjgvEipp zMY(5_rRWxvRce5u(M9g#8oP?)Zr~dr$CEo`6QGFyPI7ak=^-{YEzgwZdFgNKW8qJl ziDE*8T0o(DGBypK-)}~E*UW5aU1n}%<j&4n9`orjJ-Ew?shLa_?aOcvplp)LGzBx& z&9C$U2hl{PByFfzarUYBbo1sqUk>s8jf_yO`i(z1$ccM5%l)S;$N5)+R=oQO2Z~eT z>G>#p6i{=1P$8u`_bZQLKGKuQkYj6a+4xF9_4v{&gu?}KJk{kG<?T*%#rH6nx1rwh z7MHntg2U4OxRL6p#DW|33SD6XkNoabKGpV`0Uw!Ebp^41l#R@huaa-(G~b^{kC8Zv zV)$~H<*VIL6St<v*yHv=jKvm?!#Pz+UY?ykp)(9A`5b7DXg*}baz9!Tra3{g{ba+n z=DfMHq}BNT;=wFfhkwDea;fc_Y{9Qtg)eh+g6(iQCaxCgiZqOdCze+=-_XJIa^Unh zZ09P1i;U0S`IF+DXBy(AJPgy%dDjm`u6TS~3-ID59z3$)?=w24W@tX?qnB1?lS`Zx z+JFem70}4QtelZw{Tn^sTtgN6`aC(#)-G9(pB#W@?}m*ko%um#Ewn7-4?$gM#OrOf zqU2Ay)Xl*QUX;^9eXon02MjpU{ME@!y#!-a<Ydm!C6a<F?^#PlcQ_9R?mLm8e=m{T zb2UM6nb)UyfQ@4h-*(DeEZo0)aY0$7Hc2QU(J$g+;xRyU5q6_s5ZxchTXMO-Yhv#F zc1;9bk=vIM1xi0VGl`5eRVT=Lwj5^ZCdbBQG{mOLIWLOemV$kHovYCZWU8Z!pmT;Y zl(n$o?P0jAVBZ|2`pe%&#a*5!=3p0M=%_dn&@*KGO{)K7V?;OE#TG7d+PNPLcX*CA z#*suEvKXz$Q!szUe}WM@v=rRitJmV{Tt1fRDBjHbsyk-KB0$C?nzUN;39>p{E~sEd zzeDdgq~=|Gb~_1b8mYt*zS_5CQTVwQ@XDhu5?N_%l<FKi@UJrO`32f(^a;XcaOVG1 z)O?iniG<t?w0nmuNFgB-EXesi`1nl62=>!Ggj5)2nxu%XnWFc5?pHx?HUz`7X6H>a zVptjT$jfF>#p;G;I@-3cLO1fSqUD<qa&3z~+Gcn6FURycMP;``3cw_iDvB~6+SntJ z5J<26r&cM0dmU)0>ZId`Iz`DRyjgSb--lp+(p3LE(_?R1!T9+D?UyK7+Lq31gKeir zw$Zh@9o%5b%n?X_s=Y~lxrl+S*%G(xpQt5HJZ{nFlj>2^D0?o7$~~{?WvNbT6XLFl z-Hn1twCWq=>4=W+yi##cOKwnBOX}xIS-_-Bu!#IS;mcKHzFdJu%5}W$)DR|)f7=uM zY3Z=%*lFO;%Uqx1hgYojq6LA3y7NhqdUOUV=$Jno56gOj-5M%P%^E?wV{S*ZnaW~9 z;sN(0(ZP#B4jac3Nv|o9+Db7{rgC!PO2CdOTU=jJ)qeIK;kN|r2fDwfISi#onfx3) zYbWYpe;l)2qq{il%~9^wz>j=~Mlf(!AGXx1nXofr_R3KGctSs%h8F3yHLG25WI>f= zlgCcny+3lY4vTEk!ti%VlvP*gQAc#GRGqnK8HUZ2dJSa%1Fhn>!d|qPJh!UhfsUA2 zA<eNJ&EKOBp(y3o)R@m$B$8U;-%l-=52#KLmX~hsuAh_YAg^H@7&JACm@LfWvw7Ag zZ@MbZbNAQtYH8^5_TK0@gnn^X-N%#a*hZy6IF1glV8b(W!dA<K?v}T_KiwXu!jLJ# z8^!*0GP(FMJ8pgeG@e305?ys-!9g3?RoQcs+?WJ6;N3=q1NxdtLLwkA<tLqYC4a=h zGj5%UlRE4wh~RKlZ}KB{b0>m9qSXIIW)VULN{rendcw7$S95>db|()Zy&W(Sfbe+> z=4z76MhY5DhM3TE#HKx*A8R-PEEltMm}pP>Rs29uOeZ!vQ-?dw<Yebj`qPRd>B?CC z_fXYH?X`L~LF>|^MWtThC2-msYM=d}(eadHXV6>MejC&7%S5A0F<-H!{jEd1oRPXG zw)ICs)-Ew`((m%ONk7sFaelS5xmf*#KN}b~!BdbEa>_x(L@!WLGe5r{IM-J&YEr8$ z+(rGCvyWjTQnnVKoI_7uJ*8b1Dc!S$U_Z5u>~2W|Wv!Pi@t)2_)Zsy${nvF3+PSyI zgh!xknv9xV4a`Vnv?(TxgwdS`>||th;Fn!)neN)p*Tf0sF{+_cChR;q!!u<jAKxSp zeizbdm4+%Yd)3Cen?MXbUkEIqDXT!o-wkllIfZB~XJC7%CSYjc2_qEy$>fQdk|;4< zuHS;KG%pr-(k43W1n%rael=65G*)>`_~L~(ro6<%AZA%n+PESBmelzdKhV^rO?QON z@_SIA>{S&148{-$>r>mtIgCwi#zzUPG~UPx{h4w3^>hLE@!=6)%Enfvr^HYw4jJD9 z;S8^*R9r@X%QduiTGLMJpsYe#)0ynz-EKVvu9qSq%@F_4qKsV=Xw3DH`BG9N@@4RM zE)6>zqRj66E2u1iCEte0GHMNFk#;|Jn%l)0qN65wX(ubI0!)Ge;!xg*EM!lyAN?En zufD2dAY$g%Vn+sip1_ccpTJDNhDqXMX)bXMGNQi>)T*^1;NM4aUTs5bCgZI^;Ogpn zeVHlj2ezyW{1_NNex5ET7;l|@h}P498Zle6u2=A8+9OjtY6re%${8)7LPioFa!>KD zb-FNsAu^3Zs<63M)@~|HOh0Q?N4gqW>$ZLN1@*)*VN_z@gvb(@)1V%vyxcu|nnd4w zmM5Qx2&)-`;$o4tO!E-#v{E$5%s0Kb;X8Qyn~IcGmF?i18~?HDAPI3qC(QB0l9N&g zq^558BHL@_)z#MzTto;_#v%n)q`BZtg5>gKvuvLF`ZN)F{hu#YvVYPtk~wS9*}p~g zN!aU6(qW?B+euEmw+Kl*@C@$0`F8$82hmub1s`#!L{nz6buRuXEC%bR7mYCm%Bul7 z#^s;>3*bi2L#bRxx=c^_bnVNw8X`x7O~X7MjlQ<U6+aAc_ft`aT4nJ#%hq>qi$sgi z(_xBF<~O}BF@@X_(jiwgdH285B0hwO-2K`G4>u&ZVkr?@crTdaqo3c2xHx{viLgCn z+$?Y{(7x8}IiIt40mJ1pTm;~dmsyz2Z8Nj$9xOJCgwv#abQfDyi>T|6?(OT?Z|J~$ zXIc?d22cH%Z8A1w14USCc@r2l_vHALrszzw&7Gl<<&MWhGfIM|dDs!kL04RnA;XtY z?rl6BI6PH80689PEeaGCRO}MDEuGv2*acFgjUs>!jp~V;>0#OhgQq)27EZ%W^-tIu zaETv2I_&IRZ~I=Cv9Vn&N>e5*Yia}sI^Y&L;=I&NTX2qroZm5O_fWs0A<BpQDjG~I z*=Ba$JJBo_KAU0YoQ%>w#8?`<B;5JLp*co0nSzfXu+2|tA*E10ovjU0Q#OhEfvG36 zn<*AE^x16LPm}y{XnaFzbC2K@!4AI>?c#y8%J16>OPLtj;#nY4s{E$ybr9$)hOi!Z zQC+jZF^r-fgoRzaCP;wrGfetJ&FKCsWBBHF{+Ax}?jb%!BRTFBY1EIV7Ha0zn=^Ny zt%WU<PQLBBhvAejL%nV}99?dxlI9sP7TdzYBH%NQ-AIdEO-bu`bBT32=pW4(J;7fl zQ(`Wr-+Tu(hLul#^w~khWLNEe;N$#EciJwZEQ6Dx%E1|mYPN4|T-%^x?*RQaK+`Zd zRh_O-m~;RvTH&&;!5Lw2q9F5WHpd|aA?ka`y5aG9=C^7a5}hJ#z98d!cJA}FcWC}7 ziri+5Eg#O>h5*_u{36UyxEzV`sZ-7yOF_wNJD8f|e99Y-hqUopR9P>s?GAM9FGw}| zpU1izcL?iLT|Rc(M!G2tN$3xs8svq0?EHLK^FviMLO^u}@TPH8q&!-~24=zq*1E2Z z)w}VdU)kwErB8`ufa9h+-Gcd*5iY-Sh?;ef2KP*;jnVX*&YxYL6XPBa*uti2+s=NJ zfDZ}Z<1)jidq7c~+L)SEvV`u8)zldG7%#;x-uf+6!inVBCrhB5Ah?mk>XR9_?N5Zw zaZx;sfiSEMz7j{|_cvinpwOCCrecuyxV9Wl6gqZJ-+bNl6fkN2DPj->)^FKTTg{6$ zjG!$OzF!76hqB5oA<cJAxL8Ep&w;ecc`Q#P2W(xtBz8S5-|O_PzVF+pwP>o`iI1?J z$BA>3$fM5h69Yy`!kvxF;Y85q=xWIK_Eqq^+3L{vaq|z~^$M|fZnoW#10;XFs8@uK zSjD^1Fw^H1r1l{JuzB?lG9n$YRL<GLLf4N%)StR+Y?p)6lRlV<aOOO=weq*04F{qp z=@(8WyOG_;5#KAW2w3OvJB&>E*N~#E5yj|F-L5)Y&BQvo>rzRF7y1%`3vgRK%TTAM z?-`8<Xn*fiD}|3f2~FBJUrk+1vN9rFmJWbhTG#wi`xT=|g8S}~G*i^-B8xfKxEaXf zl6^(}T*IxZja{-OCht}*RhYkN(mi^Br(@&t+NZthMR>4S`_(n17YaM<38AA2+Z(H* zIK0rJ0zU+>BKTM1Rbq4-^OmX=?#X;DFelAoUAe+Wf%^0|?cM&%o3oJiR+y-4_X-LY z&f2A^ILM`H$B{&bs)07Fw{mR~OpEcLU6pPMbOS?*_q=z-cH7eLQDh}d@<5K1K*7qT zf%k_&2c90gjN}qXTNy?*%z1kVHfB@Po833GXdx5bI%Lziqhp8b`{u5U7^7{CO_}aY zEO-~o9P`DuEvJV{)rV^ubXmrz!Lh&A>@O8DB~KEZf4nF#Y@~dkWc4;i?H=qpp{{q8 zRF@D3VHax!i0zJOaknzg#BJYuJR;L+QHOu6pV@W~_}R7kLDmS);;j+;?UNwYb^j~l zuC^@%JL~J(Wc};u_>_~f@#{`XZ%GDm48aWYwSk8>EN$js=-xPk8@}WUW<7Y_+_>J{ zjDCs3VFuxh1CHZhh7{OGuKs=9A~?tF@Q^--Pgb-@PE~yMZPJp1<@A31DC;}eExBoY zGPVl`NgnKp#kWi%&Ty$thB6eMZtrRX9SjB3(9xmFwjtm9^#y;#CmR;a)uF+^YfeG} zi^Nd&!5`Qm&L6VQnVQD5*Cm<w*(W+~HdIuR1LOlWDtfZ?!X5VY`-2J0m3?gp36l*% zvYxFkc<SENF)2c|BdtOXnHRjCg~kn|m>J6@j@ThS3zP^;TokwL;|a*&HubiYX^-zB zfBrygJ$N*I7%duoA2vE@gs&Kn6I?Ws4ts4SHf+oT*P%s<w4p1E%-L>kbcNK1`*?ck zjL5F8=8P+E4t>bIgs`=ZO6$z)NJ{8+`u44MvRan5dmk`DFBg@@cf;jT9J`=y^*D!0 znM`h{#Nefp*f*Dqy@uE=k72%6)4lXoDw+|#2h@E#urE?u?7gd}=3gqgSS(ZrqgL#y z=ha(jT$NZ-I}Be;BZ<}q6G>~y!+G-tcj$FH(Q<O!A&qSD*1OMyGApe_+L-r2tHw?U zy(6CjKM39>M&*-Cclwb$MRNMig%ac(S`<`|3RFFu**HLM!CXh3s4=O9C*V@4DiVVI z_4=9^E$7@b<Jg*wp}*FZyN6i2?DO!{P8iCn<Yk62XJ`M%x%2VGS0*7T;*{jROJGys z0cS&7i~MHro(zYv-Zsfr8m}wgd0Tx7;aXF~a?sejUC;1vaRD=A3e9*+p()7{WT78x zL_yu035OqO@?#U1rhPgj9+M_GTKldu&2?w|9-gw)&8^@p-pFaZLrY4aU=!n#Ch>RT zgqz0lepp@D7u!hNwXw=|Z+d$&x|+J^!YxRA%%aqtM;4=VUvzn1rdI4SaF3R5=!4R4 zSvou-9Ql?xQ{5i2d)p&AWvr@Avu$h8d%Lj_uw{gQUE^(T?)%-%kA6=V9slQ+Gr5TS z&Caz(R%GDTPCAK9nsjxs4LMJ2*t?HYJ`#4TcxK17q5RCEwvN=5-8<pQUm<5VztrIH zT}9xTQ{(unb_~V$+ooEEhtZ`dWr%{!X+1T{BsamX;cwJiHZlf2NYY2Av$IYAtbY;t zzCOaxT)linCAPN4F{XXHY-})b3>kU^&pDY?#J{H@MRe1gg8s|lm+jBaI#X}Y)-Jr4 zKzn6o9Ol=N_E0~c#)$PW-1+tP+bDpPMek;UFD8_=xCG>5VJ<7apBe4?J=LY<-4Nn> zLNt=GI8%dt1n*<S8DR2uDtmY%EQR8j1nLlKH4wRoY=l@mZ1&D$-BVIGuZitybq~mp zUaT;@ng7+s=>0nfsA3sUUV9JZ-?uUd3Zl*oBNSD3*^`mvt}SivOop2(Io#?ggP-u) zW^ZGa-H;vC8psN<-DN@29QTWX%R=^#aRLtpe%6U<W6<sr%VUQ%$*w)0)?ZlXTS=v3 z``kx5j(v`u#DNKtf6q9EyfXF*@{9W9VZFNGs~Ydo{jPZz8}1{E;{3xBbYIVEpink) z(eG-9$BrLcLfaqW5lBY$JnqEthT^XAm*(U>%1VBTo@mFPjml+oOlpej;=TRyX)Pxn zJ|k`$eD_n{5%xPX)M|Tx%GA6&q<*^hmza#sMW%YsSXAK58sF_Y+8FyAlG?OQF`b<x zO`VU+o<akqCo)#o9mBf=m%j^o6put-u0^{{1~r7ZK}-5~JOrb8jWX2C4vVApwdgeF zmOr%h{w@!_p55mjnzzp2R(NXq@o7$NS>Nf=F5AZEo!D3vh9D+VLGzH$ryBC0CV>{L zZr%)iwgvND=cSn2eu><0aOVaYvhdz}@kqZd#)js~7kNYJ`d}KfjE&z`4yP>Yvy(AD z+zo+SWwN%+YFQL4j_>pLPaPmRgY>~O*O^<~*2^Zgy^*5t_l*dHW7lB+O$PnjAL<=v zDOEoI-*4gufZ-Q2Uu3w`f4YWLc`p1%iRfDH|4{Y$tZqn&=uZ$Lz>3mUeb3Y0N)Y@B z1J+l0J2Qi;B@KM|V887z0G_{H9tFESHJ*27|G(;5-12|c9R4BJxBzuL6wrp{tcgJ? zUqAeNodK_ho^R~GkN9uvq)~zOjdM0LV>&wR907BK=O66P|2F;$gobZm(q;?IS=-d6 zMMzX~|062#T>qW%^Lze8r5iDT6@U?I{fao$Sfvp{ZsCS^<j*?5*65f!{X?+>9MexQ z#C^{XzW$#puCM>+itEn*z2f>m*LZ^;|5XnDr$QS{`;>_Rc6*pVwIApt{{LZPQ5+1P z=jTe>U^hz-2^Gx0Llyld@A-lG?<4+%N(}W+sJdIaVb<dNq4kql;K+7Isc2VfjLX-X z8Xli4wre}VXP^ADo^0<LcbET<H2(X{{->V(EOUeV_Yr%;kS$&81eR|#Tr?CF_>Jvt znGH?sjZB$6Y#shFR8Yjj!O+;+)P>B*)ZEfeh~l`llY-3BM2JG2OOaL4LBiC+Qr64K z^o^I&TVpS4V?GlK5n&`j4}ORNTT>T9G7noDJ7<0mA&P(4<%hKYbhA*9{X^nnEkq&q z$03=9q6(RWy^|>!7c&QwF)JGz88<gG8z&bxHya}vJ1ZMI3oGP@n~9B+pNpHHormmS zFA8BKNK4Sk#Ef4}Qu<%+An$}IEL>b1_*q!o-QAhpIhgI8%vsp@`1t;qVP|K8D43i* z?OY5!nCzS>|7qlZ*^xALHg>XfaIv(vBl~05(8%7^MTmmpkE4IT{<SV!hkrY=bN&}Q z2t^hTLkAW%W>%JeTZRl2gfQWka56P?v3GiFZ*L>~&y`DCI+?oLJ6SuENxb1Eqf@uE zGqHDfrWa)Suk!!!{~wl2B@JCng&}-6m{@t3Sh?S_v-7iZ@N;r%{ipH&6#bXsiuNX! zW}g4sa5nb0tPs4c{Oo-H8vdU|{~9jH@(0O(Li7**e^&Qj`v0HMnHc|@oP(>A%|8NU zV$5P{V`^(^=i&^Z$M(O|Gco2jvv;yJbP=|+H8eM6aj-KNWciQI|J(WfbJmaqf#fF3 zzmxNSP5s|N{Xf$95AOd*%>UEqUnkFk`xn<=T+br#Eb-s$`itvX1fC`Sn_Yi#J&VAz z#DBBvFRo`1c$WBYcKyZmECSCG|IMzyxSmDeS>nIh^%vK(2s}&tH@p7gdKQ6aiT`HT zUtG^3@GSA)?D~uASp=RX{+nHYaXpK`v&4V1>o2Zn5qOsPZ+88~^(+F<693Jvzqp=7 z;926o+4UFKvj{v({5QM);(8W=XNmu2*I!)EBJeEn-|YH}>sbVzCH|XTe{nsFz_Y~v zx7mgCAKN`l?I3$R+##DehW+p_0RRg@PEzbGWS`5Thz<7aQvd0IThG!79vRbN*jpOn z>O{*XEI;Iu&_*AIwmGl*e9vX4+D+}nJncfR->LFiI^~qQFB!!q7Mot@!6M@GG*f+j z#B(O1=|1hebsinQ^(%^i3G#(J`1V|Y!+aD6E;z8@6%JB@K6EXEy45uGBAfpEkt&8Q z;~lZS##uDq4trs$&!&EhyLDN)6dmwJ+_t*-2*7m;Lp?5RsPIEm<xVJZqpe&n)gg)q z|KV20Z%of<V6fDw7?1fvz^Q87yuRW*zuMSSP>&2?FVOk?4!7>ueYk3x5semA7xq^; zv0Q~x>ue2w=0^&E$aL;j2I<3N{_pN4V|g*UHiJ0~bf_-!&DXXXCvjAZbpkK9iDkX= z^8`E$y;(J-01Y#(`<9@BF>meM<jNpc3)Xk(s33ztzCd++nL8>JfnYdVELuuH&8YI* z^vHm1S@7vVWl>lVs{v{c0*q*nIMb7HE9L2uGn>>65-2|2srY?b9dqne<!nmbtR)X& z1N<5k6q)p=5q))sK6AU{258t{!HT&h@Gzn%;-Q_)qZ{Um>Jy&?Q6VZQBoXyC6euVI zG#tN5Ph1$7uIL{rb76Eoq%OE2U15iTRFOb>qUNVodZnY=Ezt=ty);5>06YDich--m z!-fbmZ83}uD_3%m10u)Ra{NW@W_}lwGPUi-<^up?g*p_b862q#01((@9>bczB)?`8 z-;O!)utK7NO%m=3A*9<aor)v!=~88)<97CY{el}nbcvay;0CRy3>wb%V(H`Z?JH#1 zRZTtOy>LTPshs4dex(0EdfH60t9)LY!qB`644Y-K^x3w^PE`ik87yCk_Md)%W=*?x zlRTyLY@|L+&utza@&VGh9{eLxNp~Rw(a&qsEHkp998yVR9nM~fRh9r|#QyR)A_v}d zkffpEpmin|Exob4T}`Yk`oh42yIBLxO6b&q`wjhCC+TA9A=*sZ462K9=walFHvrLZ zXaZ|ZK>_q5MN8tKGtS0y$&zQkVM&nZ4e){V#pTSy0sqJ@kav^dI2s;M3lcr0r2U8n zq8$SzGjOF|QrNo*9GY2K!?Js$brn&6i)(8>G@@O33yfb;Zhy=|<HZJWL<CpgBJg7V zX{;+CXrKeNRv*^F?MYgzjmG!h0kw9So9!pF=-F<ynAcJKo%E&6WyiJ~Jn%Kgb_?#9 z{Jt+(UF2DSsC&esB~W^{kJg%&|EpAQUtKQnH+8OY_x7iSs>^(hNttv1$9#?P@Pfw? z17{QUZa=862aKGyx-5OCW*k-#YjNLxcZ&|S2X+vnxD+l=$2^Jh+(GN6P~hG%3gaQ) zsnG4{elA7-7PN#P<)!{=XFM>wfMcs6XinC(_UBsxpf+-iC1H77qO8Q%*ZhM|^YpZn ze_k=c({bY2SB-HId8u{=+<J4rZ*Tn$>#_({_4}HW0?Y_EMJPHm;32d1ohG%otSi~{ zt7}-q?G^QC)tJZ%O*~v}I_fMC&Y$@Vu%aK%U}}5D)Gh4qzptwL{j1}>w{J<@fg|rL zoC~u+Rsu*KDJ6%;HMyY$Y+r=xKvr>G2a)A;h>ZAm>(_A->^#XSUn_g@L-cXq5;N8D zct78K<AUW%x=9J_69CQy>vJhP<@!<M&isXTHw%iDd&<5g>M=3z<RX7$Va;)y8)<%z z%+ChPs=WKiaYkq|=~Pv9Pb=i>KliKAw=?!k^&sWL-6KoXM_!bNZ9!$~1`=S)3)~@S z{b+vy-{SA|r87t6LbY9s(Q-daYPkeXSB{~!65U~yNK_&R2kfBK>E`p=o3t>`dvAX; zg$zN^RS~fgUA73C)t&E==?q!G?y?TzucXC)qE;w*!CU{;UdHmTAlhs<XXiQl25#6v z@BOi#OO?yun$j&?LS&7KEX!90&Pd}bXi;Ek|GYUz*2e3@nr!)}`(;`!p~L;zi30nf z80-wBzB$l_V?C*DN7kP}-%sF@mcYEJz*6u<GSC7O%kfZl=0U$#zW2Jhm29*)&vt!F zw*#*9!iR!KdXE^JjWjcZZXm%&_q3^}55BJ&yUXnz!|7a4g@d+z*srx;oK=La$BWY7 z5754za;j||>m8$oSem?IWi2$9SHH0OS$2=qe?*b&2G4rAiAaVhLPC5$h1jR&oB+Kt z)m|@?NYclON0vw$7hCVOH&S;P$hS)(M??(B`17up6dE(#J0H<F9ZDhnvCo|xjO{-d zSKKWsLQK5>h18nME>pK*i`yMoXlZoQO}vCB1n_<5Nq0(mjpuu1hgzCpMAGPpyIP!@ z*3fSq%J9qAPrA6IcrnIs0iwqQSagxxJ11d-BW8mow17ep_SFIZU$D^}&Va*(OsdcJ z*js~2W`mqo5Z|4^Ua87>tI>%{tb6eaT*Y;90UEB4@G-PN7&=+P#cv7Onra=d!={q0 zl+d<iA>IQhZnIl?4klP`8L;mrFHlpBb7TB9&wUF~@L2v^E$#VCVyNwiK2z$2&F+Uo zL#jiYv4Em0=#`z^n!=tFL;={tl-Ei}>Gz|8w<VT)`}e<cj&hpnH*X)4avC~bZ20<j zXS4=rLgxvuUmHvJ2Km=&Oyqbj=}on1B9Ppi-jmHpnjX%?D841}6F$vHE@g-Wnq2KG znBPBAular^5TrNHPcHMGM@NRW^~JVXeJ3DXFI<0CgxsWAIk|`AhMsAWiWa~DX8V(b z$MWiDPtcxtF!uPb?{;@)_S|-_m6d#F3_Wz;z;(S1&w|9|%UZ(HSAk?F{RhM2*HVvX zq{}<)-<Zu0XQH&j?6WenE#|<HcMB3AakLR`y#{fE8y#`<$&%YokznMliRJauLQohQ za(7scOlswj|FH}p$Xzcm#mS}<Cyck!2Kk$m!!(-_wJdaa>(uJtJHdkiRBw%l%h}@} z9IU%f$7KiaC3P6u0O=#be7E6!g4$Yj?6@Z;FCFG5Pl{<=fy<kID4)Y>mGL2lU3XB~ zi>7dLnoCE2UU~q_lY$;&s;ws83%{C^;HE~sYvyGKCot?P`TmLAF+4Qr$<R<4%((EK zAUJD%3MP6V*!zkO+Y}NL?&VPz>hV}3LVyp{fn`Tp)l^B}=;1amT+;x|6`Khl1Cpz5 z+fLcvS;;Jjb8WQbrqc?F{+l&ud6^3u0Y7cZ5l<{$st{jhrNIJ+B5LNwwMq7m8klst zYQOX!qq0JwKR$-zS}s$$^yZ!eJTgp3FE6Wf75x~yEJ)S#&{-D_Cb54<b&3I9^u3GG zzt+Ahib+7;v%LaHXhhHq>d?PE*hFsSRaxCHbY9i9MgVfR>1Q}yNxuWknWt{PHB}&q z=Dh3G#{dHKzpSz_!*vB;^n<#BoVon)fFy1-U}EJL^Y?}jYoP@Ygmo++`)v>?{Um}0 z;Jppc4S_bGzBB}?4NY(4cmZAz!p&;=R1_9109r<|_L~8f&k!O3$nT3-K%(%#&87~# zC;<5J;RD#D6bNMMmGb&f0SpkwB(iFbnKB5_gSn6ds>J{HKzj0<xIrHz_#Ovxvxkl^ z6Ci+u;lsOtfU5ThK$kv)0tkr11Of&=7~ztwl)<||nvi~D7+{4B3OFGE0Upr+ASew0 zB$U8EmH)%X46o-Jgb;xDHoqVq{%CQj59di>0ER0>h@bmEesHPlZP1|taGCfa9;D<1 z8sI@)AF|h$_S150;-EzDuJk$|I6p#Fzr;8hYPc{Z$It~<(v&CCfE7VVgE$fZd|a7< ztHT)R0NDFP#DMl`mR*P=6l>u&j0gr$0O_ce5D{>{8~5k)UgaLp_-kJRz{Ubq1Q8zC z1_2@;f8S{W4ip$C5=~PqOQ?g9qGZdXzi++)7R5Y}DAe`*BX(M=ETgbpkXda%VTg9< zl%J{!K-m^^-~jaZUm9d!MHy}#f$BWhQyvHj(u%0g(|16+bqKs=G@PgvnsmiwvyJ)+ zJ|a4tXp8jJg(+kbc0>T9Ma<nu0;u}>ZWCE<kxB-dj7#^;FZ~zS1{j|}qQW{_L+em^ zm|$K&W4!4Aof#FVHHs($i8c^NYp)9I&ITwz2iOp5_a;F^%YQ@!*Wt#{U5rFCG0vQT zTCIR(VXh?!`ci~BBcL{wet`-D#Oue%Ewi!#!|T1d18gsW&z^Qx?&X&JKqNCTnAy*{ z%n6zHe#A!<m>NU(xyANRhX9qObH`Uec6PF!-xWA$_nXCFfCfzyyUdOB*DVXm!;?8O z0}i^zsrETs0QWcBo`ABJOvf+6Z-De6q>sqJ!k;s3TD?2uta|A99Vh^Q#OESy3jt^W zy5u-WAB%7Q;oIT6j*P$MD3FH?d}N>gsh`>TPw}wtMH&SR00}wdQ6Nuhb!ejARr+=j zzXJo9;2{TV${)y*NPw|W*fhY}I#`d-2Mj~QHxzcD1!UN`&y5UbUG@J2$-00<=Zro# zYe41wvOALXdO2VXO5geXxp{DH53)Lmj~FnCZh3iAMwr?_x;&-=Y=B9HO!I-e>PO;$ zVY$c(UVZGYj0#?*htO{?0cqWG44Ad4cPiRnU!lMS@35%ReY|hc`CK7?r@es>$9hrp z%TB5N%x$hki3*_ltn;}&{(|)^3VLR*E-TLL7WvndE7qHU{FZbiK$Uvp4xJhg*izV3 z_zb(k5Vt0{@2{Fj9Ke5QCeMfgwRJf;sbYjB0FUv$a?*`zG4??^Km>9Myp`<<u0h@g zdPpMAL0xq%uPyIBz6K!i@TK;kfF8_f`JB;L{2PR#j`);0bq{)RX}wVP*e|z9BucOk z9UwpA(+*~@%!WoNi;{_f#y|Yg!^w9EuugOCuN9H8PQGJ3RzC0oo2unPp+GJI1rnE} z{hZ0}kNWGo2jYj`0CMK72#*H)k06-`4ENKq9xXsttz^+g*2~BlB|u790ae4;7U|E7 zUnY{skRZepk3mk_g+AlE`ypYNJ(1_klfcI(N=EtPDZvCtB6Jas;&g7P0J0%~)7a;= zG$KBD2TXT#ig1d>MV>N+Z0M%J2Z^7EWGp!)nh&)O>;Tm#=t0!&xhGb0Xi*B7cuyt# z&JB-0!`X*!*V5QAfC){6Y9kEIQ!3<dEPP(LE9{%EE0}j1-!7q~kD)iDUB5;XB%q?- zV(v*Tvi{so&#I}e_IoQv$S|V=g*WyVzVd^j)Jm)_-`j`UK^;JLTReakv$uiCwC<)D zMG3_GO5KVu(eEoaaC0!N)Y@$5jZwI9Mbt&N`T8)SD2$gSzy+o8<hAYflBOpvkodN8 z@;y~M#x(|+W-C(8bfdjD*}4ZlAOX3WdowZCFc7HYHD}2)ZHsR7atSVg3WEDfkX-;h zXl?wR!=(Kz$zYw6_X~%YTOw9cI@F>r+KKOL{>;nt9~m)4Z5v?+)b75X+o{XSYJg`) zuPOoXAvX4rDzQ#v*%gfCQ%NQ<@u3W0oSPlqOeoG92PUCWRkZqe+H2|MLN`U<#<f%K zem?x##ivZY_+0`L%rZ2x;f1$#^t*wO-Y>SWGLH((o9!rX0_fS8PTt$iJ*pxEyvXoI zn)>!TsBQoCoQo@}zYanGUwRqrJ_W*AX<6Q{`QfwKM@viq69cAs?cFLmCcs99z&<k6 zLrMV{;UERI@M`X~K*L6Lv@t%ALy?i28Be%6&VRLb!^QFKy%N<C1*nD>95vD|W{zar zu65KXt9bT@qX5~+43fzacwI4mH{7~g)ZF<8M+5+Qc(13WNM=n(Yyl1D;B<U&{!2R* z29O&12_dczH^kHRvUo>3bs&)*CO~4?Y5mI%$|p9#iN>=Q1s+tGfWqOe;$Be$|Ddka zpc0{W2jAL}a>)NMdK7TKAlbdCd<Fg$S{Y=ttm=?#uSu+^gbBPbRiX+Lsqt$)Yk>48 z)b%2^xnKZ%MxRhuRhpQugABDNXtP<ae9I`$2tX^KTDaBN+ZroBT{cn))nFrmi`$Od n_#kdQB&2NX)7XrLN-*hWjTUj7Kr-Q<+k?nSDM?m}8wCAd=Cx^~ diff --git a/Logo/twitter.wall.fw.png b/Logo/twitter.wall.fw.png deleted file mode 100644 index 2c26ea0c3c0ad4e5e41bcf267e59e976e043e6b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94674 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ഺ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%0DF!xe{qP(|EkkNzFglA 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 Cc4jUB diff --git a/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs b/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs index f7fb1ed3b..58f28c7e4 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs @@ -26,7 +26,7 @@ namespace NzbDrone.Core.Notifications.Xbmc var parameters = new JObject( new JProperty("title", title), new JProperty("message", message), - new JProperty("image", "https://raw.github.com/NzbDrone/NzbDrone/master/Logo/64.png"), + new JProperty("image", "https://raw.github.com/NzbDrone/NzbDrone/develop/Logo/64.png"), new JProperty("displaytime", settings.DisplayTime * 1000)); var postJson = BuildJsonRequest("GUI.ShowNotification", parameters); From 7d4a514a68d8490650441009cd18d39b00785319 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 13 Feb 2014 18:18:11 -0800 Subject: [PATCH 18/42] Changed how running a process and waiting for exit is handled --- .../Processes/ProcessProvider.cs | 9 ++------- src/NzbDrone.Mono/NzbDroneProcessProvider.cs | 20 +++++++++++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs index e36c86691..d1c4774e5 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -179,9 +179,7 @@ namespace NzbDrone.Common.Processes public ProcessOutput StartAndCapture(string path, string args = null) { var output = new ProcessOutput(); - var process = Start(path, args, s => output.Standard.Add(s), error => output.Error.Add(error)); - - WaitForExit(process); + Start(path, args, s => output.Standard.Add(s), error => output.Error.Add(error)).WaitForExit(); return output; } @@ -190,10 +188,7 @@ namespace NzbDrone.Common.Processes { Logger.Trace("Waiting for process {0} to exit.", process.ProcessName); - if (!process.HasExited) - { - process.WaitForExit(); - } + process.WaitForExit(); } public void SetPriority(int processId, ProcessPriorityClass priority) diff --git a/src/NzbDrone.Mono/NzbDroneProcessProvider.cs b/src/NzbDrone.Mono/NzbDroneProcessProvider.cs index 79a2b3579..1804c077d 100644 --- a/src/NzbDrone.Mono/NzbDroneProcessProvider.cs +++ b/src/NzbDrone.Mono/NzbDroneProcessProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NLog; using NzbDrone.Common.Model; using NzbDrone.Common.Processes; @@ -9,10 +10,12 @@ namespace NzbDrone.Mono public class NzbDroneProcessProvider : INzbDroneProcessProvider { private readonly IProcessProvider _processProvider; + private readonly Logger _logger; - public NzbDroneProcessProvider(IProcessProvider processProvider) + public NzbDroneProcessProvider(IProcessProvider processProvider, Logger logger) { _processProvider = processProvider; + _logger = logger; } public List<ProcessInfo> FindNzbDroneProcesses() @@ -21,10 +24,19 @@ namespace NzbDrone.Mono return monoProcesses.Where(c => { - var processArgs = _processProvider.StartAndCapture("ps", String.Format("-p {0} -o args=", c.Id)); + try + { + 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")); + return processArgs.Standard.Any(p => p.Contains(ProcessProvider.NZB_DRONE_PROCESS_NAME + ".exe") || + p.Contains(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME + ".exe")); + } + catch (InvalidOperationException ex) + { + _logger.WarnException("Error getting process arguments", ex); + return false; + } + }).ToList(); } } From d86a54d208d27b7f7386bfdf47f9dae1c3e6a4d8 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Tue, 18 Feb 2014 23:19:21 -0800 Subject: [PATCH 19/42] Failed download handling won't error when download client hasn't been configured --- .../Download/FailedDownloadService.cs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index 487da94cb..373ddc567 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using System.Net; using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.History; @@ -46,7 +45,14 @@ namespace NzbDrone.Core.Download private void CheckQueue(List<History.History> grabbedHistory, List<History.History> failedHistory) { - var downloadClientQueue = GetDownloadClient().GetQueue().ToList(); + var downloadClient = GetDownloadClient(); + + if (downloadClient == null) + { + return; + } + + var downloadClientQueue = downloadClient.GetQueue().ToList(); var failedItems = downloadClientQueue.Where(q => q.Title.StartsWith("ENCRYPTED / ")).ToList(); if (!failedItems.Any()) @@ -78,14 +84,21 @@ namespace NzbDrone.Core.Download if (_configService.RemoveFailedDownloads) { _logger.Info("Removing encrypted download from queue: {0}", failedItem.Title.Replace("ENCRYPTED / ", "")); - GetDownloadClient().RemoveFromQueue(failedItem.Id); + downloadClient.RemoveFromQueue(failedItem.Id); } } } private void CheckHistory(List<History.History> grabbedHistory, List<History.History> failedHistory) { - var downloadClientHistory = GetDownloadClient().GetHistory(0, 20).ToList(); + var downloadClient = GetDownloadClient(); + + if (downloadClient == null) + { + return; + } + + var downloadClientHistory = downloadClient.GetHistory(0, 20).ToList(); var failedItems = downloadClientHistory.Where(h => h.Status == HistoryStatus.Failed).ToList(); if (!failedItems.Any()) @@ -117,7 +130,7 @@ namespace NzbDrone.Core.Download if (_configService.RemoveFailedDownloads) { _logger.Info("Removing failed download from history: {0}", failedItem.Title); - GetDownloadClient().RemoveFromHistory(failedItem.Id); + downloadClient.RemoveFromHistory(failedItem.Id); } } } @@ -152,7 +165,14 @@ namespace NzbDrone.Core.Download private IDownloadClient GetDownloadClient() { - return _downloadClientProvider.GetDownloadClient(); + var downloadClient = _downloadClientProvider.GetDownloadClient(); + + if (downloadClient == null) + { + _logger.Trace("No download client is configured"); + } + + return downloadClient; } public void Execute(CheckForFailedDownloadCommand message) From 811122f879743f84e94f5e356f93143cb875e8ce Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Tue, 18 Feb 2014 23:28:30 -0800 Subject: [PATCH 20/42] Fixed: Multi episode naming example --- .../Settings/MediaManagement/Naming/NamingView.js | 14 ++++++++------ .../MediaManagement/Naming/NamingViewTemplate.html | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/UI/Settings/MediaManagement/Naming/NamingView.js b/src/UI/Settings/MediaManagement/Naming/NamingView.js index 2ae43fbef..14229fd41 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingView.js +++ b/src/UI/Settings/MediaManagement/Naming/NamingView.js @@ -19,13 +19,15 @@ define( singleEpisodeExample : '.x-single-episode-example', multiEpisodeExample : '.x-multi-episode-example', dailyEpisodeExample : '.x-daily-episode-example', - namingTokenHelper : '.x-naming-token-helper' + namingTokenHelper : '.x-naming-token-helper', + multiEpisodeStyle : '.x-multi-episode-style' }, events: { 'change .x-rename-episodes' : '_setFailedDownloadOptionsVisibility', 'click .x-show-wizard' : '_showWizard', - 'click .x-naming-token-helper a' : '_addToken' + 'click .x-naming-token-helper a' : '_addToken', + 'change .x-multi-episode-style' : '_multiEpisodeFomatChanged' }, regions: { @@ -58,10 +60,6 @@ define( }, _updateSamples: function () { - if (!_.has(this.model.changed, 'standardEpisodeFormat') && !_.has(this.model.changed, 'dailyEpisodeFormat')) { - return; - } - this.namingSampleModel.fetch({ data: this.model.toJSON() }); }, @@ -92,6 +90,10 @@ define( this.ui.namingTokenHelper.removeClass('open'); input.focus(); + }, + + multiEpisodeFormatChanged: function () { + this.model.set('multiEpisodeStyle', this.ui.multiEpisodeStyle.val()); } }); diff --git a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html index 00908678c..c72a6524e 100644 --- a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html +++ b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.html @@ -88,7 +88,7 @@ <label class="control-label">Multi-Episode Style</label> <div class="controls"> - <select class="inputClass" name="multiEpisodeStyle"> + <select class="inputClass x-multi-episode-style" name="multiEpisodeStyle"> <option value="0">Extend</option> <option value="1">Duplicate</option> <option value="2">Repeat</option> From 46f904d16575783284790d0ea9a0361f4c6604f5 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 18:23:55 -0800 Subject: [PATCH 21/42] Refactored retention spec --- .../RetentionSpecificationFixture.cs | 53 +++++++++---------- .../Specifications/RetentionSpecification.cs | 3 +- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs index 45db8a529..2305954bc 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs @@ -20,65 +20,62 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { parseResult = new RemoteEpisode { - Release = new ReleaseInfo - { - PublishDate = DateTime.Now.AddDays(-100) - } + Release = new ReleaseInfo() }; } - private void WithUnlimitedRetention() + private void WithRetention(int days) { - Mocker.GetMock<IConfigService>().SetupGet(c => c.Retention).Returns(0); + Mocker.GetMock<IConfigService>().SetupGet(c => c.Retention).Returns(days); } - private void WithLongRetention() + private void WithAge(int days) { - Mocker.GetMock<IConfigService>().SetupGet(c => c.Retention).Returns(1000); - } - - private void WithShortRetention() - { - Mocker.GetMock<IConfigService>().SetupGet(c => c.Retention).Returns(10); - } - - private void WithEqualRetention() - { - Mocker.GetMock<IConfigService>().SetupGet(c => c.Retention).Returns(100); + parseResult.Release.PublishDate = DateTime.Now.AddDays(-days); } [Test] - public void unlimited_retention_should_return_true() + public void should_return_true_when_retention_is_set_to_zero() { - WithUnlimitedRetention(); + WithRetention(0); + WithAge(100); + Subject.IsSatisfiedBy(parseResult, null).Should().BeTrue(); } [Test] - public void longer_retention_should_return_true() + public void should_return_true_when_release_if_younger_than_retention() { - WithLongRetention(); + WithRetention(1000); + WithAge(100); + Subject.IsSatisfiedBy(parseResult, null).Should().BeTrue(); } [Test] - public void equal_retention_should_return_true() + public void should_return_true_when_release_and_retention_are_the_same() { - WithEqualRetention(); + WithRetention(100); + WithAge(100); + Subject.IsSatisfiedBy(parseResult, null).Should().BeTrue(); } [Test] - public void shorter_retention_should_return_false() + public void should_return_false_when_old_than_retention() { - WithShortRetention(); + WithRetention(10); + WithAge(100); + Subject.IsSatisfiedBy(parseResult, null).Should().BeFalse(); } [Test] - public void zeroDay_report_should_return_true() + public void should_return_true_if_release_came_out_today_and_retention_is_zero() { - WithUnlimitedRetention(); + WithRetention(0); + WithAge(100); + Subject.IsSatisfiedBy(parseResult, null).Should().BeTrue(); } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs index a2f4dee15..c74289e55 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs @@ -28,9 +28,10 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public virtual bool IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { var age = subject.Release.Age; + var retention = _configService.Retention; _logger.Trace("Checking if report meets retention requirements. {0}", age); - if (_configService.Retention > 0 && age > _configService.Retention) + if (retention > 0 && age > retention) { _logger.Trace("Report age: {0} rejected by user's retention limit", age); return false; From d703bc8dc5e044c276c218a6ee1aff5eee34ae3e Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 18:40:19 -0800 Subject: [PATCH 22/42] Fixed: SABnzbd test with fail if the API Key is wrong --- src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs | 2 +- src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 79777d009..69601663f 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -127,7 +127,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd var settings = new SabnzbdSettings(); settings.InjectFrom(message); - _sabnzbdProxy.GetVersion(settings); + _sabnzbdProxy.GetCategories(settings); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index be2c499d4..6d43e443e 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -85,12 +85,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd var request = new RestRequest(); var action = "mode=get_cats"; - SabnzbdCategoryResponse response; - - if (!Json.TryDeserialize<SabnzbdCategoryResponse>(ProcessRequest(request, action, settings), out response)) - { - response = new SabnzbdCategoryResponse(); - } + var response = Json.Deserialize<SabnzbdCategoryResponse>(ProcessRequest(request, action, settings)); return response; } From d51517d60cb35247a7dd8e22804e791519de2fe3 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 22:02:11 -0800 Subject: [PATCH 23/42] Add existing series shows a loading message Fixed: Message to tell users series are being loaded from trakt --- src/UI/AddSeries/AddSeriesLayoutTemplate.html | 1 + src/UI/AddSeries/AddSeriesView.js | 1 - .../AddExistingSeriesCollectionView.js | 18 ++++++++++++++++-- ...ddExistingSeriesCollectionViewTemplate.html | 5 +++++ src/UI/AddSeries/addSeries.less | 5 +++++ 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.html diff --git a/src/UI/AddSeries/AddSeriesLayoutTemplate.html b/src/UI/AddSeries/AddSeriesLayoutTemplate.html index 88dc147aa..b9ccc6c17 100644 --- a/src/UI/AddSeries/AddSeriesLayoutTemplate.html +++ b/src/UI/AddSeries/AddSeriesLayoutTemplate.html @@ -20,3 +20,4 @@ <!--</div>--> </div> <div id="add-series-workspace"/> + diff --git a/src/UI/AddSeries/AddSeriesView.js b/src/UI/AddSeries/AddSeriesView.js index 179cc4a52..951d3431b 100644 --- a/src/UI/AddSeries/AddSeriesView.js +++ b/src/UI/AddSeries/AddSeriesView.js @@ -71,7 +71,6 @@ define( }, onShow: function () { - this.searchResult.show(this.resultCollectionView); this.ui.seriesSearch.focus(); }, diff --git a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js index 45659ffd8..7de5aee81 100644 --- a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js +++ b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js @@ -6,9 +6,15 @@ define( 'AddSeries/Existing/UnmappedFolderCollection' ], function (Marionette, AddSeriesView, UnmappedFolderCollection) { - return Marionette.CollectionView.extend({ + return Marionette.CompositeView.extend({ - itemView: AddSeriesView, + itemView : AddSeriesView, + itemViewContainer: '.x-loading-folders', + template : 'AddSeries/Existing/AddExistingSeriesCollectionViewTemplate', + + ui: { + loadingFolders: '.x-loading-folders' + }, initialize: function () { this.collection = new UnmappedFolderCollection(); @@ -19,6 +25,10 @@ define( this._showAndSearch(0); }, + appendHtml: function(collectionView, itemView, index){ + collectionView.ui.loadingFolders.before(itemView.el); + }, + _showAndSearch: function (index) { var self = this; var model = this.collection.at(index); @@ -35,6 +45,10 @@ define( } }); } + + else { + this.ui.loadingFolders.hide(); + } }, itemViewOptions: { diff --git a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.html b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.html new file mode 100644 index 000000000..ca693b1f6 --- /dev/null +++ b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.html @@ -0,0 +1,5 @@ +<div class="x-existing-folders"> + <div class="loading-folders x-loading-folders"> + Loading search results from trakt for your series, this may take a few minutes. + </div> +</div> \ No newline at end of file diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less index ee04cb289..42dd309a0 100644 --- a/src/UI/AddSeries/addSeries.less +++ b/src/UI/AddSeries/addSeries.less @@ -104,6 +104,11 @@ width: 140px; } } + + .loading-folders { + margin : 30px 0px; + text-align: center; + } } li.add-new { From b05d2c17e51c0c710e3ce8c9a5245df5c0818bd3 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 22:23:47 -0800 Subject: [PATCH 24/42] Labels for add series options --- .../AddSeries/SearchResultViewTemplate.html | 12 +++++++++- src/UI/AddSeries/addSeries.less | 24 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/UI/AddSeries/SearchResultViewTemplate.html b/src/UI/AddSeries/SearchResultViewTemplate.html index 04747d8cb..5a266ecf0 100644 --- a/src/UI/AddSeries/SearchResultViewTemplate.html +++ b/src/UI/AddSeries/SearchResultViewTemplate.html @@ -17,7 +17,17 @@ </h2> </div> <div class="row new-series-overview x-overview"> - {{overview}} + <div class="overview-internal"> + {{overview}} + </div> + </div> + <div class="row labels"> + {{#unless path}} + <div class="span4">Path</div> + {{/unless}} + + <div class="span1 starting-season starting-season-label">Starting Season</div> + <div class="span2">Quality Profile</div> </div> <div class="row"> <form class="form-inline"> diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less index 42dd309a0..f690e6413 100644 --- a/src/UI/AddSeries/addSeries.less +++ b/src/UI/AddSeries/addSeries.less @@ -57,6 +57,8 @@ .search-item { + padding-bottom : 20px; + .series-title { .label { margin-left: 15px; @@ -65,8 +67,13 @@ } .new-series-overview { - overflow : hidden; - height : 120px; + overflow : hidden; + height : 103px; + + .overview-internal { + overflow : hidden; + height : 80px; + } } .new-series-poster { @@ -77,13 +84,14 @@ margin : 10px; } - padding-bottom : 20px; a { color : #343434; } + a:hover { text-decoration : none; } + select { font-size : 16px; } @@ -102,6 +110,16 @@ .starting-season { width: 140px; + + &.starting-season-label { + display: inline-block; + } + } + + .labels { + [class*="span"] { + margin-left: 3px; + } } } From 21afdf80a247c1780f9b9a4ee82d272b9222ab82 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 23:06:44 -0800 Subject: [PATCH 25/42] Fixed: series/episode rating is 0-10 --- src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index d95eaf648..9874df12f 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -218,7 +218,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc var tvShow = new XElement("tvshow"); tvShow.Add(new XElement("title", series.Title)); - tvShow.Add(new XElement("rating", series.Ratings.Percentage)); + tvShow.Add(new XElement("rating", (decimal)series.Ratings.Percentage/10)); tvShow.Add(new XElement("plot", series.Overview)); //Todo: probably will need to use TVDB to use this feature... @@ -373,7 +373,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc } details.Add(new XElement("watched", "false")); - details.Add(new XElement("rating", episode.Ratings.Percentage)); + details.Add(new XElement("rating", (decimal)episode.Ratings.Percentage/10)); //Todo: get guest stars, writer and director //details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); From 668c667917e6933329a44de805265d2b00df97dd Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Wed, 19 Feb 2014 23:17:29 -0800 Subject: [PATCH 26/42] Fixed: command+T will not target search box in UI --- src/UI/Navbar/Search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UI/Navbar/Search.js b/src/UI/Navbar/Search.js index db986c87b..324c7c5d4 100644 --- a/src/UI/Navbar/Search.js +++ b/src/UI/Navbar/Search.js @@ -10,7 +10,7 @@ define( return; } - if (e.ctrlKey) { + if (e.ctrlKey || e.metaKey || e.altKey) { return; } From 0b2b8e9bbebb30fa5e314017ec7e7a85a0255f6e Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 20 Feb 2014 08:29:41 -0800 Subject: [PATCH 27/42] Cleaned up parser tests, 1103/1113 parsing is less greedy Fixed: Importing of hashed releases --- .../ImportDecisionMakerFixture.cs | 6 +- .../NzbDrone.Core.Test.csproj | 10 + .../AbsoluteEpisodeNumberParserFixture.cs | 47 ++ .../ParserTests/CrapParserFixture.cs | 36 ++ .../ParserTests/DailyEpisodeParserFixture.cs | 76 ++++ .../ParserTests/LanguageParserFixture.cs | 48 ++ .../ParserTests/MultiEpisodeParserFixture.cs | 50 +++ .../ParserTests/NormalizeTitleFixture.cs | 96 ++++ .../ParserTests/ParserFixture.cs | 415 +----------------- .../ParserTests/PathParserFixture.cs | 46 ++ .../ParserTests/ReleaseGroupParserFixture.cs | 36 ++ .../ParserTests/SeasonParserFixture.cs | 57 +++ .../ParserTests/SingleEpisodeParserFixture.cs | 97 ++++ .../EpisodeImport/ImportDecisionMaker.cs | 2 +- .../MediaFileTableCleanupService.cs | 2 +- .../Metadata/ExistingMetadataService.cs | 2 +- src/NzbDrone.Core/Parser/Parser.cs | 3 +- src/NzbDrone.Core/Parser/ParsingService.cs | 131 +++--- 18 files changed, 671 insertions(+), 489 deletions(-) create mode 100644 src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs create mode 100644 src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs index dcd8a12dc..0dea398ea 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs @@ -77,7 +77,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport }; Mocker.GetMock<IParsingService>() - .Setup(c => c.GetEpisodes(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>())) + .Setup(c => c.GetLocalEpisode(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>())) .Returns(_localEpisode); @@ -150,7 +150,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport { GivenSpecifications(_pass1); - Mocker.GetMock<IParsingService>().Setup(c => c.GetEpisodes(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>())) + Mocker.GetMock<IParsingService>().Setup(c => c.GetLocalEpisode(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>())) .Throws<TestException>(); _videoFiles = new List<String> @@ -168,7 +168,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport Subject.GetImportDecisions(_videoFiles, _series, false); Mocker.GetMock<IParsingService>() - .Verify(c => c.GetEpisodes(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>()), Times.Exactly(_videoFiles.Count)); + .Verify(c => c.GetLocalEpisode(It.IsAny<String>(), It.IsAny<Series>(), It.IsAny<Boolean>()), Times.Exactly(_videoFiles.Count)); ExceptionVerification.ExpectedErrors(3); } diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 3ee136d83..8e2770fc7 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -180,6 +180,16 @@ <Compile Include="NotificationTests\Xbmc\OnDownloadFixture.cs" /> <Compile Include="OrganizerTests\BuildFilePathFixture.cs" /> <Compile Include="OrganizerTests\GetSeriesFolderFixture.cs" /> + <Compile Include="ParserTests\AbsoluteEpisodeNumberParserFixture.cs" /> + <Compile Include="ParserTests\ReleaseGroupParserFixture.cs" /> + <Compile Include="ParserTests\LanguageParserFixture.cs" /> + <Compile Include="ParserTests\SeasonParserFixture.cs" /> + <Compile Include="ParserTests\NormalizeTitleFixture.cs" /> + <Compile Include="ParserTests\CrapParserFixture.cs" /> + <Compile Include="ParserTests\DailyEpisodeParserFixture.cs" /> + <Compile Include="ParserTests\SingleEpisodeParserFixture.cs" /> + <Compile Include="ParserTests\PathParserFixture.cs" /> + <Compile Include="ParserTests\MultiEpisodeParserFixture.cs" /> <Compile Include="ParserTests\ParsingServiceTests\GetEpisodesFixture.cs" /> <Compile Include="ParserTests\ParsingServiceTests\GetSeriesFixture.cs" /> <Compile Include="ParserTests\ParsingServiceTests\MapFixture.cs" /> diff --git a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs new file mode 100644 index 000000000..d86a4b563 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class AbsoluteEpisodeNumberParserFixture : CoreTest + { + [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "High School DxD", 7, 0, 0)] + [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Working!!", 6, 0, 0)] + [TestCase("[Commie]_Senki_Zesshou_Symphogear_-_11_[65F220B4]", "Senki_Zesshou_Symphogear", 11, 0, 0)] + [TestCase("[Underwater]_Rinne_no_Lagrange_-_12_(720p)_[5C7BC4F9]", "Rinne_no_Lagrange", 12, 0, 0)] + [TestCase("[Commie]_Rinne_no_Lagrange_-_15_[E76552EA]", "Rinne_no_Lagrange", 15, 0, 0)] + [TestCase("[HorribleSubs]_Hunter_X_Hunter_-_33_[720p]", "Hunter_X_Hunter", 33, 0, 0)] + [TestCase("[HorribleSubs]_Fairy_Tail_-_145_[720p]", "Fairy_Tail", 145, 0, 0)] + [TestCase("[HorribleSubs] Tonari no Kaibutsu-kun - 13 [1080p].mkv", "Tonari no Kaibutsu-kun", 13, 0, 0)] + [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", "Yes.Pretty.Cure.5.Go.Go!", 31, 0, 0)] + [TestCase("[K-F] One Piece 214", "One Piece", 214, 0, 0)] + [TestCase("[K-F] One Piece S10E14 214", "One Piece", 214, 10, 14)] + [TestCase("[K-F] One Piece 10x14 214", "One Piece", 214, 10, 14)] + [TestCase("[K-F] One Piece 214 10x14", "One Piece", 214, 10, 14)] +// [TestCase("One Piece S10E14 214", "One Piece", 214, 10, 14)] +// [TestCase("One Piece 10x14 214", "One Piece", 214, 10, 14)] +// [TestCase("One Piece 214 10x14", "One Piece", 214, 10, 14)] +// [TestCase("214 One Piece 10x14", "One Piece", 214, 10, 14)] + [TestCase("Bleach - 031 - The Resolution to Kill [Lunar].avi", "Bleach", 31, 0, 0)] + [TestCase("Bleach - 031 - The Resolution to Kill [Lunar]", "Bleach", 31, 0, 0)] + [TestCase("[ACX]Hack Sign 01 Role Play [Kosaka] [9C57891E].mkv", "Hack Sign", 1, 0, 0)] + [TestCase("[SFW-sage] Bakuman S3 - 12 [720p][D07C91FC]", "Bakuman S3", 12, 0, 0)] + [TestCase("ducktales_e66_time_is_money_part_one_marking_time", "DuckTales", 66, 0, 0)] + public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Should().NotBeNull(); + result.AbsoluteEpisodeNumbers.First().Should().Be(absoluteEpisodeNumber); + result.SeasonNumber.Should().Be(seasonNumber); + result.EpisodeNumbers.FirstOrDefault().Should().Be(episodeNumber); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); + result.FullSeason.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs new file mode 100644 index 000000000..83fd99d00 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Expansive; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class CrapParserFixture : CoreTest + { + [TestCase("76El6LcgLzqb426WoVFg1vVVVGx4uCYopQkfjmLe")] + [TestCase("Vrq6e1Aba3U amCjuEgV5R2QvdsLEGYF3YQAQkw8")] + [TestCase("TDAsqTea7k4o6iofVx3MQGuDK116FSjPobMuh8oB")] + [TestCase("yp4nFodAAzoeoRc467HRh1mzuT17qeekmuJ3zFnL")] + [TestCase("oxXo8S2272KE1 lfppvxo3iwEJBrBmhlQVK1gqGc")] + [TestCase("dPBAtu681Ycy3A4NpJDH6kNVQooLxqtnsW1Umfiv")] + [TestCase("password - \"bdc435cb-93c4-4902-97ea-ca00568c3887.337\" yEnc")] + [TestCase("185d86a343e39f3341e35c4dad3f9959")] + [TestCase("ba27283b17c00d01193eacc02a8ba98eeb523a76")] + [TestCase("45a55debe3856da318cc35882ad07e43cd32fd15")] + [TestCase("86420f8ee425340d8894bf3bc636b66404b95f18")] + [TestCase("ce39afb7da6cf7c04eba3090f0a309f609883862")] + [TestCase("THIS SHOULD NEVER PARSE")] + public void should_not_parse_crap(string title) + { + Parser.Parser.ParseTitle(title).Should().BeNull(); + ExceptionVerification.IgnoreWarns(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs new file mode 100644 index 000000000..e889dc607 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Expansive; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class DailyEpisodeParserFixture : CoreTest + { + [TestCase("Conan 2011 04 18 Emma Roberts HDTV XviD BFF", "Conan", 2011, 04, 18)] + [TestCase("The Tonight Show With Jay Leno 2011 04 15 1080i HDTV DD5 1 MPEG2 TrollHD", "The Tonight Show With Jay Leno", 2011, 04, 15)] + [TestCase("The.Daily.Show.2010.10.11.Johnny.Knoxville.iTouch-MW", "The.Daily.Show", 2010, 10, 11)] + [TestCase("The Daily Show - 2011-04-12 - Gov. Deval Patrick", "The.Daily.Show", 2011, 04, 12)] + [TestCase("2011.01.10 - Denis Leary - HD TV.mkv", "", 2011, 1, 10)] + [TestCase("2011.03.13 - Denis Leary - HD TV.mkv", "", 2011, 3, 13)] + [TestCase("The Tonight Show with Jay Leno - 2011-06-16 - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo", "The Tonight Show with Jay Leno", 2011, 6, 16)] + [TestCase("2020.NZ.2012.16.02.PDTV.XviD-C4TV", "2020nz", 2012, 2, 16)] + [TestCase("2020.NZ.2012.13.02.PDTV.XviD-C4TV", "2020nz", 2012, 2, 13)] + [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "2020nz", 2011, 12, 2)] + public void should_parse_daily_episode(string postTitle, string title, int year, int month, int day) + { + var result = Parser.Parser.ParseTitle(postTitle); + var airDate = new DateTime(year, month, day); + result.Should().NotBeNull(); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); + result.AirDate.Should().Be(airDate.ToString(Episode.AIR_DATE_FORMAT)); + result.EpisodeNumbers.Should().BeEmpty(); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + } + + [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] + [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] + [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] + [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] + [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] + [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] + [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] + public void should_not_accept_ancient_daily_series(string title) + { + var yearTooLow = title.Expand(new { year = 1950, month = 10, day = 14 }); + Parser.Parser.ParseTitle(yearTooLow).Should().BeNull(); + } + + [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] + [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] + [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] + [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] + [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] + [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] + [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] + public void should_not_accept_future_dates(string title) + { + var twoDaysFromNow = DateTime.Now.AddDays(2); + + var validDate = title.Expand(new { year = twoDaysFromNow.Year, month = twoDaysFromNow.Month.ToString("00"), day = twoDaysFromNow.Day.ToString("00") }); + + Parser.Parser.ParseTitle(validDate).Should().BeNull(); + } + + [Test] + public void should_fail_if_episode_is_far_in_future() + { + var title = string.Format("{0:yyyy.MM.dd} - Denis Leary - HD TV.mkv", DateTime.Now.AddDays(2)); + + Parser.Parser.ParseTitle(title).Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs new file mode 100644 index 000000000..1b200d5c1 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class LanguageParserFixture : CoreTest + { + [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", Language.English)] + [TestCase("Castle.2009.S01E14.French.HDTV.XviD-LOL", Language.French)] + [TestCase("Castle.2009.S01E14.Spanish.HDTV.XviD-LOL", Language.Spanish)] + [TestCase("Castle.2009.S01E14.German.HDTV.XviD-LOL", Language.German)] + [TestCase("Castle.2009.S01E14.Germany.HDTV.XviD-LOL", Language.English)] + [TestCase("Castle.2009.S01E14.Italian.HDTV.XviD-LOL", Language.Italian)] + [TestCase("Castle.2009.S01E14.Danish.HDTV.XviD-LOL", Language.Danish)] + [TestCase("Castle.2009.S01E14.Dutch.HDTV.XviD-LOL", Language.Dutch)] + [TestCase("Castle.2009.S01E14.Japanese.HDTV.XviD-LOL", Language.Japanese)] + [TestCase("Castle.2009.S01E14.Cantonese.HDTV.XviD-LOL", Language.Cantonese)] + [TestCase("Castle.2009.S01E14.Mandarin.HDTV.XviD-LOL", Language.Mandarin)] + [TestCase("Castle.2009.S01E14.Korean.HDTV.XviD-LOL", Language.Korean)] + [TestCase("Castle.2009.S01E14.Russian.HDTV.XviD-LOL", Language.Russian)] + [TestCase("Castle.2009.S01E14.Polish.HDTV.XviD-LOL", Language.Polish)] + [TestCase("Castle.2009.S01E14.Vietnamese.HDTV.XviD-LOL", Language.Vietnamese)] + [TestCase("Castle.2009.S01E14.Swedish.HDTV.XviD-LOL", Language.Swedish)] + [TestCase("Castle.2009.S01E14.Norwegian.HDTV.XviD-LOL", Language.Norwegian)] + [TestCase("Castle.2009.S01E14.Finnish.HDTV.XviD-LOL", Language.Finnish)] + [TestCase("Castle.2009.S01E14.Turkish.HDTV.XviD-LOL", Language.Turkish)] + [TestCase("Castle.2009.S01E14.Portuguese.HDTV.XviD-LOL", Language.Portuguese)] + [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", Language.English)] + [TestCase("person.of.interest.1x19.ita.720p.bdmux.x264-novarip", Language.Italian)] + [TestCase("Salamander.S01E01.FLEMISH.HDTV.x264-BRiGAND", Language.Flemish)] + [TestCase("H.Polukatoikia.S03E13.Greek.PDTV.XviD-Ouzo", Language.Greek)] + [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 should_parse_language(string postTitle, Language language) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Language.Should().Be(language); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs new file mode 100644 index 000000000..40644a880 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Expansive; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class MultiEpisodeParserFixture : CoreTest + { + [TestCase("WEEDS.S03E01-06.DUAL.BDRip.XviD.AC3.-HELLYWOOD", "WEEDS", 3, new[] { 1, 2, 3, 4, 5, 6 })] + [TestCase("Two.and.a.Half.Men.103.104.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Men", 1, new[] { 3, 4 })] + [TestCase("Weeds.S03E01.S03E02.720p.HDTV.X264-DIMENSION", "Weeds", 3, new[] { 1, 2 })] + [TestCase("The Borgias S01e01 e02 ShoHD On Demand 1080i DD5 1 ALANiS", "The Borgias", 1, new[] { 1, 2 })] + [TestCase("White.Collar.2x04.2x05.720p.BluRay-FUTV", "White.Collar", 2, new[] { 4, 5 })] + [TestCase("Desperate.Housewives.S07E22E23.720p.HDTV.X264-DIMENSION", "Desperate.Housewives", 7, new[] { 22, 23 })] + [TestCase("Desparate Housewives - S07E22 - S07E23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] + [TestCase("S03E01.S03E02.720p.HDTV.X264-DIMENSION", "", 3, new[] { 1, 2 })] + [TestCase("Desparate Housewives - S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] + [TestCase("S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "", 7, new[] { 22, 23 })] + [TestCase("2x04x05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] + [TestCase("S02E04E05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] + [TestCase("S02E03-04-05.720p.BluRay-FUTV", "", 2, new[] { 3, 4, 5 })] + [TestCase("Breakout.Kings.S02E09-E10.HDTV.x264-ASAP", "Breakout Kings", 2, new[] { 9, 10 })] + [TestCase("Breakout Kings - 2x9-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] + [TestCase("Breakout Kings - 2x09-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] + [TestCase("Hell on Wheels S02E09 E10 HDTV x264 EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] + [TestCase("Hell.on.Wheels.S02E09-E10.720p.HDTV.x264-EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] + [TestCase("Grey's Anatomy - 8x01_02 - Free Falling", "Grey's Anatomy", 8, new [] { 1,2 })] + [TestCase("8x01_02 - Free Falling", "", 8, new[] { 1, 2 })] + [TestCase("Kaamelott.S01E91-E100", "Kaamelott", 1, new[] { 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 })] + [TestCase("Neighbours.S29E161-E165.PDTV.x264-FQM", "Neighbours", 29, new[] { 161, 162, 163, 164, 165 })] + [TestCase("Shortland.Street.S22E5363-E5366.HDTV.x264-FiHTV", "Shortland Street", 22, new[] { 5363, 5364, 5365, 5366 })] + public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.SeasonNumber.Should().Be(season); + result.EpisodeNumbers.Should().BeEquivalentTo(episodes); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs new file mode 100644 index 000000000..19c42dff4 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Expansive; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class NormalizeTitleFixture : CoreTest + { + [TestCase("Conan", "conan")] + [TestCase("The Tonight Show With Jay Leno", "tonightshowwithjayleno")] + [TestCase("The.Daily.Show", "dailyshow")] + [TestCase("Castle (2009)", "castle2009")] + [TestCase("Parenthood.2010", "parenthood2010")] + [TestCase("Law_and_Order_SVU", "lawordersvu")] + public void should_normalize_series_title(string parsedSeriesName, string seriesName) + { + var result = parsedSeriesName.CleanSeriesTitle(); + result.Should().Be(seriesName); + } + + [TestCase("CaPitAl", "capital")] + [TestCase("peri.od", "period")] + [TestCase("this.^&%^**$%@#$!That", "thisthat")] + [TestCase("test/test", "testtest")] + [TestCase("90210", "90210")] + [TestCase("24", "24")] + public void should_remove_special_characters_and_casing(string dirty, string clean) + { + var result = dirty.CleanSeriesTitle(); + result.Should().Be(clean); + } + + [TestCase("the")] + [TestCase("and")] + [TestCase("or")] + [TestCase("a")] + [TestCase("an")] + [TestCase("of")] + public void should_remove_common_words(string word) + { + var dirtyFormat = new[] + { + "word.{0}.word", + "word {0} word", + "word-{0}-word", + "{0}.word.word", + "{0}-word-word", + "{0} word word", + "word.word.{0}", + "word-word-{0}", + "word-word {0}", + }; + + foreach (var s in dirtyFormat) + { + var dirty = String.Format(s, word); + dirty.CleanSeriesTitle().Should().Be("wordword"); + } + + } + + [TestCase("the")] + [TestCase("and")] + [TestCase("or")] + [TestCase("a")] + [TestCase("an")] + [TestCase("of")] + public void should_not_remove_common_words_in_the_middle_of_word(string word) + { + var dirtyFormat = new[] + { + "word.{0}word", + "word {0}word", + "word-{0}word", + "word{0}.word", + "word{0}-word", + "word{0}-word", + }; + + foreach (var s in dirtyFormat) + { + var dirty = String.Format(s, word); + dirty.CleanSeriesTitle().Should().Be(("word" + word.ToLower() + "word")); + } + + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index b0078900d..359699d13 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -2,11 +2,8 @@ using System; using System.Linq; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Common.Expansive; using NzbDrone.Core.Parser; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.ParserTests { @@ -26,179 +23,6 @@ namespace NzbDrone.Core.Test.ParserTests * Superman.-.The.Man.of.Steel.1994-05.33.hybrid.DreamGirl-Novus-HD */ - [TestCase("Sonny.With.a.Chance.S02E15", "Sonny.With.a.Chance", 2, 15)] - [TestCase("Two.and.a.Half.Me.103.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 1, 3)] - [TestCase("Two.and.a.Half.Me.113.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 1, 13)] - [TestCase("Two.and.a.Half.Me.1013.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 10, 13)] - [TestCase("Chuck.4x05.HDTV.XviD-LOL", "Chuck", 4, 5)] - [TestCase("The.Girls.Next.Door.S03E06.DVDRip.XviD-WiDE", "The.Girls.Next.Door", 3, 6)] - [TestCase("Degrassi.S10E27.WS.DSR.XviD-2HD", "Degrassi", 10, 27)] - [TestCase("Parenthood.2010.S02E14.HDTV.XviD-LOL", "Parenthood 2010", 2, 14)] - [TestCase("Hawaii Five 0 S01E19 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1, 19)] - [TestCase("The Event S01E14 A Message Back 720p WEB DL DD5 1 H264 SURFER", "The Event", 1, 14)] - [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] - [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] - [TestCase("Adventure.Inc.S03E19.DVDRip.XviD-OSiTV", "Adventure.Inc", 3, 19)] - [TestCase("S03E09 WS PDTV XviD FUtV", "", 3, 9)] - [TestCase("5x10 WS PDTV XviD FUtV", "", 5, 10)] - [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", "Castle 2009", 1, 14)] - [TestCase("Pride.and.Prejudice.1995.S03E20.HDTV.XviD-LOL", "Pride and Prejudice 1995", 3, 20)] - [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "The.Office", 3, 115)] - [TestCase(@"Parks and Recreation - S02E21 - 94 Meetings - 720p TV.mkv", "Parks and Recreation", 2, 21)] - [TestCase(@"24-7 Penguins-Capitals- Road to the NHL Winter Classic - S01E03 - Episode 3.mkv", "24-7 Penguins-Capitals- Road to the NHL Winter Classic", 1, 3)] - [TestCase("Adventure.Inc.S03E19.DVDRip.\"XviD\"-OSiTV", "Adventure.Inc", 3, 19)] - [TestCase("Hawaii Five-0 (2010) - 1x05 - Nalowale (Forgotten/Missing)", "Hawaii Five-0 (2010)", 1, 5)] - [TestCase("Hawaii Five-0 (2010) - 1x05 - Title", "Hawaii Five-0 (2010)", 1, 5)] - [TestCase("House - S06E13 - 5 to 9 [DVD]", "House", 6, 13)] - [TestCase("The Mentalist - S02E21 - 18-5-4", "The Mentalist", 2, 21)] - [TestCase("Breaking.In.S01E07.21.0.Jump.Street.720p.WEB-DL.DD5.1.h.264-KiNGS", "Breaking In", 1, 7)] - [TestCase("CSI.525", "CSI", 5, 25)] - [TestCase("King of the Hill - 10x12 - 24 Hour Propane People [SDTV]", "King of the Hill", 10, 12)] - [TestCase("Brew Masters S01E06 3 Beers For Batali DVDRip XviD SPRiNTER", "Brew Masters", 1, 6)] - [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part01 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 1)] - [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part 02 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 2)] - [TestCase("24-7 Flyers-Rangers- Road to the NHL Winter Classic - S01E01 - Part 1", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 1)] - [TestCase("The.Kennedys.Part.2.DSR.XviD-SYS", "The Kennedys", 1, 2)] - [TestCase("the-pacific-e07-720p", "The Pacific", 1, 7)] - [TestCase("S6E02-Unwrapped-(Playing With Food) - [DarkData]", "", 6, 2)] - [TestCase("S06E03-Unwrapped-(Number Ones Unwrapped) - [DarkData]", "", 6, 3)] - [TestCase("The Mentalist S02E21 18 5 4 720p WEB DL DD5 1 h 264 EbP", "The Mentalist", 2, 21)] - [TestCase("01x04 - Halloween, Part 1 - 720p WEB-DL", "", 1, 4)] - [TestCase("extras.s03.e05.ws.dvdrip.xvid-m00tv", "Extras", 3, 5)] - [TestCase("castle.2009.416.hdtv-lol", "Castle 2009", 4, 16)] - [TestCase("hawaii.five-0.2010.217.hdtv-lol", "Hawaii Five-0 (2010)", 2, 17)] - [TestCase("Looney Tunes - S1936E18 - I Love to Singa", "Looney Tunes", 1936, 18)] - [TestCase("American_Dad!_-_7x6_-_The_Scarlett_Getter_[SDTV]", "American Dad!", 7, 6)] - [TestCase("Falling_Skies_-_1x1_-_Live_and_Learn_[HDTV-720p]", "Falling Skies", 1, 1)] - [TestCase("Top Gear - 07x03 - 2005.11.70", "Top Gear", 7, 3)] - [TestCase("Hatfields and McCoys 2012 Part 1 REPACK 720p HDTV x264 2HD", "Hatfields and McCoys 2012", 1, 1)] - [TestCase("Glee.S04E09.Swan.Song.1080p.WEB-DL.DD5.1.H.264-ECI", "Glee", 4, 9)] - [TestCase("S08E20 50-50 Carla [DVD]", "", 8, 20)] - [TestCase("Cheers S08E20 50-50 Carla [DVD]", "Cheers", 8, 20)] - [TestCase("S02E10 6-50 to SLC [SDTV]", "", 2, 10)] - [TestCase("Franklin & Bash S02E10 6-50 to SLC [SDTV]", "Franklin & Bash", 2, 10)] - [TestCase("The_Big_Bang_Theory_-_6x12_-_The_Egg_Salad_Equivalency_[HDTV-720p]", "The Big Bang Theory", 6, 12)] - [TestCase("Top_Gear.19x06.720p_HDTV_x264-FoV", "Top Gear", 19, 6)] - [TestCase("Portlandia.S03E10.Alexandra.720p.WEB-DL.AAC2.0.H.264-CROM.mkv", "Portlandia", 3, 10)] - [TestCase("(Game of Thrones s03 e - \"Game of Thrones Season 3 Episode 10\"", "Game of Thrones", 3, 10)] - [TestCase("House.Hunters.International.S05E607.720p.hdtv.x264", "House.Hunters.International", 5, 607)] - [TestCase("Adventure.Time.With.Finn.And.Jake.S01E20.720p.BluRay.x264-DEiMOS", "Adventure.Time.With.Finn.And.Jake", 1, 20)] - [TestCase("Hostages.S01E04.2-45.PM.[HDTV-720p].mkv", "Hostages", 1, 4)] - [TestCase("S01E04", "", 1, 4)] - [TestCase("1x04", "", 1, 4)] - [TestCase("10.Things.You.Dont.Know.About.S02E04.Prohibition.HDTV.XviD-AFG", "10 Things You Dont Know About", 2, 4)] - [TestCase("30 Rock - S01E01 - Pilot.avi", "30 Rock", 1, 1)] - [TestCase("666 Park Avenue - S01E01", "666 Park Avenue", 1, 1)] - [TestCase("Warehouse 13 - S01E01", "Warehouse 13", 1, 1)] - [TestCase("Don't Trust The B---- in Apartment 23.S01E01", "Don't Trust The B---- in Apartment 23", 1, 1)] - [TestCase("Warehouse.13.S01E01", "Warehouse.13", 1, 1)] - [TestCase("Dont.Trust.The.B----.in.Apartment.23.S01E01", "Dont.Trust.The.B----.in.Apartment.23", 1, 1)] - [TestCase("24 S01E01", "24", 1, 1)] - [TestCase("24.S01E01", "24", 1, 1)] - [TestCase("Homeland - 2x12 - The Choice [HDTV-1080p].mkv", "Homeland", 2, 12)] - [TestCase("Homeland - 2x4 - New Car Smell [HDTV-1080p].mkv", "Homeland", 2, 4)] - public void ParseTitle_single(string postTitle, string title, int seasonNumber, int episodeNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.EpisodeNumbers.Should().HaveCount(1); - result.SeasonNumber.Should().Be(seasonNumber); - result.EpisodeNumbers.First().Should().Be(episodeNumber); - result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - - [TestCase(@"z:\tv shows\battlestar galactica (2003)\Season 3\S03E05 - Collaborators.mkv", 3, 5)] - [TestCase(@"z:\tv shows\modern marvels\Season 16\S16E03 - The Potato.mkv", 16, 3)] - [TestCase(@"z:\tv shows\robot chicken\Specials\S00E16 - Dear Consumer - SD TV.avi", 0, 16)] - [TestCase(@"D:\shares\TV Shows\Parks And Recreation\Season 2\S02E21 - 94 Meetings - 720p TV.mkv", 2, 21)] - [TestCase(@"D:\shares\TV Shows\Battlestar Galactica (2003)\Season 2\S02E21.avi", 2, 21)] - [TestCase("C:/Test/TV/Chuck.4x05.HDTV.XviD-LOL", 4, 5)] - [TestCase(@"P:\TV Shows\House\Season 6\S06E13 - 5 to 9 - 720p BluRay.mkv", 6, 13)] - [TestCase(@"S:\TV Drop\House - 10x11 - Title [SDTV]\1011 - Title.avi", 10, 11)] - [TestCase(@"/TV Drop/House - 10x11 - Title [SDTV]/1011 - Title.avi", 10, 11)] - [TestCase(@"S:\TV Drop\King of the Hill - 10x12 - 24 Hour Propane People [SDTV]\1012 - 24 Hour Propane People.avi", 10, 12)] - [TestCase(@"/TV Drop/King of the Hill - 10x12 - 24 Hour Propane People [SDTV]/1012 - 24 Hour Propane People.avi", 10, 12)] - [TestCase(@"S:\TV Drop\King of the Hill - 10x12 - 24 Hour Propane People [SDTV]\Hour Propane People.avi", 10, 12)] - [TestCase(@"/TV Drop/King of the Hill - 10x12 - 24 Hour Propane People [SDTV]/Hour Propane People.avi", 10, 12)] - [TestCase(@"E:\Downloads\tv\The.Big.Bang.Theory.S01E01.720p.HDTV\ajifajjjeaeaeqwer_eppj.avi", 1, 1)] - [TestCase(@"C:\Test\Unsorted\The.Big.Bang.Theory.S01E01.720p.HDTV\tbbt101.avi", 1, 1)] - public void PathParse_tests(string path, int season, int episode) - { - var result = Parser.Parser.ParsePath(path); - result.EpisodeNumbers.Should().HaveCount(1); - result.SeasonNumber.Should().Be(season); - result.EpisodeNumbers[0].Should().Be(episode); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - - ExceptionVerification.IgnoreWarns(); - } - - [TestCase("THIS SHOULD NEVER PARSE")] - public void unparsable_title_should_log_warn_and_return_null(string title) - { - Parser.Parser.ParseTitle(title).Should().BeNull(); - } - - //[Timeout(1000)] - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.XviD.AC3.-HELLYWOOD", "WEEDS", 3, new[] { 1, 2, 3, 4, 5, 6 })] - [TestCase("Two.and.a.Half.Men.103.104.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Men", 1, new[] { 3, 4 })] - [TestCase("Weeds.S03E01.S03E02.720p.HDTV.X264-DIMENSION", "Weeds", 3, new[] { 1, 2 })] - [TestCase("The Borgias S01e01 e02 ShoHD On Demand 1080i DD5 1 ALANiS", "The Borgias", 1, new[] { 1, 2 })] - [TestCase("White.Collar.2x04.2x05.720p.BluRay-FUTV", "White.Collar", 2, new[] { 4, 5 })] - [TestCase("Desperate.Housewives.S07E22E23.720p.HDTV.X264-DIMENSION", "Desperate.Housewives", 7, new[] { 22, 23 })] - [TestCase("Desparate Housewives - S07E22 - S07E23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] - [TestCase("S03E01.S03E02.720p.HDTV.X264-DIMENSION", "", 3, new[] { 1, 2 })] - [TestCase("Desparate Housewives - S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] - [TestCase("S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "", 7, new[] { 22, 23 })] - [TestCase("2x04x05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] - [TestCase("S02E04E05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] - [TestCase("S02E03-04-05.720p.BluRay-FUTV", "", 2, new[] { 3, 4, 5 })] - [TestCase("Breakout.Kings.S02E09-E10.HDTV.x264-ASAP", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Breakout Kings - 2x9-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Breakout Kings - 2x09-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Hell on Wheels S02E09 E10 HDTV x264 EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] - [TestCase("Hell.on.Wheels.S02E09-E10.720p.HDTV.x264-EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] - [TestCase("Grey's Anatomy - 8x01_02 - Free Falling", "Grey's Anatomy", 8, new [] { 1,2 })] - [TestCase("8x01_02 - Free Falling", "", 8, new[] { 1, 2 })] - [TestCase("Kaamelott.S01E91-E100", "Kaamelott", 1, new[] { 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 })] - [TestCase("Neighbours.S29E161-E165.PDTV.x264-FQM", "Neighbours", 29, new[] { 161, 162, 163, 164, 165 })] - [TestCase("Shortland.Street.S22E5363-E5366.HDTV.x264-FiHTV", "Shortland Street", 22, new[] { 5363, 5364, 5365, 5366 })] - public void TitleParse_multi(string postTitle, string title, int season, int[] episodes) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.SeasonNumber.Should().Be(season); - result.EpisodeNumbers.Should().BeEquivalentTo(episodes); - result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - - - [TestCase("Conan 2011 04 18 Emma Roberts HDTV XviD BFF", "Conan", 2011, 04, 18)] - [TestCase("The Tonight Show With Jay Leno 2011 04 15 1080i HDTV DD5 1 MPEG2 TrollHD", "The Tonight Show With Jay Leno", 2011, 04, 15)] - [TestCase("The.Daily.Show.2010.10.11.Johnny.Knoxville.iTouch-MW", "The.Daily.Show", 2010, 10, 11)] - [TestCase("The Daily Show - 2011-04-12 - Gov. Deval Patrick", "The.Daily.Show", 2011, 04, 12)] - [TestCase("2011.01.10 - Denis Leary - HD TV.mkv", "", 2011, 1, 10)] - [TestCase("2011.03.13 - Denis Leary - HD TV.mkv", "", 2011, 3, 13)] - [TestCase("The Tonight Show with Jay Leno - 2011-06-16 - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo", "The Tonight Show with Jay Leno", 2011, 6, 16)] - [TestCase("2020.NZ.2012.16.02.PDTV.XviD-C4TV", "2020nz", 2012, 2, 16)] - [TestCase("2020.NZ.2012.13.02.PDTV.XviD-C4TV", "2020nz", 2012, 2, 13)] - [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "2020nz", 2011, 12, 2)] - public void parse_daily_episodes(string postTitle, string title, int year, int month, int day) - { - var result = Parser.Parser.ParseTitle(postTitle); - var airDate = new DateTime(year, month, day); - result.Should().NotBeNull(); - result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); - result.AirDate.Should().Be(airDate.ToString(Episode.AIR_DATE_FORMAT)); - result.EpisodeNumbers.Should().BeEmpty(); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "High School DxD", 7, 0, 0)] [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Working!!", 6, 0, 0)] [TestCase("[Commie]_Senki_Zesshou_Symphogear_-_11_[65F220B4]", "Senki_Zesshou_Symphogear", 11, 0, 0)] @@ -232,138 +56,6 @@ namespace NzbDrone.Core.Test.ParserTests result.FullSeason.Should().BeFalse(); } - [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] - [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] - [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] - [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] - [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] - [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] - [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] - public void should_not_accept_ancient_daily_series(string title) - { - var yearTooLow = title.Expand(new { year = 1950, month = 10, day = 14 }); - Parser.Parser.ParseTitle(yearTooLow).Should().BeNull(); - } - - [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] - [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] - [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] - [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] - [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] - [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] - [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] - public void should_not_accept_future_dates(string title) - { - var twoDaysFromNow = DateTime.Now.AddDays(2); - - var validDate = title.Expand(new { year = twoDaysFromNow.Year, month = twoDaysFromNow.Month.ToString("00"), day = twoDaysFromNow.Day.ToString("00") }); - - Parser.Parser.ParseTitle(validDate).Should().BeNull(); - } - - [Test] - public void parse_daily_should_fail_if_episode_is_far_in_future() - { - var title = string.Format("{0:yyyy.MM.dd} - Denis Leary - HD TV.mkv", DateTime.Now.AddDays(2)); - - Parser.Parser.ParseTitle(title).Should().BeNull(); - } - - [TestCase("30.Rock.Season.04.HDTV.XviD-DIMENSION", "30.Rock", 4)] - [TestCase("Parks.and.Recreation.S02.720p.x264-DIMENSION", "Parks.and.Recreation", 2)] - [TestCase("The.Office.US.S03.720p.x264-DIMENSION", "The.Office.US", 3)] - [TestCase(@"Sons.of.Anarchy.S03.720p.BluRay-CLUE\REWARD", "Sons.of.Anarchy", 3)] - [TestCase("Adventure Time S02 720p HDTV x264 CRON", "Adventure Time", 2)] - [TestCase("Sealab.2021.S04.iNTERNAL.DVDRip.XviD-VCDVaULT", "Sealab 2021", 4)] - public void full_season_release_parse(string postTitle, string title, int season) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.SeasonNumber.Should().Be(season); - result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); - result.EpisodeNumbers.Should().BeEmpty(); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeTrue(); - } - - [TestCase("Conan", "conan")] - [TestCase("The Tonight Show With Jay Leno", "tonightshowwithjayleno")] - [TestCase("The.Daily.Show", "dailyshow")] - [TestCase("Castle (2009)", "castle2009")] - [TestCase("Parenthood.2010", "parenthood2010")] - [TestCase("Law_and_Order_SVU", "lawordersvu")] - public void series_name_normalize(string parsedSeriesName, string seriesName) - { - var result = parsedSeriesName.CleanSeriesTitle(); - result.Should().Be(seriesName); - } - - [TestCase("CaPitAl", "capital")] - [TestCase("peri.od", "period")] - [TestCase("this.^&%^**$%@#$!That", "thisthat")] - [TestCase("test/test", "testtest")] - [TestCase("90210", "90210")] - [TestCase("24", "24")] - public void Normalize_Title(string dirty, string clean) - { - var result = dirty.CleanSeriesTitle(); - result.Should().Be(clean); - } - - [TestCase("the")] - [TestCase("and")] - [TestCase("or")] - [TestCase("a")] - [TestCase("an")] - [TestCase("of")] - public void Normalize_removed_common_words(string word) - { - var dirtyFormat = new[] - { - "word.{0}.word", - "word {0} word", - "word-{0}-word", - "{0}.word.word", - "{0}-word-word", - "{0} word word", - "word.word.{0}", - "word-word-{0}", - "word-word {0}", - }; - - foreach (var s in dirtyFormat) - { - var dirty = String.Format(s, word); - dirty.CleanSeriesTitle().Should().Be("wordword"); - } - - } - - [TestCase("the")] - [TestCase("and")] - [TestCase("or")] - [TestCase("a")] - [TestCase("an")] - [TestCase("of")] - public void Normalize_not_removed_common_words_in_the_middle(string word) - { - var dirtyFormat = new[] - { - "word.{0}word", - "word {0}word", - "word-{0}word", - "word{0}.word", - "word{0}-word", - "word{0}-word", - }; - - foreach (var s in dirtyFormat) - { - var dirty = String.Format(s, word); - dirty.CleanSeriesTitle().Should().Be(("word" + word.ToLower() + "word")); - } - - } - [TestCase("Chuck - 4x05 - Title", "Chuck")] [TestCase("Law & Order - 4x05 - Title", "laworder")] [TestCase("Bad Format", "badformat")] @@ -376,115 +68,10 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Hawaii Five 0", "hawaiifive0")] [TestCase("Match of the Day", "matchday")] [TestCase("Match of the Day 2", "matchday2")] - public void parse_series_name(string postTitle, string title) + public void should_parse_series_name(string postTitle, string title) { var result = Parser.Parser.ParseSeriesName(postTitle); result.Should().Be(title.CleanSeriesTitle()); } - - [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", Language.English)] - [TestCase("Castle.2009.S01E14.French.HDTV.XviD-LOL", Language.French)] - [TestCase("Castle.2009.S01E14.Spanish.HDTV.XviD-LOL", Language.Spanish)] - [TestCase("Castle.2009.S01E14.German.HDTV.XviD-LOL", Language.German)] - [TestCase("Castle.2009.S01E14.Germany.HDTV.XviD-LOL", Language.English)] - [TestCase("Castle.2009.S01E14.Italian.HDTV.XviD-LOL", Language.Italian)] - [TestCase("Castle.2009.S01E14.Danish.HDTV.XviD-LOL", Language.Danish)] - [TestCase("Castle.2009.S01E14.Dutch.HDTV.XviD-LOL", Language.Dutch)] - [TestCase("Castle.2009.S01E14.Japanese.HDTV.XviD-LOL", Language.Japanese)] - [TestCase("Castle.2009.S01E14.Cantonese.HDTV.XviD-LOL", Language.Cantonese)] - [TestCase("Castle.2009.S01E14.Mandarin.HDTV.XviD-LOL", Language.Mandarin)] - [TestCase("Castle.2009.S01E14.Korean.HDTV.XviD-LOL", Language.Korean)] - [TestCase("Castle.2009.S01E14.Russian.HDTV.XviD-LOL", Language.Russian)] - [TestCase("Castle.2009.S01E14.Polish.HDTV.XviD-LOL", Language.Polish)] - [TestCase("Castle.2009.S01E14.Vietnamese.HDTV.XviD-LOL", Language.Vietnamese)] - [TestCase("Castle.2009.S01E14.Swedish.HDTV.XviD-LOL", Language.Swedish)] - [TestCase("Castle.2009.S01E14.Norwegian.HDTV.XviD-LOL", Language.Norwegian)] - [TestCase("Castle.2009.S01E14.Finnish.HDTV.XviD-LOL", Language.Finnish)] - [TestCase("Castle.2009.S01E14.Turkish.HDTV.XviD-LOL", Language.Turkish)] - [TestCase("Castle.2009.S01E14.Portuguese.HDTV.XviD-LOL", Language.Portuguese)] - [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", Language.English)] - [TestCase("person.of.interest.1x19.ita.720p.bdmux.x264-novarip", Language.Italian)] - [TestCase("Salamander.S01E01.FLEMISH.HDTV.x264-BRiGAND", Language.Flemish)] - [TestCase("H.Polukatoikia.S03E13.Greek.PDTV.XviD-Ouzo", Language.Greek)] - [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); - result.Language.Should().Be(language); - } - - [TestCase("Hawaii Five 0 S01 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1)] - [TestCase("30 Rock S03 WS PDTV XviD FUtV", "30 Rock", 3)] - [TestCase("The Office Season 4 WS PDTV XviD FUtV", "The Office", 4)] - [TestCase("Eureka Season 1 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] - [TestCase("The Office Season4 WS PDTV XviD FUtV", "The Office", 4)] - [TestCase("Eureka S 01 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] - [TestCase("Doctor Who Confidential Season 3", "Doctor Who Confidential", 3)] - public void parse_season_info(string postTitle, string seriesName, int seasonNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - - result.SeriesTitle.Should().Be(seriesName.CleanSeriesTitle()); - result.SeasonNumber.Should().Be(seasonNumber); - result.FullSeason.Should().BeTrue(); - } - - [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER")] - [TestCase("Punky Brewster S01 EXTRAS DVDRip XviD RUNNER")] - [TestCase("Instant Star S03 EXTRAS DVDRip XviD OSiTV")] - public void parse_season_extras(string postTitle) - { - var result = Parser.Parser.ParseTitle(postTitle); - - result.Should().BeNull(); - } - - [TestCase("Lie.to.Me.S03.SUBPACK.DVDRip.XviD-REWARD")] - [TestCase("The.Middle.S02.SUBPACK.DVDRip.XviD-REWARD")] - [TestCase("CSI.S11.SUBPACK.DVDRip.XviD-REWARD")] - public void parse_season_subpack(string postTitle) - { - var result = Parser.Parser.ParseTitle(postTitle); - - result.Should().BeNull(); - } - - [TestCase("76El6LcgLzqb426WoVFg1vVVVGx4uCYopQkfjmLe")] - [TestCase("Vrq6e1Aba3U amCjuEgV5R2QvdsLEGYF3YQAQkw8")] - [TestCase("TDAsqTea7k4o6iofVx3MQGuDK116FSjPobMuh8oB")] - [TestCase("yp4nFodAAzoeoRc467HRh1mzuT17qeekmuJ3zFnL")] - [TestCase("oxXo8S2272KE1 lfppvxo3iwEJBrBmhlQVK1gqGc")] - [TestCase("dPBAtu681Ycy3A4NpJDH6kNVQooLxqtnsW1Umfiv")] - [TestCase("password - \"bdc435cb-93c4-4902-97ea-ca00568c3887.337\" yEnc")] - public void should_not_parse_crap(string title) - { - Parser.Parser.ParseTitle(title).Should().BeNull(); - ExceptionVerification.IgnoreWarns(); - } - - [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", "LOL")] - [TestCase("Castle 2009 S01E14 English HDTV XviD LOL", "LOL")] - [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER", "RUNNER")] - [TestCase("Punky.Brewster.S01.EXTRAS.DVDRip.XviD-RUNNER", "RUNNER")] - [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "C4TV")] - [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "OSiTV")] - [TestCase("The Office - S01E01 - Pilot [HTDV-480p]", "DRONE")] - [TestCase("The Office - S01E01 - Pilot [HTDV-720p]", "DRONE")] - [TestCase("The Office - S01E01 - Pilot [HTDV-1080p]", "DRONE")] - public void parse_releaseGroup(string title, string expected) - { - Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); - } - - [Test] - public void should_not_include_extension_in_releaseGroup() - { - const string path = @"C:\Test\Doctor.Who.2005.s01e01.internal.bdrip.x264-archivist.mkv"; - - Parser.Parser.ParsePath(path).ReleaseGroup.Should().Be("archivist"); - } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs new file mode 100644 index 000000000..d9f46806a --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Expansive; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class PathParserFixture : CoreTest + { + [TestCase(@"z:\tv shows\battlestar galactica (2003)\Season 3\S03E05 - Collaborators.mkv", 3, 5)] + [TestCase(@"z:\tv shows\modern marvels\Season 16\S16E03 - The Potato.mkv", 16, 3)] + [TestCase(@"z:\tv shows\robot chicken\Specials\S00E16 - Dear Consumer - SD TV.avi", 0, 16)] + [TestCase(@"D:\shares\TV Shows\Parks And Recreation\Season 2\S02E21 - 94 Meetings - 720p TV.mkv", 2, 21)] + [TestCase(@"D:\shares\TV Shows\Battlestar Galactica (2003)\Season 2\S02E21.avi", 2, 21)] + [TestCase("C:/Test/TV/Chuck.4x05.HDTV.XviD-LOL", 4, 5)] + [TestCase(@"P:\TV Shows\House\Season 6\S06E13 - 5 to 9 - 720p BluRay.mkv", 6, 13)] + [TestCase(@"S:\TV Drop\House - 10x11 - Title [SDTV]\1011 - Title.avi", 10, 11)] + [TestCase(@"/TV Drop/House - 10x11 - Title [SDTV]/1011 - Title.avi", 10, 11)] + [TestCase(@"S:\TV Drop\King of the Hill - 10x12 - 24 Hour Propane People [SDTV]\1012 - 24 Hour Propane People.avi", 10, 12)] + [TestCase(@"/TV Drop/King of the Hill - 10x12 - 24 Hour Propane People [SDTV]/1012 - 24 Hour Propane People.avi", 10, 12)] + [TestCase(@"S:\TV Drop\King of the Hill - 10x12 - 24 Hour Propane People [SDTV]\Hour Propane People.avi", 10, 12)] + [TestCase(@"/TV Drop/King of the Hill - 10x12 - 24 Hour Propane People [SDTV]/Hour Propane People.avi", 10, 12)] + [TestCase(@"E:\Downloads\tv\The.Big.Bang.Theory.S01E01.720p.HDTV\ajifajjjeaeaeqwer_eppj.avi", 1, 1)] + [TestCase(@"C:\Test\Unsorted\The.Big.Bang.Theory.S01E01.720p.HDTV\tbbt101.avi", 1, 1)] + [TestCase(@"C:\Test\Unsorted\Terminator.The.Sarah.Connor.Chronicles.S02E19.720p.BluRay.x264-SiNNERS-RP\ba27283b17c00d01193eacc02a8ba98eeb523a76.mkv", 2, 19)] + [TestCase(@"C:\Test\Unsorted\Terminator.The.Sarah.Connor.Chronicles.S02E18.720p.BluRay.x264-SiNNERS-RP\45a55debe3856da318cc35882ad07e43cd32fd15.mkv", 2, 18)] + public void should_parse_from_path(string path, int season, int episode) + { + var result = Parser.Parser.ParsePath(path); + result.EpisodeNumbers.Should().HaveCount(1); + result.SeasonNumber.Should().Be(season); + result.EpisodeNumbers[0].Should().Be(episode); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + + ExceptionVerification.IgnoreWarns(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs new file mode 100644 index 000000000..e5b3ba71f --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class ReleaseGroupParserFixture : CoreTest + { + [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", "LOL")] + [TestCase("Castle 2009 S01E14 English HDTV XviD LOL", "LOL")] + [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER", "RUNNER")] + [TestCase("Punky.Brewster.S01.EXTRAS.DVDRip.XviD-RUNNER", "RUNNER")] + [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "C4TV")] + [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "OSiTV")] + [TestCase("The Office - S01E01 - Pilot [HTDV-480p]", "DRONE")] + [TestCase("The Office - S01E01 - Pilot [HTDV-720p]", "DRONE")] + [TestCase("The Office - S01E01 - Pilot [HTDV-1080p]", "DRONE")] + public void should_parse_release_group(string title, string expected) + { + Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); + } + + [Test] + public void should_not_include_extension_in_release_roup() + { + const string path = @"C:\Test\Doctor.Who.2005.s01e01.internal.bdrip.x264-archivist.mkv"; + + Parser.Parser.ParsePath(path).ReleaseGroup.Should().Be("archivist"); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs new file mode 100644 index 000000000..2655f9d6e --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class SeasonParserFixture : CoreTest + { + [TestCase("30.Rock.Season.04.HDTV.XviD-DIMENSION", "30.Rock", 4)] + [TestCase("Parks.and.Recreation.S02.720p.x264-DIMENSION", "Parks.and.Recreation", 2)] + [TestCase("The.Office.US.S03.720p.x264-DIMENSION", "The.Office.US", 3)] + [TestCase(@"Sons.of.Anarchy.S03.720p.BluRay-CLUE\REWARD", "Sons.of.Anarchy", 3)] + [TestCase("Adventure Time S02 720p HDTV x264 CRON", "Adventure Time", 2)] + [TestCase("Sealab.2021.S04.iNTERNAL.DVDRip.XviD-VCDVaULT", "Sealab 2021", 4)] + [TestCase("Hawaii Five 0 S01 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1)] + [TestCase("30 Rock S03 WS PDTV XviD FUtV", "30 Rock", 3)] + [TestCase("The Office Season 4 WS PDTV XviD FUtV", "The Office", 4)] + [TestCase("Eureka Season 1 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] + [TestCase("The Office Season4 WS PDTV XviD FUtV", "The Office", 4)] + [TestCase("Eureka S 01 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] + [TestCase("Doctor Who Confidential Season 3", "Doctor Who Confidential", 3)] + public void should_parsefull_season_release(string postTitle, string title, int season) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.SeasonNumber.Should().Be(season); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); + result.EpisodeNumbers.Should().BeEmpty(); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeTrue(); + } + + [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER")] + [TestCase("Punky Brewster S01 EXTRAS DVDRip XviD RUNNER")] + [TestCase("Instant Star S03 EXTRAS DVDRip XviD OSiTV")] + public void should_parse_season_extras(string postTitle) + { + var result = Parser.Parser.ParseTitle(postTitle); + + result.Should().BeNull(); + } + + [TestCase("Lie.to.Me.S03.SUBPACK.DVDRip.XviD-REWARD")] + [TestCase("The.Middle.S02.SUBPACK.DVDRip.XviD-REWARD")] + [TestCase("CSI.S11.SUBPACK.DVDRip.XviD-REWARD")] + public void should_parse_season_subpack(string postTitle) + { + var result = Parser.Parser.ParseTitle(postTitle); + + result.Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs new file mode 100644 index 000000000..baca66d12 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + + [TestFixture] + public class SingleEpisodeParserFixture : CoreTest + { + [TestCase("Sonny.With.a.Chance.S02E15", "Sonny.With.a.Chance", 2, 15)] + [TestCase("Two.and.a.Half.Me.103.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 1, 3)] + [TestCase("Two.and.a.Half.Me.113.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 1, 13)] + [TestCase("Two.and.a.Half.Me.1013.720p.HDTV.X264-DIMENSION", "Two.and.a.Half.Me", 10, 13)] + [TestCase("Chuck.4x05.HDTV.XviD-LOL", "Chuck", 4, 5)] + [TestCase("The.Girls.Next.Door.S03E06.DVDRip.XviD-WiDE", "The.Girls.Next.Door", 3, 6)] + [TestCase("Degrassi.S10E27.WS.DSR.XviD-2HD", "Degrassi", 10, 27)] + [TestCase("Parenthood.2010.S02E14.HDTV.XviD-LOL", "Parenthood 2010", 2, 14)] + [TestCase("Hawaii Five 0 S01E19 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1, 19)] + [TestCase("The Event S01E14 A Message Back 720p WEB DL DD5 1 H264 SURFER", "The Event", 1, 14)] + [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] + [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] + [TestCase("Adventure.Inc.S03E19.DVDRip.XviD-OSiTV", "Adventure.Inc", 3, 19)] + [TestCase("S03E09 WS PDTV XviD FUtV", "", 3, 9)] + [TestCase("5x10 WS PDTV XviD FUtV", "", 5, 10)] + [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", "Castle 2009", 1, 14)] + [TestCase("Pride.and.Prejudice.1995.S03E20.HDTV.XviD-LOL", "Pride and Prejudice 1995", 3, 20)] + [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "The.Office", 3, 115)] + [TestCase(@"Parks and Recreation - S02E21 - 94 Meetings - 720p TV.mkv", "Parks and Recreation", 2, 21)] + [TestCase(@"24-7 Penguins-Capitals- Road to the NHL Winter Classic - S01E03 - Episode 3.mkv", "24-7 Penguins-Capitals- Road to the NHL Winter Classic", 1, 3)] + [TestCase("Adventure.Inc.S03E19.DVDRip.\"XviD\"-OSiTV", "Adventure.Inc", 3, 19)] + [TestCase("Hawaii Five-0 (2010) - 1x05 - Nalowale (Forgotten/Missing)", "Hawaii Five-0 (2010)", 1, 5)] + [TestCase("Hawaii Five-0 (2010) - 1x05 - Title", "Hawaii Five-0 (2010)", 1, 5)] + [TestCase("House - S06E13 - 5 to 9 [DVD]", "House", 6, 13)] + [TestCase("The Mentalist - S02E21 - 18-5-4", "The Mentalist", 2, 21)] + [TestCase("Breaking.In.S01E07.21.0.Jump.Street.720p.WEB-DL.DD5.1.h.264-KiNGS", "Breaking In", 1, 7)] + [TestCase("CSI.525", "CSI", 5, 25)] + [TestCase("King of the Hill - 10x12 - 24 Hour Propane People [SDTV]", "King of the Hill", 10, 12)] + [TestCase("Brew Masters S01E06 3 Beers For Batali DVDRip XviD SPRiNTER", "Brew Masters", 1, 6)] + [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part01 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 1)] + [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part 02 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 2)] + [TestCase("24-7 Flyers-Rangers- Road to the NHL Winter Classic - S01E01 - Part 1", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 1)] + [TestCase("The.Kennedys.Part.2.DSR.XviD-SYS", "The Kennedys", 1, 2)] + [TestCase("the-pacific-e07-720p", "The Pacific", 1, 7)] + [TestCase("S6E02-Unwrapped-(Playing With Food) - [DarkData]", "", 6, 2)] + [TestCase("S06E03-Unwrapped-(Number Ones Unwrapped) - [DarkData]", "", 6, 3)] + [TestCase("The Mentalist S02E21 18 5 4 720p WEB DL DD5 1 h 264 EbP", "The Mentalist", 2, 21)] + [TestCase("01x04 - Halloween, Part 1 - 720p WEB-DL", "", 1, 4)] + [TestCase("extras.s03.e05.ws.dvdrip.xvid-m00tv", "Extras", 3, 5)] + [TestCase("castle.2009.416.hdtv-lol", "Castle 2009", 4, 16)] + [TestCase("hawaii.five-0.2010.217.hdtv-lol", "Hawaii Five-0 (2010)", 2, 17)] + [TestCase("Looney Tunes - S1936E18 - I Love to Singa", "Looney Tunes", 1936, 18)] + [TestCase("American_Dad!_-_7x6_-_The_Scarlett_Getter_[SDTV]", "American Dad!", 7, 6)] + [TestCase("Falling_Skies_-_1x1_-_Live_and_Learn_[HDTV-720p]", "Falling Skies", 1, 1)] + [TestCase("Top Gear - 07x03 - 2005.11.70", "Top Gear", 7, 3)] + [TestCase("Hatfields and McCoys 2012 Part 1 REPACK 720p HDTV x264 2HD", "Hatfields and McCoys 2012", 1, 1)] + [TestCase("Glee.S04E09.Swan.Song.1080p.WEB-DL.DD5.1.H.264-ECI", "Glee", 4, 9)] + [TestCase("S08E20 50-50 Carla [DVD]", "", 8, 20)] + [TestCase("Cheers S08E20 50-50 Carla [DVD]", "Cheers", 8, 20)] + [TestCase("S02E10 6-50 to SLC [SDTV]", "", 2, 10)] + [TestCase("Franklin & Bash S02E10 6-50 to SLC [SDTV]", "Franklin & Bash", 2, 10)] + [TestCase("The_Big_Bang_Theory_-_6x12_-_The_Egg_Salad_Equivalency_[HDTV-720p]", "The Big Bang Theory", 6, 12)] + [TestCase("Top_Gear.19x06.720p_HDTV_x264-FoV", "Top Gear", 19, 6)] + [TestCase("Portlandia.S03E10.Alexandra.720p.WEB-DL.AAC2.0.H.264-CROM.mkv", "Portlandia", 3, 10)] + [TestCase("(Game of Thrones s03 e - \"Game of Thrones Season 3 Episode 10\"", "Game of Thrones", 3, 10)] + [TestCase("House.Hunters.International.S05E607.720p.hdtv.x264", "House.Hunters.International", 5, 607)] + [TestCase("Adventure.Time.With.Finn.And.Jake.S01E20.720p.BluRay.x264-DEiMOS", "Adventure.Time.With.Finn.And.Jake", 1, 20)] + [TestCase("Hostages.S01E04.2-45.PM.[HDTV-720p].mkv", "Hostages", 1, 4)] + [TestCase("S01E04", "", 1, 4)] + [TestCase("1x04", "", 1, 4)] + [TestCase("10.Things.You.Dont.Know.About.S02E04.Prohibition.HDTV.XviD-AFG", "10 Things You Dont Know About", 2, 4)] + [TestCase("30 Rock - S01E01 - Pilot.avi", "30 Rock", 1, 1)] + [TestCase("666 Park Avenue - S01E01", "666 Park Avenue", 1, 1)] + [TestCase("Warehouse 13 - S01E01", "Warehouse 13", 1, 1)] + [TestCase("Don't Trust The B---- in Apartment 23.S01E01", "Don't Trust The B---- in Apartment 23", 1, 1)] + [TestCase("Warehouse.13.S01E01", "Warehouse.13", 1, 1)] + [TestCase("Dont.Trust.The.B----.in.Apartment.23.S01E01", "Dont.Trust.The.B----.in.Apartment.23", 1, 1)] + [TestCase("24 S01E01", "24", 1, 1)] + [TestCase("24.S01E01", "24", 1, 1)] + [TestCase("Homeland - 2x12 - The Choice [HDTV-1080p].mkv", "Homeland", 2, 12)] + [TestCase("Homeland - 2x4 - New Car Smell [HDTV-1080p].mkv", "Homeland", 2, 4)] + public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Should().NotBeNull(); + result.EpisodeNumbers.Should().HaveCount(1); + result.SeasonNumber.Should().Be(seasonNumber); + result.EpisodeNumbers.First().Should().Be(episodeNumber); + result.SeriesTitle.Should().Be(title.CleanSeriesTitle()); + result.AbsoluteEpisodeNumbers.Should().BeEmpty(); + result.FullSeason.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs index 8b7c620bb..85f0a4fc0 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs @@ -57,7 +57,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport try { - var parsedEpisode = _parsingService.GetEpisodes(file, series, sceneSource); + var parsedEpisode = _parsingService.GetLocalEpisode(file, series, sceneSource); if (parsedEpisode != null) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs index 735b80400..a897a1582 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs @@ -62,7 +62,7 @@ namespace NzbDrone.Core.MediaFiles continue; } -// var localEpsiode = _parsingService.GetEpisodes(episodeFile.Path, series); +// var localEpsiode = _parsingService.GetLocalEpisode(episodeFile.Path, series); // // if (localEpsiode == null || episodes.Count != localEpsiode.Episodes.Count) // { diff --git a/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs index 8331c03a2..aca05235a 100644 --- a/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs +++ b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Metadata if (metadata.Type == MetadataType.EpisodeImage || metadata.Type == MetadataType.EpisodeMetadata) { - var localEpisode = _parsingService.GetEpisodes(possibleMetadataFile, message.Series, false); + var localEpisode = _parsingService.GetLocalEpisode(possibleMetadataFile, message.Series, false); if (localEpisode == null) { diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 564232ceb..db3b60d73 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -83,7 +83,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Supports 1103/1113 naming - new Regex(@"^(?<title>.+?)?(?:\W?(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+)))+(\W+|_|$)(?!\\)", + new Regex(@"^(?<title>.+?)?(?:\W(?<season>(?<!\d+|\(|\[|e|x)\d{2})(?<episode>(?<!e|x)\d{2}(?!p|i|\d+|\)|\]|\W\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //4-digit episode number @@ -121,7 +121,6 @@ namespace NzbDrone.Core.Parser private static readonly Regex SpecialEpisodeWordRegex = new Regex(@"\b(part|special|edition)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); - public static ParsedEpisodeInfo ParsePath(string path) { var fileInfo = new FileInfo(path); diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index a0ac387ef..e51a9c855 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using NLog; using NzbDrone.Common; @@ -13,95 +14,33 @@ namespace NzbDrone.Core.Parser { public interface IParsingService { - ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null); - ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); - LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource); + LocalEpisode GetLocalEpisode(string filename, Series series, bool sceneSource); Series GetSeries(string title); RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvRageId, SearchCriteriaBase searchCriteria = null); List<Episode> GetEpisodes(ParsedEpisodeInfo parsedEpisodeInfo, Series series, bool sceneSource, SearchCriteriaBase searchCriteria = null); + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null); + ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series); } public class ParsingService : IParsingService { private readonly IEpisodeService _episodeService; private readonly ISeriesService _seriesService; - private readonly IDiskProvider _diskProvider; private readonly ISceneMappingService _sceneMappingService; private readonly Logger _logger; public ParsingService(IEpisodeService episodeService, ISeriesService seriesService, - IDiskProvider diskProvider, ISceneMappingService sceneMappingService, Logger logger) { _episodeService = episodeService; _seriesService = seriesService; - _diskProvider = diskProvider; _sceneMappingService = sceneMappingService; _logger = logger; } - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null) - { - if (searchCriteria != null) - { - var tvdbId = _sceneMappingService.GetTvDbId(title); - if (tvdbId.HasValue) - { - if (searchCriteria.Series.TvdbId == tvdbId) - { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); - } - } - - if (tvRageId == searchCriteria.Series.TvRageId) - { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); - } - } - - var series = _seriesService.FindByTitleInexact(title); - if (series == null && tvRageId > 0) - { - series = _seriesService.FindByTvRageId(tvRageId); - } - - if (series == null) - { - _logger.Trace("No matching series {0}", title); - return null; - } - - return ParseSpecialEpisodeTitle(title, series); - } - - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) - { - // find special episode in series season 0 - var episode = _episodeService.FindEpisodeByName(series.Id, 0, title); - if (episode != null) - { - // create parsed info from tv episode - var info = new ParsedEpisodeInfo(); - info.SeriesTitle = series.Title; - info.SeriesTitleInfo = new SeriesTitleInfo(); - info.SeriesTitleInfo.Title = info.SeriesTitle; - info.SeasonNumber = episode.SeasonNumber; - info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; - info.FullSeason = false; - info.Quality = QualityParser.ParseQuality(title); - info.ReleaseGroup = Parser.ParseReleaseGroup(title); - - _logger.Info("Found special episode {0} for title '{1}'", info, title); - return info; - } - - return null; - } - - - public LocalEpisode GetEpisodes(string filename, Series series, bool sceneSource) + public LocalEpisode GetLocalEpisode(string filename, Series series, bool sceneSource) { var parsedEpisodeInfo = Parser.ParsePath(filename); @@ -109,7 +48,7 @@ namespace NzbDrone.Core.Parser if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode()) { // try to parse as a special episode - var title = System.IO.Path.GetFileNameWithoutExtension(filename); + var title = Path.GetFileNameWithoutExtension(filename); var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); if (specialEpisodeInfo != null) { @@ -286,6 +225,64 @@ namespace NzbDrone.Core.Parser return result; } + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, int tvRageId, SearchCriteriaBase searchCriteria = null) + { + if (searchCriteria != null) + { + var tvdbId = _sceneMappingService.GetTvDbId(title); + if (tvdbId.HasValue) + { + if (searchCriteria.Series.TvdbId == tvdbId) + { + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + } + } + + if (tvRageId == searchCriteria.Series.TvRageId) + { + return ParseSpecialEpisodeTitle(title, searchCriteria.Series); + } + } + + var series = _seriesService.FindByTitleInexact(title); + if (series == null && tvRageId > 0) + { + series = _seriesService.FindByTvRageId(tvRageId); + } + + if (series == null) + { + _logger.Trace("No matching series {0}", title); + return null; + } + + return ParseSpecialEpisodeTitle(title, series); + } + + public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) + { + // find special episode in series season 0 + var episode = _episodeService.FindEpisodeByName(series.Id, 0, title); + if (episode != null) + { + // create parsed info from tv episode + var info = new ParsedEpisodeInfo(); + info.SeriesTitle = series.Title; + info.SeriesTitleInfo = new SeriesTitleInfo(); + info.SeriesTitleInfo.Title = info.SeriesTitle; + info.SeasonNumber = episode.SeasonNumber; + info.EpisodeNumbers = new int[1] { episode.EpisodeNumber }; + info.FullSeason = false; + info.Quality = QualityParser.ParseQuality(title); + info.ReleaseGroup = Parser.ParseReleaseGroup(title); + + _logger.Info("Found special episode {0} for title '{1}'", info, title); + return info; + } + + return null; + } + private Series GetSeries(ParsedEpisodeInfo parsedEpisodeInfo, int tvRageId, SearchCriteriaBase searchCriteria) { var tvdbId = _sceneMappingService.GetTvDbId(parsedEpisodeInfo.SeriesTitle); From 6525fe9a6710bbd349dd724dcd1ba83033d2370a Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 20 Feb 2014 18:16:26 -0800 Subject: [PATCH 28/42] Fixed: Better support for adding series that contain special characters --- .../MetadataSourceTests/TraktProxyFixture.cs | 1 + src/NzbDrone.Core/MetadataSource/TraktProxy.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs index 4577fe5c9..40ab8f810 100644 --- a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.Test.MetadataSourceTests [TestCase("Franklin & Bash", "Franklin & Bash")] [TestCase("Mr. D", "Mr. D")] [TestCase("Rob & Big", "Rob and Big")] + [TestCase("M*A*S*H", "M*A*S*H")] public void successful_search(string title, string expected) { var result = Subject.SearchForNewSeries(title); diff --git a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs index a731a0c86..ea13eca43 100644 --- a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs @@ -4,13 +4,12 @@ using System.IO; using System.Linq; using System.Net; using System.Text.RegularExpressions; +using System.Web; 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; @@ -20,7 +19,8 @@ namespace NzbDrone.Core.MetadataSource public class TraktProxy : ISearchForNewSeries, IProvideSeriesInfo { private readonly Logger _logger; - private static readonly Regex InvalidSearchCharRegex = new Regex(@"[^a-zA-Z0-9\s-\.]", RegexOptions.Compiled); + private static readonly Regex CollapseSpaceRegex = new Regex(@"\s+", RegexOptions.Compiled); + private static readonly Regex InvalidSearchCharRegex = new Regex(@"(?:\*)", RegexOptions.Compiled); public TraktProxy(Logger logger) { @@ -166,9 +166,9 @@ namespace NzbDrone.Core.MetadataSource private static string GetSearchTerm(string phrase) { phrase = phrase.RemoveAccent().ToLower(); - phrase = phrase.Replace("&", "and"); - phrase = InvalidSearchCharRegex.Replace(phrase, string.Empty); - phrase = phrase.CleanSpaces().Replace(" ", "+"); + phrase = InvalidSearchCharRegex.Replace(phrase, ""); + phrase = CollapseSpaceRegex.Replace(phrase, " ").Trim().ToLower(); + phrase = HttpUtility.UrlEncode(phrase); return phrase; } From 36387dd13f682d35f1c3779ebb12f3b8f52414ba Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 20 Feb 2014 18:30:30 -0800 Subject: [PATCH 29/42] Fixed: Prevent queue errors from filling up UI with errors --- src/NzbDrone.Core/Queue/QueueService.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index ce81a1bfd..963a2a747 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using NLog; using NzbDrone.Core.Download; @@ -30,9 +31,17 @@ namespace NzbDrone.Core.Queue return new List<Queue>(); } - var queueItems = downloadClient.GetQueue(); + try + { + var queueItems = downloadClient.GetQueue(); - return MapQueue(queueItems); + return MapQueue(queueItems); + } + catch (Exception ex) + { + _logger.Error("Error getting queue from download client: " + downloadClient.ToString(), ex); + return new List<Queue>(); + } } private List<Queue> MapQueue(IEnumerable<QueueItem> queueItems) From 0a837be9ff41d4bfa0bc2b5d8d814f1de3c983bd Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Thu, 20 Feb 2014 23:03:36 -0800 Subject: [PATCH 30/42] Many (update/insert/delete) DB operations now use transactions Fixed: Improved series/episode info refresh speed --- .../DataAugmentation/Xem/XemService.cs | 1 - .../Datastore/BasicRepository.cs | 46 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs index b7165a7f0..fc0146c7b 100644 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs +++ b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Web.UI.WebControls; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Core.Messaging.Events; diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index f4125c0f2..6f6ed553e 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Security.Cryptography.X509Certificates; using Marr.Data; using Marr.Data.QGen; using NzbDrone.Core.Datastore.Events; @@ -142,23 +143,44 @@ namespace NzbDrone.Core.Datastore public void InsertMany(IList<TModel> models) { - foreach (var model in models) + using (var unitOfWork = new UnitOfWork(() => DataMapper)) { - Insert(model); + unitOfWork.BeginTransaction(); + + foreach (var model in models) + { + unitOfWork.DB.Insert(model); + } + + unitOfWork.Commit(); } } public void UpdateMany(IList<TModel> models) { - foreach (var model in models) + using (var unitOfWork = new UnitOfWork(() => DataMapper)) { - Update(model); + unitOfWork.BeginTransaction(); + + foreach (var model in models) + { + var localModel = model; + + if (model.Id == 0) + { + throw new InvalidOperationException("Can't update model with ID 0"); + } + + unitOfWork.DB.Update(model, c => c.Id == localModel.Id); + } + + unitOfWork.Commit(); } } public void DeleteMany(List<TModel> models) { - models.ForEach(Delete); + DeleteMany(models.Select(m => m.Id)); } public TModel Upsert(TModel model) @@ -179,7 +201,19 @@ namespace NzbDrone.Core.Datastore public void DeleteMany(IEnumerable<int> ids) { - ids.ToList().ForEach(Delete); + using (var unitOfWork = new UnitOfWork(() => DataMapper)) + { + unitOfWork.BeginTransaction(); + + foreach (var id in ids) + { + var localId = id; + + unitOfWork.DB.Delete<TModel>(c => c.Id == localId); + } + + unitOfWork.Commit(); + } } public void Purge() From 78dacf68503b87ca114aade8b4eb77f880bb6d63 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 00:53:29 -0800 Subject: [PATCH 31/42] Fixed: Performance issues when processing results from indexers (RSS/Search) --- .../Configuration/ConfigRepository.cs | 4 +-- .../Datastore/BasicRepository.cs | 3 +-- .../DecisionEngine/DownloadDecisionMaker.cs | 5 ++-- .../QualityUpgradableSpecification.cs | 1 - .../AcceptableSizeSpecification.cs | 23 ++++++---------- src/NzbDrone.Core/Jobs/JobRepository.cs | 2 +- .../Metadata/Files/MetadataFileRepository.cs | 2 +- .../Qualities/QualityDefinitionRepository.cs | 2 +- src/NzbDrone.Core/Tv/EpisodeRepository.cs | 27 ++++++++++++++----- src/NzbDrone.Core/Tv/EpisodeService.cs | 2 -- src/NzbDrone.Core/Tv/SeriesRepository.cs | 18 ++++++++----- src/NzbDrone.Core/Tv/SeriesService.cs | 2 -- 12 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/NzbDrone.Core/Configuration/ConfigRepository.cs b/src/NzbDrone.Core/Configuration/ConfigRepository.cs index 0c21b2793..7aef7d26f 100644 --- a/src/NzbDrone.Core/Configuration/ConfigRepository.cs +++ b/src/NzbDrone.Core/Configuration/ConfigRepository.cs @@ -21,9 +21,7 @@ namespace NzbDrone.Core.Configuration public Config Get(string key) { - return Query.SingleOrDefault(c => c.Key == key); + return Query.Where(c => c.Key == key).SingleOrDefault(); } - - } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index 6f6ed553e..74354e28f 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using System.Security.Cryptography.X509Certificates; using Marr.Data; using Marr.Data.QGen; using NzbDrone.Core.Datastore.Events; @@ -74,7 +73,7 @@ namespace NzbDrone.Core.Datastore public TModel Get(int id) { - var model = DataMapper.Query<TModel>().SingleOrDefault(c => c.Id == id); + var model = Query.Where(c => c.Id == id).SingleOrDefault(); if (model == null) { diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index b8b77c5a3..bae6f3d60 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.DecisionEngine private readonly IParsingService _parsingService; private readonly Logger _logger; - public DownloadDecisionMaker(IEnumerable<IRejectWithReason> specifications, IParsingService parsingService, Logger logger) + public DownloadDecisionMaker(IEnumerable<IDecisionEngineSpecification> specifications, IParsingService parsingService, Logger logger) { _specifications = specifications; _parsingService = parsingService; @@ -100,13 +100,12 @@ namespace NzbDrone.Core.DecisionEngine yield return decision; } } - } private DownloadDecision GetDecisionForReport(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria = null) { var reasons = _specifications.Select(c => EvaluateSpec(c, remoteEpisode, searchCriteria)) - .Where(c => !string.IsNullOrWhiteSpace(c)); + .Where(c => !string.IsNullOrWhiteSpace(c)); return new DownloadDecision(remoteEpisode, reasons.ToArray()); } diff --git a/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs index 3330be7ab..aae2a6d6b 100644 --- a/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs @@ -1,6 +1,5 @@ using NLog; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.DecisionEngine { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs index 8069e8201..8cd175703 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -27,7 +27,6 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public virtual bool IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { - _logger.Trace("Beginning size check for: {0}", subject); var quality = subject.ParsedEpisodeInfo.Quality.Quality; @@ -45,21 +44,17 @@ namespace NzbDrone.Core.DecisionEngine.Specifications } var qualityDefinition = _qualityDefinitionService.Get(quality); + var minSize = qualityDefinition.MinSize.Megabytes(); + //Multiply maxSize by Series.Runtime + minSize = minSize * subject.Series.Runtime * subject.Episodes.Count; + + //If the parsed size is smaller than minSize we don't want it + if (subject.Release.Size < minSize) { - var minSize = qualityDefinition.MinSize.Megabytes(); - - //Multiply maxSize by Series.Runtime - minSize = minSize * subject.Series.Runtime * subject.Episodes.Count; - - //If the parsed size is smaller than minSize we don't want it - if (subject.Release.Size < minSize) - { - _logger.Trace("Item: {0}, Size: {1} is smaller than minimum allowed size ({2}), rejecting.", subject, subject.Release.Size, minSize); - return false; - } + _logger.Trace("Item: {0}, Size: {1} is smaller than minimum allowed size ({2}), rejecting.", subject, subject.Release.Size, minSize); + return false; } - if (qualityDefinition.MaxSize == 0) { _logger.Trace("Max size is 0 (unlimited) - skipping check."); @@ -84,10 +79,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications return false; } } - _logger.Trace("Item: {0}, meets size constraints.", subject); return true; } - } } diff --git a/src/NzbDrone.Core/Jobs/JobRepository.cs b/src/NzbDrone.Core/Jobs/JobRepository.cs index 8e2aa5858..b09e598b4 100644 --- a/src/NzbDrone.Core/Jobs/JobRepository.cs +++ b/src/NzbDrone.Core/Jobs/JobRepository.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Jobs public ScheduledTask GetDefinition(Type type) { - return Query.Single(c => c.TypeName == type.FullName); + return Query.Where(c => c.TypeName == type.FullName).Single(); } public void SetLastExecutionTime(int id, DateTime executionTime) diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs index 38889fbb3..64f7b871e 100644 --- a/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileRepository.cs @@ -56,7 +56,7 @@ namespace NzbDrone.Core.Metadata.Files public MetadataFile FindByPath(string path) { - return Query.SingleOrDefault(c => c.RelativePath == path); + return Query.Where(c => c.RelativePath == path).SingleOrDefault(); } } } diff --git a/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs b/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs index f73fb2de8..0b669f331 100644 --- a/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs +++ b/src/NzbDrone.Core/Qualities/QualityDefinitionRepository.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Qualities { try { - return Query.Single(q => (int)q.Quality == qualityId); + return Query.Where(q => (int) q.Quality == qualityId).Single(); } catch (InvalidOperationException e) { diff --git a/src/NzbDrone.Core/Tv/EpisodeRepository.cs b/src/NzbDrone.Core/Tv/EpisodeRepository.cs index 58099d8c3..dcbe99e1e 100644 --- a/src/NzbDrone.Core/Tv/EpisodeRepository.cs +++ b/src/NzbDrone.Core/Tv/EpisodeRepository.cs @@ -37,22 +37,31 @@ namespace NzbDrone.Core.Tv public Episode Find(int seriesId, int season, int episodeNumber) { - return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.SeasonNumber == season && s.EpisodeNumber == episodeNumber); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.SeasonNumber == season) + .AndWhere(s => s.EpisodeNumber == episodeNumber) + .SingleOrDefault(); } public Episode Find(int seriesId, int absoluteEpisodeNumber) { - return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.AbsoluteEpisodeNumber == absoluteEpisodeNumber); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.AbsoluteEpisodeNumber == absoluteEpisodeNumber) + .SingleOrDefault(); } public Episode Get(int seriesId, String date) { - return Query.Single(s => s.SeriesId == seriesId && s.AirDate == date); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.AirDate == date) + .Single(); } public Episode Find(int seriesId, String date) { - return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.AirDate == date); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.AirDate == date) + .SingleOrDefault(); } public List<Episode> GetEpisodes(int seriesId) @@ -62,7 +71,9 @@ namespace NzbDrone.Core.Tv public List<Episode> GetEpisodes(int seriesId, int seasonNumber) { - return Query.Where(s => s.SeriesId == seriesId && s.SeasonNumber == seasonNumber).ToList(); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.SeasonNumber == seasonNumber) + .ToList(); } public List<Episode> GetEpisodeByFileId(int fileId) @@ -88,10 +99,12 @@ namespace NzbDrone.Core.Tv public Episode FindEpisodeBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber) { - return Query.SingleOrDefault(s => s.SeriesId == seriesId && s.SceneSeasonNumber == seasonNumber && s.SceneEpisodeNumber == episodeNumber); + return Query.Where(s => s.SeriesId == seriesId) + .AndWhere(s => s.SceneSeasonNumber == seasonNumber) + .AndWhere(s => s.SceneEpisodeNumber == episodeNumber) + .SingleOrDefault(); } - public List<Episode> EpisodesBetweenDates(DateTime startDate, DateTime endDate) { return Query.Join<Episode, Series>(JoinType.Inner, e => e.Series, (e, s) => e.SeriesId == s.Id) diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs index 5d8064ec5..df27033a9 100644 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ b/src/NzbDrone.Core/Tv/EpisodeService.cs @@ -103,7 +103,6 @@ namespace NzbDrone.Core.Tv }); } - public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec) { var episodeResult = _episodeRepository.EpisodesWithoutFiles(pagingSpec, false); @@ -139,7 +138,6 @@ namespace NzbDrone.Core.Tv var episode = GetEpisode(episodeId); var seasonEpisodes = GetEpisodesBySeason(episode.SeriesId, episode.SeasonNumber); - //Ensure that this is either the first episode //or is the last episode in a season that has 10 or more episodes if (seasonEpisodes.First().EpisodeNumber == episode.EpisodeNumber || (seasonEpisodes.Count() >= 10 && seasonEpisodes.Last().EpisodeNumber == episode.EpisodeNumber)) diff --git a/src/NzbDrone.Core/Tv/SeriesRepository.cs b/src/NzbDrone.Core/Tv/SeriesRepository.cs index dbdc1c191..1819ac081 100644 --- a/src/NzbDrone.Core/Tv/SeriesRepository.cs +++ b/src/NzbDrone.Core/Tv/SeriesRepository.cs @@ -25,28 +25,34 @@ namespace NzbDrone.Core.Tv public bool SeriesPathExists(string path) { - return Query.Any(c => c.Path == path); + return Query.Where(c => c.Path == path).Any(); } public Series FindByTitle(string cleanTitle) { - return Query.SingleOrDefault(s => s.CleanTitle.Equals(cleanTitle, StringComparison.InvariantCultureIgnoreCase)); + cleanTitle = cleanTitle.ToLowerInvariant(); + + return Query.Where(s => s.CleanTitle == cleanTitle) + .SingleOrDefault(); } public Series FindByTitle(string cleanTitle, int year) { - return Query.SingleOrDefault(s => s.CleanTitle.Equals(cleanTitle, StringComparison.InvariantCultureIgnoreCase) && - s.Year == year); + cleanTitle = cleanTitle.ToLowerInvariant(); + + return Query.Where(s => s.CleanTitle == cleanTitle) + .AndWhere(s => s.Year == year) + .SingleOrDefault(); } public Series FindByTvdbId(int tvdbId) { - return Query.SingleOrDefault(s => s.TvdbId.Equals(tvdbId)); + return Query.Where(s => s.TvdbId == tvdbId).SingleOrDefault(); } public Series FindByTvRageId(int tvRageId) { - return Query.SingleOrDefault(s => s.TvRageId.Equals(tvRageId)); + return Query.Where(s => s.TvRageId == tvRageId).SingleOrDefault(); } public void SetSeriesType(int seriesId, SeriesTypes seriesType) diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index 2dcd44283..eeaa24054 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -98,8 +98,6 @@ namespace NzbDrone.Core.Tv return FindByTvdbId(tvdbId.Value); } - var clean = Parser.Parser.CleanSeriesTitle(title); - return _seriesRepository.FindByTitle(Parser.Parser.CleanSeriesTitle(title)); } From 515901d1be1704dd4487aa54d06b06fbee583469 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 00:59:05 -0800 Subject: [PATCH 32/42] Fixed broken decision engine tests --- .../DecisionEngineTests/DownloadDecisionMakerFixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs index d250c4d98..23b662982 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenSpecifications(params Mock<IDecisionEngineSpecification>[] mocks) { - Mocker.SetConstant<IEnumerable<IRejectWithReason>>(mocks.Select(c => c.Object)); + Mocker.SetConstant<IEnumerable<IDecisionEngineSpecification>>(mocks.Select(c => c.Object)); } [Test] From 0d14a2df9e01173ef7de8dbd2e09d5f8bb7530d8 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 01:09:44 -0800 Subject: [PATCH 33/42] Changed trakt test to use Castle instead of Dexter --- src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs index 40ab8f810..f2fe140d4 100644 --- a/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSourceTests/TraktProxyFixture.cs @@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.MetadataSourceTests } [TestCase(75978)] - [TestCase(79349)] + [TestCase(83462)] public void should_be_able_to_get_series_detail(int tvdbId) { var details = Subject.GetSeriesInfo(tvdbId); From acee943d470069dd07207c444fdb25d1c90ed56f Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 10:44:42 -0800 Subject: [PATCH 34/42] strip some additional special characters when searching trakt --- src/NzbDrone.Core/MetadataSource/TraktProxy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs index ea13eca43..86d7cc94f 100644 --- a/src/NzbDrone.Core/MetadataSource/TraktProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/TraktProxy.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.MetadataSource { private readonly Logger _logger; private static readonly Regex CollapseSpaceRegex = new Regex(@"\s+", RegexOptions.Compiled); - private static readonly Regex InvalidSearchCharRegex = new Regex(@"(?:\*)", RegexOptions.Compiled); + private static readonly Regex InvalidSearchCharRegex = new Regex(@"(?:\*|\(|\)|'|!)", RegexOptions.Compiled); public TraktProxy(Logger logger) { From 259c408b67295ee798df938c913537c19e61cf38 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 10:57:48 -0800 Subject: [PATCH 35/42] Added size information when Size spec rejects import --- .../EpisodeImport/Specifications/FreeSpaceSpecification.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs index 282c954a4..32d6b9054 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs @@ -41,7 +41,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications if (freeSpace < localEpisode.Size + 100.Megabytes()) { - _logger.Warn("Not enough free space to import: {0}", localEpisode); + _logger.Warn("Not enough free space ({0}) to import: {1} ({2})", freeSpace, localEpisode, localEpisode.Size); return false; } } From aed76afa523702b3a2d18a8ce52a61b5dc185204 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 11:25:17 -0800 Subject: [PATCH 36/42] Changed some special characters to more sane values in file names --- src/NzbDrone.Core/Organizer/FileNameBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 1ad4f3ec4..00eeb5e40 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -247,7 +247,7 @@ namespace NzbDrone.Core.Organizer { string result = name; string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; - string[] goodCharacters = { "+", "+", "{", "}", "!", "@", "-", "#", "`" }; + string[] goodCharacters = { "-", "-", "", "", "!", "-", "-", "", "" }; for (int i = 0; i < badCharacters.Length; i++) result = result.Replace(badCharacters[i], goodCharacters[i]); From e6e2f85d71997201f7b60569e9e377a58862b43c Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 11:30:44 -0800 Subject: [PATCH 37/42] Reverted some special characters --- src/NzbDrone.Core/Organizer/FileNameBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 00eeb5e40..e2ebcdc31 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -247,7 +247,7 @@ namespace NzbDrone.Core.Organizer { string result = name; string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; - string[] goodCharacters = { "-", "-", "", "", "!", "-", "-", "", "" }; + string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" }; for (int i = 0; i < badCharacters.Length; i++) result = result.Replace(badCharacters[i], goodCharacters[i]); From 7279b58a5819fe786c7e5d290be6c40edeb34f98 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 14:03:23 -0800 Subject: [PATCH 38/42] Metadata cleanup and fixes Fixed: Episode metadata will be renamed to match episode file names on refresh Fixed: Episode metadata is renamed when episode file is renamed --- .../Config/MediaManagementConfigModule.cs | 5 +- .../MetaData/Consumers/Fake/Fake.cs | 16 +- .../MetaData/Consumers/Xbmc/XbmcMetadata.cs | 160 ++++++++++++------ .../MetaData/Files/MetadataFilesUpdated.cs | 15 ++ src/NzbDrone.Core/MetaData/IMetadata.cs | 4 +- src/NzbDrone.Core/MetaData/MetadataService.cs | 37 +++- .../Metadata/ExistingMetadataService.cs | 12 +- .../Metadata/Files/MetadataFileService.cs | 16 +- .../Metadata/Files/MetadataFileUpdated.cs | 14 -- src/NzbDrone.Core/Metadata/MetadataBase.cs | 4 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 2 +- 11 files changed, 185 insertions(+), 100 deletions(-) create mode 100644 src/NzbDrone.Core/MetaData/Files/MetadataFilesUpdated.cs delete mode 100644 src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs index 6c8b8c65e..2ed63fc2d 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using System; +using FluentValidation; using NzbDrone.Core.Configuration; using NzbDrone.Core.Validation.Paths; @@ -11,7 +12,7 @@ namespace NzbDrone.Api.Config { SharedValidator.RuleFor(c => c.FileChmod).NotEmpty(); SharedValidator.RuleFor(c => c.FolderChmod).NotEmpty(); - SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator); + SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !String.IsNullOrWhiteSpace(c.RecycleBin)); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs b/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs index 2eeb539ac..6ae86b093 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Fake/Fake.cs @@ -1,10 +1,5 @@ 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; @@ -16,19 +11,12 @@ namespace NzbDrone.Core.Metadata.Consumers.Fake { public class FakeMetadata : MetadataBase<FakeMetadataSettings> { - 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<MetadataFile> existingMetadataFiles) + public override void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles) { throw new NotImplementedException(); } @@ -38,7 +26,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Fake throw new NotImplementedException(); } - public override void AfterRename(Series series) + public override void AfterRename(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles) { throw new NotImplementedException(); } diff --git a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs index 9874df12f..d2bac9d22 100644 --- a/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/MetaData/Consumers/Xbmc/XbmcMetadata.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.Remoting.Messaging; using System.Text; using System.Text.RegularExpressions; using System.Xml; @@ -53,8 +54,10 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc private static readonly Regex SeasonImagesRegex = new Regex(@"^season(?<season>\d{2,}|-all|-specials)-(?<type>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<MetadataFile> existingMetadataFiles) + public override void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles) { + var metadataFiles = new List<MetadataFile>(); + if (!_diskProvider.FolderExists(series.Path)) { _logger.Info("Series folder does not exist, skipping metadata creation"); @@ -63,26 +66,24 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc if (Settings.SeriesMetadata) { - WriteTvShowNfo(series, existingMetadataFiles); + metadataFiles.Add(WriteTvShowNfo(series, existingMetadataFiles)); } if (Settings.SeriesImages) { - WriteSeriesImages(series, existingMetadataFiles); + metadataFiles.AddRange(WriteSeriesImages(series, existingMetadataFiles)); } if (Settings.SeasonImages) { - WriteSeasonImages(series, existingMetadataFiles); + metadataFiles.AddRange(WriteSeasonImages(series, existingMetadataFiles)); } - var episodeFiles = GetEpisodeFiles(series.Id); - foreach (var episodeFile in episodeFiles) { if (Settings.EpisodeMetadata) { - WriteEpisodeNfo(series, episodeFile, existingMetadataFiles); + metadataFiles.Add(WriteEpisodeNfo(series, episodeFile, existingMetadataFiles)); } } @@ -90,54 +91,83 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc { if (Settings.EpisodeImages) { - WriteEpisodeImages(series, episodeFile, existingMetadataFiles); + var metadataFile = WriteEpisodeImages(series, episodeFile, existingMetadataFiles); + + if (metadataFile != null) + { + metadataFiles.Add(metadataFile); + } } } + + _eventAggregator.PublishEvent(new MetadataFilesUpdated(metadataFiles)); } public override void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload) { + var metadataFiles = new List<MetadataFile>(); + if (Settings.EpisodeMetadata) { - WriteEpisodeNfo(series, episodeFile, new List<MetadataFile>()); + metadataFiles.Add(WriteEpisodeNfo(series, episodeFile, new List<MetadataFile>())); } if (Settings.EpisodeImages) { + var metadataFile = WriteEpisodeImages(series, episodeFile, new List<MetadataFile>()); + + if (metadataFile != null) + { + metadataFiles.Add(metadataFile); + } WriteEpisodeImages(series, episodeFile, new List<MetadataFile>()); } + + _eventAggregator.PublishEvent(new MetadataFilesUpdated(metadataFiles)); } - public override void AfterRename(Series series) + public override void AfterRename(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles) { - //TODO: This should be part of the base class, but could be overwritten if the logic needs to be different - //or it could be done in MetadataService instead of having each metadata consumer do it - var episodeFiles = _mediaFileService.GetFilesBySeries(series.Id); - var episodeFilesMetadata = _metadataFileService.GetFilesBySeries(series.Id).Where(c => c.EpisodeFileId > 0).ToList(); + var episodeFilesMetadata = existingMetadataFiles.Where(c => c.EpisodeFileId > 0).ToList(); + var updatedMetadataFiles = new List<MetadataFile>(); 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); + string newFilename; - if (!fileNameWithoutExtension.Equals(episodeFilenameWithoutExtension)) + if (metadataFile.Type == MetadataType.EpisodeImage) { - var source = Path.Combine(series.Path, metadataFile.RelativePath); - var destination = Path.Combine(series.Path, fileNameWithoutExtension + extension); + newFilename = GetEpisodeImageFilename(episodeFile.Path); + } - _diskProvider.MoveFile(source, destination); - metadataFile.RelativePath = fileNameWithoutExtension + extension; + else if (metadataFile.Type == MetadataType.EpisodeMetadata) + { + newFilename = GetEpisodeNfoFilename(episodeFile.Path); + } - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadataFile)); + else + { + _logger.Trace("Unknown episode file metadata: {0}", metadataFile.RelativePath); + continue; + } + + var existingFilename = Path.Combine(series.Path, metadataFile.RelativePath); + + if (!newFilename.PathEquals(existingFilename)) + { + _diskProvider.MoveFile(existingFilename, newFilename); + metadataFile.RelativePath = DiskProviderBase.GetRelativePath(series.Path, newFilename); + + updatedMetadataFiles.Add(metadataFile); } } } + + _eventAggregator.PublishEvent(new MetadataFilesUpdated(updatedMetadataFiles)); } public override MetadataFile FindMetadataFile(Series series, string path) @@ -205,7 +235,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc return null; } - private void WriteTvShowNfo(Series series, List<MetadataFile> existingMetadataFiles) + private MetadataFile WriteTvShowNfo(Series series, List<MetadataFile> existingMetadataFiles) { _logger.Trace("Generating tvshow.nfo for: {0}", series.Title); var sb = new StringBuilder(); @@ -266,11 +296,11 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) }; - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + return metadata; } } - private void WriteSeriesImages(Series series, List<MetadataFile> existingMetadataFiles) + private IEnumerable<MetadataFile> WriteSeriesImages(Series series, List<MetadataFile> existingMetadataFiles) { foreach (var image in series.Images) { @@ -295,11 +325,11 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc RelativePath = DiskProviderBase.GetRelativePath(series.Path, destination) }; - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + yield return metadata; } } - private void WriteSeasonImages(Series series, List<MetadataFile> existingMetadataFiles) + private IEnumerable<MetadataFile> WriteSeasonImages(Series series, List<MetadataFile> existingMetadataFiles) { foreach (var season in series.Seasons) { @@ -327,14 +357,28 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc RelativePath = DiskProviderBase.GetRelativePath(series.Path, path) }; - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + yield return metadata; } } } - private void WriteEpisodeNfo(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) + private MetadataFile WriteEpisodeNfo(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) { - var filename = episodeFile.Path.Replace(Path.GetExtension(episodeFile.Path), ".nfo"); + var filename = GetEpisodeNfoFilename(episodeFile.Path); + var relativePath = DiskProviderBase.GetRelativePath(series.Path, filename); + + var existingMetadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeMetadata && + c.EpisodeFileId == episodeFile.Id); + + if (existingMetadata != null) + { + var fullPath = Path.Combine(series.Path, existingMetadata.RelativePath); + if (!filename.PathEquals(fullPath)) + { + _diskProvider.MoveFile(fullPath, filename); + existingMetadata.RelativePath = relativePath; + } + } _logger.Debug("Generating {0} for: {1}", filename, episodeFile.Path); @@ -390,8 +434,7 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc _logger.Debug("Saving episodedetails to: {0}", filename); _diskProvider.WriteAllText(filename, xmlResult.Trim(Environment.NewLine.ToCharArray())); - var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeMetadata && - c.EpisodeFileId == episodeFile.Id) ?? + var metadata = existingMetadata ?? new MetadataFile { SeriesId = series.Id, @@ -401,19 +444,38 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) }; - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + return metadata; } - private void WriteEpisodeImages(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) + private MetadataFile WriteEpisodeImages(Series series, EpisodeFile episodeFile, List<MetadataFile> existingMetadataFiles) { - var screenshot = episodeFile.Episodes.Value.First().Images.Single(i => i.CoverType == MediaCoverTypes.Screenshot); + var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - var filename = Path.ChangeExtension(episodeFile.Path, "").Trim('.') + "-thumb.jpg"; + if (screenshot == null) + { + _logger.Trace("Episode screenshot not available"); + return null; + } + + var filename = GetEpisodeImageFilename(episodeFile.Path); + var relativePath = DiskProviderBase.GetRelativePath(series.Path, filename); + + var existingMetadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeImage && + c.EpisodeFileId == episodeFile.Id); + + if (existingMetadata != null) + { + var fullPath = Path.Combine(series.Path, existingMetadata.RelativePath); + if (!filename.PathEquals(fullPath)) + { + _diskProvider.MoveFile(fullPath, filename); + existingMetadata.RelativePath = relativePath; + } + } DownloadImage(series, screenshot.Url, filename); - var metadata = existingMetadataFiles.SingleOrDefault(c => c.Type == MetadataType.EpisodeImage && - c.EpisodeFileId == episodeFile.Id) ?? + var metadata = existingMetadata ?? new MetadataFile { SeriesId = series.Id, @@ -423,21 +485,17 @@ namespace NzbDrone.Core.Metadata.Consumers.Xbmc RelativePath = DiskProviderBase.GetRelativePath(series.Path, filename) }; - _eventAggregator.PublishEvent(new MetadataFileUpdated(metadata)); + return metadata; } - private List<EpisodeFile> GetEpisodeFiles(int seriesId) + private string GetEpisodeNfoFilename(string episodeFilePath) { - var episodeFiles = _mediaFileService.GetFilesBySeries(seriesId); - var episodes = _episodeService.GetEpisodeBySeries(seriesId); + return Path.ChangeExtension(episodeFilePath, "nfo"); + } - foreach (var episodeFile in episodeFiles) - { - var localEpisodeFile = episodeFile; - episodeFile.Episodes = new LazyList<Episode>(episodes.Where(e => e.EpisodeFileId == localEpisodeFile.Id)); - } - - return episodeFiles; + private string GetEpisodeImageFilename(string episodeFilePath) + { + return Path.ChangeExtension(episodeFilePath, "").Trim('.') + "-thumb.jpg"; } } } diff --git a/src/NzbDrone.Core/MetaData/Files/MetadataFilesUpdated.cs b/src/NzbDrone.Core/MetaData/Files/MetadataFilesUpdated.cs new file mode 100644 index 000000000..98427d7dd --- /dev/null +++ b/src/NzbDrone.Core/MetaData/Files/MetadataFilesUpdated.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Metadata.Files +{ + public class MetadataFilesUpdated : IEvent + { + public List<MetadataFile> MetadataFiles { get; set; } + + public MetadataFilesUpdated(List<MetadataFile> metadataFiles) + { + MetadataFiles = metadataFiles; + } + } +} diff --git a/src/NzbDrone.Core/MetaData/IMetadata.cs b/src/NzbDrone.Core/MetaData/IMetadata.cs index 02a51554c..63fa19d73 100644 --- a/src/NzbDrone.Core/MetaData/IMetadata.cs +++ b/src/NzbDrone.Core/MetaData/IMetadata.cs @@ -8,9 +8,9 @@ namespace NzbDrone.Core.Metadata { public interface IMetadata : IProvider { - void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles); + void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles); void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload); - void AfterRename(Series series); + void AfterRename(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles); MetadataFile FindMetadataFile(Series series, string path); } } diff --git a/src/NzbDrone.Core/MetaData/MetadataService.cs b/src/NzbDrone.Core/MetaData/MetadataService.cs index 008354c75..681eed8d0 100644 --- a/src/NzbDrone.Core/MetaData/MetadataService.cs +++ b/src/NzbDrone.Core/MetaData/MetadataService.cs @@ -1,9 +1,13 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using NLog; +using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Metadata.Files; +using NzbDrone.Core.Tv; namespace NzbDrone.Core.Metadata { @@ -15,16 +19,22 @@ namespace NzbDrone.Core.Metadata private readonly IMetadataFactory _metadataFactory; private readonly IMetadataFileService _metadataFileService; private readonly ICleanMetadataService _cleanMetadataService; + private readonly IMediaFileService _mediaFileService; + private readonly IEpisodeService _episodeService; private readonly Logger _logger; public MetadataService(IMetadataFactory metadataFactory, IMetadataFileService metadataFileService, ICleanMetadataService cleanMetadataService, + IMediaFileService mediaFileService, + IEpisodeService episodeService, Logger logger) { _metadataFactory = metadataFactory; _metadataFileService = metadataFileService; _cleanMetadataService = cleanMetadataService; + _mediaFileService = mediaFileService; + _episodeService = episodeService; _logger = logger; } @@ -35,7 +45,7 @@ namespace NzbDrone.Core.Metadata foreach (var consumer in _metadataFactory.Enabled()) { - consumer.OnSeriesUpdated(message.Series, seriesMetadata.Where(c => c.Consumer == consumer.GetType().Name).ToList()); + consumer.OnSeriesUpdated(message.Series, GetMetadataFilesForConsumer(consumer, seriesMetadata), GetEpisodeFiles(message.Series.Id)); } } @@ -49,10 +59,31 @@ namespace NzbDrone.Core.Metadata public void Handle(SeriesRenamedEvent message) { + var seriesMetadata = _metadataFileService.GetFilesBySeries(message.Series.Id); + foreach (var consumer in _metadataFactory.Enabled()) { - consumer.AfterRename(message.Series); + consumer.AfterRename(message.Series, GetMetadataFilesForConsumer(consumer, seriesMetadata), GetEpisodeFiles(message.Series.Id)); } } + + private List<EpisodeFile> GetEpisodeFiles(int seriesId) + { + var episodeFiles = _mediaFileService.GetFilesBySeries(seriesId); + var episodes = _episodeService.GetEpisodeBySeries(seriesId); + + foreach (var episodeFile in episodeFiles) + { + var localEpisodeFile = episodeFile; + episodeFile.Episodes = new LazyList<Episode>(episodes.Where(e => e.EpisodeFileId == localEpisodeFile.Id)); + } + + return episodeFiles; + } + + private List<MetadataFile> GetMetadataFilesForConsumer(IMetadata consumer, List<MetadataFile> seriesMetadata) + { + return seriesMetadata.Where(c => c.Consumer == consumer.GetType().Name).ToList(); + } } } diff --git a/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs index aca05235a..4c5d89aa1 100644 --- a/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs +++ b/src/NzbDrone.Core/Metadata/ExistingMetadataService.cs @@ -12,7 +12,7 @@ using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.Metadata { - public class ExistingMetadataService : IHandleAsync<SeriesUpdatedEvent> + public class ExistingMetadataService : IHandle<SeriesUpdatedEvent> { private readonly IDiskProvider _diskProvider; private readonly IMetadataFileService _metadataFileService; @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Metadata _consumers = consumers.ToList(); } - public void HandleAsync(SeriesUpdatedEvent message) + public void Handle(SeriesUpdatedEvent message) { if (!_diskProvider.FolderExists(message.Series.Path)) return; @@ -42,7 +42,9 @@ namespace NzbDrone.Core.Metadata 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); - + + var metadataFiles = new List<MetadataFile>(); + foreach (var possibleMetadataFile in filteredFiles) { foreach (var consumer in _consumers) @@ -71,9 +73,11 @@ namespace NzbDrone.Core.Metadata metadata.EpisodeFileId = localEpisode.Episodes.First().EpisodeFileId; } - _metadataFileService.Upsert(metadata); + metadataFiles.Add(metadata); } } + + _metadataFileService.Upsert(metadataFiles); } } } diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs index 56471a0b1..e53c403af 100644 --- a/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs +++ b/src/NzbDrone.Core/Metadata/Files/MetadataFileService.cs @@ -18,14 +18,14 @@ namespace NzbDrone.Core.Metadata.Files List<MetadataFile> GetFilesByEpisodeFile(int episodeFileId); MetadataFile FindByPath(string path); List<string> FilterExistingFiles(List<string> files, Series series); - MetadataFile Upsert(MetadataFile metadataFile); + void Upsert(List<MetadataFile> metadataFiles); void Delete(int id); } public class MetadataFileService : IMetadataFileService, IHandleAsync<SeriesDeletedEvent>, IHandleAsync<EpisodeFileDeletedEvent>, - IHandle<MetadataFileUpdated> + IHandle<MetadataFilesUpdated> { private readonly IMetadataFileRepository _repository; private readonly ISeriesService _seriesService; @@ -67,10 +67,12 @@ namespace NzbDrone.Core.Metadata.Files return files.Except(seriesFiles, PathEqualityComparer.Instance).ToList(); } - public MetadataFile Upsert(MetadataFile metadataFile) + public void Upsert(List<MetadataFile> metadataFiles) { - metadataFile.LastUpdated = DateTime.UtcNow; - return _repository.Upsert(metadataFile); + metadataFiles.ForEach(m => m.LastUpdated = DateTime.UtcNow); + + _repository.InsertMany(metadataFiles.Where(m => m.Id == 0).ToList()); + _repository.UpdateMany(metadataFiles.Where(m => m.Id > 0).ToList()); } public void Delete(int id) @@ -103,9 +105,9 @@ namespace NzbDrone.Core.Metadata.Files _repository.DeleteForEpisodeFile(episodeFile.Id); } - public void Handle(MetadataFileUpdated message) + public void Handle(MetadataFilesUpdated message) { - Upsert(message.Metadata); + Upsert(message.MetadataFiles); } } } diff --git a/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs b/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs deleted file mode 100644 index 7f7b4b189..000000000 --- a/src/NzbDrone.Core/Metadata/Files/MetadataFileUpdated.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 index 29143a424..04be5860a 100644 --- a/src/NzbDrone.Core/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Metadata/MetadataBase.cs @@ -42,9 +42,9 @@ namespace NzbDrone.Core.Metadata public ProviderDefinition Definition { get; set; } - public abstract void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles); + public abstract void OnSeriesUpdated(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles); public abstract void OnEpisodeImport(Series series, EpisodeFile episodeFile, bool newDownload); - public abstract void AfterRename(Series series); + public abstract void AfterRename(Series series, List<MetadataFile> existingMetadataFiles, List<EpisodeFile> episodeFiles); public abstract MetadataFile FindMetadataFile(Series series, string path); protected TSettings Settings diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 671d53bcb..220e27eca 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -344,10 +344,10 @@ <Compile Include="Metadata\Consumers\Xbmc\XbmcMetadata.cs" /> <Compile Include="Metadata\Consumers\Xbmc\XbmcMetadataSettings.cs" /> <Compile Include="Metadata\ExistingMetadataService.cs" /> + <Compile Include="Metadata\Files\MetadataFilesUpdated.cs" /> <Compile Include="Metadata\Files\MetadataFile.cs" /> <Compile Include="Metadata\Files\MetadataFileRepository.cs" /> <Compile Include="Metadata\Files\MetadataFileService.cs" /> - <Compile Include="Metadata\Files\MetadataFileUpdated.cs" /> <Compile Include="Metadata\IMetadata.cs" /> <Compile Include="Metadata\MetadataBase.cs" /> <Compile Include="MetadataSource\Trakt\TraktException.cs" /> From 6d1cb907232ca5618c87f676e073d8aabbb7fbcf Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 15:33:11 -0800 Subject: [PATCH 39/42] Possible special is less aggressive, with tests --- .../NzbDrone.Core.Test.csproj | 1 + .../IsPossibleSpecialEpisodeFixture.cs | 39 +++++++++++++++++++ .../Parser/Model/ParsedEpisodeInfo.cs | 9 ++--- 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 8e2770fc7..fe5a842f8 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -181,6 +181,7 @@ <Compile Include="OrganizerTests\BuildFilePathFixture.cs" /> <Compile Include="OrganizerTests\GetSeriesFolderFixture.cs" /> <Compile Include="ParserTests\AbsoluteEpisodeNumberParserFixture.cs" /> + <Compile Include="ParserTests\IsPossibleSpecialEpisodeFixture.cs" /> <Compile Include="ParserTests\ReleaseGroupParserFixture.cs" /> <Compile Include="ParserTests\LanguageParserFixture.cs" /> <Compile Include="ParserTests\SeasonParserFixture.cs" /> diff --git a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs new file mode 100644 index 000000000..5d0cc3829 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class IsPossibleSpecialEpisodeFixture + { + [Test] + public void should_not_treat_files_without_a_series_title_as_a_special() + { + var parsedEpisodeInfo = new ParsedEpisodeInfo + { + EpisodeNumbers = new[]{ 7 }, + SeasonNumber = 1, + SeriesTitle = "" + }; + + parsedEpisodeInfo.IsPossibleSpecialEpisode().Should().BeFalse(); + } + + [Test] + public void should_return_true_when_episode_numbers_is_empty() + { + var parsedEpisodeInfo = new ParsedEpisodeInfo + { + SeasonNumber = 1, + SeriesTitle = "" + }; + + parsedEpisodeInfo.IsPossibleSpecialEpisode().Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs index beea7b190..47adfe26b 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs @@ -37,12 +37,9 @@ namespace NzbDrone.Core.Parser.Model public bool IsPossibleSpecialEpisode() { // if we dont have eny episode numbers we are likely a special episode and need to do a search by episode title - return string.IsNullOrEmpty(AirDate) && - ( - EpisodeNumbers.Length == 0 || - SeasonNumber == 0 || - String.IsNullOrWhiteSpace(SeriesTitle) - ); + return String.IsNullOrWhiteSpace(AirDate) && + (EpisodeNumbers.Length == 0 || SeasonNumber == 0) && + String.IsNullOrWhiteSpace(SeriesTitle); } public override string ToString() From 4515c1d15533d2584afb0cd3f23065cce7840aab Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sat, 22 Feb 2014 16:17:19 -0800 Subject: [PATCH 40/42] Blacklist cleanup Fixed: Cleanup blacklist when series is deleted Fixed: Cleanup blacklist on startup --- .../Blacklisting/BlacklistRepository.cs | 13 ++++++-- .../Blacklisting/BlacklistService.cs | 14 +++++++-- .../Housekeepers/CleanupOrphanedBlacklist.cs | 31 +++++++++++++++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs b/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs index 1a641bfca..fc6498412 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs @@ -1,4 +1,7 @@ -using NzbDrone.Core.Datastore; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Blacklisting @@ -6,6 +9,7 @@ namespace NzbDrone.Core.Blacklisting public interface IBlacklistRepository : IBasicRepository<Blacklist> { bool Blacklisted(string sourceTitle); + List<Blacklist> BlacklistedBySeries(int seriesId); } public class BlacklistRepository : BasicRepository<Blacklist>, IBlacklistRepository @@ -17,7 +21,12 @@ namespace NzbDrone.Core.Blacklisting public bool Blacklisted(string sourceTitle) { - return Query.Any(e => e.SourceTitle.Contains(sourceTitle)); + return Query.Where(e => e.SourceTitle.Contains(sourceTitle)).Any(); + } + + public List<Blacklist> BlacklistedBySeries(int seriesId) + { + return Query.Where(b => b.SeriesId == seriesId); } } } diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs index 8b3ab0a2d..fafbec44e 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs @@ -3,6 +3,7 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.Download; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tv.Events; namespace NzbDrone.Core.Blacklisting { @@ -13,7 +14,7 @@ namespace NzbDrone.Core.Blacklisting void Delete(int id); } - public class BlacklistService : IBlacklistService, IHandle<DownloadFailedEvent>, IExecute<ClearBlacklistCommand> + public class BlacklistService : IBlacklistService, IExecute<ClearBlacklistCommand>, IHandle<DownloadFailedEvent>, IHandle<SeriesDeletedEvent> { private readonly IBlacklistRepository _blacklistRepository; private readonly IRedownloadFailedDownloads _redownloadFailedDownloadService; @@ -39,6 +40,11 @@ namespace NzbDrone.Core.Blacklisting _blacklistRepository.Delete(id); } + public void Execute(ClearBlacklistCommand message) + { + _blacklistRepository.Purge(); + } + public void Handle(DownloadFailedEvent message) { var blacklist = new Blacklist @@ -55,9 +61,11 @@ namespace NzbDrone.Core.Blacklisting _redownloadFailedDownloadService.Redownload(message.SeriesId, message.EpisodeIds); } - public void Execute(ClearBlacklistCommand message) + public void Handle(SeriesDeletedEvent message) { - _blacklistRepository.Purge(); + var blacklisted = _blacklistRepository.BlacklistedBySeries(message.Series.Id); + + _blacklistRepository.DeleteMany(blacklisted); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs new file mode 100644 index 000000000..3ccb0c181 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs @@ -0,0 +1,31 @@ +using NLog; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedBlacklist : IHousekeepingTask + { + private readonly IDatabase _database; + private readonly Logger _logger; + + public CleanupOrphanedBlacklist(IDatabase database, Logger logger) + { + _database = database; + _logger = logger; + } + + public void Clean() + { + _logger.Trace("Running orphaned blacklist cleanup"); + + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM Blacklist + WHERE Id IN ( + SELECT Blacklist.Id FROM Blacklist + LEFT OUTER JOIN Series + ON Blacklist.SeriesId = Series.Id + WHERE Series.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 220e27eca..0c22a0648 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -283,6 +283,7 @@ <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedHistoryItems.cs" /> <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedMetadataFiles.cs" /> <Compile Include="Housekeeping\Housekeepers\CleanupAdditionalNamingSpecs.cs" /> + <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedBlacklist.cs" /> <Compile Include="Housekeeping\Housekeepers\UpdateCleanTitleForSeries.cs" /> <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedEpisodeFiles.cs" /> <Compile Include="Housekeeping\Housekeepers\FixFutureRunScheduledTasks.cs" /> From 828e8eb147abcbd9d1ce34dcb68905f49e09e6e5 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 23 Feb 2014 13:37:21 -0800 Subject: [PATCH 41/42] Fixed: Orphaned episode file was preventing rename preview from functioning --- src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs index 43573921e..6195f5d3a 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs @@ -75,6 +75,13 @@ namespace NzbDrone.Core.MediaFiles foreach (var file in files) { var episodesInFile = episodes.Where(e => e.EpisodeFileId == file.Id).ToList(); + + if (!episodesInFile.Any()) + { + _logger.Warn("File ({0}) is not linked to any episodes", file.Path); + continue; + } + var seasonNumber = episodesInFile.First().SeasonNumber; var newName = _filenameBuilder.BuildFilename(episodesInFile, series, file); var newPath = _filenameBuilder.BuildFilePath(series, seasonNumber, newName, Path.GetExtension(file.Path)); From 42936c956ddcb687006226aabb0139e95061fea9 Mon Sep 17 00:00:00 2001 From: Mark McDowall <markus.mcd5@gmail.com> Date: Sun, 23 Feb 2014 14:45:37 -0800 Subject: [PATCH 42/42] New: Queue in UI is now paged --- src/UI/History/Queue/QueueCollection.js | 11 +++++++++-- src/UI/History/Queue/QueueLayout.js | 15 +++++++++++---- src/UI/History/Queue/QueueLayoutTemplate.html | 6 ++++++ src/UI/History/Table/HistoryTableLayout.js | 2 -- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/UI/History/Queue/QueueCollection.js b/src/UI/History/Queue/QueueCollection.js index a66393435..b5c32e6a3 100644 --- a/src/UI/History/Queue/QueueCollection.js +++ b/src/UI/History/Queue/QueueCollection.js @@ -3,13 +3,20 @@ define( [ 'underscore', 'backbone', + 'backbone.pageable', 'History/Queue/QueueModel', 'Mixins/backbone.signalr.mixin' - ], function (_, Backbone, QueueModel) { - var QueueCollection = Backbone.Collection.extend({ + ], function (_, Backbone, PageableCollection, QueueModel) { + var QueueCollection = PageableCollection.extend({ url : window.NzbDrone.ApiRoot + '/queue', model: QueueModel, + state: { + pageSize: 15 + }, + + mode: 'client', + findEpisode: function (episodeId) { return _.find(this.models, function (queueModel) { return queueModel.get('episode').id === episodeId; diff --git a/src/UI/History/Queue/QueueLayout.js b/src/UI/History/Queue/QueueLayout.js index 612ec0297..b20044fd8 100644 --- a/src/UI/History/Queue/QueueLayout.js +++ b/src/UI/History/Queue/QueueLayout.js @@ -9,7 +9,8 @@ define( 'Cells/EpisodeTitleCell', 'Cells/QualityCell', 'History/Queue/QueueStatusCell', - 'History/Queue/TimeleftCell' + 'History/Queue/TimeleftCell', + 'Shared/Grid/Pager' ], function (Marionette, Backgrid, QueueCollection, @@ -18,12 +19,14 @@ define( EpisodeTitleCell, QualityCell, QueueStatusCell, - TimeleftCell) { + TimeleftCell, + GridPager) { return Marionette.Layout.extend({ template: 'History/Queue/QueueLayoutTemplate', regions: { - table: '#x-queue' + table: '#x-queue', + pager: '#x-queue-pager' }, columns: @@ -65,7 +68,6 @@ define( } ], - initialize: function () { this.listenTo(QueueCollection, 'sync', this._showTable); }, @@ -80,6 +82,11 @@ define( collection: QueueCollection, className : 'table table-hover' })); + + this.pager.show(new GridPager({ + columns : this.columns, + collection: QueueCollection + })); } }); }); diff --git a/src/UI/History/Queue/QueueLayoutTemplate.html b/src/UI/History/Queue/QueueLayoutTemplate.html index 113673518..89041b644 100644 --- a/src/UI/History/Queue/QueueLayoutTemplate.html +++ b/src/UI/History/Queue/QueueLayoutTemplate.html @@ -3,3 +3,9 @@ <div id="x-queue"/> </div> </div> + +<div class="row"> + <div class="span12"> + <div id="x-queue-pager"/> + </div> +</div> \ No newline at end of file diff --git a/src/UI/History/Table/HistoryTableLayout.js b/src/UI/History/Table/HistoryTableLayout.js index 3571c69d1..f4a9dcd04 100644 --- a/src/UI/History/Table/HistoryTableLayout.js +++ b/src/UI/History/Table/HistoryTableLayout.js @@ -79,7 +79,6 @@ define( } ], - initialize: function () { this.collection = new HistoryCollection({ tableName: 'history' }); this.listenTo(this.collection, 'sync', this._showTable); @@ -104,6 +103,5 @@ define( this.history.show(new LoadingView()); this.collection.fetch(); } - }); });