fixes #1001 - Support downloading

This commit is contained in:
Luke Pulverenti 2015-02-06 00:39:07 -05:00
parent 4ae6b5f675
commit b6d59c7688
44 changed files with 322 additions and 66 deletions

View File

@ -226,6 +226,18 @@ namespace MediaBrowser.Api.Library
public string TvdbId { get; set; }
}
[Route("/Items/{Id}/Download", "GET", Summary = "Downloads item media")]
[Authenticated(Roles = "download")]
public class GetDownload
{
/// <summary>
/// Gets or sets the id.
/// </summary>
/// <value>The id.</value>
[ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
public string Id { get; set; }
}
/// <summary>
/// Class LibraryService
/// </summary>
@ -289,6 +301,28 @@ namespace MediaBrowser.Api.Library
Task.Run(() => _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None));
}
public object Get(GetDownload request)
{
var item = _libraryManager.GetItemById(request.Id);
if (!item.CanDelete())
{
throw new ArgumentException("Item does not support downloading");
}
var headers = new Dictionary<string, string>();
// Quotes are valid in linux. They'll possibly cause issues here
var filename = Path.GetFileName(item.Path).Replace("\"", string.Empty);
headers["Content-Disposition"] = string.Format("inline; filename=\"{0}\"", filename);
return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions
{
Path = item.Path,
ResponseHeaders = headers
});
}
public object Get(GetFile request)
{
var item = _libraryManager.GetItemById(request.Id);
@ -458,24 +492,10 @@ namespace MediaBrowser.Api.Library
var auth = _authContext.GetAuthorizationInfo(Request);
var user = _userManager.GetUserById(auth.UserId);
if (item is Playlist || item is BoxSet)
{
// For now this is allowed if user can see the playlist
}
else if (item is ILiveTvRecording)
{
if (!user.Policy.EnableLiveTvManagement)
if (!item.CanDelete(user))
{
throw new UnauthorizedAccessException();
}
}
else
{
if (!user.Policy.EnableContentDeletion)
{
throw new UnauthorizedAccessException();
}
}
var task = _libraryManager.DeleteItem(item);

View File

@ -323,13 +323,13 @@ namespace MediaBrowser.Api.Playback
switch (qualitySetting)
{
case EncodingQuality.HighSpeed:
param += " -crf 23";
param += " -subq 0 -crf 23";
break;
case EncodingQuality.HighQuality:
param += " -crf 20";
param += " -subq 3 -crf 20";
break;
case EncodingQuality.MaxQuality:
param += " -crf 18";
param += " -subq 6 -crf 18";
break;
}
}
@ -507,7 +507,7 @@ namespace MediaBrowser.Api.Playback
}
}
return param;
return "-pix_fmt yuv420p " + param;
}
protected string GetAudioFilterParam(StreamState state, bool isHls)

View File

@ -97,6 +97,7 @@ namespace MediaBrowser.Api.Playback.Progressive
if (string.Equals(Path.GetExtension(outputPath), ".mp4", StringComparison.OrdinalIgnoreCase))
{
// Comparison: https://github.com/jansmolders86/mediacenterjs/blob/master/lib/transcoding/desktop.js
format = " -f mp4 -movflags frag_keyframe+empty_moov";
}

View File

@ -67,5 +67,10 @@ namespace MediaBrowser.Controller.Channels
{
return System.IO.Path.Combine(basePath, "channels", id.ToString("N"), "metadata");
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -3,10 +3,10 @@ using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Users;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Channels
{
@ -89,5 +89,10 @@ namespace MediaBrowser.Controller.Channels
return list;
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -1,11 +1,10 @@
using System;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Users;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Channels
{
@ -76,5 +75,10 @@ namespace MediaBrowser.Controller.Channels
{
return System.IO.Path.Combine(basePath, "channels", ChannelId, Id.ToString("N"));
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -4,11 +4,11 @@ using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Users;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Channels
{
@ -119,5 +119,10 @@ namespace MediaBrowser.Controller.Channels
{
return System.IO.Path.Combine(basePath, "channels", ChannelId, Id.ToString("N"));
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -32,6 +32,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// The _virtual children
/// </summary>

View File

@ -113,6 +113,13 @@ namespace MediaBrowser.Controller.Entities.Audio
}
}
public override bool CanDownload()
{
var locationType = LocationType;
return locationType != LocationType.Remote &&
locationType != LocationType.Virtual;
}
/// <summary>
/// Gets or sets the artist.
/// </summary>

View File

@ -1,14 +1,13 @@
using System.Runtime.Serialization;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Users;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Entities.Audio
{
@ -35,6 +34,11 @@ namespace MediaBrowser.Controller.Entities.Audio
get { return true; }
}
public override bool CanDelete()
{
return !IsAccessedByName;
}
protected override IEnumerable<BaseItem> ActualChildren
{
get

View File

@ -39,6 +39,11 @@ namespace MediaBrowser.Controller.Entities.Audio
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// Gets a value indicating whether this instance is owned item.
/// </summary>

View File

@ -239,6 +239,38 @@ namespace MediaBrowser.Controller.Entities
get { return this.GetImagePath(ImageType.Primary); }
}
public virtual bool CanDelete()
{
var locationType = LocationType;
return locationType != LocationType.Remote &&
locationType != LocationType.Virtual;
}
public virtual bool IsAuthorizedToDelete(User user)
{
return user.Policy.EnableContentDeletion;
}
public bool CanDelete(User user)
{
return CanDelete() && IsAuthorizedToDelete(user);
}
public virtual bool CanDownload()
{
return false;
}
public virtual bool IsAuthorizedToDownload(User user)
{
return user.Policy.EnableContentDownloading;
}
public bool CanDownload(User user)
{
return CanDownload() && IsAuthorizedToDownload(user);
}
/// <summary>
/// Gets or sets the date created.
/// </summary>

View File

@ -11,5 +11,10 @@ namespace MediaBrowser.Controller.Entities
{
get { return null; }
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -2,6 +2,7 @@
using MediaBrowser.Model.Configuration;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Entities
@ -37,6 +38,13 @@ namespace MediaBrowser.Controller.Entities
Tags = new List<string>();
}
public override bool CanDownload()
{
var locationType = LocationType;
return locationType != LocationType.Remote &&
locationType != LocationType.Virtual;
}
protected override bool GetBlockUnratedValue(UserPolicy config)
{
return config.BlockUnratedItems.Contains(UnratedItem.Book);

View File

@ -35,6 +35,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
public string CollectionType { get; set; }
/// <summary>

View File

@ -1,10 +1,10 @@
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Users;
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.Entities
{
@ -38,6 +38,13 @@ namespace MediaBrowser.Controller.Entities
public List<Guid> LocalTrailerIds { get; set; }
public List<Guid> RemoteTrailerIds { get; set; }
public override bool CanDownload()
{
var locationType = LocationType;
return locationType != LocationType.Remote &&
locationType != LocationType.Virtual;
}
/// <summary>
/// Gets or sets the tags.
/// </summary>

View File

@ -43,6 +43,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
public IEnumerable<BaseItem> GetTaggedItems(IEnumerable<BaseItem> inputItems)
{
return inputItems.Where(GetItemFilter());

View File

@ -34,6 +34,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// Gets a value indicating whether this instance is owned item.
/// </summary>

View File

@ -15,6 +15,10 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
IEnumerable<BaseItem> GetTaggedItems(IEnumerable<BaseItem> inputItems);
/// <summary>
/// Gets the item filter.
/// </summary>
/// <returns>Func&lt;BaseItem, System.Boolean&gt;.</returns>
Func<BaseItem, bool> GetItemFilter();
}

View File

@ -74,6 +74,11 @@ namespace MediaBrowser.Controller.Entities.Movies
}
}
public override bool IsAuthorizedToDelete(User user)
{
return true;
}
/// <summary>
/// Gets the trailer ids.
/// </summary>

View File

@ -45,6 +45,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// Gets a value indicating whether this instance is owned item.
/// </summary>

View File

@ -40,6 +40,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// Gets a value indicating whether this instance is owned item.
/// </summary>

View File

@ -40,6 +40,11 @@ namespace MediaBrowser.Controller.Entities
return result.Items;
}
public override bool CanDelete()
{
return false;
}
public override IEnumerable<BaseItem> GetRecursiveChildren(User user, Func<BaseItem, bool> filter)
{
var result = GetItems(new InternalItemsQuery

View File

@ -64,6 +64,19 @@ namespace MediaBrowser.Controller.Entities
LinkedAlternateVersions = new List<LinkedChild>();
}
public override bool CanDownload()
{
if (VideoType == VideoType.HdDvd || VideoType == VideoType.Dvd ||
VideoType == VideoType.BluRay)
{
return false;
}
var locationType = LocationType;
return locationType != LocationType.Remote &&
locationType != LocationType.Virtual;
}
[IgnoreDataMember]
public override bool SupportsAddingToPlaylist
{

View File

@ -34,6 +34,11 @@ namespace MediaBrowser.Controller.Entities
}
}
public override bool CanDelete()
{
return false;
}
/// <summary>
/// Gets a value indicating whether this instance is owned item.
/// </summary>

View File

@ -25,5 +25,9 @@ namespace MediaBrowser.Controller.LiveTv
Task RefreshMetadata(MetadataRefreshOptions options, CancellationToken cancellationToken);
PlayAccess GetPlayAccess(User user);
bool CanDelete();
bool CanDelete(User user);
}
}

View File

@ -1,4 +1,5 @@
using System.Runtime.Serialization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@ -93,5 +94,10 @@ namespace MediaBrowser.Controller.LiveTv
{
return System.IO.Path.Combine(basePath, "livetv", Id.ToString("N"));
}
public override bool IsAuthorizedToDelete(User user)
{
return user.Policy.EnableLiveTvManagement;
}
}
}

View File

@ -1,5 +1,4 @@
using System.Runtime.Serialization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@ -8,6 +7,7 @@ using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Users;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
namespace MediaBrowser.Controller.LiveTv
{
@ -135,5 +135,10 @@ namespace MediaBrowser.Controller.LiveTv
{
return System.IO.Path.Combine(basePath, "livetv", Id.ToString("N"), "metadata");
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -1,13 +1,13 @@
using System.Runtime.Serialization;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Users;
using System;
using System.Linq;
using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using MediaBrowser.Model.Users;
namespace MediaBrowser.Controller.LiveTv
{
@ -215,5 +215,10 @@ namespace MediaBrowser.Controller.LiveTv
{
return System.IO.Path.Combine(basePath, "livetv", Id.ToString("N"));
}
public override bool CanDelete()
{
return false;
}
}
}

View File

@ -92,5 +92,10 @@ namespace MediaBrowser.Controller.LiveTv
{
return System.IO.Path.Combine(basePath, "livetv", Id.ToString("N"));
}
public override bool IsAuthorizedToDelete(User user)
{
return user.Policy.EnableLiveTvManagement;
}
}
}

View File

@ -1,7 +1,5 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using System;
@ -40,6 +38,11 @@ namespace MediaBrowser.Controller.Playlists
}
}
public override bool IsAuthorizedToDelete(User user)
{
return true;
}
public override bool IsSaveLocalMetadataEnabled()
{
return true;

View File

@ -631,13 +631,13 @@ namespace MediaBrowser.MediaEncoding.Encoder
switch (qualitySetting)
{
case EncodingQuality.HighSpeed:
param += " -crf 23";
param += " -subq 0 -crf 23";
break;
case EncodingQuality.HighQuality:
param += " -crf 20";
param += " -subq 3 -crf 20";
break;
case EncodingQuality.MaxQuality:
param += " -crf 18";
param += " -subq 6 -crf 18";
break;
}
}
@ -740,7 +740,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
param += " -level " + state.Options.Level.Value.ToString(UsCulture);
}
return param;
return "-pix_fmt yuv420p " + param;
}
protected string GetVideoBitrateParam(EncodingJob state, string videoCodec, bool isHls)

View File

@ -56,6 +56,8 @@ namespace MediaBrowser.Model.Dto
public int? AirsBeforeEpisodeNumber { get; set; }
public int? AbsoluteEpisodeNumber { get; set; }
public bool? DisplaySpecialsWithSeasons { get; set; }
public bool? CanDelete { get; set; }
public bool? CanDownload { get; set; }
public string PreferredMetadataLanguage { get; set; }
public string PreferredMetadataCountryCode { get; set; }

View File

@ -99,6 +99,12 @@ namespace MediaBrowser.Model.LiveTv
/// <value>The path.</value>
public string Path { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance can delete.
/// </summary>
/// <value><c>null</c> if [can delete] contains no value, <c>true</c> if [can delete]; otherwise, <c>false</c>.</value>
public bool? CanDelete { get; set; }
/// <summary>
/// Overview of the recording.
/// </summary>

View File

@ -1,5 +1,4 @@

namespace MediaBrowser.Model.Querying
namespace MediaBrowser.Model.Querying
{
/// <summary>
/// Used to control the data that gets attached to DtoBaseItems
@ -26,6 +25,16 @@ namespace MediaBrowser.Model.Querying
/// </summary>
Budget,
/// <summary>
/// The can delete
/// </summary>
CanDelete,
/// <summary>
/// The can download
/// </summary>
CanDownload,
/// <summary>
/// The chapters
/// </summary>

View File

@ -42,6 +42,7 @@ namespace MediaBrowser.Model.Users
public bool EnableMediaPlayback { get; set; }
public bool EnableContentDeletion { get; set; }
public bool EnableContentDownloading { get; set; }
/// <summary>
/// Gets or sets a value indicating whether [enable synchronize].
@ -80,6 +81,8 @@ namespace MediaBrowser.Model.Users
EnabledDevices = new string[] { };
EnableAllDevices = true;
EnableContentDownloading = true;
}
}
}

View File

@ -284,6 +284,20 @@ namespace MediaBrowser.Server.Implementations.Dto
AttachLinkedChildImages(dto, playlist, user, options);
}
if (fields.Contains(ItemFields.CanDelete))
{
dto.CanDelete = user == null
? item.CanDelete()
: item.CanDelete(user);
}
if (fields.Contains(ItemFields.CanDownload))
{
dto.CanDownload = user == null
? item.CanDownload()
: item.CanDownload(user);
}
return dto;
}

View File

@ -74,7 +74,9 @@ namespace MediaBrowser.Server.Implementations.HttpServer.Security
ValidateUserAccess(user, request, authAttribtues, auth);
}
if (!IsExemptFromRoles(auth, authAttribtues))
var info = (AuthenticationInfo)request.Items["OriginalAuthenticationInfo"];
if (!IsExemptFromRoles(auth, authAttribtues, info))
{
var roles = authAttribtues.GetRoles().ToList();
@ -142,7 +144,7 @@ namespace MediaBrowser.Server.Implementations.HttpServer.Security
StringComparer.OrdinalIgnoreCase);
}
private bool IsExemptFromRoles(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues)
private bool IsExemptFromRoles(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues, AuthenticationInfo tokenInfo)
{
if (!_config.Configuration.IsStartupWizardCompleted &&
authAttribtues.AllowBeforeStartupWizard)
@ -150,6 +152,16 @@ namespace MediaBrowser.Server.Implementations.HttpServer.Security
return true;
}
if (string.IsNullOrWhiteSpace(auth.Token))
{
return true;
}
if (tokenInfo != null && string.IsNullOrWhiteSpace(tokenInfo.UserId))
{
return true;
}
return false;
}
@ -175,6 +187,16 @@ namespace MediaBrowser.Server.Implementations.HttpServer.Security
};
}
}
if (roles.Contains("download", StringComparer.OrdinalIgnoreCase))
{
if (user == null || !user.Policy.EnableContentDownloading)
{
throw new SecurityException("User does not have download access.")
{
SecurityExceptionType = SecurityExceptionType.Unauthenticated
};
}
}
}
private bool IsValidConnectKey(string token)

View File

@ -229,6 +229,10 @@ namespace MediaBrowser.Server.Implementations.LiveTv
ServerId = _appHost.SystemId
};
dto.CanDelete = user == null
? recording.CanDelete()
: recording.CanDelete(user);
dto.MediaStreams = dto.MediaSources.SelectMany(i => i.MediaStreams).ToList();
if (info.Status == RecordingStatus.InProgress)

View File

@ -48,6 +48,7 @@
"LabelFailed": "(failed)",
"ButtonHelp": "Help",
"ButtonSave": "Save",
"ButtonDownload": "Download",
"SyncJobStatusQueued": "Queued",
"SyncJobStatusConverting": "Converting",
"SyncJobStatusFailed": "Failed",
@ -56,6 +57,7 @@
"SyncJobStatusReadyToTransfer": "Ready to Transfer",
"SyncJobStatusTransferring": "Transferring",
"SyncJobStatusCompletedWithError": "Synced with errors",
"SyncJobItemStatusReadyToTransfer": "Ready to Transfer",
"LabelCollection": "Collection",
"HeaderAddToCollection": "Add to Collection",
"NewCollectionNameExample": "Example: Star Wars Collection",

View File

@ -285,10 +285,10 @@
"ButtonHelp": "Help",
"OptionAllowUserToManageServer": "Allow this user to manage the server",
"HeaderFeatureAccess": "Feature Access",
"OptionAllowMediaPlayback": "Allow media playback",
"OptionAllowBrowsingLiveTv": "Allow browsing of live tv",
"OptionAllowDeleteLibraryContent": "Allow deletion of library content",
"OptionAllowManageLiveTv": "Allow management of live tv recordings",
"OptionAllowMediaPlayback": "Media playback",
"OptionAllowBrowsingLiveTv": "Live TV",
"OptionAllowDeleteLibraryContent": "Media deletion",
"OptionAllowManageLiveTv": "Live TV recording management",
"OptionAllowRemoteControlOthers": "Allow remote control of other users",
"OptionAllowRemoteSharedDevices": "Allow remote control of shared devices",
"OptionAllowRemoteSharedDevicesHelp": "Dlna devices are considered shared until a user begins controlling it.",
@ -1362,7 +1362,8 @@
"LabelEnableSingleImageInDidlLimitHelp": "Some devices will not render properly if multiple images are embedded within Didl.",
"TabActivity": "Activity",
"TitleSync": "Sync",
"OptionAllowSyncContent": "Allow syncing media to devices",
"OptionAllowSyncContent": "Sync",
"OptionAllowContentDownloading": "Media downloading",
"NameSeasonUnknown": "Season Unknown",
"NameSeasonNumber": "Season {0}",
"LabelNewUserNameHelp": "Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)",

View File

@ -311,8 +311,10 @@ namespace MediaBrowser.Server.Implementations.Sync
var itemByName = item as IItemByName;
if (itemByName != null)
{
var itemByNameFilter = itemByName.GetItemFilter();
return user.RootFolder
.GetRecursiveChildren(user, itemByName.GetItemFilter());
.GetRecursiveChildren(user, i => !i.IsFolder && itemByNameFilter(i));
}
if (item.IsFolder)

View File

@ -414,7 +414,6 @@ namespace MediaBrowser.WebDashboard.Api
"indexpage.js",
"itembynamedetailpage.js",
"itemdetailpage.js",
"itemgallery.js",
"itemlistpage.js",
"librarypathmapping.js",
"reports.js",

View File

@ -1656,9 +1656,6 @@
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="dashboard-ui\itemgallery.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="dashboard-ui\librarysettings.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@ -1707,9 +1704,6 @@
<Content Include="dashboard-ui\scripts\edititemmetadata.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="dashboard-ui\scripts\itemgallery.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="dashboard-ui\scripts\librarysettings.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>