From ca3a0c5dc9824844a4591b4b22822bb8351169ae Mon Sep 17 00:00:00 2001 From: Luke Pulverenti Date: Wed, 16 Oct 2013 19:35:11 -0400 Subject: [PATCH] fixes #592 - Add options to import missing and future episodes --- MediaBrowser.Api/TvShowsService.cs | 60 ++- MediaBrowser.Api/UserLibrary/ItemsService.cs | 59 ++- .../UserLibrary/UserLibraryService.cs | 16 +- MediaBrowser.Controller/Entities/Folder.cs | 7 + .../Configuration/ServerConfiguration.cs | 11 + MediaBrowser.Model/Querying/ItemQuery.cs | 22 +- MediaBrowser.Model/Querying/NextUpQuery.cs | 19 +- .../Movies/FanArtMovieProvider.cs | 22 +- .../TV/EpisodeIndexNumberProvider.cs | 3 +- .../TV/RemoteSeasonProvider.cs | 7 +- .../TV/SeriesPostScanTask.cs | 370 +++++++++++++++++- .../ServerConfigurationManager.cs | 4 +- ...MediaBrowser.Server.Implementations.csproj | 4 +- .../Session/SessionManager.cs | 15 +- .../packages.config | 2 +- .../Api/DashboardService.cs | 1 + .../MediaBrowser.WebDashboard.csproj | 6 + 17 files changed, 581 insertions(+), 47 deletions(-) diff --git a/MediaBrowser.Api/TvShowsService.cs b/MediaBrowser.Api/TvShowsService.cs index 15d6c0ae2d..fad17814ec 100644 --- a/MediaBrowser.Api/TvShowsService.cs +++ b/MediaBrowser.Api/TvShowsService.cs @@ -1,4 +1,6 @@ -using MediaBrowser.Controller.Dto; +using System.Collections; +using System.Globalization; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -46,6 +48,18 @@ namespace MediaBrowser.Api [ApiMember(Name = "Fields", Description = "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, CriticRatingSummary, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, OverviewHtml, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] public string Fields { get; set; } + [ApiMember(Name = "ExcludeLocationTypes", Description = "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] + public string ExcludeLocationTypes { get; set; } + + [ApiMember(Name = "MinPremiereDate", Description = "Optional. The minimum premiere date. Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string MinPremiereDate { get; set; } + + [ApiMember(Name = "MaxPremiereDate", Description = "Optional. The maximum premiere date. Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string MaxPremiereDate { get; set; } + + [ApiMember(Name = "HasPremiereDate", Description = "Optional filter by items with premiere dates.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public bool? HasPremiereDate { get; set; } + /// /// Gets the item fields. /// @@ -159,7 +173,7 @@ namespace MediaBrowser.Api var itemsList = user.RootFolder .GetRecursiveChildren(user, i => i is Series) .AsParallel() - .Select(i => GetNextUp((Series)i, user)) + .Select(i => GetNextUp((Series)i, user, request)) .ToList(); itemsList = itemsList @@ -202,8 +216,9 @@ namespace MediaBrowser.Api /// /// The series. /// The user. + /// The request. /// Task{Episode}. - private Tuple GetNextUp(Series series, User user) + private Tuple GetNextUp(Series series, User user, GetNextUpEpisodes request) { var allEpisodes = series.GetRecursiveChildren(user) .OfType() @@ -211,6 +226,8 @@ namespace MediaBrowser.Api .ThenByDescending(i => i.IndexNumber ?? 0) .ToList(); + allEpisodes = FilterItems(request, allEpisodes).ToList(); + Episode lastWatched = null; var lastWatchedDate = DateTime.MinValue; Episode nextUp = null; @@ -244,6 +261,43 @@ namespace MediaBrowser.Api return new Tuple(null, lastWatchedDate); } + + private IEnumerable FilterItems(GetNextUpEpisodes request, IEnumerable items) + { + // ExcludeLocationTypes + if (!string.IsNullOrEmpty(request.ExcludeLocationTypes)) + { + var vals = request.ExcludeLocationTypes.Split(','); + + items = items + .Where(f => !vals.Contains(f.LocationType.ToString(), StringComparer.OrdinalIgnoreCase)) + .ToList(); + } + + if (!string.IsNullOrEmpty(request.MinPremiereDate)) + { + var date = DateTime.ParseExact(request.MinPremiereDate, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + items = items.Where(i => !i.PremiereDate.HasValue || i.PremiereDate.Value >= date); + } + + if (!string.IsNullOrEmpty(request.MaxPremiereDate)) + { + var date = DateTime.ParseExact(request.MaxPremiereDate, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + items = items.Where(i => !i.PremiereDate.HasValue || i.PremiereDate.Value <= date); + } + + if (request.HasPremiereDate.HasValue) + { + var val = request.HasPremiereDate.Value; + + items = items.Where(i => i.PremiereDate.HasValue == val); + } + + return items; + } + /// /// Applies the paging. /// diff --git a/MediaBrowser.Api/UserLibrary/ItemsService.cs b/MediaBrowser.Api/UserLibrary/ItemsService.cs index aff12569f1..cb01dae733 100644 --- a/MediaBrowser.Api/UserLibrary/ItemsService.cs +++ b/MediaBrowser.Api/UserLibrary/ItemsService.cs @@ -1,4 +1,5 @@ -using MediaBrowser.Controller.Dto; +using System.Globalization; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; @@ -180,6 +181,21 @@ namespace MediaBrowser.Api.UserLibrary [ApiMember(Name = "IsHD", Description = "Optional filter by items that are HD or not.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] public bool? IsHD { get; set; } + [ApiMember(Name = "ExcludeLocationTypes", Description = "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] + public string ExcludeLocationTypes { get; set; } + + [ApiMember(Name = "LocationTypes", Description = "Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] + public string LocationTypes { get; set; } + + [ApiMember(Name = "MinPremiereDate", Description = "Optional. The minimum premiere date. Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string MinPremiereDate { get; set; } + + [ApiMember(Name = "MaxPremiereDate", Description = "Optional. The maximum premiere date. Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string MaxPremiereDate { get; set; } + + [ApiMember(Name = "HasPremiereDate", Description = "Optional filter by items with premiere dates.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public bool? HasPremiereDate { get; set; } + public bool IncludeIndexContainers { get; set; } } @@ -298,7 +314,7 @@ namespace MediaBrowser.Api.UserLibrary else if (request.Recursive) { - items = ((Folder) item).GetRecursiveChildren(user); + items = ((Folder)item).GetRecursiveChildren(user); } else { @@ -577,6 +593,20 @@ namespace MediaBrowser.Api.UserLibrary items = items.Where(f => vals.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)); } + // ExcludeLocationTypes + if (!string.IsNullOrEmpty(request.ExcludeLocationTypes)) + { + var vals = request.ExcludeLocationTypes.Split(','); + items = items.Where(f => !vals.Contains(f.LocationType.ToString(), StringComparer.OrdinalIgnoreCase)); + } + + // LocationTypes + if (!string.IsNullOrEmpty(request.LocationTypes)) + { + var vals = request.LocationTypes.Split(','); + items = items.Where(f => vals.Contains(f.LocationType.ToString(), StringComparer.OrdinalIgnoreCase)); + } + if (!string.IsNullOrEmpty(request.NameStartsWithOrGreater)) { items = items.Where(i => string.Compare(request.NameStartsWithOrGreater, i.SortName, StringComparison.CurrentCultureIgnoreCase) < 1); @@ -645,7 +675,7 @@ namespace MediaBrowser.Api.UserLibrary var vals = request.AllGenres.Split(','); items = items.Where(f => vals.All(v => f.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))); } - + // Apply studio filter if (!string.IsNullOrEmpty(request.Studios)) { @@ -818,11 +848,32 @@ namespace MediaBrowser.Api.UserLibrary { return song.ParentIndexNumber.HasValue && song.ParentIndexNumber.Value == filterValue; } - + return true; }); } + if (!string.IsNullOrEmpty(request.MinPremiereDate)) + { + var date = DateTime.ParseExact(request.MinPremiereDate, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + items = items.Where(i => !i.PremiereDate.HasValue || i.PremiereDate.Value >= date); + } + + if (!string.IsNullOrEmpty(request.MaxPremiereDate)) + { + var date = DateTime.ParseExact(request.MaxPremiereDate, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + items = items.Where(i => !i.PremiereDate.HasValue || i.PremiereDate.Value <= date); + } + + if (request.HasPremiereDate.HasValue) + { + var val = request.HasPremiereDate.Value; + + items = items.Where(i => i.PremiereDate.HasValue == val); + } + return items; } diff --git a/MediaBrowser.Api/UserLibrary/UserLibraryService.cs b/MediaBrowser.Api/UserLibrary/UserLibraryService.cs index 3041ce6cee..4b3f35e96b 100644 --- a/MediaBrowser.Api/UserLibrary/UserLibraryService.cs +++ b/MediaBrowser.Api/UserLibrary/UserLibraryService.cs @@ -1,4 +1,5 @@ -using MediaBrowser.Controller.Dto; +using System.Globalization; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; @@ -184,8 +185,8 @@ namespace MediaBrowser.Api.UserLibrary [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] public Guid UserId { get; set; } - [ApiMember(Name = "DatePlayed", Description = "The date the item was played (if any)", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public DateTime? DatePlayed { get; set; } + [ApiMember(Name = "DatePlayed", Description = "The date the item was played (if any). Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string DatePlayed { get; set; } /// /// Gets or sets the id. @@ -671,7 +672,14 @@ namespace MediaBrowser.Api.UserLibrary { var user = _userManager.GetUserById(request.UserId); - var task = UpdatePlayedStatus(user, request.Id, true, request.DatePlayed); + DateTime? datePlayed = null; + + if (!string.IsNullOrEmpty(request.DatePlayed)) + { + datePlayed = DateTime.ParseExact(request.DatePlayed, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + } + + var task = UpdatePlayedStatus(user, request.Id, true, datePlayed); return ToOptimizedResult(task.Result); } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index d18fa82bc4..5b89eb0663 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -725,6 +725,13 @@ namespace MediaBrowser.Controller.Entities foreach (var item in itemsRemoved) { + if (item.LocationType == LocationType.Virtual || + item.LocationType == LocationType.Remote) + { + // Don't remove these because there's no way to accurately validate them. + continue; + } + if (!string.IsNullOrEmpty(item.Path) && IsPathOffline(item.Path)) { item.IsOffline = true; diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 6c6cc32f30..55d90d077c 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -228,6 +228,17 @@ namespace MediaBrowser.Model.Configuration public bool EnableVideoImageExtraction { get; set; } + /// + /// Gets or sets a value indicating whether [create virtual missing episodes]. + /// + /// true if [create virtual missing episodes]; otherwise, false. + public bool CreateVirtualMissingEpisodes { get; set; } + /// + /// Gets or sets a value indicating whether [create virtual future episodes]. + /// + /// true if [create virtual future episodes]; otherwise, false. + public bool CreateVirtualFutureEpisodes { get; set; } + public ImageSavingConvention ImageSavingConvention { get; set; } /// diff --git a/MediaBrowser.Model/Querying/ItemQuery.cs b/MediaBrowser.Model/Querying/ItemQuery.cs index 76269c56cd..d351474eb7 100644 --- a/MediaBrowser.Model/Querying/ItemQuery.cs +++ b/MediaBrowser.Model/Querying/ItemQuery.cs @@ -235,13 +235,31 @@ namespace MediaBrowser.Model.Querying /// /// true if [include index containers]; otherwise, false. public bool IncludeIndexContainers { get; set; } - + + /// + /// Gets or sets the location types. + /// + /// The location types. + public LocationType[] LocationTypes { get; set; } + /// + /// Gets or sets the exclude location types. + /// + /// The exclude location types. + public LocationType[] ExcludeLocationTypes { get; set; } + + public bool? HasPremiereDate { get; set; } + public DateTime? MinPremiereDate { get; set; } + public DateTime? MaxPremiereDate { get; set; } + /// /// Initializes a new instance of the class. /// public ItemQuery() { - SortBy = new string[] {}; + LocationTypes = new LocationType[] { }; + ExcludeLocationTypes = new LocationType[] { }; + + SortBy = new string[] { }; Filters = new ItemFilter[] {}; diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index 973383fa31..1b7b45ca30 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -1,4 +1,6 @@ - +using MediaBrowser.Model.Entities; +using System; + namespace MediaBrowser.Model.Querying { public class NextUpQuery @@ -26,5 +28,20 @@ namespace MediaBrowser.Model.Querying /// /// The fields. public ItemFields[] Fields { get; set; } + + /// + /// Gets or sets the exclude location types. + /// + /// The exclude location types. + public LocationType[] ExcludeLocationTypes { get; set; } + + public bool? HasPremiereDate { get; set; } + public DateTime? MinPremiereDate { get; set; } + public DateTime? MaxPremiereDate { get; set; } + + public NextUpQuery() + { + ExcludeLocationTypes = new LocationType[] { }; + } } } diff --git a/MediaBrowser.Providers/Movies/FanArtMovieProvider.cs b/MediaBrowser.Providers/Movies/FanArtMovieProvider.cs index dff2a63c53..5715da2f15 100644 --- a/MediaBrowser.Providers/Movies/FanArtMovieProvider.cs +++ b/MediaBrowser.Providers/Movies/FanArtMovieProvider.cs @@ -300,17 +300,17 @@ namespace MediaBrowser.Providers.Movies string path; - if (ConfigurationManager.Configuration.DownloadMovieImages.Disc && !item.HasImage(ImageType.Disc)) - { - var node = doc.SelectSingleNode("//fanart/movie/movieposters/movieposter[@lang = \"" + language + "\"]/@url") ?? - doc.SelectSingleNode("//fanart/movie/movieposters/movieposter/@url"); - path = node != null ? node.Value : null; - if (!string.IsNullOrEmpty(path)) - { - await _providerManager.SaveImage(item, path, FanArtResourcePool, ImageType.Disc, null, cancellationToken) - .ConfigureAwait(false); - } - } + //if (ConfigurationManager.Configuration.DownloadMovieImages.Disc && !item.HasImage(ImageType.Disc)) + //{ + // var node = doc.SelectSingleNode("//fanart/movie/movieposters/movieposter[@lang = \"" + language + "\"]/@url") ?? + // doc.SelectSingleNode("//fanart/movie/movieposters/movieposter/@url"); + // path = node != null ? node.Value : null; + // if (!string.IsNullOrEmpty(path)) + // { + // await _providerManager.SaveImage(item, path, FanArtResourcePool, ImageType.Disc, null, cancellationToken) + // .ConfigureAwait(false); + // } + //} cancellationToken.ThrowIfCancellationRequested(); diff --git a/MediaBrowser.Providers/TV/EpisodeIndexNumberProvider.cs b/MediaBrowser.Providers/TV/EpisodeIndexNumberProvider.cs index aa59ff7cf9..fc8f55ae14 100644 --- a/MediaBrowser.Providers/TV/EpisodeIndexNumberProvider.cs +++ b/MediaBrowser.Providers/TV/EpisodeIndexNumberProvider.cs @@ -3,6 +3,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using System; using System.Threading; @@ -33,7 +34,7 @@ namespace MediaBrowser.Providers.TV /// true if XXXX, false otherwise public override bool Supports(BaseItem item) { - return item is Episode; + return item is Episode && item.LocationType != LocationType.Virtual && item.LocationType != LocationType.Remote; } /// diff --git a/MediaBrowser.Providers/TV/RemoteSeasonProvider.cs b/MediaBrowser.Providers/TV/RemoteSeasonProvider.cs index de9e5c0185..3436a137f5 100644 --- a/MediaBrowser.Providers/TV/RemoteSeasonProvider.cs +++ b/MediaBrowser.Providers/TV/RemoteSeasonProvider.cs @@ -101,7 +101,7 @@ namespace MediaBrowser.Providers.TV } } - protected override DateTime CompareDate(BaseItem item) + protected override bool NeedsRefreshBasedOnCompareDate(BaseItem item, BaseProviderInfo providerInfo) { var season = (Season)item; var seriesId = season.Series != null ? season.Series.GetProviderId(MetadataProviders.Tvdb) : null; @@ -115,11 +115,10 @@ namespace MediaBrowser.Providers.TV if (imagesFileInfo.Exists) { - return imagesFileInfo.LastWriteTimeUtc; + return imagesFileInfo.LastWriteTimeUtc > providerInfo.LastRefreshed; } } - - return base.CompareDate(item); + return false; } /// diff --git a/MediaBrowser.Providers/TV/SeriesPostScanTask.cs b/MediaBrowser.Providers/TV/SeriesPostScanTask.cs index aad5c83e7b..8ad03e62bb 100644 --- a/MediaBrowser.Providers/TV/SeriesPostScanTask.cs +++ b/MediaBrowser.Providers/TV/SeriesPostScanTask.cs @@ -1,9 +1,19 @@ -using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml; namespace MediaBrowser.Providers.TV { @@ -13,20 +23,24 @@ namespace MediaBrowser.Providers.TV /// The _library manager /// private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IDirectoryWatchers _directoryWatchers; - public SeriesPostScanTask(ILibraryManager libraryManager) + public SeriesPostScanTask(ILibraryManager libraryManager, ILogger logger, IDirectoryWatchers directoryWatchers, IServerConfigurationManager config) { _libraryManager = libraryManager; + _logger = logger; + _directoryWatchers = directoryWatchers; + _config = config; } public Task Run(IProgress progress, CancellationToken cancellationToken) { - RunInternal(progress, cancellationToken); - - return Task.FromResult(true); + return RunInternal(progress, cancellationToken); } - private void RunInternal(IProgress progress, CancellationToken cancellationToken) + private async Task RunInternal(IProgress progress, CancellationToken cancellationToken) { var seriesList = _libraryManager.RootFolder .RecursiveChildren @@ -39,6 +53,8 @@ namespace MediaBrowser.Providers.TV { cancellationToken.ThrowIfCancellationRequested(); + await new MissingEpisodeProvider(_logger, _directoryWatchers, _config).Run(series, cancellationToken).ConfigureAwait(false); + var episodes = series.RecursiveChildren .OfType() .ToList(); @@ -67,4 +83,346 @@ namespace MediaBrowser.Providers.TV } } } + + class MissingEpisodeProvider + { + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IDirectoryWatchers _directoryWatchers; + + private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + public MissingEpisodeProvider(ILogger logger, IDirectoryWatchers directoryWatchers, IServerConfigurationManager config) + { + _logger = logger; + _directoryWatchers = directoryWatchers; + _config = config; + } + + public async Task Run(Series series, CancellationToken cancellationToken) + { + var tvdbId = series.GetProviderId(MetadataProviders.Tvdb); + + // Can't proceed without a tvdb id + if (string.IsNullOrEmpty(tvdbId)) + { + return; + } + + var seriesDataPath = RemoteSeriesProvider.GetSeriesDataPath(_config.ApplicationPaths, tvdbId); + + var episodeFiles = Directory.EnumerateFiles(seriesDataPath, "*.xml", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileNameWithoutExtension) + .Where(i => i.StartsWith("episode-", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var episodeLookup = episodeFiles + .Select(i => + { + var parts = i.Split('-'); + + if (parts.Length == 3) + { + var seasonNumberString = parts[1]; + + int seasonNumber; + + if (int.TryParse(seasonNumberString, NumberStyles.Integer, UsCulture, out seasonNumber)) + { + var episodeNumberString = parts[2]; + + int episodeNumber; + + if (int.TryParse(episodeNumberString, NumberStyles.Integer, UsCulture, out episodeNumber)) + { + return new Tuple(seasonNumber, episodeNumber); + } + } + } + + return new Tuple(-1, -1); + }) + .Where(i => i.Item1 != -1 && i.Item2 != -1) + .ToList(); + + var existingEpisodes = series.RecursiveChildren + .OfType() + .Where(i => i.IndexNumber.HasValue && i.ParentIndexNumber.HasValue) + .ToList(); + + var hasChanges = false; + + if (_config.Configuration.CreateVirtualMissingEpisodes || _config.Configuration.CreateVirtualFutureEpisodes) + { + if (_config.Configuration.EnableInternetProviders) + { + hasChanges = await AddMissingEpisodes(series, seriesDataPath, existingEpisodes, episodeLookup, cancellationToken).ConfigureAwait(false); + } + } + + var anyRemoved = await RemoveObsoleteMissingEpsiodes(series, existingEpisodes, cancellationToken).ConfigureAwait(false); + + if (hasChanges || anyRemoved) + { + await series.RefreshMetadata(cancellationToken, true).ConfigureAwait(false); + await series.ValidateChildren(new Progress(), cancellationToken, true).ConfigureAwait(false); + } + } + + /// + /// Adds the missing episodes. + /// + /// The series. + /// The series data path. + /// The existing episodes. + /// The episode lookup. + /// The cancellation token. + /// Task. + private async Task AddMissingEpisodes(Series series, string seriesDataPath, List existingEpisodes, IEnumerable> episodeLookup, CancellationToken cancellationToken) + { + var hasChanges = false; + + foreach (var tuple in episodeLookup) + { + if (tuple.Item1 <= 0) + { + // Ignore season zeros + continue; + } + + if (tuple.Item2 <= 0) + { + // Ignore episode zeros + continue; + } + + var existingEpisode = GetExistingEpisode(existingEpisodes, tuple); + + if (existingEpisode != null) + { + continue; + } + + var airDate = GetAirDate(seriesDataPath, tuple.Item1, tuple.Item2); + + if (!airDate.HasValue) + { + continue; + } + + if (airDate.Value < DateTime.UtcNow && _config.Configuration.CreateVirtualMissingEpisodes) + { + // tvdb has a lot of nearly blank episodes + _logger.Info("Creating virtual missing episode {0} {1}x{2}", series.Name, tuple.Item1, tuple.Item2); + + await AddEpisode(series, tuple.Item1, tuple.Item2, cancellationToken).ConfigureAwait(false); + + hasChanges = true; + } + else if (airDate.Value > DateTime.UtcNow && _config.Configuration.CreateVirtualFutureEpisodes) + { + // tvdb has a lot of nearly blank episodes + _logger.Info("Creating virtual future episode {0} {1}x{2}", series.Name, tuple.Item1, tuple.Item2); + + await AddEpisode(series, tuple.Item1, tuple.Item2, cancellationToken).ConfigureAwait(false); + + hasChanges = true; + } + } + + return hasChanges; + } + + /// + /// Removes the virtual entry after a corresponding physical version has been added + /// + private async Task RemoveObsoleteMissingEpsiodes(Series series, List existingEpisodes, CancellationToken cancellationToken) + { + var physicalEpisodes = existingEpisodes + .Where(i => i.LocationType != LocationType.Virtual) + .ToList(); + + var episodesToRemove = existingEpisodes + .Where(i => i.LocationType == LocationType.Virtual) + .Where(i => + { + if (i.IndexNumber.HasValue && i.ParentIndexNumber.HasValue) + { + return physicalEpisodes.Any(p => p.ParentIndexNumber.HasValue && p.ParentIndexNumber.Value == i.ParentIndexNumber.Value && p.ContainsEpisodeNumber(i.IndexNumber.Value)); + } + + return false; + }) + .ToList(); + + var hasChanges = false; + + foreach (var episodeToRemove in episodesToRemove) + { + _logger.Info("Removing {0} {1}x{2}", series.Name, episodeToRemove.ParentIndexNumber, episodeToRemove.IndexNumber); + + await episodeToRemove.Parent.RemoveChild(episodeToRemove, cancellationToken).ConfigureAwait(false); + + hasChanges = true; + } + + return hasChanges; + } + + /// + /// Adds the episode. + /// + /// The series. + /// The season number. + /// The episode number. + /// The cancellation token. + /// Task. + private async Task AddEpisode(Series series, int seasonNumber, int episodeNumber, CancellationToken cancellationToken) + { + var season = series.Children.OfType().FirstOrDefault(i => i.IndexNumber.HasValue && i.IndexNumber.Value == seasonNumber); + + if (season == null) + { + season = await AddSeason(series, seasonNumber, cancellationToken).ConfigureAwait(false); + } + + var name = string.Format("Episode {0}", episodeNumber.ToString(UsCulture)); + + var episode = new Episode + { + Name = string.Format("Episode {0}", episodeNumber.ToString(UsCulture)), + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Parent = season, + DisplayMediaType = typeof(Episode).Name, + Id = (series.Id + name).GetMBId(typeof(Episode)) + }; + + await season.AddChild(episode, cancellationToken).ConfigureAwait(false); + + await episode.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds the season. + /// + /// The series. + /// The season number. + /// The cancellation token. + /// Task{Season}. + private async Task AddSeason(Series series, int seasonNumber, CancellationToken cancellationToken) + { + _logger.Info("Creating Season {0} entry for {1}", seasonNumber, series.Name); + + var name = string.Format("Season {0}", seasonNumber.ToString(UsCulture)); + + var path = Path.Combine(series.Path, name); + + var season = new Season + { + Name = name, + IndexNumber = seasonNumber, + Path = path, + Parent = series, + DisplayMediaType = typeof(Season).Name + }; + + _directoryWatchers.TemporarilyIgnore(path); + + try + { + var info = Directory.CreateDirectory(path); + + season.DateCreated = info.CreationTimeUtc; + season.DateModified = info.LastWriteTimeUtc; + + await series.AddChild(season, cancellationToken).ConfigureAwait(false); + + await season.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } + finally + { + _directoryWatchers.RemoveTempIgnore(path); + } + + + return season; + } + + /// + /// Gets the existing episode. + /// + /// The existing episodes. + /// The tuple. + /// Episode. + private Episode GetExistingEpisode(IEnumerable existingEpisodes, Tuple tuple) + { + return existingEpisodes + .FirstOrDefault(i => (i.ParentIndexNumber ?? -1) == tuple.Item1 && i.ContainsEpisodeNumber(tuple.Item2)); + } + + /// + /// Gets the air date. + /// + /// The series data path. + /// The season number. + /// The episode number. + /// System.Nullable{DateTime}. + private DateTime? GetAirDate(string seriesDataPath, int seasonNumber, int episodeNumber) + { + // First open up the tvdb xml file and make sure it has valid data + var filename = string.Format("episode-{0}-{1}.xml", seasonNumber.ToString(UsCulture), episodeNumber.ToString(UsCulture)); + + var xmlPath = Path.Combine(seriesDataPath, filename); + + // It appears the best way to filter out invalid entries is to only include those with valid air dates + + using (var streamReader = new StreamReader(xmlPath, Encoding.UTF8)) + { + // Use XmlReader for best performance + using (var reader = XmlReader.Create(streamReader, new XmlReaderSettings + { + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true, + ValidationType = ValidationType.None + })) + { + reader.MoveToContent(); + + // Loop through each element + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "FirstAired": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + DateTime date; + if (DateTime.TryParse(val, out date)) + { + return date.ToUniversalTime(); + } + } + + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + } + + return null; + } + } } diff --git a/MediaBrowser.Server.Implementations/Configuration/ServerConfigurationManager.cs b/MediaBrowser.Server.Implementations/Configuration/ServerConfigurationManager.cs index 96946bdab2..8165e11eb0 100644 --- a/MediaBrowser.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/MediaBrowser.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,5 +1,4 @@ -using System.IO; -using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Implementations.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -7,6 +6,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; +using System.IO; namespace MediaBrowser.Server.Implementations.Configuration { diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index b00e32afd0..0f05ccf2a2 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -37,9 +37,9 @@ ..\packages\Alchemy.2.2.1\lib\net40\Alchemy.dll - + False - ..\packages\MediaBrowser.BdInfo.1.0.0.3\lib\net45\BdInfo.dll + ..\packages\MediaBrowser.BdInfo.1.0.0.5\lib\net20\BDInfo.dll ..\packages\ServiceStack.OrmLite.Sqlite32.3.9.63\lib\net40\ServiceStack.OrmLite.SqliteNET.dll diff --git a/MediaBrowser.Server.Implementations/Session/SessionManager.cs b/MediaBrowser.Server.Implementations/Session/SessionManager.cs index 3cbd54c7b8..265cab1e71 100644 --- a/MediaBrowser.Server.Implementations/Session/SessionManager.cs +++ b/MediaBrowser.Server.Implementations/Session/SessionManager.cs @@ -473,14 +473,17 @@ namespace MediaBrowser.Server.Implementations.Session { var session = GetSessionForRemoteControl(sessionId); + var items = command.ItemIds.Select(i => _libraryManager.GetItemById(new Guid(i))) + .ToList(); + + if (items.Any(i => i.LocationType == LocationType.Virtual)) + { + throw new ArgumentException("Virtual items are not playable."); + } + if (command.PlayCommand != PlayCommand.PlayNow) { - if (command.ItemIds.Any(i => - { - var item = _libraryManager.GetItemById(new Guid(i)); - - return !session.QueueableMediaTypes.Contains(item.MediaType, StringComparer.OrdinalIgnoreCase); - })) + if (items.Any(i => !session.QueueableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase))) { throw new ArgumentException(string.Format("Session {0} is unable to queue the requested media type.", session.Id)); } diff --git a/MediaBrowser.Server.Implementations/packages.config b/MediaBrowser.Server.Implementations/packages.config index e0dd4d4733..771a5c8b2a 100644 --- a/MediaBrowser.Server.Implementations/packages.config +++ b/MediaBrowser.Server.Implementations/packages.config @@ -1,7 +1,7 @@  - + diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs index ac38b767cc..264eaeeeb8 100644 --- a/MediaBrowser.WebDashboard/Api/DashboardService.cs +++ b/MediaBrowser.WebDashboard/Api/DashboardService.cs @@ -482,6 +482,7 @@ namespace MediaBrowser.WebDashboard.Api "mediaplayer.js", "metadataconfigurationpage.js", "metadataimagespage.js", + "metadatatv.js", "moviegenres.js", "movies.js", "moviepeople.js", diff --git a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj index 5f74078fbb..741627d283 100644 --- a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj +++ b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj @@ -329,9 +329,15 @@ PreserveNewest + + PreserveNewest + PreserveNewest + + PreserveNewest + PreserveNewest