diff options
| author | Luke Pulverenti <luke.pulverenti@gmail.com> | 2013-04-15 11:10:12 -0400 |
|---|---|---|
| committer | Luke Pulverenti <luke.pulverenti@gmail.com> | 2013-04-15 11:10:12 -0400 |
| commit | 30d6e2cd6ce0702faaec73b7ffb59d9844fb6967 (patch) | |
| tree | 27407208aa35a859659ab77451e0296c3f6ca725 | |
| parent | a4cac9c95df1f169fd3457d25466f6896e12cd3f (diff) | |
made library scan a bit more conservative
17 files changed, 675 insertions, 590 deletions
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 5816b23f8..b7508a641 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -542,6 +542,7 @@ namespace MediaBrowser.Controller.Entities var options = new ParallelOptions { + MaxDegreeOfParallelism = 50 }; Parallel.ForEach(nonCachedChildren, options, child => @@ -606,6 +607,12 @@ namespace MediaBrowser.Controller.Entities _children.Add(item); } + if (saveTasks.Count > 50) + { + await Task.WhenAll(saveTasks).ConfigureAwait(false); + saveTasks.Clear(); + } + saveTasks.Add(LibraryManager.SaveItem(item, CancellationToken.None)); } @@ -642,65 +649,77 @@ namespace MediaBrowser.Controller.Entities /// <param name="cancellationToken">The cancellation token.</param> /// <param name="recursive">if set to <c>true</c> [recursive].</param> /// <returns>Task.</returns> - private Task RefreshChildren(IEnumerable<Tuple<BaseItem, bool>> children, IProgress<double> progress, CancellationToken cancellationToken, bool? recursive) + private async Task RefreshChildren(IEnumerable<Tuple<BaseItem, bool>> children, IProgress<double> progress, CancellationToken cancellationToken, bool? recursive) { var list = children.ToList(); var percentages = new ConcurrentDictionary<Guid, double>(list.Select(i => new KeyValuePair<Guid, double>(i.Item1.Id, 0))); - var tasks = list.Select(tuple => Task.Run(async () => + var tasks = new List<Task>(); + + foreach (var tuple in list) { - cancellationToken.ThrowIfCancellationRequested(); + if (tasks.Count > 50) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } - var child = tuple.Item1; + Tuple<BaseItem, bool> currentTuple = tuple; - //refresh it - await child.RefreshMetadata(cancellationToken, resetResolveArgs: child.IsFolder).ConfigureAwait(false); + tasks.Add(Task.Run(async () => + { + cancellationToken.ThrowIfCancellationRequested(); - // Refresh children if a folder and the item changed or recursive is set to true - var refreshChildren = child.IsFolder && (tuple.Item2 || (recursive.HasValue && recursive.Value)); + var child = currentTuple.Item1; - if (refreshChildren) - { - // Don't refresh children if explicitly set to false - if (recursive.HasValue && recursive.Value == false) + //refresh it + await child.RefreshMetadata(cancellationToken, resetResolveArgs: child.IsFolder).ConfigureAwait(false); + + // Refresh children if a folder and the item changed or recursive is set to true + var refreshChildren = child.IsFolder && (currentTuple.Item2 || (recursive.HasValue && recursive.Value)); + + if (refreshChildren) { - refreshChildren = false; + // Don't refresh children if explicitly set to false + if (recursive.HasValue && recursive.Value == false) + { + refreshChildren = false; + } } - } - if (refreshChildren) - { - cancellationToken.ThrowIfCancellationRequested(); + if (refreshChildren) + { + cancellationToken.ThrowIfCancellationRequested(); - var innerProgress = new ActionableProgress<double>(); + var innerProgress = new ActionableProgress<double>(); - innerProgress.RegisterAction(p => + innerProgress.RegisterAction(p => + { + percentages.TryUpdate(child.Id, p / 100, percentages[child.Id]); + + var percent = percentages.Values.Sum(); + percent /= list.Count; + + progress.Report((90 * percent) + 10); + }); + + await ((Folder)child).ValidateChildren(innerProgress, cancellationToken, recursive).ConfigureAwait(false); + } + else { - percentages.TryUpdate(child.Id, p / 100, percentages[child.Id]); + percentages.TryUpdate(child.Id, 1, percentages[child.Id]); var percent = percentages.Values.Sum(); percent /= list.Count; progress.Report((90 * percent) + 10); - }); - - await ((Folder) child).ValidateChildren(innerProgress, cancellationToken, recursive).ConfigureAwait(false); - } - else - { - percentages.TryUpdate(child.Id, 1, percentages[child.Id]); - - var percent = percentages.Values.Sum(); - percent /= list.Count; - - progress.Report((90 * percent) + 10); - } - })); + } + })); + } cancellationToken.ThrowIfCancellationRequested(); - return Task.WhenAll(tasks); + await Task.WhenAll(tasks).ConfigureAwait(false); } /// <summary> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 90d3810d0..9dd558308 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -148,7 +148,7 @@ <Compile Include="Providers\IImageEnhancer.cs" /> <Compile Include="Providers\ImagesByNameProvider.cs" /> <Compile Include="Providers\MediaInfo\BaseFFMpegProvider.cs" /> - <Compile Include="Providers\MediaInfo\FFMpegAudioImageProvider.cs" /> + <Compile Include="Providers\MediaInfo\AudioImageProvider.cs" /> <Compile Include="Providers\MediaInfo\BaseFFProbeProvider.cs" /> <Compile Include="Providers\BaseProviderInfo.cs" /> <Compile Include="Providers\Movies\FanArtMovieProvider.cs" /> @@ -170,7 +170,6 @@ <Compile Include="Providers\TV\RemoteSeriesProvider.cs" /> <Compile Include="Providers\TV\SeriesProviderFromXml.cs" /> <Compile Include="Providers\TV\SeriesXmlParser.cs" /> - <Compile Include="Providers\MediaInfo\FFMpegVideoImageProvider.cs" /> <Compile Include="Resolvers\IResolverIgnoreRule.cs" /> <Compile Include="Resolvers\EntityResolutionHelper.cs" /> <Compile Include="Resolvers\ResolverPriority.cs" /> diff --git a/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs b/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs index 4700f41f5..509d1e187 100644 --- a/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs +++ b/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs @@ -24,12 +24,6 @@ namespace MediaBrowser.Controller.MediaInfo internal FileSystemRepository VideoImageCache { get; set; } /// <summary> - /// Gets or sets the image cache. - /// </summary> - /// <value>The image cache.</value> - internal FileSystemRepository AudioImageCache { get; set; } - - /// <summary> /// Gets or sets the subtitle cache. /// </summary> /// <value>The subtitle cache.</value> @@ -54,7 +48,6 @@ namespace MediaBrowser.Controller.MediaInfo _libraryManager = libraryManager; VideoImageCache = new FileSystemRepository(VideoImagesDataPath); - AudioImageCache = new FileSystemRepository(AudioImagesDataPath); SubtitleCache = new FileSystemRepository(SubtitleCachePath); } diff --git a/MediaBrowser.Controller/Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/AudioImageProvider.cs new file mode 100644 index 000000000..05e4ba1e3 --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/AudioImageProvider.cs @@ -0,0 +1,101 @@ +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Uses ffmpeg to create video images + /// </summary> + public class AudioImageProvider : BaseMetadataProvider + { + /// <summary> + /// Initializes a new instance of the <see cref="BaseMetadataProvider" /> class. + /// </summary> + /// <param name="logManager">The log manager.</param> + /// <param name="configurationManager">The configuration manager.</param> + public AudioImageProvider(ILogManager logManager, IServerConfigurationManager configurationManager) + : base(logManager, configurationManager) + { + } + + /// <summary> + /// The true task result + /// </summary> + protected static readonly Task<bool> TrueTaskResult = Task.FromResult(true); + + /// <summary> + /// Supportses the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + public override bool Supports(BaseItem item) + { + return item.LocationType == LocationType.FileSystem && item is Audio; + } + + /// <summary> + /// Override this to return the date that should be compared to the last refresh date + /// to determine if this provider should be re-fetched. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>DateTime.</returns> + protected override DateTime CompareDate(BaseItem item) + { + return item.DateModified; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Last; } + } + + /// <summary> + /// Needses the refresh internal. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="providerInfo">The provider info.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected override bool NeedsRefreshInternal(BaseItem item, BaseProviderInfo providerInfo) + { + if (!string.IsNullOrEmpty(item.PrimaryImagePath)) + { + return false; + } + return base.NeedsRefreshInternal(item, providerInfo); + } + + /// <summary> + /// Fetches metadata and returns true or false indicating if any work that requires persistence was done + /// </summary> + /// <param name="item">The item.</param> + /// <param name="force">if set to <c>true</c> [force].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + public override Task<bool> FetchAsync(BaseItem item, bool force, CancellationToken cancellationToken) + { + if (force || string.IsNullOrEmpty(item.PrimaryImagePath)) + { + var album = item.ResolveArgs.Parent as MusicAlbum; + + if (album != null) + { + // First try to use the parent's image + item.PrimaryImagePath = item.ResolveArgs.Parent.PrimaryImagePath; + } + } + + SetLastRefreshed(item, DateTime.UtcNow); + return TrueTaskResult; + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs deleted file mode 100644 index e1cbc6932..000000000 --- a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs +++ /dev/null @@ -1,143 +0,0 @@ -using MediaBrowser.Common.MediaInfo; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Logging; -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Controller.Providers.MediaInfo -{ - /// <summary> - /// Uses ffmpeg to create video images - /// </summary> - public class FFMpegAudioImageProvider : BaseFFMpegProvider<Audio> - { - public FFMpegAudioImageProvider(ILogManager logManager, IServerConfigurationManager configurationManager, IMediaEncoder mediaEncoder) - : base(logManager, configurationManager, mediaEncoder) - { - } - - /// <summary> - /// The true task result - /// </summary> - protected static readonly Task<bool> TrueTaskResult = Task.FromResult(true); - - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public override MetadataProviderPriority Priority - { - get { return MetadataProviderPriority.Last; } - } - - /// <summary> - /// The _locks - /// </summary> - private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new ConcurrentDictionary<string, SemaphoreSlim>(); - - /// <summary> - /// Gets the lock. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.Object.</returns> - private SemaphoreSlim GetLock(string filename) - { - return _locks.GetOrAdd(filename, key => new SemaphoreSlim(1, 1)); - } - - /// <summary> - /// Needses the refresh internal. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="providerInfo">The provider info.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> - protected override bool NeedsRefreshInternal(BaseItem item, BaseProviderInfo providerInfo) - { - if (!string.IsNullOrEmpty(item.PrimaryImagePath)) - { - return false; - } - return base.NeedsRefreshInternal(item, providerInfo); - } - - /// <summary> - /// Fetches metadata and returns true or false indicating if any work that requires persistence was done - /// </summary> - /// <param name="item">The item.</param> - /// <param name="force">if set to <c>true</c> [force].</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.Boolean}.</returns> - public override async Task<bool> FetchAsync(BaseItem item, bool force, CancellationToken cancellationToken) - { - var success = ProviderRefreshStatus.Success; - - if (force || string.IsNullOrEmpty(item.PrimaryImagePath)) - { - var album = item.ResolveArgs.Parent as MusicAlbum; - - if (album != null) - { - // First try to use the parent's image - item.PrimaryImagePath = item.ResolveArgs.Parent.PrimaryImagePath; - } - - // If it's still empty see if there's an embedded image - if (force || string.IsNullOrEmpty(item.PrimaryImagePath)) - { - var audio = (Audio)item; - - if (audio.MediaStreams != null && audio.MediaStreams.Any(s => s.Type == MediaStreamType.Video)) - { - var filename = album != null && string.IsNullOrEmpty(audio.Album + album.DateModified.Ticks) ? (audio.Id.ToString() + audio.DateModified.Ticks) : audio.Album; - - var path = Kernel.Instance.FFMpegManager.AudioImageCache.GetResourcePath(filename + "_primary", ".jpg"); - - if (!Kernel.Instance.FFMpegManager.AudioImageCache.ContainsFilePath(path)) - { - var semaphore = GetLock(path); - - // Acquire a lock - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - // Check again - if (!Kernel.Instance.FFMpegManager.AudioImageCache.ContainsFilePath(path)) - { - try - { - await MediaEncoder.ExtractImage(new[] { audio.Path }, InputType.AudioFile, null, path, cancellationToken).ConfigureAwait(false); - } - catch - { - success = ProviderRefreshStatus.Failure; - } - finally - { - semaphore.Release(); - } - } - else - { - semaphore.Release(); - } - } - - if (success == ProviderRefreshStatus.Success) - { - // Image is already in the cache - audio.PrimaryImagePath = path; - } - } - } - } - - SetLastRefreshed(item, DateTime.UtcNow, success); - return true; - } - } -} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs deleted file mode 100644 index dff4a4ea1..000000000 --- a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs +++ /dev/null @@ -1,188 +0,0 @@ -using MediaBrowser.Common.IO; -using MediaBrowser.Common.MediaInfo; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Logging; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Controller.Providers.MediaInfo -{ - /// <summary> - /// Uses ffmpeg to create video images - /// </summary> - public class FfMpegVideoImageProvider : BaseFFMpegProvider<Video> - { - /// <summary> - /// The _iso manager - /// </summary> - private readonly IIsoManager _isoManager; - - /// <summary> - /// Initializes a new instance of the <see cref="FfMpegVideoImageProvider" /> class. - /// </summary> - /// <param name="isoManager">The iso manager.</param> - /// <param name="logManager">The log manager.</param> - /// <param name="configurationManager">The configuration manager.</param> - /// <param name="mediaEncoder">The media encoder.</param> - public FfMpegVideoImageProvider(IIsoManager isoManager, ILogManager logManager, IServerConfigurationManager configurationManager, IMediaEncoder mediaEncoder) - : base(logManager, configurationManager, mediaEncoder) - { - _isoManager = isoManager; - } - - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public override MetadataProviderPriority Priority - { - get { return MetadataProviderPriority.Last; } - } - - /// <summary> - /// Supportses the specified item. - /// </summary> - /// <param name="item">The item.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> - public override bool Supports(BaseItem item) - { - if (item.LocationType != LocationType.FileSystem) - { - return false; - } - - var video = item as Video; - - if (video != null) - { - if (video.VideoType == VideoType.Iso && _isoManager.CanMount(item.Path)) - { - return true; - } - - // We can only extract images from folder rips if we know the largest stream path - return video.VideoType == VideoType.VideoFile || video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd; - } - - return false; - } - - /// <summary> - /// Needses the refresh internal. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="providerInfo">The provider info.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> - protected override bool NeedsRefreshInternal(BaseItem item, BaseProviderInfo providerInfo) - { - if (!string.IsNullOrEmpty(item.PrimaryImagePath)) - { - return false; - } - return base.NeedsRefreshInternal(item, providerInfo); - } - - /// <summary> - /// The true task result - /// </summary> - protected static readonly Task<bool> TrueTaskResult = Task.FromResult(true); - - /// <summary> - /// Fetches metadata and returns true or false indicating if any work that requires persistence was done - /// </summary> - /// <param name="item">The item.</param> - /// <param name="force">if set to <c>true</c> [force].</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.Boolean}.</returns> - public override Task<bool> FetchAsync(BaseItem item, bool force, CancellationToken cancellationToken) - { - if (force || string.IsNullOrEmpty(item.PrimaryImagePath)) - { - var video = (Video)item; - - // We can only extract images from videos if we know there's an embedded video stream - if (video.MediaStreams != null && video.MediaStreams.Any(m => m.Type == MediaStreamType.Video)) - { - var filename = item.Id + "_" + item.DateModified.Ticks + "_primary"; - - var path = Kernel.Instance.FFMpegManager.VideoImageCache.GetResourcePath(filename, ".jpg"); - - if (!Kernel.Instance.FFMpegManager.VideoImageCache.ContainsFilePath(path)) - { - return ExtractImage(video, path, cancellationToken); - } - - // Image is already in the cache - item.PrimaryImagePath = path; - } - } - - SetLastRefreshed(item, DateTime.UtcNow); - return TrueTaskResult; - } - - /// <summary> - /// Mounts the iso if needed. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>IsoMount.</returns> - protected Task<IIsoMount> MountIsoIfNeeded(Video item, CancellationToken cancellationToken) - { - if (item.VideoType == VideoType.Iso) - { - return _isoManager.Mount(item.Path, cancellationToken); - } - - return NullMountTaskResult; - } - - /// <summary> - /// Extracts the image. - /// </summary> - /// <param name="video">The video.</param> - /// <param name="path">The path.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.Boolean}.</returns> - private async Task<bool> ExtractImage(Video video, string path, CancellationToken cancellationToken) - { - var isoMount = await MountIsoIfNeeded(video, cancellationToken).ConfigureAwait(false); - - try - { - // If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in. - // Always use 10 seconds for dvd because our duration could be out of whack - var imageOffset = video.VideoType != VideoType.Dvd && video.RunTimeTicks.HasValue && - video.RunTimeTicks.Value > 0 - ? TimeSpan.FromTicks(Convert.ToInt64(video.RunTimeTicks.Value * .1)) - : TimeSpan.FromSeconds(10); - - InputType type; - - var inputPath = MediaEncoderHelpers.GetInputArgument(video, isoMount, out type); - - await MediaEncoder.ExtractImage(inputPath, type, imageOffset, path, cancellationToken).ConfigureAwait(false); - - video.PrimaryImagePath = path; - SetLastRefreshed(video, DateTime.UtcNow); - } - catch - { - SetLastRefreshed(video, DateTime.UtcNow, ProviderRefreshStatus.Failure); - } - finally - { - if (isoMount != null) - { - isoMount.Dispose(); - } - } - - return true; - } - } -} diff --git a/MediaBrowser.Model/Entities/IHasMediaStreams.cs b/MediaBrowser.Model/Entities/IHasMediaStreams.cs index a0312aa7b..8c4ea288a 100644 --- a/MediaBrowser.Model/Entities/IHasMediaStreams.cs +++ b/MediaBrowser.Model/Entities/IHasMediaStreams.cs @@ -7,6 +7,20 @@ namespace MediaBrowser.Model.Entities /// </summary> public interface IHasMediaStreams { + /// <summary> + /// Gets or sets the media streams. + /// </summary> + /// <value>The media streams.</value> List<MediaStream> MediaStreams { get; set; } + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + string Path { get; set; } + /// <summary> + /// Gets or sets the primary image path. + /// </summary> + /// <value>The primary image path.</value> + string PrimaryImagePath { get; set; } } } diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index 66ec69f08..286710bc3 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -153,7 +153,9 @@ <Compile Include="ScheduledTasks\ChapterImagesTask.cs" /> <Compile Include="ScheduledTasks\ImageCleanupTask.cs" /> <Compile Include="ScheduledTasks\PluginUpdateTask.cs" /> + <Compile Include="ScheduledTasks\AudioImagesTask.cs" /> <Compile Include="ScheduledTasks\RefreshMediaLibraryTask.cs" /> + <Compile Include="ScheduledTasks\VideoImagesTask.cs" /> <Compile Include="ServerApplicationPaths.cs" /> <Compile Include="ServerManager\ServerManager.cs" /> <Compile Include="ServerManager\WebSocketConnection.cs" /> diff --git a/MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs b/MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs index 32c18822f..08e2eb774 100644 --- a/MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs +++ b/MediaBrowser.Server.Implementations/MediaEncoder/MediaEncoder.cs @@ -50,12 +50,12 @@ namespace MediaBrowser.Server.Implementations.MediaEncoder /// <summary> /// The video image resource pool /// </summary> - private readonly SemaphoreSlim _videoImageResourcePool = new SemaphoreSlim(2, 2); + private readonly SemaphoreSlim _videoImageResourcePool = new SemaphoreSlim(1, 1); /// <summary> /// The audio image resource pool /// </summary> - private readonly SemaphoreSlim _audioImageResourcePool = new SemaphoreSlim(3, 3); + private readonly SemaphoreSlim _audioImageResourcePool = new SemaphoreSlim(2, 2); /// <summary> /// The _subtitle extraction resource pool @@ -65,7 +65,7 @@ namespace MediaBrowser.Server.Implementations.MediaEncoder /// <summary> /// The FF probe resource pool /// </summary> - private readonly SemaphoreSlim _ffProbeResourcePool = new SemaphoreSlim(3, 3); + private readonly SemaphoreSlim _ffProbeResourcePool = new SemaphoreSlim(2, 2); /// <summary> /// Gets or sets the versioned directory path. @@ -370,7 +370,18 @@ namespace MediaBrowser.Server.Implementations.MediaEncoder try { process.Start(); + } + catch (Exception ex) + { + _ffProbeResourcePool.Release(); + + _logger.ErrorException("Error starting ffprobe", ex); + throw; + } + + try + { Task<string> standardErrorReadTask = null; // MUST read both stdout and stderr asynchronously or a deadlock may occurr diff --git a/MediaBrowser.Server.Implementations/ScheduledTasks/AudioImagesTask.cs b/MediaBrowser.Server.Implementations/ScheduledTasks/AudioImagesTask.cs new file mode 100644 index 000000000..db809a47b --- /dev/null +++ b/MediaBrowser.Server.Implementations/ScheduledTasks/AudioImagesTask.cs @@ -0,0 +1,178 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.MediaInfo; +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.ScheduledTasks +{ + /// <summary> + /// Class AudioImagesTask + /// </summary> + public class AudioImagesTask : IScheduledTask + { + /// <summary> + /// Gets or sets the image cache. + /// </summary> + /// <value>The image cache.</value> + public FileSystemRepository ImageCache { get; set; } + + /// <summary> + /// The _library manager + /// </summary> + private readonly ILibraryManager _libraryManager; + /// <summary> + /// The _media encoder + /// </summary> + private readonly IMediaEncoder _mediaEncoder; + + /// <summary> + /// The _locks + /// </summary> + private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new ConcurrentDictionary<string, SemaphoreSlim>(); + + /// <summary> + /// Initializes a new instance of the <see cref="AudioImagesTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + public AudioImagesTask(ILibraryManager libraryManager, IMediaEncoder mediaEncoder) + { + _libraryManager = libraryManager; + _mediaEncoder = mediaEncoder; + + ImageCache = new FileSystemRepository(Kernel.Instance.FFMpegManager.AudioImagesDataPath); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public string Name + { + get { return "Audio image extraction"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public string Description + { + get { return "Extracts images from audio files that do not have external images."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public string Category + { + get { return "Library"; } + } + + /// <summary> + /// Executes the task + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + var items = _libraryManager.RootFolder.RecursiveChildren + .OfType<Audio>() + .Where(i => i.LocationType == LocationType.FileSystem && string.IsNullOrEmpty(i.PrimaryImagePath) && i.MediaStreams != null && i.MediaStreams.Any(m => m.Type == MediaStreamType.Video)) + .ToList(); + + progress.Report(0); + + var numComplete = 0; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var album = item.Parent as MusicAlbum; + + var filename = album != null && string.IsNullOrEmpty(item.Album + album.DateModified.Ticks) ? (item.Id.ToString() + item.DateModified.Ticks) : item.Album; + + var path = ImageCache.GetResourcePath(filename + "_primary", ".jpg"); + + var success = true; + + if (!ImageCache.ContainsFilePath(path)) + { + var semaphore = GetLock(path); + + // Acquire a lock + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Check again + if (!ImageCache.ContainsFilePath(path)) + { + try + { + await _mediaEncoder.ExtractImage(new[] { item.Path }, InputType.AudioFile, null, path, cancellationToken).ConfigureAwait(false); + } + catch + { + success = false; + } + finally + { + semaphore.Release(); + } + } + else + { + semaphore.Release(); + } + } + + numComplete++; + double percent = numComplete; + percent /= items.Count; + + progress.Report(100 * percent); + + if (success) + { + // Image is already in the cache + item.PrimaryImagePath = path; + } + } + + progress.Report(100); + } + + /// <summary> + /// Gets the default triggers. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<ITaskTrigger> GetDefaultTriggers() + { + return new ITaskTrigger[] + { + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(1) } + }; + } + + /// <summary> + /// Gets the lock. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.Object.</returns> + private SemaphoreSlim GetLock(string filename) + { + return _locks.GetOrAdd(filename, key => new SemaphoreSlim(1, 1)); + } + } +} diff --git a/MediaBrowser.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs b/MediaBrowser.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs index 6cae2f75b..d46d4ec8a 100644 --- a/MediaBrowser.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs +++ b/MediaBrowser.Server.Implementations/ScheduledTasks/ChapterImagesTask.cs @@ -24,6 +24,9 @@ namespace MediaBrowser.Server.Implementations.ScheduledTasks /// The _logger /// </summary> private readonly ILogger _logger; + /// <summary> + /// The _library manager + /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> @@ -99,7 +102,7 @@ namespace MediaBrowser.Server.Implementations.ScheduledTasks /// <value>The name.</value> public string Name { - get { return "Create video chapter thumbnails"; } + get { return "Chapter image extraction"; } } /// <summary> diff --git a/MediaBrowser.Server.Implementations/ScheduledTasks/VideoImagesTask.cs b/MediaBrowser.Server.Implementations/ScheduledTasks/VideoImagesTask.cs new file mode 100644 index 000000000..a82c22fe9 --- /dev/null +++ b/MediaBrowser.Server.Implementations/ScheduledTasks/VideoImagesTask.cs @@ -0,0 +1,265 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.MediaInfo; +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers.MediaInfo; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.ScheduledTasks +{ + /// <summary> + /// Class VideoImagesTask + /// </summary> + public class VideoImagesTask : IScheduledTask + { + /// <summary> + /// Gets or sets the image cache. + /// </summary> + /// <value>The image cache.</value> + public FileSystemRepository ImageCache { get; set; } + + /// <summary> + /// The _library manager + /// </summary> + private readonly ILibraryManager _libraryManager; + /// <summary> + /// The _media encoder + /// </summary> + private readonly IMediaEncoder _mediaEncoder; + + /// <summary> + /// The _iso manager + /// </summary> + private readonly IIsoManager _isoManager; + + /// <summary> + /// The _locks + /// </summary> + private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new ConcurrentDictionary<string, SemaphoreSlim>(); + + /// <summary> + /// Initializes a new instance of the <see cref="AudioImagesTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="isoManager">The iso manager.</param> + public VideoImagesTask(ILibraryManager libraryManager, IMediaEncoder mediaEncoder, IIsoManager isoManager) + { + _libraryManager = libraryManager; + _mediaEncoder = mediaEncoder; + _isoManager = isoManager; + + ImageCache = new FileSystemRepository(Kernel.Instance.FFMpegManager.VideoImagesDataPath); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public string Name + { + get { return "Video image extraction"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public string Description + { + get { return "Extracts images from audio files that do not have external images."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public string Category + { + get { return "Library"; } + } + + /// <summary> + /// Executes the task + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + var items = _libraryManager.RootFolder.RecursiveChildren + .OfType<Video>() + .Where(i => + { + if (!string.IsNullOrEmpty(i.PrimaryImagePath)) + { + return false; + } + + if (i.LocationType != LocationType.FileSystem) + { + return false; + } + + if (i.VideoType == VideoType.HdDvd) + { + return false; + } + + if (i.VideoType == VideoType.Iso && !i.IsoType.HasValue) + { + return false; + } + + return i.MediaStreams != null && i.MediaStreams.Any(m => m.Type == MediaStreamType.Video); + }) + .ToList(); + + progress.Report(0); + + var numComplete = 0; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var filename = item.Id + "_" + item.DateModified.Ticks + "_primary"; + + var path = ImageCache.GetResourcePath(filename, ".jpg"); + + var success = true; + + if (!ImageCache.ContainsFilePath(path)) + { + var semaphore = GetLock(path); + + // Acquire a lock + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Check again + if (!ImageCache.ContainsFilePath(path)) + { + try + { + await ExtractImage(item, path, cancellationToken).ConfigureAwait(false); + } + catch + { + success = false; + } + finally + { + semaphore.Release(); + } + } + else + { + semaphore.Release(); + } + } + + numComplete++; + double percent = numComplete; + percent /= items.Count; + + progress.Report(100 * percent); + + if (success) + { + // Image is already in the cache + item.PrimaryImagePath = path; + } + } + + progress.Report(100); + } + + /// <summary> + /// Extracts the image. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="path">The path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task ExtractImage(Video video, string path, CancellationToken cancellationToken) + { + var isoMount = await MountIsoIfNeeded(video, cancellationToken).ConfigureAwait(false); + + try + { + // If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in. + // Always use 10 seconds for dvd because our duration could be out of whack + var imageOffset = video.VideoType != VideoType.Dvd && video.RunTimeTicks.HasValue && + video.RunTimeTicks.Value > 0 + ? TimeSpan.FromTicks(Convert.ToInt64(video.RunTimeTicks.Value * .1)) + : TimeSpan.FromSeconds(10); + + InputType type; + + var inputPath = MediaEncoderHelpers.GetInputArgument(video, isoMount, out type); + + await _mediaEncoder.ExtractImage(inputPath, type, imageOffset, path, cancellationToken).ConfigureAwait(false); + + video.PrimaryImagePath = path; + } + finally + { + if (isoMount != null) + { + isoMount.Dispose(); + } + } + } + + /// <summary> + /// The null mount task result + /// </summary> + protected readonly Task<IIsoMount> NullMountTaskResult = Task.FromResult<IIsoMount>(null); + + /// <summary> + /// Mounts the iso if needed. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{IIsoMount}.</returns> + protected Task<IIsoMount> MountIsoIfNeeded(Video item, CancellationToken cancellationToken) + { + if (item.VideoType == VideoType.Iso) + { + return _isoManager.Mount(item.Path, cancellationToken); + } + + return NullMountTaskResult; + } + + /// <summary> + /// Gets the default triggers. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<ITaskTrigger> GetDefaultTriggers() + { + return new ITaskTrigger[] + { + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(2) } + }; + } + + /// <summary> + /// Gets the lock. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.Object.</returns> + private SemaphoreSlim GetLock(string filename) + { + return _locks.GetOrAdd(filename, key => new SemaphoreSlim(1, 1)); + } + } +} diff --git a/MediaBrowser.ServerApplication/Controls/ItemUpdateNotification.xaml.cs b/MediaBrowser.ServerApplication/Controls/ItemUpdateNotification.xaml.cs index 1f224f24d..30e39f6b0 100644 --- a/MediaBrowser.ServerApplication/Controls/ItemUpdateNotification.xaml.cs +++ b/MediaBrowser.ServerApplication/Controls/ItemUpdateNotification.xaml.cs @@ -1,4 +1,5 @@ -using MediaBrowser.Controller.Entities; +using System.Linq; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; @@ -159,7 +160,7 @@ namespace MediaBrowser.ServerApplication.Controls DisplayTitle(item); DisplayRating(item); - var path = MultiItemUpdateNotification.GetImagePath(item); + var path = GetImagePath(item); if (string.IsNullOrEmpty(path)) { @@ -211,6 +212,44 @@ namespace MediaBrowser.ServerApplication.Controls } /// <summary> + /// Gets the image path. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>System.String.</returns> + internal static string GetImagePath(BaseItem item) + { + // Try our best to find an image + var path = item.PrimaryImagePath; + + if (string.IsNullOrEmpty(path) && item.BackdropImagePaths != null) + { + path = item.BackdropImagePaths.FirstOrDefault(); + } + + if (string.IsNullOrEmpty(path)) + { + path = item.GetImage(ImageType.Thumb); + } + + if (string.IsNullOrEmpty(path)) + { + path = item.GetImage(ImageType.Art); + } + + if (string.IsNullOrEmpty(path)) + { + path = item.GetImage(ImageType.Logo); + } + + if (string.IsNullOrEmpty(path)) + { + path = item.GetImage(ImageType.Disc); + } + + return path; + } + + /// <summary> /// Displays the rating. /// </summary> /// <param name="item">The item.</param> diff --git a/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml b/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml deleted file mode 100644 index 5d3fb785c..000000000 --- a/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml +++ /dev/null @@ -1,37 +0,0 @@ -<UserControl x:Class="MediaBrowser.ServerApplication.Controls.MultiItemUpdateNotification" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - mc:Ignorable="d" - d:DesignHeight="300" d:DesignWidth="300"> - - <Grid MaxHeight="400" MaxWidth="600" Margin="20"> - <Border BorderThickness="0" Background="#333333"> - <Border.Effect> - <DropShadowEffect BlurRadius="25" ShadowDepth="0"> - - </DropShadowEffect> - </Border.Effect> - </Border> - <Grid> - <Grid.Background> - <LinearGradientBrush SpreadMethod="Reflect" ColorInterpolationMode="SRgbLinearInterpolation" StartPoint="0,0" EndPoint="0,1" > - <GradientStop Color="#ff222222" Offset="0" /> - <GradientStop Color="#ffbbbbbb" Offset="1" /> - </LinearGradientBrush> - </Grid.Background> - - <Grid Margin="20"> - <Grid.RowDefinitions> - <RowDefinition Height="auto"></RowDefinition> - <RowDefinition Height="*"></RowDefinition> - </Grid.RowDefinitions> - - <TextBlock x:Name="header" FontSize="26" Foreground="White" Grid.Row="0"></TextBlock> - - <UniformGrid x:Name="itemsPanel" Columns="4" Margin="0 20 0 0" Grid.Row="1"></UniformGrid> - </Grid> - </Grid> - </Grid> -</UserControl> diff --git a/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml.cs b/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml.cs deleted file mode 100644 index 9d58c0227..000000000 --- a/MediaBrowser.ServerApplication/Controls/MultiItemUpdateNotification.xaml.cs +++ /dev/null @@ -1,151 +0,0 @@ -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace MediaBrowser.ServerApplication.Controls -{ - /// <summary> - /// Interaction logic for MultiItemUpdateNotification.xaml - /// </summary> - public partial class MultiItemUpdateNotification : UserControl - { - /// <summary> - /// The logger - /// </summary> - private readonly ILogger Logger; - - /// <summary> - /// Gets the children changed event args. - /// </summary> - /// <value>The children changed event args.</value> - private List<BaseItem> Items - { - get { return DataContext as List<BaseItem>; } - } - - /// <summary> - /// Initializes a new instance of the <see cref="MultiItemUpdateNotification" /> class. - /// </summary> - public MultiItemUpdateNotification(ILogger logger) - { - if (logger == null) - { - throw new ArgumentNullException("logger"); - } - - Logger = logger; - - InitializeComponent(); - - Loaded += MultiItemUpdateNotification_Loaded; - } - - /// <summary> - /// Handles the Loaded event of the MultiItemUpdateNotification control. - /// </summary> - /// <param name="sender">The source of the event.</param> - /// <param name="e">The <see cref="RoutedEventArgs" /> instance containing the event data.</param> - void MultiItemUpdateNotification_Loaded(object sender, RoutedEventArgs e) - { - header.Text = string.Format("{0} New Items!", Items.Count); - - PopulateItems(); - } - - /// <summary> - /// Populates the items. - /// </summary> - private void PopulateItems() - { - itemsPanel.Children.Clear(); - - var items = Items; - - const int maxItemsToDisplay = 8; - var index = 0; - - foreach (var item in items) - { - if (index >= maxItemsToDisplay) - { - break; - } - - // Try our best to find an image - var path = GetImagePath(item); - - if (string.IsNullOrEmpty(path)) - { - continue; - } - - Image img; - - try - { - img = App.Instance.GetImage(path); - } - catch (FileNotFoundException) - { - Logger.Error("Image file not found {0}", path); - continue; - } - - img.Stretch = Stretch.Uniform; - img.Margin = new Thickness(0, 0, 5, 5); - img.ToolTip = ItemUpdateNotification.GetDisplayName(item, true); - RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.Fant); - itemsPanel.Children.Add(img); - - index++; - } - } - - - - /// <summary> - /// Gets the image path. - /// </summary> - /// <param name="item">The item.</param> - /// <returns>System.String.</returns> - internal static string GetImagePath(BaseItem item) - { - // Try our best to find an image - var path = item.PrimaryImagePath; - - if (string.IsNullOrEmpty(path) && item.BackdropImagePaths != null) - { - path = item.BackdropImagePaths.FirstOrDefault(); - } - - if (string.IsNullOrEmpty(path)) - { - path = item.GetImage(ImageType.Thumb); - } - - if (string.IsNullOrEmpty(path)) - { - path = item.GetImage(ImageType.Art); - } - - if (string.IsNullOrEmpty(path)) - { - path = item.GetImage(ImageType.Logo); - } - - if (string.IsNullOrEmpty(path)) - { - path = item.GetImage(ImageType.Disc); - } - - return path; - } - } -} diff --git a/MediaBrowser.ServerApplication/EntryPoints/NewItemNotifier.cs b/MediaBrowser.ServerApplication/EntryPoints/NewItemNotifier.cs index 4a1f0ac74..ae898f0f9 100644 --- a/MediaBrowser.ServerApplication/EntryPoints/NewItemNotifier.cs +++ b/MediaBrowser.ServerApplication/EntryPoints/NewItemNotifier.cs @@ -109,7 +109,7 @@ namespace MediaBrowser.ServerApplication.EntryPoints } // Show the notification - if (newItems.Count == 1) + if (newItems.Count > 0) { Application.Current.Dispatcher.InvokeAsync(() => { @@ -122,19 +122,6 @@ namespace MediaBrowser.ServerApplication.EntryPoints }, PopupAnimation.Slide, 6000)); }); } - else if (newItems.Count > 1) - { - Application.Current.Dispatcher.InvokeAsync(() => - { - var window = (MainWindow)Application.Current.MainWindow; - - window.Dispatcher.InvokeAsync(() => window.MbTaskbarIcon.ShowCustomBalloon(new MultiItemUpdateNotification(_logger) - { - DataContext = newItems - - }, PopupAnimation.Slide, 6000)); - }); - } } /// <summary> diff --git a/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj b/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj index dedc90fe5..38ece1cdb 100644 --- a/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj +++ b/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj @@ -194,10 +194,6 @@ <SubType>Designer</SubType> <Generator>MSBuild:Compile</Generator> </Page> - <Page Include="Controls\MultiItemUpdateNotification.xaml"> - <SubType>Designer</SubType> - <Generator>MSBuild:Compile</Generator> - </Page> <Page Include="LibraryExplorer.xaml"> <SubType>Designer</SubType> <Generator>MSBuild:Compile</Generator> @@ -221,9 +217,6 @@ <Compile Include="Controls\ItemUpdateNotification.xaml.cs"> <DependentUpon>ItemUpdateNotification.xaml</DependentUpon> </Compile> - <Compile Include="Controls\MultiItemUpdateNotification.xaml.cs"> - <DependentUpon>MultiItemUpdateNotification.xaml</DependentUpon> - </Compile> <Compile Include="Implementations\DotNetZipClient.cs" /> <Compile Include="LibraryExplorer.xaml.cs"> <DependentUpon>LibraryExplorer.xaml</DependentUpon> |
