Bläddra i källkod

fixes #791 - Support server-side playlists

Luke Pulverenti 11 år sedan
förälder
incheckning
2714127d2b
33 ändrade filer med 636 tillägg och 130 borttagningar
  1. 79 2
      MediaBrowser.Api/PlaylistService.cs
  2. 3 3
      MediaBrowser.Api/UserLibrary/BaseItemsRequest.cs
  3. 23 1
      MediaBrowser.Controller/Entities/BaseItem.cs
  4. 0 5
      MediaBrowser.Controller/Entities/BasePluginFolder.cs
  5. 7 1
      MediaBrowser.Controller/Entities/Folder.cs
  6. 13 0
      MediaBrowser.Controller/Entities/LinkedChild.cs
  7. 11 1
      MediaBrowser.Controller/Entities/Movies/BoxSet.cs
  8. 9 0
      MediaBrowser.Controller/Entities/TV/Season.cs
  9. 9 0
      MediaBrowser.Controller/Entities/TV/Series.cs
  10. 0 1
      MediaBrowser.Controller/Entities/UserView.cs
  11. 72 5
      MediaBrowser.Controller/Playlists/Playlist.cs
  12. 4 0
      MediaBrowser.Controller/Playlists/PlaylistCreationOptions.cs
  13. 72 0
      MediaBrowser.Controller/Providers/BaseItemXmlParser.cs
  14. 1 1
      MediaBrowser.Dlna/ContentDirectory/ControlHandler.cs
  15. 1 0
      MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj
  16. 0 54
      MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs
  17. 8 6
      MediaBrowser.LocalMetadata/Savers/FolderXmlSaver.cs
  18. 68 0
      MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs
  19. 22 9
      MediaBrowser.LocalMetadata/Savers/XmlSaverHelpers.cs
  20. 13 1
      MediaBrowser.Model/Dto/BaseItemDto.cs
  21. 1 1
      MediaBrowser.Server.Implementations/Channels/ChannelDownloadScheduledTask.cs
  22. 1 7
      MediaBrowser.Server.Implementations/Collections/CollectionManager.cs
  23. 1 0
      MediaBrowser.Server.Implementations/Collections/ManualCollectionsFolder.cs
  24. 46 1
      MediaBrowser.Server.Implementations/Dto/DtoService.cs
  25. 1 1
      MediaBrowser.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs
  26. 38 0
      MediaBrowser.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
  27. 9 2
      MediaBrowser.Server.Implementations/Localization/JavaScript/javascript.json
  28. 5 1
      MediaBrowser.Server.Implementations/Localization/Server/server.json
  29. 1 0
      MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj
  30. 13 4
      MediaBrowser.Server.Implementations/Playlists/ManualPlaylistsFolder.cs
  31. 86 19
      MediaBrowser.Server.Implementations/Playlists/PlaylistManager.cs
  32. 4 1
      MediaBrowser.WebDashboard/Api/DashboardService.cs
  33. 15 3
      MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj

+ 79 - 2
MediaBrowser.Api/PlaylistService.cs

@@ -1,9 +1,12 @@
 using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Playlists;
 using MediaBrowser.Model.Querying;
 using ServiceStack;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
@@ -18,6 +21,9 @@ namespace MediaBrowser.Api
 
         [ApiMember(Name = "Ids", Description = "Item Ids to add to the playlist", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
         public string Ids { get; set; }
+
+        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
+        public string UserId { get; set; }
     }
 
     [Route("/Playlists/{Id}/Items", "POST", Summary = "Adds items to a playlist")]
@@ -37,16 +43,55 @@ namespace MediaBrowser.Api
         public string Id { get; set; }
     }
 
+    [Route("/Playlists/{Id}/Items", "GET", Summary = "Gets the original items of a playlist")]
+    public class GetPlaylistItems : IReturn<QueryResult<BaseItemDto>>, IHasItemFields
+    {
+        [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
+        public string Id { get; set; }
+
+        /// <summary>
+        /// Gets or sets the user id.
+        /// </summary>
+        /// <value>The user id.</value>
+        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "GET")]
+        public Guid? 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; }
+
+        /// <summary>
+        /// Fields to return within the items, in addition to basic information
+        /// </summary>
+        /// <value>The fields.</value>
+        [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; }
+    }
+
     [Authenticated]
     public class PlaylistService : BaseApiService
     {
         private readonly IPlaylistManager _playlistManager;
         private readonly IDtoService _dtoService;
+        private readonly IUserManager _userManager;
+        private readonly ILibraryManager _libraryManager;
 
-        public PlaylistService(IDtoService dtoService, IPlaylistManager playlistManager)
+        public PlaylistService(IDtoService dtoService, IPlaylistManager playlistManager, IUserManager userManager, ILibraryManager libraryManager)
         {
             _dtoService = dtoService;
             _playlistManager = playlistManager;
+            _userManager = userManager;
+            _libraryManager = libraryManager;
         }
 
         public object Post(CreatePlaylist request)
@@ -54,7 +99,8 @@ namespace MediaBrowser.Api
             var task = _playlistManager.CreatePlaylist(new PlaylistCreationOptions
             {
                 Name = request.Name,
-                ItemIdList = (request.Ids ?? string.Empty).Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToList()
+                ItemIdList = (request.Ids ?? string.Empty).Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToList(),
+                UserId = request.UserId
             });
 
             var item = task.Result;
@@ -80,5 +126,36 @@ namespace MediaBrowser.Api
 
             //Task.WaitAll(task);
         }
+
+        public object Get(GetPlaylistItems request)
+        {
+            var playlist = (Playlist)_libraryManager.GetItemById(request.Id);
+            var user = request.UserId.HasValue ? _userManager.GetUserById(request.UserId.Value) : null;
+            var items = playlist.GetManageableItems().ToArray();
+
+            var count = items.Length;
+
+            if (request.StartIndex.HasValue)
+            {
+                items = items.Skip(request.StartIndex.Value).ToArray();
+            }
+
+            if (request.Limit.HasValue)
+            {
+                items = items.Take(request.Limit.Value).ToArray();
+            }
+
+            var dtos = items
+                   .Select(i => _dtoService.GetBaseItemDto(i, request.GetItemFields().ToList(), user))
+                   .ToArray();
+
+            var result = new ItemsResult
+            {
+                Items = dtos,
+                TotalRecordCount = count
+            };
+
+            return ToOptimizedResult(result);
+        }
     }
 }

+ 3 - 3
MediaBrowser.Api/UserLibrary/BaseItemsRequest.cs

@@ -1,9 +1,9 @@
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
 using ServiceStack;
 using System;
+using System.Collections.Generic;
+using System.Linq;
 
 namespace MediaBrowser.Api.UserLibrary
 {

+ 23 - 1
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1006,6 +1006,18 @@ namespace MediaBrowser.Controller.Entities
 
         private BaseItem FindLinkedChild(LinkedChild info)
         {
+            if (!string.IsNullOrWhiteSpace(info.ItemName))
+            {
+                if (string.Equals(info.ItemType, "musicgenre", StringComparison.OrdinalIgnoreCase))
+                {
+                    return LibraryManager.GetMusicGenre(info.ItemName);
+                }
+                if (string.Equals(info.ItemType, "musicartist", StringComparison.OrdinalIgnoreCase))
+                {
+                    return LibraryManager.GetArtist(info.ItemName);
+                }
+            }
+
             if (!string.IsNullOrEmpty(info.Path))
             {
                 var itemByPath = LibraryManager.RootFolder.FindByPath(info.Path);
@@ -1028,7 +1040,17 @@ namespace MediaBrowser.Controller.Entities
                         {
                             if (info.ItemYear.HasValue)
                             {
-                                return info.ItemYear.Value == (i.ProductionYear ?? -1);
+                                if (info.ItemYear.Value != (i.ProductionYear ?? -1))
+                                {
+                                    return false;
+                                }
+                            }
+                            if (info.ItemIndexNumber.HasValue)
+                            {
+                                if (info.ItemIndexNumber.Value != (i.IndexNumber ?? -1))
+                                {
+                                    return false;
+                                }
                             }
                             return true;
                         }

+ 0 - 5
MediaBrowser.Controller/Entities/BasePluginFolder.cs

@@ -7,11 +7,6 @@ namespace MediaBrowser.Controller.Entities
     /// </summary>
     public abstract class BasePluginFolder : Folder, ICollectionFolder, IByReferenceItem
     {
-        protected BasePluginFolder()
-        {
-            DisplayMediaType = "CollectionFolder";
-        }
-
         public virtual string CollectionType
         {
             get { return null; }

+ 7 - 1
MediaBrowser.Controller/Entities/Folder.cs

@@ -38,6 +38,12 @@ namespace MediaBrowser.Controller.Entities
             Tags = new List<string>();
         }
 
+        [IgnoreDataMember]
+        public virtual bool IsPreSorted
+        {
+            get { return false; }
+        }
+
         /// <summary>
         /// Gets a value indicating whether this instance is folder.
         /// </summary>
@@ -855,7 +861,7 @@ namespace MediaBrowser.Controller.Entities
         /// <param name="includeLinkedChildren">if set to <c>true</c> [include linked children].</param>
         /// <returns>IEnumerable{BaseItem}.</returns>
         /// <exception cref="System.ArgumentNullException"></exception>
-        public IEnumerable<BaseItem> GetRecursiveChildren(User user, bool includeLinkedChildren = true)
+        public virtual IEnumerable<BaseItem> GetRecursiveChildren(User user, bool includeLinkedChildren = true)
         {
             if (user == null)
             {

+ 13 - 0
MediaBrowser.Controller/Entities/LinkedChild.cs

@@ -12,12 +12,25 @@ namespace MediaBrowser.Controller.Entities
         public string ItemName { get; set; }
         public string ItemType { get; set; }
         public int? ItemYear { get; set; }
+        public int? ItemIndexNumber { get; set; }
 
         /// <summary>
         /// Serves as a cache
         /// </summary>
         [IgnoreDataMember]
         public Guid? ItemId { get; set; }
+
+        public static LinkedChild Create(BaseItem item)
+        {
+            return new LinkedChild
+            {
+                ItemName = item.Name,
+                ItemYear = item.ProductionYear,
+                ItemType = item.GetType().Name,
+                Type = LinkedChildType.Manual,
+                ItemIndexNumber = item.IndexNumber
+            };
+        }
     }
 
     public enum LinkedChildType

+ 11 - 1
MediaBrowser.Controller/Entities/Movies/BoxSet.cs

@@ -1,4 +1,5 @@
-using MediaBrowser.Common.Progress;
+using System.Runtime.Serialization;
+using MediaBrowser.Common.Progress;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Entities;
@@ -58,6 +59,15 @@ namespace MediaBrowser.Controller.Entities.Movies
             return config.BlockUnratedItems.Contains(UnratedItem.Movie);
         }
 
+        [IgnoreDataMember]
+        public override bool IsPreSorted
+        {
+            get
+            {
+                return true;
+            }
+        }
+
         public override IEnumerable<BaseItem> GetChildren(User user, bool includeLinkedChildren)
         {
             var children = base.GetChildren(user, includeLinkedChildren);

+ 9 - 0
MediaBrowser.Controller/Entities/TV/Season.cs

@@ -29,6 +29,15 @@ namespace MediaBrowser.Controller.Entities.TV
             }
         }
 
+        [IgnoreDataMember]
+        public override bool IsPreSorted
+        {
+            get
+            {
+                return true;
+            }
+        }
+
         /// <summary>
         /// We want to group into our Series
         /// </summary>

+ 9 - 0
MediaBrowser.Controller/Entities/TV/Series.cs

@@ -39,6 +39,15 @@ namespace MediaBrowser.Controller.Entities.TV
             DisplaySpecialsWithSeasons = true;
         }
 
+        [IgnoreDataMember]
+        public override bool IsPreSorted
+        {
+            get
+            {
+                return true;
+            }
+        }
+
         public bool DisplaySpecialsWithSeasons { get; set; }
 
         public List<Guid> LocalTrailerIds { get; set; }

+ 0 - 1
MediaBrowser.Controller/Entities/UserView.cs

@@ -1,7 +1,6 @@
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Model.Entities;
-using MoreLinq;
 using System;
 using System.Collections.Generic;
 using System.Linq;

+ 72 - 5
MediaBrowser.Controller/Playlists/Playlist.cs

@@ -1,20 +1,87 @@
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using System;
 using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.Serialization;
 
 namespace MediaBrowser.Controller.Playlists
 {
     public class Playlist : Folder
     {
-        public List<string> ItemIds { get; set; }
+        public string OwnerUserId { get; set; }
 
-        public Playlist()
+        public override IEnumerable<BaseItem> GetChildren(User user, bool includeLinkedChildren)
         {
-            ItemIds = new List<string>();
+            return GetPlayableItems(user);
         }
 
-        public override IEnumerable<BaseItem> GetChildren(User user, bool includeLinkedChildren)
+        public override IEnumerable<BaseItem> GetRecursiveChildren(User user, bool includeLinkedChildren = true)
+        {
+            return GetPlayableItems(user);
+        }
+
+        public IEnumerable<BaseItem> GetManageableItems()
+        {
+            return GetLinkedChildren();
+        }
+
+        private IEnumerable<BaseItem> GetPlayableItems(User user)
+        {
+            return GetPlaylistItems(MediaType, base.GetChildren(user, true), user);
+        }
+
+        public static IEnumerable<BaseItem> GetPlaylistItems(string playlistMediaType, IEnumerable<BaseItem> inputItems, User user)
+        {
+            return inputItems.SelectMany(i =>
+            {
+                var folder = i as Folder;
+
+                if (folder != null)
+                {
+                    var items = folder.GetRecursiveChildren(user, true)
+                        .Where(m => !m.IsFolder && string.Equals(m.MediaType, playlistMediaType, StringComparison.OrdinalIgnoreCase));
+
+                    if (!folder.IsPreSorted)
+                    {
+                        items = LibraryManager.Sort(items, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending);
+                    }
+
+                    return items;
+                }
+
+                return new[] { i };
+            });
+        }
+
+        [IgnoreDataMember]
+        public override bool IsPreSorted
+        {
+            get
+            {
+                return true;
+            }
+        }
+
+        public string PlaylistMediaType { get; set; }
+
+        public override string MediaType
+        {
+            get
+            {
+                return PlaylistMediaType;
+            }
+        }
+
+        public void SetMediaType(string value)
+        {
+            PlaylistMediaType = value;
+        }
+
+        public override bool IsVisible(User user)
         {
-            return base.GetChildren(user, includeLinkedChildren);
+            return base.IsVisible(user) && string.Equals(user.Id.ToString("N"), OwnerUserId);
         }
     }
 }

+ 4 - 0
MediaBrowser.Controller/Playlists/PlaylistCreationOptions.cs

@@ -8,6 +8,10 @@ namespace MediaBrowser.Controller.Playlists
 
         public List<string> ItemIdList { get; set; }
 
+        public string MediaType { get; set; }
+
+        public string UserId { get; set; }
+
         public PlaylistCreationOptions()
         {
             ItemIdList = new List<string>();

+ 72 - 0
MediaBrowser.Controller/Providers/BaseItemXmlParser.cs

@@ -1283,6 +1283,78 @@ namespace MediaBrowser.Controller.Providers
             return new[] { personInfo };
         }
 
+        protected LinkedChild GetLinkedChild(XmlReader reader)
+        {
+            reader.MoveToContent();
+
+            var linkedItem = new LinkedChild
+            {
+                Type = LinkedChildType.Manual
+            };
+
+            while (reader.Read())
+            {
+                if (reader.NodeType == XmlNodeType.Element)
+                {
+                    switch (reader.Name)
+                    {
+                        case "Name":
+                            {
+                                linkedItem.ItemName = reader.ReadElementContentAsString();
+                                break;
+                            }
+
+                        case "Type":
+                            {
+                                linkedItem.ItemType = reader.ReadElementContentAsString();
+                                break;
+                            }
+
+                        case "Year":
+                            {
+                                var val = reader.ReadElementContentAsString();
+
+                                if (!string.IsNullOrWhiteSpace(val))
+                                {
+                                    int rval;
+
+                                    if (int.TryParse(val, NumberStyles.Integer, _usCulture, out rval))
+                                    {
+                                        linkedItem.ItemYear = rval;
+                                    }
+                                }
+
+                                break;
+                            }
+
+                        case "IndexNumber":
+                            {
+                                var val = reader.ReadElementContentAsString();
+
+                                if (!string.IsNullOrWhiteSpace(val))
+                                {
+                                    int rval;
+
+                                    if (int.TryParse(val, NumberStyles.Integer, _usCulture, out rval))
+                                    {
+                                        linkedItem.ItemIndexNumber = rval;
+                                    }
+                                }
+
+                                break;
+                            }
+
+                        default:
+                            reader.Skip();
+                            break;
+                    }
+                }
+            }
+
+            return string.IsNullOrWhiteSpace(linkedItem.ItemName) || string.IsNullOrWhiteSpace(linkedItem.ItemType) ? null : linkedItem;
+        }
+
+
         /// <summary>
         /// Used to split names of comma or pipe delimeted genres and people
         /// </summary>

+ 1 - 1
MediaBrowser.Dlna/ContentDirectory/ControlHandler.cs

@@ -462,7 +462,7 @@ namespace MediaBrowser.Dlna.ContentDirectory
 
             items = FilterUnsupportedContent(items);
 
-            if (folder is Series || folder is Season || folder is BoxSet)
+            if (folder.IsPreSorted)
             {
                 return items;
             }

+ 1 - 0
MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj

@@ -83,6 +83,7 @@
     <Compile Include="Savers\GameXmlSaver.cs" />
     <Compile Include="Savers\MovieXmlSaver.cs" />
     <Compile Include="Savers\PersonXmlSaver.cs" />
+    <Compile Include="Savers\PlaylistXmlSaver.cs" />
     <Compile Include="Savers\SeasonXmlSaver.cs" />
     <Compile Include="Savers\SeriesXmlSaver.cs" />
     <Compile Include="Savers\XmlSaverHelpers.cs" />

+ 0 - 54
MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs

@@ -71,59 +71,5 @@ namespace MediaBrowser.LocalMetadata.Parsers
 
             item.LinkedChildren = list;
         }
-
-        private LinkedChild GetLinkedChild(XmlReader reader)
-        {
-            reader.MoveToContent();
-
-            var linkedItem = new LinkedChild
-            {
-                Type = LinkedChildType.Manual
-            };
-
-            while (reader.Read())
-            {
-                if (reader.NodeType == XmlNodeType.Element)
-                {
-                    switch (reader.Name)
-                    {
-                        case "Name":
-                            {
-                                linkedItem.ItemName = reader.ReadElementContentAsString();
-                                break;
-                            }
-
-                        case "Type":
-                            {
-                                linkedItem.ItemType = reader.ReadElementContentAsString();
-                                break;
-                            }
-
-                        case "Year":
-                            {
-                                var val = reader.ReadElementContentAsString();
-
-                                if (!string.IsNullOrWhiteSpace(val))
-                                {
-                                    int rval;
-
-                                    if (int.TryParse(val, NumberStyles.Integer, UsCulture, out rval))
-                                    {
-                                        linkedItem.ItemYear = rval;
-                                    }
-                                }
-
-                                break;
-                            }
-
-                        default:
-                            reader.Skip();
-                            break;
-                    }
-                }
-            }
-
-            return string.IsNullOrWhiteSpace(linkedItem.ItemName) || string.IsNullOrWhiteSpace(linkedItem.ItemType) ? null : linkedItem;
-        }
     }
 }

+ 8 - 6
MediaBrowser.LocalMetadata/Savers/FolderXmlSaver.cs

@@ -1,12 +1,13 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Text;
-using System.Threading;
-using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
 
 namespace MediaBrowser.LocalMetadata.Savers
 {
@@ -37,7 +38,8 @@ namespace MediaBrowser.LocalMetadata.Savers
             {
                 if (!(item is Series) && !(item is BoxSet) && !(item is MusicArtist) && !(item is MusicAlbum) &&
                     !(item is Season) &&
-                    !(item is GameSystem))
+                    !(item is GameSystem) &&
+                    !(item is Playlist))
                 {
                     return updateType >= ItemUpdateType.MetadataDownload;
                 }

+ 68 - 0
MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs

@@ -0,0 +1,68 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+namespace MediaBrowser.LocalMetadata.Savers
+{
+    public class PlaylistXmlSaver : IMetadataFileSaver
+    {
+        public string Name
+        {
+            get
+            {
+                return "Media Browser Xml";
+            }
+        }
+
+        /// <summary>
+        /// Determines whether [is enabled for] [the specified item].
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="updateType">Type of the update.</param>
+        /// <returns><c>true</c> if [is enabled for] [the specified item]; otherwise, <c>false</c>.</returns>
+        public bool IsEnabledFor(IHasMetadata item, ItemUpdateType updateType)
+        {
+            if (!item.SupportsLocalMetadata)
+            {
+                return false;
+            }
+
+            return item is BoxSet && updateType >= ItemUpdateType.MetadataDownload;
+        }
+
+        /// <summary>
+        /// Saves the specified item.
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        public void Save(IHasMetadata item, CancellationToken cancellationToken)
+        {
+            var builder = new StringBuilder();
+
+            builder.Append("<Item>");
+
+            XmlSaverHelpers.AddCommonNodes((BoxSet)item, builder);
+
+            builder.Append("</Item>");
+
+            var xmlFilePath = GetSavePath(item);
+
+            XmlSaverHelpers.Save(builder, xmlFilePath, new List<string> { });
+        }
+
+        /// <summary>
+        /// Gets the save path.
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <returns>System.String.</returns>
+        public string GetSavePath(IHasMetadata item)
+        {
+            return Path.Combine(item.Path, "playlist.xml");
+        }
+    }
+}

+ 22 - 9
MediaBrowser.LocalMetadata/Savers/XmlSaverHelpers.cs

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Movies;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Model.Entities;
 
 namespace MediaBrowser.LocalMetadata.Savers
@@ -109,7 +110,8 @@ namespace MediaBrowser.LocalMetadata.Savers
                     "VoteCount",
                     "Website",
                     "Zap2ItId",
-                    "CollectionItems"
+                    "CollectionItems",
+                    "PlaylistItems"
 
         }.ToDictionary(i => i, StringComparer.OrdinalIgnoreCase);
 
@@ -631,10 +633,16 @@ namespace MediaBrowser.LocalMetadata.Savers
                 builder.Append("</Persons>");
             }
 
-            var folder = item as BoxSet;
-            if (folder != null)
+            var boxset = item as BoxSet;
+            if (boxset != null)
             {
-                AddCollectionItems(folder, builder);
+                AddLinkedChildren(boxset, builder, "CollectionItems", "CollectionItem");
+            }
+
+            var playlist = item as Playlist;
+            if (playlist != null)
+            {
+                AddLinkedChildren(playlist, builder, "PlaylistItems", "PlaylistItem");
             }
         }
 
@@ -693,7 +701,7 @@ namespace MediaBrowser.LocalMetadata.Savers
             }
         }
 
-        public static void AddCollectionItems(Folder item, StringBuilder builder)
+        public static void AddLinkedChildren(Folder item, StringBuilder builder, string pluralNodeName, string singularNodeName)
         {
             var items = item.LinkedChildren
                 .Where(i => i.Type == LinkedChildType.Manual && !string.IsNullOrWhiteSpace(i.ItemName))
@@ -704,10 +712,10 @@ namespace MediaBrowser.LocalMetadata.Savers
                 return;
             }
 
-            builder.Append("<CollectionItems>");
+            builder.Append("<" + pluralNodeName + ">");
             foreach (var link in items)
             {
-                builder.Append("<CollectionItem>");
+                builder.Append("<" + singularNodeName + ">");
 
                 builder.Append("<Name>" + SecurityElement.Escape(link.ItemName) + "</Name>");
                 builder.Append("<Type>" + SecurityElement.Escape(link.ItemType) + "</Type>");
@@ -717,9 +725,14 @@ namespace MediaBrowser.LocalMetadata.Savers
                     builder.Append("<Year>" + SecurityElement.Escape(link.ItemYear.Value.ToString(UsCulture)) + "</Year>");
                 }
 
-                builder.Append("</CollectionItem>");
+                if (link.ItemIndexNumber.HasValue)
+                {
+                    builder.Append("<IndexNumber>" + SecurityElement.Escape(link.ItemIndexNumber.Value.ToString(UsCulture)) + "</IndexNumber>");
+                }
+
+                builder.Append("</" + singularNodeName + ">");
             }
-            builder.Append("</CollectionItems>");
+            builder.Append("</" + pluralNodeName + ">");
         }
     }
 }

+ 13 - 1
MediaBrowser.Model/Dto/BaseItemDto.cs

@@ -594,7 +594,19 @@ namespace MediaBrowser.Model.Dto
         /// </summary>
         /// <value>The parent thumb image tag.</value>
         public string ParentThumbImageTag { get; set; }
-        
+
+        /// <summary>
+        /// Gets or sets the parent primary image item identifier.
+        /// </summary>
+        /// <value>The parent primary image item identifier.</value>
+        public string ParentPrimaryImageItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the parent primary image tag.
+        /// </summary>
+        /// <value>The parent primary image tag.</value>
+        public string ParentPrimaryImageTag { get; set; }
+
         /// <summary>
         /// Gets or sets the chapters.
         /// </summary>

+ 1 - 1
MediaBrowser.Server.Implementations/Channels/ChannelDownloadScheduledTask.cs

@@ -239,7 +239,7 @@ namespace MediaBrowser.Server.Implementations.Channels
                 throw new ApplicationException("Unexpected response type encountered: " + response.ContentType);
             }
 
-            File.Move(response.TempFilePath, destination);
+            File.Copy(response.TempFilePath, destination, true);
 
             await RefreshMediaSourceItem(destination, cancellationToken).ConfigureAwait(false);
 

+ 1 - 7
MediaBrowser.Server.Implementations/Collections/CollectionManager.cs

@@ -162,13 +162,7 @@ namespace MediaBrowser.Server.Implementations.Collections
                     throw new ArgumentException("Item already exists in collection");
                 }
 
-                list.Add(new LinkedChild
-                {
-                    ItemName = item.Name,
-                    ItemYear = item.ProductionYear,
-                    ItemType = item.GetType().Name,
-                    Type = LinkedChildType.Manual
-                });
+                list.Add(LinkedChild.Create(item));
 
                 var supportsGrouping = item as ISupportsBoxSetGrouping;
 

+ 1 - 0
MediaBrowser.Server.Implementations/Collections/ManualCollectionsFolder.cs

@@ -8,6 +8,7 @@ namespace MediaBrowser.Server.Implementations.Collections
         public ManualCollectionsFolder()
         {
             Name = "Collections";
+            DisplayMediaType = "CollectionFolder";
         }
 
         public override bool IsVisible(User user)

+ 46 - 1
MediaBrowser.Server.Implementations/Dto/DtoService.cs

@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Sync;
 using MediaBrowser.Model.Drawing;
@@ -179,6 +180,11 @@ namespace MediaBrowser.Server.Implementations.Dto
                 }
             }
 
+            if (item is Playlist)
+            {
+                AttachLinkedChildImages(dto, (Folder)item, user);
+            }
+
             return dto;
         }
 
@@ -819,7 +825,7 @@ namespace MediaBrowser.Server.Implementations.Dto
                 dto.DisplayOrder = hasDisplayOrder.DisplayOrder;
             }
 
-            var collectionFolder = item as CollectionFolder;
+            var collectionFolder = item as ICollectionFolder;
             if (collectionFolder != null)
             {
                 dto.CollectionType = collectionFolder.CollectionType;
@@ -1211,6 +1217,45 @@ namespace MediaBrowser.Server.Implementations.Dto
             }
         }
 
+        private void AttachLinkedChildImages(BaseItemDto dto, Folder folder, User user)
+        {
+            List<BaseItem> linkedChildren = null;
+
+            if (dto.BackdropImageTags.Count == 0)
+            {
+                if (linkedChildren == null)
+                {
+                    linkedChildren = user == null
+                        ? folder.GetRecursiveChildren().ToList()
+                        : folder.GetRecursiveChildren(user, true).ToList();
+                }
+                var parentWithBackdrop = linkedChildren.FirstOrDefault(i => i.GetImages(ImageType.Backdrop).Any());
+
+                if (parentWithBackdrop != null)
+                {
+                    dto.ParentBackdropItemId = GetDtoId(parentWithBackdrop);
+                    dto.ParentBackdropImageTags = GetBackdropImageTags(parentWithBackdrop);
+                }
+            }
+
+            if (!dto.ImageTags.ContainsKey(ImageType.Primary))
+            {
+                if (linkedChildren == null)
+                {
+                    linkedChildren = user == null
+                        ? folder.GetRecursiveChildren().ToList()
+                        : folder.GetRecursiveChildren(user, true).ToList();
+                }
+                var parentWithImage = linkedChildren.FirstOrDefault(i => i.GetImages(ImageType.Primary).Any());
+
+                if (parentWithImage != null)
+                {
+                    dto.ParentPrimaryImageItemId = GetDtoId(parentWithImage);
+                    dto.ParentPrimaryImageTag = GetImageCacheTag(parentWithImage, ImageType.Primary);
+                }
+            }
+        }
+
         private string GetMappedPath(IHasMetadata item)
         {
             var path = item.Path;

+ 1 - 1
MediaBrowser.Server.Implementations/FileOrganization/OrganizerScheduledTask.cs

@@ -75,7 +75,7 @@ namespace MediaBrowser.Server.Implementations.FileOrganization
 
         public bool IsEnabled
         {
-            get { return !GetTvOptions().IsEnabled; }
+            get { return GetTvOptions().IsEnabled; }
         }
     }
 }

+ 38 - 0
MediaBrowser.Server.Implementations/Library/Resolvers/PlaylistResolver.cs

@@ -0,0 +1,38 @@
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using System;
+using System.IO;
+
+namespace MediaBrowser.Server.Implementations.Library.Resolvers
+{
+    public class PlaylistResolver : FolderResolver<Playlist>
+    {
+        /// <summary>
+        /// Resolves the specified args.
+        /// </summary>
+        /// <param name="args">The args.</param>
+        /// <returns>BoxSet.</returns>
+        protected override Playlist Resolve(ItemResolveArgs args)
+        {
+            // It's a boxset if all of the following conditions are met:
+            // Is a Directory
+            // Contains [playlist] in the path
+            if (args.IsDirectory)
+            {
+                var filename = Path.GetFileName(args.Path);
+
+                if (string.IsNullOrEmpty(filename))
+                {
+                    return null;
+                }
+
+                if (filename.IndexOf("[playlist]", StringComparison.OrdinalIgnoreCase) != -1)
+                {
+                    return new Playlist { Path = args.Path };
+                }
+            }
+
+            return null;
+        }
+    }
+}

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

@@ -323,6 +323,13 @@
     "HeaderSelectPlayer": "Select Player:",
     "ButtonSelect": "Select",
     "ButtonNew": "New",
-    "MessageInternetExplorerWebm":  "For best results with Internet Explorer please install the WebM plugin for IE.",
-    "HeaderVideoError":  "Video Error"
+    "MessageInternetExplorerWebm": "For best results with Internet Explorer please install the WebM plugin for IE.",
+    "HeaderVideoError": "Video Error",
+    "ButtonAddToPlaylist": "Add to playlist",
+    "HeaderAddToPlaylist": "Add to Playlist",
+    "LabelName": "Name:",
+    "ButtonSubmit": "Submit",
+    "LabelSelectPlaylist":  "Playlist:",
+    "OptionNewPlaylist":  "New playlist...",
+    "MessageAddedToPlaylistSuccess":  "Ok"
 }

+ 5 - 1
MediaBrowser.Server.Implementations/Localization/Server/server.json

@@ -808,6 +808,8 @@
     "TabNextUp": "Next Up",
     "MessageNoMovieSuggestionsAvailable": "No movie suggestions are currently available. Start watching and rating your movies, and then come back to view your recommendations.",
     "MessageNoCollectionsAvailable": "Collections allow you to enjoy personalized groupings of Movies, Series, Albums, Books and Games. Click the New button to start creating Collections.",
+    "MessageNoPlaylistsAvailable":  "Playlists allow you to create lists of content to play consecutively at a time. To add items to playlists, right click or tap and hold, then select Add to Playlist.",
+    "MessageNoPlaylistItemsAvailable":  "This playlist is currently empty.",
     "HeaderWelcomeToMediaBrowserWebClient": "Welcome to the Media Browser Web Client",
     "ButtonDismiss": "Dismiss",
     "MessageLearnHowToCustomize": "Learn how to customize this page to your own personal tastes. Click your user icon in the top right corner of the screen to view and update your preferences.",
@@ -915,5 +917,7 @@
     "OptionProtocolHls": "Http Live Streaming",
     "LabelContext": "Context:",
     "OptionContextStreaming": "Streaming",
-    "OptionContextStatic": "Sync"
+    "OptionContextStatic": "Sync",
+    "ButtonAddToPlaylist":  "Add to playlist",
+    "TabPlaylists":  "Playlists"
 }

+ 1 - 0
MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj

@@ -166,6 +166,7 @@
     <Compile Include="Library\LibraryManager.cs" />
     <Compile Include="Library\MusicManager.cs" />
     <Compile Include="Library\Resolvers\PhotoResolver.cs" />
+    <Compile Include="Library\Resolvers\PlaylistResolver.cs" />
     <Compile Include="Library\SearchEngine.cs" />
     <Compile Include="Library\ResolverHelper.cs" />
     <Compile Include="Library\Resolvers\Audio\AudioResolver.cs" />

+ 13 - 4
MediaBrowser.Server.Implementations/Playlists/ManualPlaylistsFolder.cs

@@ -1,5 +1,7 @@
-using MediaBrowser.Common.Configuration;
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Playlists;
 using System.IO;
 using System.Linq;
 
@@ -14,8 +16,15 @@ namespace MediaBrowser.Server.Implementations.Playlists
 
         public override bool IsVisible(User user)
         {
-            return GetChildren(user, true).Any() &&
-                base.IsVisible(user);
+            return base.IsVisible(user) && GetRecursiveChildren(user, false)
+                .OfType<Playlist>()
+                .Any(i => string.Equals(i.OwnerUserId, user.Id.ToString("N")));
+        }
+
+        protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user)
+        {
+            return RecursiveChildren
+                .OfType<Playlist>();
         }
 
         public override bool IsHidden
@@ -48,7 +57,7 @@ namespace MediaBrowser.Server.Implementations.Playlists
 
         public BasePluginFolder GetFolder()
         {
-            var path = Path.Combine(_appPaths.DataPath, "playlists");
+            var path = Path.Combine(_appPaths.CachePath, "playlists");
 
             Directory.CreateDirectory(path);
 

+ 86 - 19
MediaBrowser.Server.Implementations/Playlists/PlaylistManager.cs

@@ -1,8 +1,10 @@
 using MediaBrowser.Common.IO;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Logging;
 using System;
 using System.Collections.Generic;
@@ -41,9 +43,6 @@ namespace MediaBrowser.Server.Implementations.Playlists
         {
             var name = options.Name;
 
-            // Need to use the [boxset] suffix
-            // If internet metadata is not found, or if xml saving is off there will be no collection.xml
-            // This could cause it to get re-resolved as a plain folder
             var folderName = _fileSystem.GetValidFilename(name) + " [playlist]";
 
             var parentFolder = GetPlaylistsFolder(null);
@@ -53,7 +52,55 @@ namespace MediaBrowser.Server.Implementations.Playlists
                 throw new ArgumentException();
             }
 
+            if (string.IsNullOrWhiteSpace(options.MediaType))
+            {
+                foreach (var itemId in options.ItemIdList)
+                {
+                    var item = _libraryManager.GetItemById(itemId);
+
+                    if (item == null)
+                    {
+                        throw new ArgumentException("No item exists with the supplied Id");
+                    }
+
+                    if (!string.IsNullOrWhiteSpace(item.MediaType))
+                    {
+                        options.MediaType = item.MediaType;
+                    }
+                    else if (item is MusicArtist || item is MusicAlbum || item is MusicGenre)
+                    {
+                        options.MediaType = MediaType.Audio;
+                    }
+                    else if (item is Genre)
+                    {
+                        options.MediaType = MediaType.Video;
+                    }
+                    else
+                    {
+                        var folder = item as Folder;
+                        if (folder != null)
+                        {
+                            options.MediaType = folder.GetRecursiveChildren()
+                                .Where(i => !i.IsFolder)
+                                .Select(i => i.MediaType)
+                                .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
+                        }
+                    }
+
+                    if (!string.IsNullOrWhiteSpace(options.MediaType))
+                    {
+                        break;
+                    }
+                }
+            }
+
+            if (string.IsNullOrWhiteSpace(options.MediaType))
+            {
+                throw new ArgumentException("A playlist media type is required.");
+            }
+
             var path = Path.Combine(parentFolder.Path, folderName);
+            path = GetTargetPath(path);
 
             _iLibraryMonitor.ReportFileSystemChangeBeginning(path);
 
@@ -61,24 +108,27 @@ namespace MediaBrowser.Server.Implementations.Playlists
             {
                 Directory.CreateDirectory(path);
 
-                var collection = new Playlist
+                var playlist = new Playlist
                 {
                     Name = name,
                     Parent = parentFolder,
-                    Path = path
+                    Path = path,
+                    OwnerUserId = options.UserId
                 };
 
-                await parentFolder.AddChild(collection, CancellationToken.None).ConfigureAwait(false);
+                playlist.SetMediaType(options.MediaType);
+
+                await parentFolder.AddChild(playlist, CancellationToken.None).ConfigureAwait(false);
 
-                await collection.RefreshMetadata(new MetadataRefreshOptions(), CancellationToken.None)
+                await playlist.RefreshMetadata(new MetadataRefreshOptions { ForceSave = true }, CancellationToken.None)
                     .ConfigureAwait(false);
 
                 if (options.ItemIdList.Count > 0)
                 {
-                    await AddToPlaylist(collection.Id.ToString("N"), options.ItemIdList);
+                    await AddToPlaylist(playlist.Id.ToString("N"), options.ItemIdList);
                 }
 
-                return collection;
+                return playlist;
             }
             finally
             {
@@ -87,11 +137,28 @@ namespace MediaBrowser.Server.Implementations.Playlists
             }
         }
 
+        private string GetTargetPath(string path)
+        {
+            while (Directory.Exists(path))
+            {
+                path += "1";
+            }
+
+            return path;
+        }
+
+        private IEnumerable<BaseItem> GetPlaylistItems(IEnumerable<string> itemIds, string playlistMediaType, User user)
+        {
+            var items = itemIds.Select(i => _libraryManager.GetItemById(i)).Where(i => i != null);
+
+            return Playlist.GetPlaylistItems(playlistMediaType, items, user);
+        }
+
         public async Task AddToPlaylist(string playlistId, IEnumerable<string> itemIds)
         {
-            var collection = _libraryManager.GetItemById(playlistId) as Playlist;
+            var playlist = _libraryManager.GetItemById(playlistId) as Playlist;
 
-            if (collection == null)
+            if (playlist == null)
             {
                 throw new ArgumentException("No Playlist exists with the supplied Id");
             }
@@ -110,17 +177,17 @@ namespace MediaBrowser.Server.Implementations.Playlists
 
                 itemList.Add(item);
 
-                list.Add(new LinkedChild
-                {
-                    Type = LinkedChildType.Manual,
-                    ItemId = item.Id
-                });
+                list.Add(LinkedChild.Create(item));
             }
 
-            collection.LinkedChildren.AddRange(list);
+            playlist.LinkedChildren.AddRange(list);
+
+            await playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            await playlist.RefreshMetadata(new MetadataRefreshOptions{
+            
+                ForceSave = true
 
-            await collection.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-            await collection.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
+            }, CancellationToken.None).ConfigureAwait(false);
         }
 
         public Task RemoveFromPlaylist(string playlistId, IEnumerable<int> indeces)

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

@@ -528,6 +528,7 @@ namespace MediaBrowser.WebDashboard.Api
                                 "chromecast.js",
                                 "backdrops.js",
                                 "sync.js",
+                                "playlistmanager.js",
 
                                 "mediaplayer.js",
                                 "mediaplayer-video.js",
@@ -621,6 +622,9 @@ namespace MediaBrowser.WebDashboard.Api
                                 "notificationsetting.js",
                                 "notificationsettings.js",
                                 "playlist.js",
+                                "playlists.js",
+                                "playlistedit.js",
+
                                 "plugincatalogpage.js",
                                 "pluginspage.js",
                                 "remotecontrol.js",
@@ -676,7 +680,6 @@ namespace MediaBrowser.WebDashboard.Api
                                       "librarymenu.css",
                                       "librarybrowser.css",
                                       "detailtable.css",
-                                      "posteritem.css",
                                       "card.css",
                                       "tileitem.css",
                                       "metadataeditor.css",

+ 15 - 3
MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj

@@ -347,6 +347,12 @@
     <Content Include="dashboard-ui\notificationlist.html">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
+    <Content Include="dashboard-ui\playlistedit.html">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="dashboard-ui\playlists.html">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
     <Content Include="dashboard-ui\reports.html">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
@@ -527,9 +533,6 @@
     <Content Include="dashboard-ui\css\pluginupdates.css">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
-    <Content Include="dashboard-ui\css\posteritem.css">
-      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-    </Content>
     <Content Include="dashboard-ui\css\remotecontrol.css">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
@@ -665,6 +668,15 @@
     <Content Include="dashboard-ui\scripts\notificationlist.js">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
+    <Content Include="dashboard-ui\scripts\playlistedit.js">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="dashboard-ui\scripts\playlistmanager.js">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <Content Include="dashboard-ui\scripts\playlists.js">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
     <Content Include="dashboard-ui\scripts\reports.js">
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>