瀏覽代碼

fixes #839 - Support getting latest channel items

Luke Pulverenti 11 年之前
父節點
當前提交
9e57e16aa9

+ 62 - 0
MediaBrowser.Api/ChannelService.cs

@@ -107,6 +107,53 @@ namespace MediaBrowser.Api
         }
     }
 
+    [Route("/Channels/Items/Latest", "GET", Summary = "Gets channel items")]
+    public class GetLatestChannelItems : IReturn<QueryResult<BaseItemDto>>, IHasItemFields
+    {
+        /// <summary>
+        /// Gets or sets the user id.
+        /// </summary>
+        /// <value>The user id.</value>
+        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
+        public string UserId { get; set; }
+
+        /// <summary>
+        /// Skips over a given number of items within the results. Use for paging.
+        /// </summary>
+        /// <value>The start index.</value>
+        [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
+        public int? StartIndex { get; set; }
+
+        /// <summary>
+        /// The maximum number of items to return
+        /// </summary>
+        /// <value>The limit.</value>
+        [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")]
+        public int? Limit { get; set; }
+
+        [ApiMember(Name = "Filters", Description = "Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
+        public string Filters { get; set; }
+
+        [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, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
+        public string Fields { get; set; }
+
+        /// <summary>
+        /// Gets the filters.
+        /// </summary>
+        /// <returns>IEnumerable{ItemFilter}.</returns>
+        public IEnumerable<ItemFilter> GetFilters()
+        {
+            var val = Filters;
+
+            if (string.IsNullOrEmpty(val))
+            {
+                return new ItemFilter[] { };
+            }
+
+            return val.Split(',').Select(v => (ItemFilter)Enum.Parse(typeof(ItemFilter), v, true));
+        }
+    }
+    
     [Route("/Channels/Folder", "GET", Summary = "Gets the users channel folder, along with configured images")]
     public class GetChannelFolder : IReturn<BaseItemDto>
     {
@@ -173,5 +220,20 @@ namespace MediaBrowser.Api
 
             return ToOptimizedResult(result);
         }
+
+        public object Get(GetLatestChannelItems request)
+        {
+            var result = _channelManager.GetLatestChannelItems(new AllChannelMediaQuery
+            {
+                Limit = request.Limit,
+                StartIndex = request.StartIndex,
+                UserId = request.UserId,
+                Filters = request.GetFilters().ToArray(),
+                Fields = request.GetItemFields().ToList()
+
+            }, CancellationToken.None).Result;
+
+            return ToOptimizedResult(result);
+        }
     }
 }

+ 8 - 0
MediaBrowser.Controller/Channels/IChannelManager.cs

@@ -59,6 +59,14 @@ namespace MediaBrowser.Controller.Channels
         /// <returns>Task{QueryResult{BaseItemDto}}.</returns>
         Task<QueryResult<BaseItemDto>> GetAllMedia(AllChannelMediaQuery query, CancellationToken cancellationToken);
 
+        /// <summary>
+        /// Gets the latest media.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task{QueryResult{BaseItemDto}}.</returns>
+        Task<QueryResult<BaseItemDto>> GetLatestChannelItems(AllChannelMediaQuery query, CancellationToken cancellationToken);
+        
         /// <summary>
         /// Gets the channel items.
         /// </summary>

+ 3 - 3
MediaBrowser.Model/Channels/ChannelFeatures.cs

@@ -63,10 +63,10 @@ namespace MediaBrowser.Model.Channels
         public bool CanFilter { get; set; }
 
         /// <summary>
-        /// Gets or sets a value indicating whether this instance can download all media.
+        /// Gets or sets a value indicating whether [supports content downloading].
         /// </summary>
-        /// <value><c>true</c> if this instance can download all media; otherwise, <c>false</c>.</value>
-        public bool CanDownloadAllMedia { get; set; }
+        /// <value><c>true</c> if [supports content downloading]; otherwise, <c>false</c>.</value>
+        public bool SupportsContentDownloading { get; set; }
 
         public ChannelFeatures()
         {

+ 10 - 1
MediaBrowser.Model/Channels/ChannelQuery.cs

@@ -1,4 +1,7 @@
-namespace MediaBrowser.Model.Channels
+using MediaBrowser.Model.Querying;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.Channels
 {
     public class ChannelQuery
     {
@@ -54,7 +57,13 @@
             ChannelIds = new string[] { };
 
             ContentTypes = new ChannelMediaContentType[] { };
+
+            Filters = new ItemFilter[] { };
+            Fields = new List<ItemFields>();
         }
+
+        public ItemFilter[] Filters { get; set; }
+        public List<ItemFields> Fields { get; set; }
     }
 
 }

+ 1 - 1
MediaBrowser.Providers/Manager/ProviderManager.cs

@@ -335,7 +335,7 @@ namespace MediaBrowser.Providers.Manager
                     return false;
                 }
 
-                if (provider is IRemoteImageProvider)
+                if (provider is IRemoteImageProvider || provider is IDynamicImageProvider)
                 {
                     if (!ConfigurationManager.Configuration.EnableInternetProviders)
                     {

+ 116 - 36
MediaBrowser.Server.Implementations/Channels/ChannelDownloadScheduledTask.cs

@@ -1,6 +1,7 @@
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.IO;
 using MediaBrowser.Common.Net;
+using MediaBrowser.Common.Progress;
 using MediaBrowser.Common.ScheduledTasks;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;
@@ -9,6 +10,7 @@ using MediaBrowser.Model.Channels;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.Querying;
 using System;
 using System.Collections.Generic;
 using System.IO;
@@ -26,8 +28,9 @@ namespace MediaBrowser.Server.Implementations.Channels
         private readonly IHttpClient _httpClient;
         private readonly IFileSystem _fileSystem;
         private readonly ILibraryManager _libraryManager;
+        private readonly IUserManager _userManager;
 
-        public ChannelDownloadScheduledTask(IChannelManager manager, IServerConfigurationManager config, ILogger logger, IHttpClient httpClient, IFileSystem fileSystem, ILibraryManager libraryManager)
+        public ChannelDownloadScheduledTask(IChannelManager manager, IServerConfigurationManager config, ILogger logger, IHttpClient httpClient, IFileSystem fileSystem, ILibraryManager libraryManager, IUserManager userManager)
         {
             _manager = manager;
             _config = config;
@@ -35,6 +38,7 @@ namespace MediaBrowser.Server.Implementations.Channels
             _httpClient = httpClient;
             _fileSystem = fileSystem;
             _libraryManager = libraryManager;
+            _userManager = userManager;
         }
 
         public string Name
@@ -55,70 +59,118 @@ namespace MediaBrowser.Server.Implementations.Channels
         public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
         {
             CleanChannelContent(cancellationToken);
-            progress.Report(5);
 
-            await DownloadChannelContent(cancellationToken, progress).ConfigureAwait(false);
+            var users = _userManager.Users.Select(i => i.Id.ToString("N")).ToList();
+
+            var numComplete = 0;
+
+            foreach (var user in users)
+            {
+                double percentPerUser = 1;
+                percentPerUser /= users.Count;
+                var startingPercent = numComplete * percentPerUser * 100;
+
+                var innerProgress = new ActionableProgress<double>();
+                innerProgress.RegisterAction(p => progress.Report(startingPercent + (.8 * p)));
+
+                await DownloadContent(user, cancellationToken, innerProgress).ConfigureAwait(false);
+
+                numComplete++;
+                double percent = numComplete;
+                percent /= users.Count;
+                progress.Report(percent * 100);
+            }
+
             progress.Report(100);
         }
 
-        private void CleanChannelContent(CancellationToken cancellationToken)
+        private async Task DownloadContent(string user,
+            CancellationToken cancellationToken,
+            IProgress<double> progress)
         {
-            if (!_config.Configuration.ChannelOptions.MaxDownloadAge.HasValue)
+            var innerProgress = new ActionableProgress<double>();
+            innerProgress.RegisterAction(p => progress.Report(0 + (.8 * p)));
+            await DownloadAllChannelContent(user, cancellationToken, innerProgress).ConfigureAwait(false);
+            progress.Report(80);
+
+            innerProgress = new ActionableProgress<double>();
+            innerProgress.RegisterAction(p => progress.Report(80 + (.2 * p)));
+            await DownloadLatestChannelContent(user, cancellationToken, progress).ConfigureAwait(false);
+            progress.Report(100);
+        }
+
+        private async Task DownloadLatestChannelContent(string userId,
+            CancellationToken cancellationToken,
+            IProgress<double> progress)
+        {
+            var result = await _manager.GetLatestChannelItems(new AllChannelMediaQuery
             {
-                return;
-            }
+                UserId = userId
 
-            var minDateModified = DateTime.UtcNow.AddDays(0 - _config.Configuration.ChannelOptions.MaxDownloadAge.Value);
+            }, cancellationToken).ConfigureAwait(false);
+
+            progress.Report(5);
+
+            var innerProgress = new ActionableProgress<double>();
+            innerProgress.RegisterAction(p => progress.Report(5 + (.95 * p)));
 
             var path = _manager.ChannelDownloadPath;
 
-            try
-            {
-                DeleteCacheFilesFromDirectory(cancellationToken, path, minDateModified, new Progress<double>());
-            }
-            catch (DirectoryNotFoundException)
-            {
-                // No biggie here. Nothing to delete
-            }
+            await DownloadChannelContent(result, path, cancellationToken, innerProgress).ConfigureAwait(false);
         }
 
-        private async Task DownloadChannelContent(CancellationToken cancellationToken, IProgress<double> progress)
+        private async Task DownloadAllChannelContent(string userId,
+            CancellationToken cancellationToken,
+            IProgress<double> progress)
         {
-            if (_config.Configuration.ChannelOptions.DownloadingChannels.Length == 0)
-            {
-                return;
-            }
-
             var result = await _manager.GetAllMedia(new AllChannelMediaQuery
             {
-                ChannelIds = _config.Configuration.ChannelOptions.DownloadingChannels
+                UserId = userId
 
             }, cancellationToken).ConfigureAwait(false);
 
+            progress.Report(5);
+
+            var innerProgress = new ActionableProgress<double>();
+            innerProgress.RegisterAction(p => progress.Report(5 + (.95 * p)));
+
             var path = _manager.ChannelDownloadPath;
 
+            await DownloadChannelContent(result, path, cancellationToken, innerProgress).ConfigureAwait(false);
+        }
+
+        private async Task DownloadChannelContent(QueryResult<BaseItemDto> result,
+            string path,
+            CancellationToken cancellationToken,
+            IProgress<double> progress)
+        {
             var numComplete = 0;
 
             foreach (var item in result.Items)
             {
-                try
-                {
-                    await DownloadChannelItem(item, cancellationToken, path);
-                }
-                catch (OperationCanceledException)
+                if (_config.Configuration.ChannelOptions.DownloadingChannels.Contains(item.ChannelId))
                 {
-                    break;
-                }
-                catch (Exception ex)
-                {
-                    _logger.ErrorException("Error downloading channel content for {0}", ex, item.Name);
+                    try
+                    {
+                        await DownloadChannelItem(item, cancellationToken, path);
+                    }
+                    catch (OperationCanceledException)
+                    {
+                        break;
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.ErrorException("Error downloading channel content for {0}", ex, item.Name);
+                    }
                 }
 
                 numComplete++;
                 double percent = numComplete;
                 percent /= result.Items.Length;
-                progress.Report(percent * 95 + 5);
+                progress.Report(percent * 100);
             }
+
+            progress.Report(100);
         }
 
         private async Task DownloadChannelItem(BaseItemDto item,
@@ -212,6 +264,27 @@ namespace MediaBrowser.Server.Implementations.Channels
                 };
         }
 
+        private void CleanChannelContent(CancellationToken cancellationToken)
+        {
+            if (!_config.Configuration.ChannelOptions.MaxDownloadAge.HasValue)
+            {
+                return;
+            }
+
+            var minDateModified = DateTime.UtcNow.AddDays(0 - _config.Configuration.ChannelOptions.MaxDownloadAge.Value);
+
+            var path = _manager.ChannelDownloadPath;
+
+            try
+            {
+                DeleteCacheFilesFromDirectory(cancellationToken, path, minDateModified, new Progress<double>());
+            }
+            catch (DirectoryNotFoundException)
+            {
+                // No biggie here. Nothing to delete
+            }
+        }
+
         /// <summary>
         /// Deletes the cache files from directory with a last write time less than a given date
         /// </summary>
@@ -260,15 +333,22 @@ namespace MediaBrowser.Server.Implementations.Channels
             }
         }
 
+        /// <summary>
+        /// Gets a value indicating whether this instance is hidden.
+        /// </summary>
+        /// <value><c>true</c> if this instance is hidden; otherwise, <c>false</c>.</value>
         public bool IsHidden
         {
             get
             {
-                return !_manager.GetAllChannelFeatures()
-                    .Any(i => i.CanDownloadAllMedia && _config.Configuration.ChannelOptions.DownloadingChannels.Contains(i.Id));
+                return !_manager.GetAllChannelFeatures().Any();
             }
         }
 
+        /// <summary>
+        /// Gets a value indicating whether this instance is enabled.
+        /// </summary>
+        /// <value><c>true</c> if this instance is enabled; otherwise, <c>false</c>.</value>
         public bool IsEnabled
         {
             get

+ 119 - 11
MediaBrowser.Server.Implementations/Channels/ChannelManager.cs

@@ -53,6 +53,14 @@ namespace MediaBrowser.Server.Implementations.Channels
             _localization = localization;
         }
 
+        private TimeSpan CacheLength
+        {
+            get
+            {
+                return TimeSpan.FromDays(1);
+            }
+        }
+
         public void AddParts(IEnumerable<IChannel> channels, IEnumerable<IChannelFactory> factories)
         {
             _channels = channels.ToArray();
@@ -443,6 +451,7 @@ namespace MediaBrowser.Server.Implementations.Channels
             InternalChannelFeatures features)
         {
             var isIndexable = provider is IIndexableChannel;
+            var supportsLatest = provider is ISupportsLatestMedia;
 
             return new ChannelFeatures
             {
@@ -453,10 +462,10 @@ namespace MediaBrowser.Server.Implementations.Channels
                 MaxPageSize = features.MaxPageSize,
                 MediaTypes = features.MediaTypes,
                 SupportsSortOrderToggle = features.SupportsSortOrderToggle,
-                SupportsLatestMedia = provider is ISupportsLatestMedia,
+                SupportsLatestMedia = supportsLatest,
                 Name = channel.Name,
                 Id = channel.Id.ToString("N"),
-                CanDownloadAllMedia = isIndexable
+                SupportsContentDownloading = isIndexable || supportsLatest
             };
         }
 
@@ -470,6 +479,105 @@ namespace MediaBrowser.Server.Implementations.Channels
             return ("Channel " + name).GetMBId(typeof(Channel));
         }
 
+        public async Task<QueryResult<BaseItemDto>> GetLatestChannelItems(AllChannelMediaQuery query, CancellationToken cancellationToken)
+        {
+            var user = string.IsNullOrWhiteSpace(query.UserId)
+                ? null
+                : _userManager.GetUserById(new Guid(query.UserId));
+
+            var channels = _channels;
+
+            if (query.ChannelIds.Length > 0)
+            {
+                // Avoid implicitly captured closure
+                var ids = query.ChannelIds;
+                channels = channels
+                    .Where(i => ids.Contains(GetInternalChannelId(i.Name).ToString("N")))
+                    .ToArray();
+            }
+
+            // Avoid implicitly captured closure
+            var userId = query.UserId;
+
+            var tasks = channels
+                .Select(async i =>
+                {
+                    var indexable = i as ISupportsLatestMedia;
+
+                    if (indexable != null)
+                    {
+                        try
+                        {
+                            var result = await indexable.GetLatestMedia(new ChannelLatestMediaSearch
+                            {
+                                UserId = userId
+
+                            }, cancellationToken).ConfigureAwait(false);
+
+                            var resultItems = result.ToList();
+
+                            return new Tuple<IChannel, ChannelItemResult>(i, new ChannelItemResult
+                            {
+                                Items = resultItems,
+                                TotalRecordCount = resultItems.Count
+                            });
+                        }
+                        catch (Exception ex)
+                        {
+                            _logger.ErrorException("Error getting all media from {0}", ex, i.Name);
+                        }
+                    }
+                    return new Tuple<IChannel, ChannelItemResult>(i, new ChannelItemResult { });
+                });
+
+            var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+            var totalCount = results.Length;
+
+            IEnumerable<Tuple<IChannel, ChannelItemInfo>> items = results
+                .SelectMany(i => i.Item2.Items.Select(m => new Tuple<IChannel, ChannelItemInfo>(i.Item1, m)))
+                .OrderBy(i => i.Item2.Name);
+
+            if (query.ContentTypes.Length > 0)
+            {
+                // Avoid implicitly captured closure
+                var contentTypes = query.ContentTypes;
+
+                items = items.Where(i => contentTypes.Contains(i.Item2.ContentType));
+            }
+
+            // Avoid implicitly captured closure
+            var token = cancellationToken;
+            var itemTasks = items.Select(i =>
+            {
+                var channelProvider = i.Item1;
+                var channel = GetChannel(GetInternalChannelId(channelProvider.Name).ToString("N"));
+                return GetChannelItemEntity(i.Item2, channelProvider, channel, token);
+            });
+
+            IEnumerable<BaseItem> internalItems = await Task.WhenAll(itemTasks).ConfigureAwait(false);
+
+            internalItems = ApplyFilters(internalItems, query.Filters, user);
+
+            if (query.StartIndex.HasValue)
+            {
+                internalItems = internalItems.Skip(query.StartIndex.Value);
+            }
+            if (query.Limit.HasValue)
+            {
+                internalItems = internalItems.Take(query.Limit.Value);
+            }
+
+            var returnItemArray = internalItems.Select(i => _dtoService.GetBaseItemDto(i, query.Fields, user))
+                .ToArray();
+
+            return new QueryResult<BaseItemDto>
+            {
+                TotalRecordCount = totalCount,
+                Items = returnItemArray
+            };
+        }
+
         public async Task<QueryResult<BaseItemDto>> GetAllMedia(AllChannelMediaQuery query, CancellationToken cancellationToken)
         {
             var user = string.IsNullOrWhiteSpace(query.UserId)
@@ -480,11 +588,16 @@ namespace MediaBrowser.Server.Implementations.Channels
 
             if (query.ChannelIds.Length > 0)
             {
+                // Avoid implicitly captured closure
+                var ids = query.ChannelIds;
                 channels = channels
-                    .Where(i => query.ChannelIds.Contains(GetInternalChannelId(i.Name).ToString("N")))
+                    .Where(i => ids.Contains(GetInternalChannelId(i.Name).ToString("N")))
                     .ToArray();
             }
 
+            // Avoid implicitly captured closure
+            var userId = query.UserId;
+
             var tasks = channels
                 .Select(async i =>
                 {
@@ -496,7 +609,7 @@ namespace MediaBrowser.Server.Implementations.Channels
                         {
                             var result = await indexable.GetAllMedia(new InternalAllChannelMediaQuery
                             {
-                                UserId = query.UserId
+                                UserId = userId
 
                             }, cancellationToken).ConfigureAwait(false);
 
@@ -546,12 +659,7 @@ namespace MediaBrowser.Server.Implementations.Channels
 
             var internalItems = await Task.WhenAll(itemTasks).ConfigureAwait(false);
 
-            // Get everything
-            var fields = Enum.GetNames(typeof(ItemFields))
-                    .Select(i => (ItemFields)Enum.Parse(typeof(ItemFields), i, true))
-                    .ToList();
-
-            var returnItemArray = internalItems.Select(i => _dtoService.GetBaseItemDto(i, fields, user))
+            var returnItemArray = internalItems.Select(i => _dtoService.GetBaseItemDto(i, query.Fields, user))
                 .ToArray();
 
             return new QueryResult<BaseItemDto>
@@ -641,7 +749,7 @@ namespace MediaBrowser.Server.Implementations.Channels
         {
             var userId = user.Id.ToString("N");
 
-            var cacheLength = TimeSpan.FromDays(1);
+            var cacheLength = CacheLength;
             var cachePath = GetChannelDataCachePath(channel, userId, folderId, sortField, sortDescending);
 
             try

+ 2 - 1
MediaBrowser.Server.Implementations/Localization/JavaScript/javascript.json

@@ -147,5 +147,6 @@
 	"ButtonRemove": "Remove",
 	"LabelChapterDownloaders": "Chapter downloaders:",
 	"LabelChapterDownloadersHelp": "Enable and rank your preferred chapter downloaders in order of priority. Lower priority downloaders will only be used to fill in missing information.",
-	"HeaderFavoriteAlbums": "Favorite Albums"
+	"HeaderFavoriteAlbums": "Favorite Albums",
+	"HeaderLatestChannelMedia": "Latest Channel Items"
 }

+ 2 - 0
MediaBrowser.Server.Implementations/Localization/Server/server.json

@@ -785,11 +785,13 @@
 	"LabelHomePageSection1": "Home page section one:",
 	"LabelHomePageSection2": "Home page section two:",
 	"LabelHomePageSection3": "Home page section three:",
+	"LabelHomePageSection4": "Home page section four:",
 	"OptionMyLibraryButtons": "My library (buttons)",
 	"OptionMyLibrary": "My library",
 	"OptionMyLibrarySmall": "My library (small)",
 	"OptionResumablemedia": "Resume",
 	"OptionLatestMedia": "Latest media",
+	"OptionLatestChannelMedia": "Latest channel items",
 	"OptionNone": "None",
 	"HeaderLiveTv": "Live TV",
 	"HeaderReports": "Reports",

+ 1 - 0
MediaBrowser.WebDashboard/Api/DashboardService.cs

@@ -538,6 +538,7 @@ namespace MediaBrowser.WebDashboard.Api
                                 "autoorganizetv.js",
                                 "autoorganizelog.js",
                                 "channels.js",
+                                "channelslatest.js",
                                 "channelitems.js",
                                 "channelsettings.js",
                                 "dashboardgeneral.js",

+ 6 - 0
MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj

@@ -106,6 +106,9 @@
     <Content Include="dashboard-ui\channelsettings.html">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
+    <Content Include="dashboard-ui\channelslatest.html">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
     <Content Include="dashboard-ui\css\chromecast.css">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
@@ -622,6 +625,9 @@
     <Content Include="dashboard-ui\scripts\channelsettings.js">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
+    <Content Include="dashboard-ui\scripts\channelslatest.js">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
     <Content Include="dashboard-ui\scripts\chromecast.js">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>