diff options
Diffstat (limited to 'MediaBrowser.Controller')
149 files changed, 24713 insertions, 6469 deletions
diff --git a/MediaBrowser.Controller/Drawing/DrawingUtils.cs b/MediaBrowser.Controller/Drawing/DrawingUtils.cs deleted file mode 100644 index 8e2f829b98..0000000000 --- a/MediaBrowser.Controller/Drawing/DrawingUtils.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System;
-using System.Drawing;
-
-namespace MediaBrowser.Controller.Drawing
-{
- public static class DrawingUtils
- {
- /// <summary>
- /// Resizes a set of dimensions
- /// </summary>
- public static Size Resize(int currentWidth, int currentHeight, int? width, int? height, int? maxWidth, int? maxHeight)
- {
- return Resize(new Size(currentWidth, currentHeight), width, height, maxWidth, maxHeight);
- }
-
- /// <summary>
- /// Resizes a set of dimensions
- /// </summary>
- /// <param name="size">The original size object</param>
- /// <param name="width">A new fixed width, if desired</param>
- /// <param name="height">A new fixed neight, if desired</param>
- /// <param name="maxWidth">A max fixed width, if desired</param>
- /// <param name="maxHeight">A max fixed height, if desired</param>
- /// <returns>A new size object</returns>
- public static Size Resize(Size size, int? width, int? height, int? maxWidth, int? maxHeight)
- {
- decimal newWidth = size.Width;
- decimal newHeight = size.Height;
-
- if (width.HasValue && height.HasValue)
- {
- newWidth = width.Value;
- newHeight = height.Value;
- }
-
- else if (height.HasValue)
- {
- newWidth = GetNewWidth(newHeight, newWidth, height.Value);
- newHeight = height.Value;
- }
-
- else if (width.HasValue)
- {
- newHeight = GetNewHeight(newHeight, newWidth, width.Value);
- newWidth = width.Value;
- }
-
- if (maxHeight.HasValue && maxHeight < newHeight)
- {
- newWidth = GetNewWidth(newHeight, newWidth, maxHeight.Value);
- newHeight = maxHeight.Value;
- }
-
- if (maxWidth.HasValue && maxWidth < newWidth)
- {
- newHeight = GetNewHeight(newHeight, newWidth, maxWidth.Value);
- newWidth = maxWidth.Value;
- }
-
- return new Size(Convert.ToInt32(newWidth), Convert.ToInt32(newHeight));
- }
-
- private static decimal GetNewWidth(decimal currentHeight, decimal currentWidth, int newHeight)
- {
- decimal scaleFactor = newHeight;
- scaleFactor /= currentHeight;
- scaleFactor *= currentWidth;
-
- return scaleFactor;
- }
-
- private static decimal GetNewHeight(decimal currentHeight, decimal currentWidth, int newWidth)
- {
- decimal scaleFactor = newWidth;
- scaleFactor /= currentWidth;
- scaleFactor *= currentHeight;
-
- return scaleFactor;
- }
- }
-}
diff --git a/MediaBrowser.Controller/Drawing/ImageManager.cs b/MediaBrowser.Controller/Drawing/ImageManager.cs new file mode 100644 index 0000000000..b493a97afe --- /dev/null +++ b/MediaBrowser.Controller/Drawing/ImageManager.cs @@ -0,0 +1,600 @@ +using MediaBrowser.Common.Drawing; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Drawing +{ + /// <summary> + /// Class ImageManager + /// </summary> + public class ImageManager : BaseManager<Kernel> + { + /// <summary> + /// Gets the image size cache. + /// </summary> + /// <value>The image size cache.</value> + private FileSystemRepository ImageSizeCache { get; set; } + + /// <summary> + /// Gets or sets the resized image cache. + /// </summary> + /// <value>The resized image cache.</value> + private FileSystemRepository ResizedImageCache { get; set; } + /// <summary> + /// Gets the cropped image cache. + /// </summary> + /// <value>The cropped image cache.</value> + private FileSystemRepository CroppedImageCache { get; set; } + + /// <summary> + /// Gets the cropped image cache. + /// </summary> + /// <value>The cropped image cache.</value> + private FileSystemRepository EnhancedImageCache { get; set; } + + /// <summary> + /// The cached imaged sizes + /// </summary> + private readonly ConcurrentDictionary<string, Task<ImageSize>> _cachedImagedSizes = new ConcurrentDictionary<string, Task<ImageSize>>(); + + /// <summary> + /// Initializes a new instance of the <see cref="ImageManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public ImageManager(Kernel kernel) + : base(kernel) + { + ImageSizeCache = new FileSystemRepository(Path.Combine(Kernel.ApplicationPaths.ImageCachePath, "image-sizes")); + ResizedImageCache = new FileSystemRepository(Path.Combine(Kernel.ApplicationPaths.ImageCachePath, "resized-images")); + CroppedImageCache = new FileSystemRepository(Path.Combine(Kernel.ApplicationPaths.ImageCachePath, "cropped-images")); + EnhancedImageCache = new FileSystemRepository(Path.Combine(Kernel.ApplicationPaths.ImageCachePath, "enhanced-images")); + } + + /// <summary> + /// Processes an image by resizing to target dimensions + /// </summary> + /// <param name="entity">The entity that owns the image</param> + /// <param name="imageType">The image type</param> + /// <param name="imageIndex">The image index (currently only used with backdrops)</param> + /// <param name="cropWhitespace">if set to <c>true</c> [crop whitespace].</param> + /// <param name="dateModified">The last date modified of the original image file</param> + /// <param name="toStream">The stream to save the new image to</param> + /// <param name="width">Use if a fixed width is required. Aspect ratio will be preserved.</param> + /// <param name="height">Use if a fixed height is required. Aspect ratio will be preserved.</param> + /// <param name="maxWidth">Use if a max width is required. Aspect ratio will be preserved.</param> + /// <param name="maxHeight">Use if a max height is required. Aspect ratio will be preserved.</param> + /// <param name="quality">Quality level, from 0-100. Currently only applies to JPG. The default value should suffice.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">entity</exception> + public async Task ProcessImage(BaseItem entity, ImageType imageType, int imageIndex, bool cropWhitespace, DateTime dateModified, Stream toStream, int? width, int? height, int? maxWidth, int? maxHeight, int? quality) + { + if (entity == null) + { + throw new ArgumentNullException("entity"); + } + + if (toStream == null) + { + throw new ArgumentNullException("toStream"); + } + + var originalImagePath = GetImagePath(entity, imageType, imageIndex); + + if (cropWhitespace) + { + try + { + originalImagePath = GetCroppedImage(originalImagePath, dateModified); + } + catch (Exception ex) + { + // We have to have a catch-all here because some of the .net image methods throw a plain old Exception + Logger.ErrorException("Error cropping image", ex); + } + } + + try + { + // Enhance if we have enhancers + var ehnancedImagePath = await GetEnhancedImage(originalImagePath, dateModified, entity, imageType, imageIndex).ConfigureAwait(false); + + // If the path changed update dateModified + if (!ehnancedImagePath.Equals(originalImagePath, StringComparison.OrdinalIgnoreCase)) + { + dateModified = File.GetLastWriteTimeUtc(ehnancedImagePath); + originalImagePath = ehnancedImagePath; + } + } + catch + { + Logger.Error("Error enhancing image"); + } + + var originalImageSize = await GetImageSize(originalImagePath, dateModified).ConfigureAwait(false); + + // Determine the output size based on incoming parameters + var newSize = DrawingUtils.Resize(originalImageSize, width, height, maxWidth, maxHeight); + + if (!quality.HasValue) + { + quality = 90; + } + + var cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality.Value, dateModified); + + // Grab the cache file if it already exists + try + { + using (var fileStream = new FileStream(cacheFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + await fileStream.CopyToAsync(toStream).ConfigureAwait(false); + } + return; + } + catch (FileNotFoundException) + { + // Cache file doesn't exist. No biggie. + } + + using (var fileStream = File.OpenRead(originalImagePath)) + { + using (var originalImage = Bitmap.FromStream(fileStream, true, false)) + { + var newWidth = Convert.ToInt32(newSize.Width); + var newHeight = Convert.ToInt32(newSize.Height); + + // Graphics.FromImage will throw an exception if the PixelFormat is Indexed, so we need to handle that here + var thumbnail = originalImage.PixelFormat.HasFlag(PixelFormat.Indexed) ? new Bitmap(originalImage, newWidth, newHeight) : new Bitmap(newWidth, newHeight, originalImage.PixelFormat); + + // Preserve the original resolution + thumbnail.SetResolution(originalImage.HorizontalResolution, originalImage.VerticalResolution); + + var thumbnailGraph = Graphics.FromImage(thumbnail); + + thumbnailGraph.CompositingQuality = CompositingQuality.HighQuality; + thumbnailGraph.SmoothingMode = SmoothingMode.HighQuality; + thumbnailGraph.InterpolationMode = InterpolationMode.HighQualityBicubic; + thumbnailGraph.PixelOffsetMode = PixelOffsetMode.HighQuality; + thumbnailGraph.CompositingMode = CompositingMode.SourceOver; + + thumbnailGraph.DrawImage(originalImage, 0, 0, newWidth, newHeight); + + var outputFormat = originalImage.RawFormat; + + using (var memoryStream = new MemoryStream { }) + { + // Save to the memory stream + thumbnail.Save(outputFormat, memoryStream, quality.Value); + + var bytes = memoryStream.ToArray(); + + var outputTask = Task.Run(async () => await toStream.WriteAsync(bytes, 0, bytes.Length)); + + // Save to the cache location + using (var cacheFileStream = new FileStream(cacheFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + // Save to the filestream + await cacheFileStream.WriteAsync(bytes, 0, bytes.Length); + } + + await outputTask.ConfigureAwait(false); + } + + thumbnailGraph.Dispose(); + thumbnail.Dispose(); + } + } + } + + /// <summary> + /// Gets the cache file path based on a set of parameters + /// </summary> + /// <param name="originalPath">The path to the original image file</param> + /// <param name="outputSize">The size to output the image in</param> + /// <param name="quality">Quality level, from 0-100. Currently only applies to JPG. The default value should suffice.</param> + /// <param name="dateModified">The last modified date of the image</param> + /// <returns>System.String.</returns> + private string GetCacheFilePath(string originalPath, ImageSize outputSize, int quality, DateTime dateModified) + { + var filename = originalPath; + + filename += "width=" + outputSize.Width; + + filename += "height=" + outputSize.Height; + + filename += "quality=" + quality; + + filename += "datemodified=" + dateModified.Ticks; + + return ResizedImageCache.GetResourcePath(filename, Path.GetExtension(originalPath)); + } + + + /// <summary> + /// Gets image dimensions + /// </summary> + /// <param name="imagePath">The image path.</param> + /// <param name="dateModified">The date modified.</param> + /// <returns>Task{ImageSize}.</returns> + /// <exception cref="System.ArgumentNullException">imagePath</exception> + public Task<ImageSize> GetImageSize(string imagePath, DateTime dateModified) + { + if (string.IsNullOrEmpty(imagePath)) + { + throw new ArgumentNullException("imagePath"); + } + + var name = imagePath + "datemodified=" + dateModified.Ticks; + + return _cachedImagedSizes.GetOrAdd(name, keyName => GetImageSizeTask(keyName, imagePath)); + } + + /// <summary> + /// Gets cached image dimensions, or results null if non-existant + /// </summary> + /// <param name="keyName">Name of the key.</param> + /// <param name="imagePath">The image path.</param> + /// <returns>Task{ImageSize}.</returns> + private Task<ImageSize> GetImageSizeTask(string keyName, string imagePath) + { + return Task.Run(() => GetImageSize(keyName, imagePath)); + } + + /// <summary> + /// Gets the size of the image. + /// </summary> + /// <param name="keyName">Name of the key.</param> + /// <param name="imagePath">The image path.</param> + /// <returns>ImageSize.</returns> + private ImageSize GetImageSize(string keyName, string imagePath) + { + // Now check the file system cache + var fullCachePath = ImageSizeCache.GetResourcePath(keyName, ".pb"); + + try + { + var result = Kernel.ProtobufSerializer.DeserializeFromFile<int[]>(fullCachePath); + + return new ImageSize { Width = result[0], Height = result[1] }; + } + catch (FileNotFoundException) + { + // Cache file doesn't exist no biggie + } + + var size = ImageHeader.GetDimensions(imagePath); + + var imageSize = new ImageSize { Width = size.Width, Height = size.Height }; + + // Update the file system cache + CacheImageSize(fullCachePath, size.Width, size.Height); + + return imageSize; + } + + /// <summary> + /// Caches image dimensions + /// </summary> + /// <param name="cachePath">The cache path.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + private void CacheImageSize(string cachePath, int width, int height) + { + var output = new[] { width, height }; + + Kernel.ProtobufSerializer.SerializeToFile(output, cachePath); + } + + /// <summary> + /// Gets the image path. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>System.String.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + /// <exception cref="System.InvalidOperationException"></exception> + public string GetImagePath(BaseItem item, ImageType imageType, int imageIndex) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (imageType == ImageType.Backdrop) + { + if (item.BackdropImagePaths == null) + { + throw new InvalidOperationException(string.Format("Item {0} does not have any Backdrops.", item.Name)); + } + + return item.BackdropImagePaths[imageIndex]; + } + + if (imageType == ImageType.Screenshot) + { + if (item.ScreenshotImagePaths == null) + { + throw new InvalidOperationException(string.Format("Item {0} does not have any Screenshots.", item.Name)); + } + + return item.ScreenshotImagePaths[imageIndex]; + } + + if (imageType == ImageType.ChapterImage) + { + var video = (Video)item; + + if (video.Chapters == null) + { + throw new InvalidOperationException(string.Format("Item {0} does not have any Chapters.", item.Name)); + } + + return video.Chapters[imageIndex].ImagePath; + } + + return item.GetImage(imageType); + } + + /// <summary> + /// Gets the image date modified. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>DateTime.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public DateTime GetImageDateModified(BaseItem item, ImageType imageType, int imageIndex) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var imagePath = GetImagePath(item, imageType, imageIndex); + + return GetImageDateModified(item, imagePath); + } + + /// <summary> + /// Gets the image date modified. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imagePath">The image path.</param> + /// <returns>DateTime.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public DateTime GetImageDateModified(BaseItem item, string imagePath) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (string.IsNullOrEmpty(imagePath)) + { + throw new ArgumentNullException("imagePath"); + } + + var metaFileEntry = item.ResolveArgs.GetMetaFileByPath(imagePath); + + // If we didn't the metafile entry, check the Season + if (!metaFileEntry.HasValue) + { + var episode = item as Episode; + + if (episode != null && episode.Season != null) + { + episode.Season.ResolveArgs.GetMetaFileByPath(imagePath); + } + } + + // See if we can avoid a file system lookup by looking for the file in ResolveArgs + return metaFileEntry == null ? File.GetLastWriteTimeUtc(imagePath) : metaFileEntry.Value.LastWriteTimeUtc; + } + + /// <summary> + /// Crops whitespace from an image, caches the result, and returns the cached path + /// </summary> + /// <param name="originalImagePath">The original image path.</param> + /// <param name="dateModified">The date modified.</param> + /// <returns>System.String.</returns> + private string GetCroppedImage(string originalImagePath, DateTime dateModified) + { + var name = originalImagePath; + name += "datemodified=" + dateModified.Ticks; + + var croppedImagePath = CroppedImageCache.GetResourcePath(name, Path.GetExtension(originalImagePath)); + + if (!CroppedImageCache.ContainsFilePath(croppedImagePath)) + { + using (var fileStream = File.OpenRead(originalImagePath)) + { + using (var originalImage = (Bitmap)Bitmap.FromStream(fileStream, true, false)) + { + var outputFormat = originalImage.RawFormat; + + using (var croppedImage = originalImage.CropWhitespace()) + { + using (var cacheFileStream = new FileStream(croppedImagePath, FileMode.Create)) + { + croppedImage.Save(outputFormat, cacheFileStream, 100); + } + } + } + } + } + + return croppedImagePath; + } + + /// <summary> + /// Runs an image through the image enhancers, caches the result, and returns the cached path + /// </summary> + /// <param name="originalImagePath">The original image path.</param> + /// <param name="dateModified">The date modified of the original image file.</param> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>System.String.</returns> + /// <exception cref="System.ArgumentNullException">originalImagePath</exception> + public async Task<string> GetEnhancedImage(string originalImagePath, DateTime dateModified, BaseItem item, ImageType imageType, int imageIndex) + { + if (string.IsNullOrEmpty(originalImagePath)) + { + throw new ArgumentNullException("originalImagePath"); + } + + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var supportedEnhancers = Kernel.ImageEnhancers.Where(i => i.Supports(item, imageType)).ToList(); + + // No enhancement - don't cache + if (supportedEnhancers.Count == 0) + { + return originalImagePath; + } + + var cacheGuid = GetImageCacheTag(originalImagePath, dateModified, supportedEnhancers, item, imageType); + + // All enhanced images are saved as png to allow transparency + var enhancedImagePath = EnhancedImageCache.GetResourcePath(cacheGuid + ".png"); + + if (!EnhancedImageCache.ContainsFilePath(enhancedImagePath)) + { + using (var fileStream = File.OpenRead(originalImagePath)) + { + using (var originalImage = Image.FromStream(fileStream, true, false)) + { + //Pass the image through registered enhancers + using (var newImage = await ExecuteImageEnhancers(supportedEnhancers, originalImage, item, imageType, imageIndex).ConfigureAwait(false)) + { + //And then save it in the cache + newImage.Save(enhancedImagePath, ImageFormat.Png); + } + } + } + } + + return enhancedImagePath; + } + + /// <summary> + /// Gets the image cache tag. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imagePath">The image path.</param> + /// <returns>Guid.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Guid GetImageCacheTag(BaseItem item, ImageType imageType, string imagePath) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (string.IsNullOrEmpty(imagePath)) + { + throw new ArgumentNullException("imagePath"); + } + + var dateModified = GetImageDateModified(item, imagePath); + + var supportedEnhancers = Kernel.ImageEnhancers.Where(i => i.Supports(item, imageType)); + + return GetImageCacheTag(imagePath, dateModified, supportedEnhancers, item, imageType); + } + + /// <summary> + /// Gets the image cache tag. + /// </summary> + /// <param name="originalImagePath">The original image path.</param> + /// <param name="dateModified">The date modified of the original image file.</param> + /// <param name="imageEnhancers">The image enhancers.</param> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <returns>Guid.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Guid GetImageCacheTag(string originalImagePath, DateTime dateModified, IEnumerable<BaseImageEnhancer> imageEnhancers, BaseItem item, ImageType imageType) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (imageEnhancers == null) + { + throw new ArgumentNullException("imageEnhancers"); + } + + if (string.IsNullOrEmpty(originalImagePath)) + { + throw new ArgumentNullException("originalImagePath"); + } + + // Cache name is created with supported enhancers combined with the last config change so we pick up new config changes + var cacheKeys = imageEnhancers.Select(i => i.GetType().Name + i.LastConfigurationChange(item, imageType).Ticks).ToList(); + cacheKeys.Add(originalImagePath + dateModified.Ticks); + + return string.Join("|", cacheKeys.ToArray()).GetMD5(); + } + + /// <summary> + /// Executes the image enhancers. + /// </summary> + /// <param name="imageEnhancers">The image enhancers.</param> + /// <param name="originalImage">The original image.</param> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>Task{EnhancedImage}.</returns> + private async Task<Image> ExecuteImageEnhancers(IEnumerable<BaseImageEnhancer> imageEnhancers, Image originalImage, BaseItem item, ImageType imageType, int imageIndex) + { + var result = originalImage; + + // Run the enhancers sequentially in order of priority + foreach (var enhancer in imageEnhancers) + { + result = await enhancer.EnhanceImageAsync(item, result, imageType, imageIndex).ConfigureAwait(false); + } + + return result; + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + ImageSizeCache.Dispose(); + ResizedImageCache.Dispose(); + CroppedImageCache.Dispose(); + EnhancedImageCache.Dispose(); + } + + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/Drawing/ImageProcessor.cs b/MediaBrowser.Controller/Drawing/ImageProcessor.cs deleted file mode 100644 index 29e40d17d7..0000000000 --- a/MediaBrowser.Controller/Drawing/ImageProcessor.cs +++ /dev/null @@ -1,148 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Model.Entities;
-using System;
-using System.Drawing;
-using System.Drawing.Drawing2D;
-using System.Drawing.Imaging;
-using System.IO;
-using System.Linq;
-
-namespace MediaBrowser.Controller.Drawing
-{
- public static class ImageProcessor
- {
- /// <summary>
- /// Processes an image by resizing to target dimensions
- /// </summary>
- /// <param name="entity">The entity that owns the image</param>
- /// <param name="imageType">The image type</param>
- /// <param name="imageIndex">The image index (currently only used with backdrops)</param>
- /// <param name="toStream">The stream to save the new image to</param>
- /// <param name="width">Use if a fixed width is required. Aspect ratio will be preserved.</param>
- /// <param name="height">Use if a fixed height is required. Aspect ratio will be preserved.</param>
- /// <param name="maxWidth">Use if a max width is required. Aspect ratio will be preserved.</param>
- /// <param name="maxHeight">Use if a max height is required. Aspect ratio will be preserved.</param>
- /// <param name="quality">Quality level, from 0-100. Currently only applies to JPG. The default value should suffice.</param>
- public static void ProcessImage(BaseEntity entity, ImageType imageType, int imageIndex, Stream toStream, int? width, int? height, int? maxWidth, int? maxHeight, int? quality)
- {
- Image originalImage = Image.FromFile(GetImagePath(entity, imageType, imageIndex));
-
- // Determine the output size based on incoming parameters
- Size newSize = DrawingUtils.Resize(originalImage.Size, width, height, maxWidth, maxHeight);
-
- Bitmap thumbnail;
-
- // Graphics.FromImage will throw an exception if the PixelFormat is Indexed, so we need to handle that here
- if (originalImage.PixelFormat.HasFlag(PixelFormat.Indexed))
- {
- thumbnail = new Bitmap(originalImage, newSize.Width, newSize.Height);
- }
- else
- {
- thumbnail = new Bitmap(newSize.Width, newSize.Height, originalImage.PixelFormat);
- }
-
- thumbnail.MakeTransparent();
-
- // Preserve the original resolution
- thumbnail.SetResolution(originalImage.HorizontalResolution, originalImage.VerticalResolution);
-
- Graphics thumbnailGraph = Graphics.FromImage(thumbnail);
-
- thumbnailGraph.CompositingQuality = CompositingQuality.HighQuality;
- thumbnailGraph.SmoothingMode = SmoothingMode.HighQuality;
- thumbnailGraph.InterpolationMode = InterpolationMode.HighQualityBicubic;
- thumbnailGraph.PixelOffsetMode = PixelOffsetMode.HighQuality;
- thumbnailGraph.CompositingMode = CompositingMode.SourceOver;
-
- thumbnailGraph.DrawImage(originalImage, 0, 0, newSize.Width, newSize.Height);
-
- ImageFormat outputFormat = originalImage.RawFormat;
-
- // Write to the output stream
- SaveImage(outputFormat, thumbnail, toStream, quality);
-
- thumbnailGraph.Dispose();
- thumbnail.Dispose();
- originalImage.Dispose();
- }
-
- public static string GetImagePath(BaseEntity entity, ImageType imageType, int imageIndex)
- {
- var item = entity as BaseItem;
-
- if (item != null)
- {
- if (imageType == ImageType.Logo)
- {
- return item.LogoImagePath;
- }
- if (imageType == ImageType.Backdrop)
- {
- return item.BackdropImagePaths.ElementAt(imageIndex);
- }
- if (imageType == ImageType.Banner)
- {
- return item.BannerImagePath;
- }
- if (imageType == ImageType.Art)
- {
- return item.ArtImagePath;
- }
- if (imageType == ImageType.Thumbnail)
- {
- return item.ThumbnailImagePath;
- }
- }
-
- return entity.PrimaryImagePath;
- }
-
- public static void SaveImage(ImageFormat outputFormat, Image newImage, Stream toStream, int? quality)
- {
- // Use special save methods for jpeg and png that will result in a much higher quality image
- // All other formats use the generic Image.Save
- if (ImageFormat.Jpeg.Equals(outputFormat))
- {
- SaveJpeg(newImage, toStream, quality);
- }
- else if (ImageFormat.Png.Equals(outputFormat))
- {
- newImage.Save(toStream, ImageFormat.Png);
- }
- else
- {
- newImage.Save(toStream, outputFormat);
- }
- }
-
- public static void SaveJpeg(Image image, Stream target, int? quality)
- {
- if (!quality.HasValue)
- {
- quality = 90;
- }
-
- using (var encoderParameters = new EncoderParameters(1))
- {
- encoderParameters.Param[0] = new EncoderParameter(Encoder.Quality, quality.Value);
- image.Save(target, GetImageCodecInfo("image/jpeg"), encoderParameters);
- }
- }
-
- public static ImageCodecInfo GetImageCodecInfo(string mimeType)
- {
- ImageCodecInfo[] info = ImageCodecInfo.GetImageEncoders();
-
- for (int i = 0; i < info.Length; i++)
- {
- ImageCodecInfo ici = info[i];
- if (ici.MimeType.Equals(mimeType, StringComparison.OrdinalIgnoreCase))
- {
- return ici;
- }
- }
- return info[1];
- }
- }
-}
diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs new file mode 100644 index 0000000000..c4fda4fa27 --- /dev/null +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Specialized folder that can have items added to it's children by external entities. + /// Used for our RootFolder so plug-ins can add items. + /// </summary> + public class AggregateFolder : Folder + { + /// <summary> + /// The _virtual children + /// </summary> + private readonly ConcurrentBag<BaseItem> _virtualChildren = new ConcurrentBag<BaseItem>(); + + /// <summary> + /// Gets the virtual children. + /// </summary> + /// <value>The virtual children.</value> + public ConcurrentBag<BaseItem> VirtualChildren + { + get { return _virtualChildren; } + } + + /// <summary> + /// Adds the virtual child. + /// </summary> + /// <param name="child">The child.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddVirtualChild(BaseItem child) + { + if (child == null) + { + throw new ArgumentNullException(); + } + + _virtualChildren.Add(child); + } + + /// <summary> + /// Get the children of this folder from the actual file system + /// </summary> + /// <returns>IEnumerable{BaseItem}.</returns> + protected override IEnumerable<BaseItem> GetNonCachedChildren() + { + return base.GetNonCachedChildren().Concat(_virtualChildren); + } + + /// <summary> + /// Finds the virtual child. + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException">id</exception> + public BaseItem FindVirtualChild(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + + return _virtualChildren.FirstOrDefault(i => i.Id == id); + } + } +} diff --git a/MediaBrowser.Controller/Entities/Audio.cs b/MediaBrowser.Controller/Entities/Audio.cs deleted file mode 100644 index 61e901dd22..0000000000 --- a/MediaBrowser.Controller/Entities/Audio.cs +++ /dev/null @@ -1,14 +0,0 @@ -
-namespace MediaBrowser.Controller.Entities
-{
- public class Audio : BaseItem
- {
- public int BitRate { get; set; }
- public int Channels { get; set; }
- public int SampleRate { get; set; }
-
- public string Artist { get; set; }
- public string Album { get; set; }
- public string AlbumArtist { get; set; }
- }
-}
diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs new file mode 100644 index 0000000000..511b589e53 --- /dev/null +++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs @@ -0,0 +1,78 @@ +using MediaBrowser.Model.Entities; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities.Audio +{ + /// <summary> + /// Class Audio + /// </summary> + public class Audio : BaseItem, IHasMediaStreams + { + /// <summary> + /// Gets or sets the media streams. + /// </summary> + /// <value>The media streams.</value> + public List<MediaStream> MediaStreams { get; set; } + + /// <summary> + /// Override this to true if class should be grouped under a container in indicies + /// The container class should be defined via IndexContainer + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool GroupInIndex + { + get + { + return true; + } + } + + /// <summary> + /// The unknown album + /// </summary> + private static readonly MusicAlbum UnknownAlbum = new MusicAlbum {Name = "<Unknown>"}; + /// <summary> + /// Override this to return the folder that should be used to construct a container + /// for this item in an index. GroupInIndex should be true as well. + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public override Folder IndexContainer + { + get + { + return Parent is MusicAlbum ? Parent : Album != null ? new MusicAlbum {Name = Album, PrimaryImagePath = PrimaryImagePath } : UnknownAlbum; + } + } + + /// <summary> + /// Gets or sets the artist. + /// </summary> + /// <value>The artist.</value> + public string Artist { get; set; } + /// <summary> + /// Gets or sets the album. + /// </summary> + /// <value>The album.</value> + public string Album { get; set; } + /// <summary> + /// Gets or sets the album artist. + /// </summary> + /// <value>The album artist.</value> + public string AlbumArtist { get; set; } + + /// <summary> + /// Gets the type of the media. + /// </summary> + /// <value>The type of the media.</value> + public override string MediaType + { + get + { + return Model.Entities.MediaType.Audio; + } + } + } +} diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs new file mode 100644 index 0000000000..9e4e3c5424 --- /dev/null +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities.Audio +{ + /// <summary> + /// Class MusicAlbum + /// </summary> + public class MusicAlbum : Folder + { + /// <summary> + /// Songs will group into us so don't also include us in the index + /// </summary> + /// <value><c>true</c> if [include in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool IncludeInIndex + { + get + { + return false; + } + } + + /// <summary> + /// Override this to true if class should be grouped under a container in indicies + /// The container class should be defined via IndexContainer + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool GroupInIndex + { + get + { + return true; + } + } + + /// <summary> + /// The unknwon artist + /// </summary> + private static readonly MusicArtist UnknwonArtist = new MusicArtist {Name = "<Unknown>"}; + + /// <summary> + /// Override this to return the folder that should be used to construct a container + /// for this item in an index. GroupInIndex should be true as well. + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public override Folder IndexContainer + { + get { return Parent as MusicArtist ?? UnknwonArtist; } + } + + /// <summary> + /// Override to point to first child (song) if not explicitly defined + /// </summary> + /// <value>The primary image path.</value> + [IgnoreDataMember] + public override string PrimaryImagePath + { + get + { + if (base.PrimaryImagePath == null) + { + var child = Children.FirstOrDefault(); + + return child == null ? base.PrimaryImagePath : child.PrimaryImagePath; + } + + return base.PrimaryImagePath; + } + set + { + base.PrimaryImagePath = value; + } + } + + /// <summary> + /// Override to point to first child (song) + /// </summary> + /// <value>The production year.</value> + public override int? ProductionYear + { + get + { + var child = Children.FirstOrDefault(); + + return child == null ? base.ProductionYear : child.ProductionYear; + } + set + { + base.ProductionYear = value; + } + } + + /// <summary> + /// Override to point to first child (song) + /// </summary> + /// <value>The genres.</value> + public override List<string> Genres + { + get + { + var child = Children.FirstOrDefault(); + + return child == null ? base.Genres : child.Genres; + } + set + { + base.Genres = value; + } + } + + /// <summary> + /// Override to point to first child (song) + /// </summary> + /// <value>The studios.</value> + public override List<string> Studios + { + get + { + var child = Children.FirstOrDefault(); + + return child == null ? base.Studios : child.Studios; + } + set + { + base.Studios = value; + } + } + } +} diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs new file mode 100644 index 0000000000..b2fc04873f --- /dev/null +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -0,0 +1,10 @@ + +namespace MediaBrowser.Controller.Entities.Audio +{ + /// <summary> + /// Class MusicArtist + /// </summary> + public class MusicArtist : Folder + { + } +} diff --git a/MediaBrowser.Controller/Entities/BaseEntity.cs b/MediaBrowser.Controller/Entities/BaseEntity.cs deleted file mode 100644 index 5b4a360c1f..0000000000 --- a/MediaBrowser.Controller/Entities/BaseEntity.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Providers;
-
-namespace MediaBrowser.Controller.Entities
-{
- /// <summary>
- /// Provides a base entity for all of our types
- /// </summary>
- public abstract class BaseEntity
- {
- public string Name { get; set; }
-
- public Guid Id { get; set; }
-
- public string Path { get; set; }
-
- public Folder Parent { get; set; }
-
- public string PrimaryImagePath { get; set; }
-
- public DateTime DateCreated { get; set; }
-
- public DateTime DateModified { get; set; }
-
- public override string ToString()
- {
- return Name;
- }
- protected Dictionary<Guid, BaseProviderInfo> _providerData;
- /// <summary>
- /// Holds persistent data for providers like last refresh date.
- /// Providers can use this to determine if they need to refresh.
- /// The BaseProviderInfo class can be extended to hold anything a provider may need.
- ///
- /// Keyed by a unique provider ID.
- /// </summary>
- public Dictionary<Guid, BaseProviderInfo> ProviderData
- {
- get
- {
- if (_providerData == null) _providerData = new Dictionary<Guid, BaseProviderInfo>();
- return _providerData;
- }
- set
- {
- _providerData = value;
- }
- }
-
- protected ItemResolveEventArgs _resolveArgs;
- /// <summary>
- /// We attach these to the item so that we only ever have to hit the file system once
- /// (this includes the children of the containing folder)
- /// Use ResolveArgs.FileSystemChildren to check for the existence of files instead of File.Exists
- /// </summary>
- public ItemResolveEventArgs ResolveArgs
- {
- get
- {
- if (_resolveArgs == null)
- {
- _resolveArgs = new ItemResolveEventArgs()
- {
- FileInfo = FileData.GetFileData(this.Path),
- Parent = this.Parent,
- Cancel = false,
- Path = this.Path
- };
- _resolveArgs = FileSystemHelper.FilterChildFileSystemEntries(_resolveArgs, (this.Parent != null && this.Parent.IsRoot));
- }
- return _resolveArgs;
- }
- set
- {
- _resolveArgs = value;
- }
- }
-
- /// <summary>
- /// Refresh metadata on us by execution our provider chain
- /// </summary>
- /// <returns>true if a provider reports we changed</returns>
- public bool RefreshMetadata()
- {
- Kernel.Instance.ExecuteMetadataProviders(this).ConfigureAwait(false);
- return true;
- }
-
- }
-}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 4c9008b22c..e583e3a6a0 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1,202 +1,1382 @@ -using MediaBrowser.Model.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.IO;
-using System;
-using System.Threading.Tasks;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace MediaBrowser.Controller.Entities
-{
- public abstract class BaseItem : BaseEntity, IHasProviderIds
- {
-
- public IEnumerable<string> PhysicalLocations
- {
- get
- {
- return _resolveArgs.PhysicalLocations;
- }
- }
-
- public string SortName { get; set; }
-
- /// <summary>
- /// When the item first debuted. For movies this could be premiere date, episodes would be first aired
- /// </summary>
- public DateTime? PremiereDate { get; set; }
-
- public string LogoImagePath { get; set; }
-
- public string ArtImagePath { get; set; }
-
- public string ThumbnailImagePath { get; set; }
-
- public string BannerImagePath { get; set; }
-
- public IEnumerable<string> BackdropImagePaths { get; set; }
-
- public string OfficialRating { get; set; }
-
- public string CustomRating { get; set; }
- public string CustomPin { get; set; }
-
- public string Language { get; set; }
- public string Overview { get; set; }
- public List<string> Taglines { get; set; }
-
- /// <summary>
- /// Using a Dictionary to prevent duplicates
- /// </summary>
- public Dictionary<string,PersonInfo> People { get; set; }
-
- public List<string> Studios { get; set; }
-
- public List<string> Genres { get; set; }
-
- public string DisplayMediaType { get; set; }
-
- public float? CommunityRating { get; set; }
- public long? RunTimeTicks { get; set; }
-
- public string AspectRatio { get; set; }
- public int? ProductionYear { get; set; }
-
- /// <summary>
- /// If the item is part of a series, this is it's number in the series.
- /// This could be episode number, album track number, etc.
- /// </summary>
- public int? IndexNumber { get; set; }
-
- /// <summary>
- /// For an episode this could be the season number, or for a song this could be the disc number.
- /// </summary>
- public int? ParentIndexNumber { get; set; }
-
- public IEnumerable<Video> LocalTrailers { get; set; }
-
- public string TrailerUrl { get; set; }
-
- public Dictionary<string, string> ProviderIds { get; set; }
-
- public Dictionary<Guid, UserItemData> UserData { get; set; }
-
- public UserItemData GetUserData(User user, bool createIfNull)
- {
- if (UserData == null || !UserData.ContainsKey(user.Id))
- {
- if (createIfNull)
- {
- AddUserData(user, new UserItemData());
- }
- else
- {
- return null;
- }
- }
-
- return UserData[user.Id];
- }
-
- private void AddUserData(User user, UserItemData data)
- {
- if (UserData == null)
- {
- UserData = new Dictionary<Guid, UserItemData>();
- }
-
- UserData[user.Id] = data;
- }
-
- /// <summary>
- /// Determines if a given user has access to this item
- /// </summary>
- internal bool IsParentalAllowed(User user)
- {
- return true;
- }
-
- /// <summary>
- /// Finds an item by ID, recursively
- /// </summary>
- public virtual BaseItem FindItemById(Guid id)
- {
- if (Id == id)
- {
- return this;
- }
-
- if (LocalTrailers != null)
- {
- return LocalTrailers.FirstOrDefault(i => i.Id == id);
- }
-
- return null;
- }
-
- public virtual bool IsFolder
- {
- get
- {
- return false;
- }
- }
-
- /// <summary>
- /// Determine if we have changed vs the passed in copy
- /// </summary>
- /// <param name="copy"></param>
- /// <returns></returns>
- public virtual bool IsChanged(BaseItem copy)
- {
- bool changed = copy.DateModified != this.DateModified;
- if (changed) MediaBrowser.Common.Logging.Logger.LogDebugInfo(this.Name + " changed - original creation: " + this.DateCreated + " new creation: " + copy.DateCreated + " original modified: " + this.DateModified + " new modified: " + copy.DateModified);
- return changed;
- }
-
- /// <summary>
- /// Determines if the item is considered new based on user settings
- /// </summary>
- public bool IsRecentlyAdded(User user)
- {
- return (DateTime.UtcNow - DateCreated).TotalDays < user.RecentItemDays;
- }
-
- public void AddPerson(PersonInfo person)
- {
- if (People == null)
- {
- People = new Dictionary<string, PersonInfo>(StringComparer.OrdinalIgnoreCase);
- }
-
- People[person.Name] = person;
- }
-
- /// <summary>
- /// Marks the item as either played or unplayed
- /// </summary>
- public virtual void SetPlayedStatus(User user, bool wasPlayed)
- {
- UserItemData data = GetUserData(user, true);
-
- if (wasPlayed)
- {
- data.PlayCount = Math.Max(data.PlayCount, 1);
- }
- else
- {
- data.PlayCount = 0;
- data.PlaybackPositionTicks = 0;
- }
- }
-
- /// <summary>
- /// Do whatever refreshing is necessary when the filesystem pertaining to this item has changed.
- /// </summary>
- /// <returns></returns>
- public virtual Task ChangedExternally()
- {
- return Task.Run(() => RefreshMetadata());
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Localization; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class BaseItem + /// </summary> + public abstract class BaseItem : IHasProviderIds + { + /// <summary> + /// The trailer folder name + /// </summary> + public const string TrailerFolderName = "trailers"; + + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public virtual string Name { get; set; } + + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public virtual Guid Id { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public virtual string Path { get; set; } + + /// <summary> + /// Gets or sets the type of the location. + /// </summary> + /// <value>The type of the location.</value> + public virtual LocationType LocationType + { + get + { + if (string.IsNullOrEmpty(Path)) + { + return LocationType.Virtual; + } + + return System.IO.Path.IsPathRooted(Path) ? LocationType.FileSystem : LocationType.Remote; + } + } + + /// <summary> + /// This is just a helper for convenience + /// </summary> + /// <value>The primary image path.</value> + [IgnoreDataMember] + public virtual string PrimaryImagePath + { + get { return GetImage(ImageType.Primary); } + set { SetImage(ImageType.Primary, value); } + } + + /// <summary> + /// Gets or sets the images. + /// </summary> + /// <value>The images.</value> + public Dictionary<string, string> Images { get; set; } + + /// <summary> + /// Gets or sets the date created. + /// </summary> + /// <value>The date created.</value> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date modified. + /// </summary> + /// <value>The date modified.</value> + public DateTime DateModified { get; set; } + + /// <summary> + /// Returns a <see cref="System.String" /> that represents this instance. + /// </summary> + /// <returns>A <see cref="System.String" /> that represents this instance.</returns> + public override string ToString() + { + return Name; + } + + /// <summary> + /// Returns true if this item should not attempt to fetch metadata + /// </summary> + /// <value><c>true</c> if [dont fetch meta]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public virtual bool DontFetchMeta + { + get + { + if (Path != null) + { + return Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1; + } + + return false; + } + } + + /// <summary> + /// Determines whether the item has a saved local image of the specified name (jpg or png). + /// </summary> + /// <param name="name">The name.</param> + /// <returns><c>true</c> if [has local image] [the specified item]; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException">name</exception> + public bool HasLocalImage(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + return ResolveArgs.ContainsMetaFileByName(name + ".jpg") || + ResolveArgs.ContainsMetaFileByName(name + ".png"); + } + + /// <summary> + /// Should be overridden to return the proper folder where metadata lives + /// </summary> + /// <value>The meta location.</value> + [IgnoreDataMember] + public virtual string MetaLocation + { + get + { + return Path ?? ""; + } + } + + /// <summary> + /// The _provider data + /// </summary> + private Dictionary<Guid, BaseProviderInfo> _providerData; + /// <summary> + /// Holds persistent data for providers like last refresh date. + /// Providers can use this to determine if they need to refresh. + /// The BaseProviderInfo class can be extended to hold anything a provider may need. + /// Keyed by a unique provider ID. + /// </summary> + /// <value>The provider data.</value> + public Dictionary<Guid, BaseProviderInfo> ProviderData + { + get + { + return _providerData ?? (_providerData = new Dictionary<Guid, BaseProviderInfo>()); + } + set + { + _providerData = value; + } + } + + /// <summary> + /// The _file system stamp + /// </summary> + private Guid? _fileSystemStamp; + /// <summary> + /// Gets a directory stamp, in the form of a string, that can be used for + /// comparison purposes to determine if the file system entries for this item have changed. + /// </summary> + /// <value>The file system stamp.</value> + [IgnoreDataMember] + public Guid FileSystemStamp + { + get + { + if (!_fileSystemStamp.HasValue) + { + _fileSystemStamp = GetFileSystemStamp(); + } + + return _fileSystemStamp.Value; + } + } + + /// <summary> + /// Gets the type of the media. + /// </summary> + /// <value>The type of the media.</value> + [IgnoreDataMember] + public virtual string MediaType + { + get + { + return null; + } + } + + /// <summary> + /// Gets a directory stamp, in the form of a string, that can be used for + /// comparison purposes to determine if the file system entries for this item have changed. + /// </summary> + /// <returns>Guid.</returns> + private Guid GetFileSystemStamp() + { + // If there's no path or the item is a file, there's nothing to do + if (LocationType != LocationType.FileSystem || !ResolveArgs.IsDirectory) + { + return Guid.Empty; + } + + var sb = new StringBuilder(); + + // Record the name of each file + // Need to sort these because accoring to msdn docs, our i/o methods are not guaranteed in any order + foreach (var file in ResolveArgs.FileSystemChildren.OrderBy(f => f.cFileName)) + { + sb.Append(file.cFileName); + } + foreach (var file in ResolveArgs.MetadataFiles.OrderBy(f => f.cFileName)) + { + sb.Append(file.cFileName); + } + + return sb.ToString().GetMD5(); + } + + /// <summary> + /// The _resolve args + /// </summary> + private ItemResolveArgs _resolveArgs; + /// <summary> + /// The _resolve args initialized + /// </summary> + private bool _resolveArgsInitialized; + /// <summary> + /// The _resolve args sync lock + /// </summary> + private object _resolveArgsSyncLock = new object(); + /// <summary> + /// We attach these to the item so that we only ever have to hit the file system once + /// (this includes the children of the containing folder) + /// Use ResolveArgs.FileSystemDictionary to check for the existence of files instead of File.Exists + /// </summary> + /// <value>The resolve args.</value> + [IgnoreDataMember] + public ItemResolveArgs ResolveArgs + { + get + { + try + { + LazyInitializer.EnsureInitialized(ref _resolveArgs, ref _resolveArgsInitialized, ref _resolveArgsSyncLock, () => CreateResolveArgs()); + + } + catch (IOException ex) + { + Logger.LogException("Error creating resolve args for ", ex, Path); + + throw; + } + + return _resolveArgs; + } + set + { + _resolveArgs = value; + _resolveArgsInitialized = value != null; + + // Null this out so that it can be lazy loaded again + _fileSystemStamp = null; + } + } + + /// <summary> + /// Resets the resolve args. + /// </summary> + /// <param name="pathInfo">The path info.</param> + public void ResetResolveArgs(WIN32_FIND_DATA? pathInfo) + { + ResolveArgs = CreateResolveArgs(pathInfo); + } + + /// <summary> + /// Creates ResolveArgs on demand + /// </summary> + /// <param name="pathInfo">The path info.</param> + /// <returns>ItemResolveArgs.</returns> + /// <exception cref="System.IO.IOException">Unable to retrieve file system info for + path</exception> + protected internal virtual ItemResolveArgs CreateResolveArgs(WIN32_FIND_DATA? pathInfo = null) + { + var path = Path; + + // non file-system entries will not have a path + if (string.IsNullOrEmpty(path)) + { + return new ItemResolveArgs + { + FileInfo = new WIN32_FIND_DATA() + }; + } + + if (UseParentPathToCreateResolveArgs) + { + path = System.IO.Path.GetDirectoryName(path); + } + + pathInfo = pathInfo ?? FileSystem.GetFileData(path); + + if (!pathInfo.HasValue) + { + throw new IOException("Unable to retrieve file system info for " + path); + } + + var args = new ItemResolveArgs + { + FileInfo = pathInfo.Value, + Path = path, + Parent = Parent + }; + + // Gather child folder and files + + if (args.IsDirectory) + { + // When resolving the root, we need it's grandchildren (children of user views) + var flattenFolderDepth = args.IsPhysicalRoot ? 2 : 0; + + args.FileSystemDictionary = FileData.GetFilteredFileSystemEntries(args.Path, flattenFolderDepth: flattenFolderDepth, args: args); + } + + //update our dates + EntityResolutionHelper.EnsureDates(this, args); + + return args; + } + + /// <summary> + /// Some subclasses will stop resolving at a directory and point their Path to a file within. This will help ensure the on-demand resolve args are identical to the + /// original ones. + /// </summary> + /// <value><c>true</c> if [use parent path to create resolve args]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + protected virtual bool UseParentPathToCreateResolveArgs + { + get + { + return false; + } + } + + /// <summary> + /// Gets or sets the name of the sort. + /// </summary> + /// <value>The name of the sort.</value> + public string SortName { get; set; } + + /// <summary> + /// Gets or sets the parent. + /// </summary> + /// <value>The parent.</value> + [IgnoreDataMember] + public Folder Parent { get; set; } + + /// <summary> + /// Gets the collection folder parent. + /// </summary> + /// <value>The collection folder parent.</value> + [IgnoreDataMember] + public Folder CollectionFolder + { + get + { + if (this is AggregateFolder) + { + return null; + } + + if (IsFolder) + { + var iCollectionFolder = this as ICollectionFolder; + + if (iCollectionFolder != null) + { + return (Folder)this; + } + } + + var parent = Parent; + + while (parent != null) + { + var iCollectionFolder = parent as ICollectionFolder; + + if (iCollectionFolder != null) + { + return parent; + } + + parent = parent.Parent; + } + + return null; + } + } + + /// <summary> + /// When the item first debuted. For movies this could be premiere date, episodes would be first aired + /// </summary> + /// <value>The premiere date.</value> + public DateTime? PremiereDate { get; set; } + + /// <summary> + /// Gets or sets the display type of the media. + /// </summary> + /// <value>The display type of the media.</value> + public virtual string DisplayMediaType { get; set; } + + /// <summary> + /// Gets or sets the backdrop image paths. + /// </summary> + /// <value>The backdrop image paths.</value> + public List<string> BackdropImagePaths { get; set; } + + /// <summary> + /// Gets or sets the screenshot image paths. + /// </summary> + /// <value>The screenshot image paths.</value> + public List<string> ScreenshotImagePaths { get; set; } + + /// <summary> + /// Gets or sets the official rating. + /// </summary> + /// <value>The official rating.</value> + public string OfficialRating { get; set; } + + /// <summary> + /// Gets or sets the custom rating. + /// </summary> + /// <value>The custom rating.</value> + public string CustomRating { get; set; } + + /// <summary> + /// Gets or sets the language. + /// </summary> + /// <value>The language.</value> + public string Language { get; set; } + /// <summary> + /// Gets or sets the overview. + /// </summary> + /// <value>The overview.</value> + public string Overview { get; set; } + /// <summary> + /// Gets or sets the taglines. + /// </summary> + /// <value>The taglines.</value> + public List<string> Taglines { get; set; } + + /// <summary> + /// Gets or sets the people. + /// </summary> + /// <value>The people.</value> + public List<PersonInfo> People { get; set; } + + /// <summary> + /// Override this if you need to combine/collapse person information + /// </summary> + /// <value>All people.</value> + [IgnoreDataMember] + public virtual IEnumerable<PersonInfo> AllPeople + { + get { return People; } + } + + /// <summary> + /// Gets or sets the studios. + /// </summary> + /// <value>The studios.</value> + public virtual List<string> Studios { get; set; } + + /// <summary> + /// Gets or sets the genres. + /// </summary> + /// <value>The genres.</value> + public virtual List<string> Genres { get; set; } + + /// <summary> + /// Gets or sets the community rating. + /// </summary> + /// <value>The community rating.</value> + public float? CommunityRating { get; set; } + /// <summary> + /// Gets or sets the run time ticks. + /// </summary> + /// <value>The run time ticks.</value> + public long? RunTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the aspect ratio. + /// </summary> + /// <value>The aspect ratio.</value> + public string AspectRatio { get; set; } + /// <summary> + /// Gets or sets the production year. + /// </summary> + /// <value>The production year.</value> + public virtual int? ProductionYear { get; set; } + + /// <summary> + /// If the item is part of a series, this is it's number in the series. + /// This could be episode number, album track number, etc. + /// </summary> + /// <value>The index number.</value> + public int? IndexNumber { get; set; } + + /// <summary> + /// For an episode this could be the season number, or for a song this could be the disc number. + /// </summary> + /// <value>The parent index number.</value> + public int? ParentIndexNumber { get; set; } + + /// <summary> + /// The _local trailers + /// </summary> + private List<Video> _localTrailers; + /// <summary> + /// The _local trailers initialized + /// </summary> + private bool _localTrailersInitialized; + /// <summary> + /// The _local trailers sync lock + /// </summary> + private object _localTrailersSyncLock = new object(); + /// <summary> + /// Gets the local trailers. + /// </summary> + /// <value>The local trailers.</value> + [IgnoreDataMember] + public List<Video> LocalTrailers + { + get + { + LazyInitializer.EnsureInitialized(ref _localTrailers, ref _localTrailersInitialized, ref _localTrailersSyncLock, LoadLocalTrailers); + return _localTrailers; + } + private set + { + _localTrailers = value; + + if (value == null) + { + _localTrailersInitialized = false; + } + } + } + + /// <summary> + /// Loads local trailers from the file system + /// </summary> + /// <returns>List{Video}.</returns> + private List<Video> LoadLocalTrailers() + { + ItemResolveArgs resolveArgs; + + try + { + resolveArgs = ResolveArgs; + } + catch (IOException ex) + { + Logger.LogException("Error getting ResolveArgs for {0}", ex, Path); + return new List<Video> { }; + } + + if (!resolveArgs.IsDirectory) + { + return new List<Video> { }; + } + + var folder = resolveArgs.GetFileSystemEntryByName(TrailerFolderName); + + // Path doesn't exist. No biggie + if (folder == null) + { + return new List<Video> { }; + } + + IEnumerable<WIN32_FIND_DATA> files; + + try + { + files = FileSystem.GetFiles(folder.Value.Path); + } + catch (IOException ex) + { + Logger.LogException("Error loading trailers for {0}", ex, Name); + return new List<Video> { }; + } + + return Kernel.Instance.LibraryManager.GetItems<Video>(files, null).Select(video => + { + // Try to retrieve it from the db. If we don't find it, use the resolved version + var dbItem = Kernel.Instance.ItemRepository.RetrieveItem(video.Id) as Video; + + if (dbItem != null) + { + dbItem.ResolveArgs = video.ResolveArgs; + video = dbItem; + } + + return video; + }).ToList(); + } + + /// <summary> + /// Overrides the base implementation to refresh metadata for local trailers + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="forceSave">if set to <c>true</c> [is new item].</param> + /// <param name="forceRefresh">if set to <c>true</c> [force].</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <param name="resetResolveArgs">if set to <c>true</c> [reset resolve args].</param> + /// <returns>true if a provider reports we changed</returns> + public virtual async Task<bool> RefreshMetadata(CancellationToken cancellationToken, bool forceSave = false, bool forceRefresh = false, bool allowSlowProviders = true, bool resetResolveArgs = true) + { + if (resetResolveArgs) + { + ResolveArgs = null; + } + + // Lazy load these again + LocalTrailers = null; + + // Refresh for the item + var itemRefreshTask = Kernel.Instance.ProviderManager.ExecuteMetadataProviders(this, cancellationToken, forceRefresh, allowSlowProviders); + + cancellationToken.ThrowIfCancellationRequested(); + + // Refresh metadata for local trailers + var trailerTasks = LocalTrailers.Select(i => i.RefreshMetadata(cancellationToken, forceSave, forceRefresh, allowSlowProviders)); + + cancellationToken.ThrowIfCancellationRequested(); + + // Await the trailer tasks + await Task.WhenAll(trailerTasks).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + // Get the result from the item task + var changed = await itemRefreshTask.ConfigureAwait(false); + + if (changed || forceSave) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Kernel.Instance.ItemRepository.SaveItem(this, cancellationToken).ConfigureAwait(false); + } + + return changed; + } + + /// <summary> + /// Clear out all metadata properties. Extend for sub-classes. + /// </summary> + public virtual void ClearMetaValues() + { + Images = null; + SortName = null; + PremiereDate = null; + BackdropImagePaths = null; + OfficialRating = null; + CustomRating = null; + Overview = null; + Taglines = null; + Language = null; + Studios = null; + Genres = null; + CommunityRating = null; + RunTimeTicks = null; + AspectRatio = null; + ProductionYear = null; + ProviderIds = null; + DisplayMediaType = GetType().Name; + ResolveArgs = null; + } + + /// <summary> + /// Gets or sets the trailer URL. + /// </summary> + /// <value>The trailer URL.</value> + public List<string> TrailerUrls { get; set; } + + /// <summary> + /// Gets or sets the provider ids. + /// </summary> + /// <value>The provider ids.</value> + public Dictionary<string, string> ProviderIds { get; set; } + + /// <summary> + /// Override this to false if class should be ignored for indexing purposes + /// </summary> + /// <value><c>true</c> if [include in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public virtual bool IncludeInIndex + { + get { return true; } + } + + /// <summary> + /// Override this to true if class should be grouped under a container in indicies + /// The container class should be defined via IndexContainer + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public virtual bool GroupInIndex + { + get { return false; } + } + + /// <summary> + /// Override this to return the folder that should be used to construct a container + /// for this item in an index. GroupInIndex should be true as well. + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public virtual Folder IndexContainer + { + get { return null; } + } + + /// <summary> + /// The _user data + /// </summary> + private IEnumerable<UserItemData> _userData; + /// <summary> + /// The _user data initialized + /// </summary> + private bool _userDataInitialized; + /// <summary> + /// The _user data sync lock + /// </summary> + private object _userDataSyncLock = new object(); + /// <summary> + /// Gets the user data. + /// </summary> + /// <value>The user data.</value> + [IgnoreDataMember] + public IEnumerable<UserItemData> UserData + { + get + { + // Call ToList to exhaust the stream because we'll be iterating over this multiple times + LazyInitializer.EnsureInitialized(ref _userData, ref _userDataInitialized, ref _userDataSyncLock, () => Kernel.Instance.UserDataRepository.RetrieveUserData(this).ToList()); + return _userData; + } + private set + { + _userData = value; + + if (value == null) + { + _userDataInitialized = false; + } + } + } + + /// <summary> + /// Gets the user data. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="createIfNull">if set to <c>true</c> [create if null].</param> + /// <returns>UserItemData.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public UserItemData GetUserData(User user, bool createIfNull) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + if (UserData == null) + { + if (!createIfNull) + { + return null; + } + + AddOrUpdateUserData(user, new UserItemData { UserId = user.Id }); + } + + var data = UserData.FirstOrDefault(u => u.UserId == user.Id); + + if (data == null && createIfNull) + { + data = new UserItemData { UserId = user.Id }; + AddOrUpdateUserData(user, data); + } + + return data; + } + + /// <summary> + /// Adds the or update user data. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="data">The data.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddOrUpdateUserData(User user, UserItemData data) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + if (data == null) + { + throw new ArgumentNullException(); + } + + data.UserId = user.Id; + + if (UserData == null) + { + UserData = new[] { data }; + } + else + { + var list = UserData.Where(u => u.UserId != user.Id).ToList(); + list.Add(data); + UserData = list; + } + } + + /// <summary> + /// The _user data id + /// </summary> + protected Guid _userDataId; //cache this so it doesn't have to be re-constructed on every reference + /// <summary> + /// Return the id that should be used to key user data for this item. + /// Default is just this Id but subclasses can use provider Ids for transportability. + /// </summary> + /// <value>The user data id.</value> + [IgnoreDataMember] + public virtual Guid UserDataId + { + get + { + return _userDataId == Guid.Empty ? (_userDataId = Id) : _userDataId; + } + } + + /// <summary> + /// Determines if a given user has access to this item + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if [is parental allowed] [the specified user]; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public bool IsParentalAllowed(User user) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + return user.Configuration.MaxParentalRating == null || Ratings.Level(CustomRating ?? OfficialRating) <= user.Configuration.MaxParentalRating; + } + + /// <summary> + /// Determines if this folder should be visible to a given user. + /// Default is just parental allowed. Can be overridden for more functionality. + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if the specified user is visible; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public virtual bool IsVisible(User user) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + return IsParentalAllowed(user); + } + + /// <summary> + /// Finds an item by ID, recursively + /// </summary> + /// <param name="id">The id.</param> + /// <param name="user">The user.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException">id</exception> + public virtual BaseItem FindItemById(Guid id, User user) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + + if (Id == id) + { + return this; + } + + if (LocalTrailers != null) + { + return LocalTrailers.FirstOrDefault(i => i.Id == id); + } + + return null; + } + + /// <summary> + /// Finds the particular item by searching through our parents and, if not found there, loading from repo + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentException"></exception> + protected BaseItem FindParentItem(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException(); + } + + var parent = Parent; + while (parent != null && !parent.IsRoot) + { + if (parent.Id == id) return parent; + parent = parent.Parent; + } + + //not found - load from repo + return Kernel.Instance.ItemRepository.RetrieveItem(id); + } + + /// <summary> + /// Gets a value indicating whether this instance is folder. + /// </summary> + /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public virtual bool IsFolder + { + get + { + return false; + } + } + + /// <summary> + /// Determine if we have changed vs the passed in copy + /// </summary> + /// <param name="copy">The copy.</param> + /// <returns><c>true</c> if the specified copy has changed; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public virtual bool HasChanged(BaseItem copy) + { + if (copy == null) + { + throw new ArgumentNullException(); + } + + var changed = copy.DateModified != DateModified; + if (changed) + { + Logger.LogDebugInfo(Name + " changed - original creation: " + DateCreated + " new creation: " + copy.DateCreated + " original modified: " + DateModified + " new modified: " + copy.DateModified); + } + return changed; + } + + /// <summary> + /// Determines if the item is considered new based on user settings + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if [is recently added] [the specified user]; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public bool IsRecentlyAdded(User user) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + return (DateTime.UtcNow - DateCreated).TotalDays < Kernel.Instance.Configuration.RecentItemDays; + } + + /// <summary> + /// Determines if the item is considered recently played on user settings + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if [is recently played] [the specified user]; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public bool IsRecentlyPlayed(User user) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + var data = GetUserData(user, false); + + if (data == null || data.LastPlayedDate == null || data.PlayCount == 0) + { + return false; + } + + return (DateTime.UtcNow - data.LastPlayedDate.Value).TotalDays < Kernel.Instance.Configuration.RecentlyPlayedDays; + } + + /// <summary> + /// Adds people to the item + /// </summary> + /// <param name="people">The people.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddPeople(IEnumerable<PersonInfo> people) + { + if (people == null) + { + throw new ArgumentNullException(); + } + + foreach (var person in people) + { + AddPerson(person); + } + } + + /// <summary> + /// Adds a person to the item + /// </summary> + /// <param name="person">The person.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddPerson(PersonInfo person) + { + if (person == null) + { + throw new ArgumentNullException(); + } + + if (string.IsNullOrWhiteSpace(person.Name)) + { + throw new ArgumentNullException(); + } + + if (People == null) + { + People = new List<PersonInfo>(); + } + + // Check for dupes based on the combination of Name and Type + + if (!People.Any(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type.Equals(person.Type, StringComparison.OrdinalIgnoreCase))) + { + People.Add(person); + } + } + + /// <summary> + /// Adds studios to the item + /// </summary> + /// <param name="studios">The studios.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddStudios(IEnumerable<string> studios) + { + if (studios == null) + { + throw new ArgumentNullException(); + } + + foreach (var name in studios) + { + AddStudio(name); + } + } + + /// <summary> + /// Adds a studio to the item + /// </summary> + /// <param name="name">The name.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddStudio(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(); + } + + if (Studios == null) + { + Studios = new List<string>(); + } + + if (!Studios.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + Studios.Add(name); + } + } + + /// <summary> + /// Adds a tagline to the item + /// </summary> + /// <param name="name">The name.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddTagline(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(); + } + + if (Taglines == null) + { + Taglines = new List<string>(); + } + + if (!Taglines.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + Taglines.Add(name); + } + } + + /// <summary> + /// Adds a TrailerUrl to the item + /// </summary> + /// <param name="url">The URL.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddTrailerUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentNullException(); + } + + if (TrailerUrls == null) + { + TrailerUrls = new List<string>(); + } + + if (!TrailerUrls.Contains(url, StringComparer.OrdinalIgnoreCase)) + { + TrailerUrls.Add(url); + } + } + + /// <summary> + /// Adds a genre to the item + /// </summary> + /// <param name="name">The name.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddGenre(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(); + } + + if (Genres == null) + { + Genres = new List<string>(); + } + + if (!Genres.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + Genres.Add(name); + } + } + + /// <summary> + /// Adds genres to the item + /// </summary> + /// <param name="genres">The genres.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddGenres(IEnumerable<string> genres) + { + if (genres == null) + { + throw new ArgumentNullException(); + } + + foreach (var name in genres) + { + AddGenre(name); + } + } + + /// <summary> + /// Marks the item as either played or unplayed + /// </summary> + /// <param name="user">The user.</param> + /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public virtual Task SetPlayedStatus(User user, bool wasPlayed) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + var data = GetUserData(user, true); + + if (wasPlayed) + { + data.PlayCount = Math.Max(data.PlayCount, 1); + + if (!data.LastPlayedDate.HasValue) + { + data.LastPlayedDate = DateTime.UtcNow; + } + } + else + { + //I think it is okay to do this here. + // if this is only called when a user is manually forcing something to un-played + // then it probably is what we want to do... + data.PlayCount = 0; + data.PlaybackPositionTicks = 0; + data.LastPlayedDate = null; + } + + data.Played = wasPlayed; + + return Kernel.Instance.UserDataManager.SaveUserDataForItem(user, this, data); + } + + /// <summary> + /// Do whatever refreshing is necessary when the filesystem pertaining to this item has changed. + /// </summary> + /// <returns>Task.</returns> + public virtual Task ChangedExternally() + { + return RefreshMetadata(CancellationToken.None); + } + + /// <summary> + /// Finds a parent of a given type + /// </summary> + /// <typeparam name="T"></typeparam> + /// <returns>``0.</returns> + public T FindParent<T>() + where T : Folder + { + var parent = Parent; + + while (parent != null) + { + var result = parent as T; + if (result != null) + { + return result; + } + + parent = parent.Parent; + } + + return null; + } + + /// <summary> + /// Gets an image + /// </summary> + /// <param name="type">The type.</param> + /// <returns>System.String.</returns> + /// <exception cref="System.ArgumentException">Backdrops should be accessed using Item.Backdrops</exception> + public string GetImage(ImageType type) + { + if (type == ImageType.Backdrop) + { + throw new ArgumentException("Backdrops should be accessed using Item.Backdrops"); + } + if (type == ImageType.Screenshot) + { + throw new ArgumentException("Screenshots should be accessed using Item.Screenshots"); + } + + if (Images == null) + { + return null; + } + + string val; + Images.TryGetValue(type.ToString(), out val); + return val; + } + + /// <summary> + /// Gets an image + /// </summary> + /// <param name="type">The type.</param> + /// <returns><c>true</c> if the specified type has image; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentException">Backdrops should be accessed using Item.Backdrops</exception> + public bool HasImage(ImageType type) + { + if (type == ImageType.Backdrop) + { + throw new ArgumentException("Backdrops should be accessed using Item.Backdrops"); + } + if (type == ImageType.Screenshot) + { + throw new ArgumentException("Screenshots should be accessed using Item.Screenshots"); + } + + return !string.IsNullOrEmpty(GetImage(type)); + } + + /// <summary> + /// Sets an image + /// </summary> + /// <param name="type">The type.</param> + /// <param name="path">The path.</param> + /// <exception cref="System.ArgumentException">Backdrops should be accessed using Item.Backdrops</exception> + public void SetImage(ImageType type, string path) + { + if (type == ImageType.Backdrop) + { + throw new ArgumentException("Backdrops should be accessed using Item.Backdrops"); + } + if (type == ImageType.Screenshot) + { + throw new ArgumentException("Screenshots should be accessed using Item.Screenshots"); + } + + var typeKey = type.ToString(); + + // If it's null remove the key from the dictionary + if (string.IsNullOrEmpty(path)) + { + if (Images != null) + { + if (Images.ContainsKey(typeKey)) + { + Images.Remove(typeKey); + } + } + } + else + { + // Ensure it exists + if (Images == null) + { + Images = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + + Images[typeKey] = path; + } + } + + /// <summary> + /// Deletes the image. + /// </summary> + /// <param name="type">The type.</param> + /// <returns>Task.</returns> + public async Task DeleteImage(ImageType type) + { + if (!HasImage(type)) + { + return; + } + + // Delete the source file + File.Delete(GetImage(type)); + + // Remove it from the item + SetImage(type, null); + + // Refresh metadata + await RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + } + } +} diff --git a/MediaBrowser.Controller/Entities/BasePluginFolder.cs b/MediaBrowser.Controller/Entities/BasePluginFolder.cs new file mode 100644 index 0000000000..7cabcf9f01 --- /dev/null +++ b/MediaBrowser.Controller/Entities/BasePluginFolder.cs @@ -0,0 +1,49 @@ +using MediaBrowser.Common.Extensions; +using System; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Plugins derive from and export this class to create a folder that will appear in the root along + /// with all the other actual physical folders in the system. + /// </summary> + public abstract class BasePluginFolder : Folder, ICollectionFolder + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public override Guid Id + { + get + { + // This doesn't get populated through the normal resolving process + if (base.Id == Guid.Empty) + { + base.Id = (Path ?? Name).GetMBId(GetType()); + } + return base.Id; + } + set + { + base.Id = value; + } + } + + /// <summary> + /// We don't resolve normally so need to fill this in + /// </summary> + public override string DisplayMediaType + { + get + { + return "CollectionFolder"; // Plug-in folders are collection folders + } + set + { + base.DisplayMediaType = value; + } + } + + } +} diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs new file mode 100644 index 0000000000..5684f8e802 --- /dev/null +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -0,0 +1,100 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Logging; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Specialized Folder class that points to a subset of the physical folders in the system. + /// It is created from the user-specific folders within the system root + /// </summary> + public class CollectionFolder : Folder, ICollectionFolder + { + /// <summary> + /// Gets a value indicating whether this instance is virtual folder. + /// </summary> + /// <value><c>true</c> if this instance is virtual folder; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool IsVirtualFolder + { + get + { + return true; + } + } + + /// <summary> + /// Allow different display preferences for each collection folder + /// </summary> + /// <value>The display prefs id.</value> + public override Guid DisplayPrefsId + { + get + { + return Id; + } + } + + // Cache this since it will be used a lot + /// <summary> + /// The null task result + /// </summary> + private static readonly Task NullTaskResult = Task.FromResult<object>(null); + + /// <summary> + /// Compare our current children (presumably just read from the repo) with the current state of the file system and adjust for any changes + /// ***Currently does not contain logic to maintain items that are unavailable in the file system*** + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="recursive">if set to <c>true</c> [recursive].</param> + /// <returns>Task.</returns> + protected override Task ValidateChildrenInternal(IProgress<TaskProgress> progress, CancellationToken cancellationToken, bool? recursive = null) + { + //we don't directly validate our children + //but we do need to clear out the index cache... + IndexCache = new ConcurrentDictionary<string, List<BaseItem>>(StringComparer.OrdinalIgnoreCase); + + return NullTaskResult; + } + + /// <summary> + /// Our children are actually just references to the ones in the physical root... + /// </summary> + /// <value>The actual children.</value> + protected override ConcurrentBag<BaseItem> ActualChildren + { + get + { + IEnumerable<Guid> folderIds; + + try + { + // Accessing ResolveArgs could involve file system access + folderIds = ResolveArgs.PhysicalLocations.Select(f => (f.GetMBId(typeof(Folder)))); + } + catch (IOException ex) + { + Logger.LogException("Error creating FolderIds for {0}", ex, Path); + + folderIds = new Guid[] {}; + } + + var ourChildren = + Kernel.Instance.RootFolder.Children.OfType<Folder>() + .Where(i => folderIds.Contains(i.Id)) + .SelectMany(c => c.Children); + + return new ConcurrentBag<BaseItem>(ourChildren); + } + } + } +} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 07529c80f6..cef79fe560 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1,619 +1,1047 @@ -using MediaBrowser.Model.Entities;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Common.Logging;
-using MediaBrowser.Controller.Resolvers;
-using System;
-using System.Threading.Tasks;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace MediaBrowser.Controller.Entities
-{
- public class Folder : BaseItem
- {
- #region Events
- /// <summary>
- /// Fires whenever a validation routine updates our children. The added and removed children are properties of the args.
- /// *** Will fire asynchronously. ***
- /// </summary>
- public event EventHandler<ChildrenChangedEventArgs> ChildrenChanged;
- protected void OnChildrenChanged(ChildrenChangedEventArgs args)
- {
- if (ChildrenChanged != null)
- {
- Task.Run( () =>
- {
- ChildrenChanged(this, args);
- Kernel.Instance.OnLibraryChanged(args);
- });
- }
- }
-
- #endregion
-
- public override bool IsFolder
- {
- get
- {
- return true;
- }
- }
-
- public bool IsRoot { get; set; }
-
- public bool IsVirtualFolder
- {
- get
- {
- return Parent != null && Parent.IsRoot;
- }
- }
- protected object childLock = new object();
- protected List<BaseItem> children;
- protected virtual List<BaseItem> ActualChildren
- {
- get
- {
- if (children == null)
- {
- LoadChildren();
- }
- return children;
- }
-
- set
- {
- children = value;
- }
- }
-
- /// <summary>
- /// thread-safe access to the actual children of this folder - without regard to user
- /// </summary>
- public IEnumerable<BaseItem> Children
- {
- get
- {
- lock (childLock)
- return ActualChildren.ToList();
- }
- }
-
- /// <summary>
- /// thread-safe access to all recursive children of this folder - without regard to user
- /// </summary>
- public IEnumerable<BaseItem> RecursiveChildren
- {
- get
- {
- foreach (var item in Children)
- {
- yield return item;
-
- var subFolder = item as Folder;
-
- if (subFolder != null)
- {
- foreach (var subitem in subFolder.RecursiveChildren)
- {
- yield return subitem;
- }
- }
- }
- }
- }
-
-
- /// <summary>
- /// Loads and validates our children
- /// </summary>
- protected virtual void LoadChildren()
- {
- //first - load our children from the repo
- lock (childLock)
- children = GetCachedChildren();
-
- //then kick off a validation against the actual file system
- Task.Run(() => ValidateChildren());
- }
-
- protected bool ChildrenValidating = false;
-
- /// <summary>
- /// Compare our current children (presumably just read from the repo) with the current state of the file system and adjust for any changes
- /// ***Currently does not contain logic to maintain items that are unavailable in the file system***
- /// </summary>
- /// <returns></returns>
- protected async virtual void ValidateChildren()
- {
- if (ChildrenValidating) return; //only ever want one of these going at once and don't want them to fire off in sequence so don't use lock
- ChildrenValidating = true;
- bool changed = false; //this will save us a little time at the end if nothing changes
- var changedArgs = new ChildrenChangedEventArgs(this);
- //get the current valid children from filesystem (or wherever)
- var nonCachedChildren = await GetNonCachedChildren();
- if (nonCachedChildren == null) return; //nothing to validate
- //build a dictionary of the current children we have now by Id so we can compare quickly and easily
- Dictionary<Guid, BaseItem> currentChildren;
- lock (childLock)
- currentChildren = ActualChildren.ToDictionary(i => i.Id);
-
- //create a list for our validated children
- var validChildren = new List<BaseItem>();
- //now traverse the valid children and find any changed or new items
- foreach (var child in nonCachedChildren)
- {
- BaseItem currentChild;
- currentChildren.TryGetValue(child.Id, out currentChild);
- if (currentChild == null)
- {
- //brand new item - needs to be added
- changed = true;
- changedArgs.ItemsAdded.Add(child);
- //refresh it
- child.RefreshMetadata();
- Logger.LogInfo("New Item Added to Library: ("+child.GetType().Name+") "+ child.Name + " (" + child.Path + ")");
- //save it in repo...
-
- //and add it to our valid children
- validChildren.Add(child);
- //fire an added event...?
- //if it is a folder we need to validate its children as well
- Folder folder = child as Folder;
- if (folder != null)
- {
- folder.ValidateChildren();
- //probably need to refresh too...
- }
- }
- else
- {
- //existing item - check if it has changed
- if (currentChild.IsChanged(child))
- {
- changed = true;
- //update resolve args and refresh meta
- // Note - we are refreshing the existing child instead of the newly found one so the "Except" operation below
- // will identify this item as the same one
- currentChild.ResolveArgs = child.ResolveArgs;
- currentChild.RefreshMetadata();
- Logger.LogInfo("Item Changed: ("+currentChild.GetType().Name+") "+ currentChild.Name + " (" + currentChild.Path + ")");
- //save it in repo...
- validChildren.Add(currentChild);
- }
- else
- {
- //current child that didn't change - just put it in the valid children
- validChildren.Add(currentChild);
- }
- }
- }
-
- //that's all the new and changed ones - now see if there are any that are missing
- changedArgs.ItemsRemoved = currentChildren.Values.Except(validChildren);
- changed |= changedArgs.ItemsRemoved != null;
-
- //now, if anything changed - replace our children
- if (changed)
- {
- if (changedArgs.ItemsRemoved != null) foreach (var item in changedArgs.ItemsRemoved) Logger.LogDebugInfo("** " + item.Name + " Removed from library.");
-
- lock (childLock)
- ActualChildren = validChildren;
- //and save children in repo...
-
- //and fire event
- this.OnChildrenChanged(changedArgs);
- }
- ChildrenValidating = false;
-
- }
-
- /// <summary>
- /// Get the children of this folder from the actual file system
- /// </summary>
- /// <returns></returns>
- protected async virtual Task<IEnumerable<BaseItem>> GetNonCachedChildren()
- {
- ItemResolveEventArgs args = new ItemResolveEventArgs()
- {
- FileInfo = FileData.GetFileData(this.Path),
- Parent = this.Parent,
- Cancel = false,
- Path = this.Path
- };
-
- // Gather child folder and files
- if (args.IsDirectory)
- {
- args.FileSystemChildren = FileData.GetFileSystemEntries(this.Path, "*").ToArray();
-
- bool isVirtualFolder = Parent != null && Parent.IsRoot;
- args = FileSystemHelper.FilterChildFileSystemEntries(args, isVirtualFolder);
- }
- else
- {
- Logger.LogError("Folder has a path that is not a directory: " + this.Path);
- return null;
- }
-
- if (!EntityResolutionHelper.ShouldResolvePathContents(args))
- {
- return null;
- }
- return (await Task.WhenAll<BaseItem>(GetChildren(args.FileSystemChildren)).ConfigureAwait(false))
- .Where(i => i != null).OrderBy(f =>
- {
- return string.IsNullOrEmpty(f.SortName) ? f.Name : f.SortName;
-
- });
-
- }
-
- /// <summary>
- /// Resolves a path into a BaseItem
- /// </summary>
- protected async Task<BaseItem> GetChild(string path, WIN32_FIND_DATA? fileInfo = null)
- {
- ItemResolveEventArgs args = new ItemResolveEventArgs()
- {
- FileInfo = fileInfo ?? FileData.GetFileData(path),
- Parent = this,
- Cancel = false,
- Path = path
- };
-
- args.FileSystemChildren = FileData.GetFileSystemEntries(path, "*").ToArray();
- args = FileSystemHelper.FilterChildFileSystemEntries(args, false);
-
- return Kernel.Instance.ResolveItem(args);
-
- }
-
- /// <summary>
- /// Finds child BaseItems for us
- /// </summary>
- protected Task<BaseItem>[] GetChildren(WIN32_FIND_DATA[] fileSystemChildren)
- {
- Task<BaseItem>[] tasks = new Task<BaseItem>[fileSystemChildren.Length];
-
- for (int i = 0; i < fileSystemChildren.Length; i++)
- {
- var child = fileSystemChildren[i];
-
- tasks[i] = GetChild(child.Path, child);
- }
-
- return tasks;
- }
-
-
- /// <summary>
- /// Get our children from the repo - stubbed for now
- /// </summary>
- /// <returns></returns>
- protected virtual List<BaseItem> GetCachedChildren()
- {
- return new List<BaseItem>();
- }
-
- /// <summary>
- /// Gets allowed children of an item
- /// </summary>
- public IEnumerable<BaseItem> GetChildren(User user)
- {
- lock(childLock)
- return ActualChildren.Where(c => c.IsParentalAllowed(user));
- }
-
- /// <summary>
- /// Gets allowed recursive children of an item
- /// </summary>
- public IEnumerable<BaseItem> GetRecursiveChildren(User user)
- {
- foreach (var item in GetChildren(user))
- {
- yield return item;
-
- var subFolder = item as Folder;
-
- if (subFolder != null)
- {
- foreach (var subitem in subFolder.GetRecursiveChildren(user))
- {
- yield return subitem;
- }
- }
- }
- }
-
- /// <summary>
- /// Folders need to validate and refresh
- /// </summary>
- /// <returns></returns>
- public override Task ChangedExternally()
- {
- return Task.Run(() =>
- {
- if (this.IsRoot)
- {
- Kernel.Instance.ReloadRoot().ConfigureAwait(false);
- }
- else
- {
- RefreshMetadata();
- ValidateChildren();
- }
- });
- }
-
- /// <summary>
- /// Since it can be slow to make all of these calculations at once, this method will provide a way to get them all back together
- /// </summary>
- public ItemSpecialCounts GetSpecialCounts(User user)
- {
- var counts = new ItemSpecialCounts();
-
- IEnumerable<BaseItem> recursiveChildren = GetRecursiveChildren(user);
-
- var recentlyAddedItems = GetRecentlyAddedItems(recursiveChildren, user);
-
- counts.RecentlyAddedItemCount = recentlyAddedItems.Count;
- counts.RecentlyAddedUnPlayedItemCount = GetRecentlyAddedUnplayedItems(recentlyAddedItems, user).Count;
- counts.InProgressItemCount = GetInProgressItems(recursiveChildren, user).Count;
- counts.PlayedPercentage = GetPlayedPercentage(recursiveChildren, user);
-
- return counts;
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that contain the given genre and are allowed for the current user
- /// </summary>
- public IEnumerable<BaseItem> GetItemsWithGenre(string genre, User user)
- {
- return GetRecursiveChildren(user).Where(f => f.Genres != null && f.Genres.Any(s => s.Equals(genre, StringComparison.OrdinalIgnoreCase)));
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that contain the given year and are allowed for the current user
- /// </summary>
- public IEnumerable<BaseItem> GetItemsWithYear(int year, User user)
- {
- return GetRecursiveChildren(user).Where(f => f.ProductionYear.HasValue && f.ProductionYear == year);
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that contain the given studio and are allowed for the current user
- /// </summary>
- public IEnumerable<BaseItem> GetItemsWithStudio(string studio, User user)
- {
- return GetRecursiveChildren(user).Where(f => f.Studios != null && f.Studios.Any(s => s.Equals(studio, StringComparison.OrdinalIgnoreCase)));
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that the user has marked as a favorite
- /// </summary>
- public IEnumerable<BaseItem> GetFavoriteItems(User user)
- {
- return GetRecursiveChildren(user).Where(c =>
- {
- UserItemData data = c.GetUserData(user, false);
-
- if (data != null)
- {
- return data.IsFavorite;
- }
-
- return false;
- });
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that contain the given person and are allowed for the current user
- /// </summary>
- public IEnumerable<BaseItem> GetItemsWithPerson(string person, User user)
- {
- return GetRecursiveChildren(user).Where(c =>
- {
- if (c.People != null)
- {
- return c.People.ContainsKey(person);
- }
-
- return false;
- });
- }
-
- /// <summary>
- /// Finds all recursive items within a top-level parent that contain the given person and are allowed for the current user
- /// </summary>
- /// <param name="personType">Specify this to limit results to a specific PersonType</param>
- public IEnumerable<BaseItem> GetItemsWithPerson(string person, string personType, User user)
- {
- return GetRecursiveChildren(user).Where(c =>
- {
- if (c.People != null)
- {
- return c.People.ContainsKey(person) && c.People[person].Type.Equals(personType, StringComparison.OrdinalIgnoreCase);
- }
-
- return false;
- });
- }
-
- /// <summary>
- /// Gets all recently added items (recursive) within a folder, based on configuration and parental settings
- /// </summary>
- public List<BaseItem> GetRecentlyAddedItems(User user)
- {
- return GetRecentlyAddedItems(GetRecursiveChildren(user), user);
- }
-
- /// <summary>
- /// Gets all recently added unplayed items (recursive) within a folder, based on configuration and parental settings
- /// </summary>
- public List<BaseItem> GetRecentlyAddedUnplayedItems(User user)
- {
- return GetRecentlyAddedUnplayedItems(GetRecursiveChildren(user), user);
- }
-
- /// <summary>
- /// Gets all in-progress items (recursive) within a folder
- /// </summary>
- public List<BaseItem> GetInProgressItems(User user)
- {
- return GetInProgressItems(GetRecursiveChildren(user), user);
- }
-
- /// <summary>
- /// Takes a list of items and returns the ones that are recently added
- /// </summary>
- private static List<BaseItem> GetRecentlyAddedItems(IEnumerable<BaseItem> itemSet, User user)
- {
- var list = new List<BaseItem>();
-
- foreach (var item in itemSet)
- {
- if (!item.IsFolder && item.IsRecentlyAdded(user))
- {
- list.Add(item);
- }
- }
-
- return list;
- }
-
- /// <summary>
- /// Takes a list of items and returns the ones that are recently added and unplayed
- /// </summary>
- private static List<BaseItem> GetRecentlyAddedUnplayedItems(IEnumerable<BaseItem> itemSet, User user)
- {
- var list = new List<BaseItem>();
-
- foreach (var item in itemSet)
- {
- if (!item.IsFolder && item.IsRecentlyAdded(user))
- {
- var userdata = item.GetUserData(user, false);
-
- if (userdata == null || userdata.PlayCount == 0)
- {
- list.Add(item);
- }
- }
- }
-
- return list;
- }
-
- /// <summary>
- /// Takes a list of items and returns the ones that are in progress
- /// </summary>
- private static List<BaseItem> GetInProgressItems(IEnumerable<BaseItem> itemSet, User user)
- {
- var list = new List<BaseItem>();
-
- foreach (var item in itemSet)
- {
- if (!item.IsFolder)
- {
- var userdata = item.GetUserData(user, false);
-
- if (userdata != null && userdata.PlaybackPositionTicks > 0)
- {
- list.Add(item);
- }
- }
- }
-
- return list;
- }
-
- /// <summary>
- /// Gets the total played percentage for a set of items
- /// </summary>
- private static decimal GetPlayedPercentage(IEnumerable<BaseItem> itemSet, User user)
- {
- itemSet = itemSet.Where(i => !(i.IsFolder));
-
- decimal totalPercent = 0;
-
- int count = 0;
-
- foreach (BaseItem item in itemSet)
- {
- count++;
-
- UserItemData data = item.GetUserData(user, false);
-
- if (data == null)
- {
- continue;
- }
-
- if (data.PlayCount > 0)
- {
- totalPercent += 100;
- }
- else if (data.PlaybackPositionTicks > 0 && item.RunTimeTicks.HasValue)
- {
- decimal itemPercent = data.PlaybackPositionTicks;
- itemPercent /= item.RunTimeTicks.Value;
- totalPercent += itemPercent;
- }
- }
-
- if (count == 0)
- {
- return 0;
- }
-
- return totalPercent / count;
- }
-
- /// <summary>
- /// Marks the item as either played or unplayed
- /// </summary>
- public override void SetPlayedStatus(User user, bool wasPlayed)
- {
- base.SetPlayedStatus(user, wasPlayed);
-
- // Now sweep through recursively and update status
- foreach (BaseItem item in GetChildren(user))
- {
- item.SetPlayedStatus(user, wasPlayed);
- }
- }
-
- /// <summary>
- /// Finds an item by ID, recursively
- /// </summary>
- public override BaseItem FindItemById(Guid id)
- {
- var result = base.FindItemById(id);
-
- if (result != null)
- {
- return result;
- }
-
- //this should be functionally equivilent to what was here since it is IEnum and works on a thread-safe copy
- return RecursiveChildren.FirstOrDefault(i => i.Id == id);
- }
-
- /// <summary>
- /// Finds an item by path, recursively
- /// </summary>
- public BaseItem FindByPath(string path)
- {
- if (PhysicalLocations.Contains(path, StringComparer.OrdinalIgnoreCase))
- {
- return this;
- }
-
- //this should be functionally equivilent to what was here since it is IEnum and works on a thread-safe copy
- return RecursiveChildren.FirstOrDefault(i => i.PhysicalLocations.Contains(path, StringComparer.OrdinalIgnoreCase));
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Localization; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using SortOrder = MediaBrowser.Controller.Sorting.SortOrder; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Folder + /// </summary> + public class Folder : BaseItem + { + /// <summary> + /// Gets a value indicating whether this instance is folder. + /// </summary> + /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool IsFolder + { + get + { + return true; + } + } + + /// <summary> + /// Gets or sets a value indicating whether this instance is physical root. + /// </summary> + /// <value><c>true</c> if this instance is physical root; otherwise, <c>false</c>.</value> + public bool IsPhysicalRoot { get; set; } + /// <summary> + /// Gets or sets a value indicating whether this instance is root. + /// </summary> + /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value> + public bool IsRoot { get; set; } + + /// <summary> + /// Gets a value indicating whether this instance is virtual folder. + /// </summary> + /// <value><c>true</c> if this instance is virtual folder; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public virtual bool IsVirtualFolder + { + get + { + return false; + } + } + + /// <summary> + /// Return the id that should be used to key display prefs for this item. + /// Default is based on the type for everything except actual generic folders. + /// </summary> + /// <value>The display prefs id.</value> + [IgnoreDataMember] + public virtual Guid DisplayPrefsId + { + get + { + var thisType = GetType(); + return thisType == typeof(Folder) ? Id : thisType.FullName.GetMD5(); + } + } + + /// <summary> + /// The _display prefs + /// </summary> + private IEnumerable<DisplayPreferences> _displayPrefs; + /// <summary> + /// The _display prefs initialized + /// </summary> + private bool _displayPrefsInitialized; + /// <summary> + /// The _display prefs sync lock + /// </summary> + private object _displayPrefsSyncLock = new object(); + /// <summary> + /// Gets the display prefs. + /// </summary> + /// <value>The display prefs.</value> + [IgnoreDataMember] + public IEnumerable<DisplayPreferences> DisplayPrefs + { + get + { + // Call ToList to exhaust the stream because we'll be iterating over this multiple times + LazyInitializer.EnsureInitialized(ref _displayPrefs, ref _displayPrefsInitialized, ref _displayPrefsSyncLock, () => Kernel.Instance.DisplayPreferencesRepository.RetrieveDisplayPrefs(this).ToList()); + return _displayPrefs; + } + private set + { + _displayPrefs = value; + + if (value == null) + { + _displayPrefsInitialized = false; + } + } + } + + /// <summary> + /// Gets the display prefs. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="createIfNull">if set to <c>true</c> [create if null].</param> + /// <returns>DisplayPreferences.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public DisplayPreferences GetDisplayPrefs(User user, bool createIfNull) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + if (DisplayPrefs == null) + { + if (!createIfNull) + { + return null; + } + + AddOrUpdateDisplayPrefs(user, new DisplayPreferences { UserId = user.Id }); + } + + var data = DisplayPrefs.FirstOrDefault(u => u.UserId == user.Id); + + if (data == null && createIfNull) + { + data = new DisplayPreferences { UserId = user.Id }; + AddOrUpdateDisplayPrefs(user, data); + } + + return data; + } + + /// <summary> + /// Adds the or update display prefs. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="data">The data.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddOrUpdateDisplayPrefs(User user, DisplayPreferences data) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + if (data == null) + { + throw new ArgumentNullException(); + } + + data.UserId = user.Id; + + if (DisplayPrefs == null) + { + DisplayPrefs = new[] { data }; + } + else + { + var list = DisplayPrefs.Where(u => u.UserId != user.Id).ToList(); + list.Add(data); + DisplayPrefs = list; + } + } + + #region Sorting + + /// <summary> + /// The _sort by options + /// </summary> + private Dictionary<string, IComparer<BaseItem>> _sortByOptions; + /// <summary> + /// Dictionary of sort options - consists of a display value (localized) and an IComparer of BaseItem + /// </summary> + /// <value>The sort by options.</value> + [IgnoreDataMember] + public Dictionary<string, IComparer<BaseItem>> SortByOptions + { + get { return _sortByOptions ?? (_sortByOptions = GetSortByOptions()); } + } + + /// <summary> + /// Returns the valid set of sort by options for this folder type. + /// Override or extend to modify. + /// </summary> + /// <returns>Dictionary{System.StringIComparer{BaseItem}}.</returns> + protected virtual Dictionary<string, IComparer<BaseItem>> GetSortByOptions() + { + return new Dictionary<string, IComparer<BaseItem>> { + {LocalizedStrings.Instance.GetString("NameDispPref"), new BaseItemComparer(SortOrder.Name)}, + {LocalizedStrings.Instance.GetString("DateDispPref"), new BaseItemComparer(SortOrder.Date)}, + {LocalizedStrings.Instance.GetString("RatingDispPref"), new BaseItemComparer(SortOrder.Rating)}, + {LocalizedStrings.Instance.GetString("RuntimeDispPref"), new BaseItemComparer(SortOrder.Runtime)}, + {LocalizedStrings.Instance.GetString("YearDispPref"), new BaseItemComparer(SortOrder.Year)} + }; + + } + + /// <summary> + /// Get a sorting comparer by name + /// </summary> + /// <param name="name">The name.</param> + /// <returns>IComparer{BaseItem}.</returns> + private IComparer<BaseItem> GetSortingFunction(string name) + { + IComparer<BaseItem> sorting; + SortByOptions.TryGetValue(name ?? "", out sorting); + return sorting ?? new BaseItemComparer(SortOrder.Name); + } + + /// <summary> + /// Get the list of sort by choices for this folder (localized). + /// </summary> + /// <value>The sort by option strings.</value> + [IgnoreDataMember] + public IEnumerable<string> SortByOptionStrings + { + get { return SortByOptions.Keys; } + } + + #endregion + + #region Indexing + + /// <summary> + /// The _index by options + /// </summary> + private Dictionary<string, Func<User, IEnumerable<BaseItem>>> _indexByOptions; + /// <summary> + /// Dictionary of index options - consists of a display value and an indexing function + /// which takes User as a parameter and returns an IEnum of BaseItem + /// </summary> + /// <value>The index by options.</value> + [IgnoreDataMember] + public Dictionary<string, Func<User, IEnumerable<BaseItem>>> IndexByOptions + { + get { return _indexByOptions ?? (_indexByOptions = GetIndexByOptions()); } + } + + /// <summary> + /// Returns the valid set of index by options for this folder type. + /// Override or extend to modify. + /// </summary> + /// <returns>Dictionary{System.StringFunc{UserIEnumerable{BaseItem}}}.</returns> + protected virtual Dictionary<string, Func<User, IEnumerable<BaseItem>>> GetIndexByOptions() + { + return new Dictionary<string, Func<User, IEnumerable<BaseItem>>> { + {LocalizedStrings.Instance.GetString("NoneDispPref"), null}, + {LocalizedStrings.Instance.GetString("PerformerDispPref"), GetIndexByPerformer}, + {LocalizedStrings.Instance.GetString("GenreDispPref"), GetIndexByGenre}, + {LocalizedStrings.Instance.GetString("DirectorDispPref"), GetIndexByDirector}, + {LocalizedStrings.Instance.GetString("YearDispPref"), GetIndexByYear}, + {LocalizedStrings.Instance.GetString("OfficialRatingDispPref"), null}, + {LocalizedStrings.Instance.GetString("StudioDispPref"), GetIndexByStudio} + }; + + } + + /// <summary> + /// Gets the index by actor. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByPerformer(User user) + { + return GetIndexByPerson(user, new List<string> { PersonType.Actor, PersonType.MusicArtist }, LocalizedStrings.Instance.GetString("PerformerDispPref")); + } + + /// <summary> + /// Gets the index by director. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByDirector(User user) + { + return GetIndexByPerson(user, new List<string> { PersonType.Director }, LocalizedStrings.Instance.GetString("DirectorDispPref")); + } + + /// <summary> + /// Gets the index by person. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="personTypes">The person types we should match on</param> + /// <param name="indexName">Name of the index.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByPerson(User user, List<string> personTypes, string indexName) + { + + // Even though this implementation means multiple iterations over the target list - it allows us to defer + // the retrieval of the individual children for each index value until they are requested. + using (new Profiler(indexName + " Index Build for " + Name)) + { + // Put this in a local variable to avoid an implicitly captured closure + var currentIndexName = indexName; + + var us = this; + var candidates = RecursiveChildren.Where(i => i.IncludeInIndex && i.AllPeople != null).ToList(); + + return candidates.AsParallel().SelectMany(i => i.AllPeople.Where(p => personTypes.Contains(p.Type)) + .Select(a => a.Name)) + .Distinct() + .Select(i => + { + try + { + return Kernel.Instance.LibraryManager.GetPerson(i).Result; + } + catch (IOException ex) + { + Logger.LogException("Error getting person {0}", ex, i); + return null; + } + catch (AggregateException ex) + { + Logger.LogException("Error getting person {0}", ex, i); + return null; + } + }) + .Where(i => i != null) + .Select(a => new IndexFolder(us, a, + candidates.Where(i => i.AllPeople.Any(p => personTypes.Contains(p.Type) && p.Name.Equals(a.Name, StringComparison.OrdinalIgnoreCase)) + ), currentIndexName)); + + } + } + + /// <summary> + /// Gets the index by studio. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByStudio(User user) + { + // Even though this implementation means multiple iterations over the target list - it allows us to defer + // the retrieval of the individual children for each index value until they are requested. + using (new Profiler("Studio Index Build for " + Name)) + { + var indexName = LocalizedStrings.Instance.GetString("StudioDispPref"); + + var candidates = RecursiveChildren.Where(i => i.IncludeInIndex && i.Studios != null).ToList(); + + return candidates.AsParallel().SelectMany(i => i.Studios) + .Distinct() + .Select(i => + { + try + { + return Kernel.Instance.LibraryManager.GetStudio(i).Result; + } + catch (IOException ex) + { + Logger.LogException("Error getting studio {0}", ex, i); + return null; + } + catch (AggregateException ex) + { + Logger.LogException("Error getting studio {0}", ex, i); + return null; + } + }) + .Where(i => i != null) + .Select(ndx => new IndexFolder(this, ndx, candidates.Where(i => i.Studios.Any(s => s.Equals(ndx.Name, StringComparison.OrdinalIgnoreCase))), indexName)); + } + } + + /// <summary> + /// Gets the index by genre. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByGenre(User user) + { + // Even though this implementation means multiple iterations over the target list - it allows us to defer + // the retrieval of the individual children for each index value until they are requested. + using (new Profiler("Genre Index Build for " + Name)) + { + var indexName = LocalizedStrings.Instance.GetString("GenreDispPref"); + + //we need a copy of this so we don't double-recurse + var candidates = RecursiveChildren.Where(i => i.IncludeInIndex && i.Genres != null).ToList(); + + return candidates.AsParallel().SelectMany(i => i.Genres) + .Distinct() + .Select(i => + { + try + { + return Kernel.Instance.LibraryManager.GetGenre(i).Result; + } + catch (IOException ex) + { + Logger.LogException("Error getting genre {0}", ex, i); + return null; + } + catch (AggregateException ex) + { + Logger.LogException("Error getting genre {0}", ex, i); + return null; + } + }) + .Where(i => i != null) + .Select(genre => new IndexFolder(this, genre, candidates.Where(i => i.Genres.Any(g => g.Equals(genre.Name, StringComparison.OrdinalIgnoreCase))), indexName) + ); + } + } + + /// <summary> + /// Gets the index by year. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GetIndexByYear(User user) + { + // Even though this implementation means multiple iterations over the target list - it allows us to defer + // the retrieval of the individual children for each index value until they are requested. + using (new Profiler("Production Year Index Build for " + Name)) + { + var indexName = LocalizedStrings.Instance.GetString("YearDispPref"); + + //we need a copy of this so we don't double-recurse + var candidates = RecursiveChildren.Where(i => i.IncludeInIndex && i.ProductionYear.HasValue).ToList(); + + return candidates.AsParallel().Select(i => i.ProductionYear.Value) + .Distinct() + .Select(i => + { + try + { + return Kernel.Instance.LibraryManager.GetYear(i).Result; + } + catch (IOException ex) + { + Logger.LogException("Error getting year {0}", ex, i); + return null; + } + catch (AggregateException ex) + { + Logger.LogException("Error getting year {0}", ex, i); + return null; + } + }) + .Where(i => i != null) + + .Select(ndx => new IndexFolder(this, ndx, candidates.Where(i => i.ProductionYear == int.Parse(ndx.Name)), indexName)); + + } + } + + /// <summary> + /// Returns the indexed children for this user from the cache. Caches them if not already there. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="indexBy">The index by.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + private IEnumerable<BaseItem> GetIndexedChildren(User user, string indexBy) + { + List<BaseItem> result; + var cacheKey = user.Name + indexBy; + IndexCache.TryGetValue(cacheKey, out result); + + if (result == null) + { + //not cached - cache it + Func<User, IEnumerable<BaseItem>> indexing; + IndexByOptions.TryGetValue(indexBy, out indexing); + result = BuildIndex(indexBy, indexing, user); + } + return result; + } + + /// <summary> + /// Get the list of indexy by choices for this folder (localized). + /// </summary> + /// <value>The index by option strings.</value> + [IgnoreDataMember] + public IEnumerable<string> IndexByOptionStrings + { + get { return IndexByOptions.Keys; } + } + + /// <summary> + /// The index cache + /// </summary> + protected ConcurrentDictionary<string, List<BaseItem>> IndexCache = new ConcurrentDictionary<string, List<BaseItem>>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// Builds the index. + /// </summary> + /// <param name="indexKey">The index key.</param> + /// <param name="indexFunction">The index function.</param> + /// <param name="user">The user.</param> + /// <returns>List{BaseItem}.</returns> + protected virtual List<BaseItem> BuildIndex(string indexKey, Func<User, IEnumerable<BaseItem>> indexFunction, User user) + { + return indexFunction != null + ? IndexCache[user.Name + indexKey] = indexFunction(user).ToList() + : null; + } + + #endregion + + /// <summary> + /// The children + /// </summary> + private ConcurrentBag<BaseItem> _children; + /// <summary> + /// The _children initialized + /// </summary> + private bool _childrenInitialized; + /// <summary> + /// The _children sync lock + /// </summary> + private object _childrenSyncLock = new object(); + /// <summary> + /// Gets or sets the actual children. + /// </summary> + /// <value>The actual children.</value> + protected virtual ConcurrentBag<BaseItem> ActualChildren + { + get + { + LazyInitializer.EnsureInitialized(ref _children, ref _childrenInitialized, ref _childrenSyncLock, LoadChildren); + return _children; + } + private set + { + _children = value; + + if (value == null) + { + _childrenInitialized = false; + } + } + } + + /// <summary> + /// thread-safe access to the actual children of this folder - without regard to user + /// </summary> + /// <value>The children.</value> + [IgnoreDataMember] + public ConcurrentBag<BaseItem> Children + { + get + { + return ActualChildren; + } + } + + /// <summary> + /// thread-safe access to all recursive children of this folder - without regard to user + /// </summary> + /// <value>The recursive children.</value> + [IgnoreDataMember] + public IEnumerable<BaseItem> RecursiveChildren + { + get + { + foreach (var item in Children) + { + yield return item; + + if (item.IsFolder) + { + var subFolder = (Folder)item; + + foreach (var subitem in subFolder.RecursiveChildren) + { + yield return subitem; + } + } + } + } + } + + + /// <summary> + /// Loads our children. Validation will occur externally. + /// We want this sychronous. + /// </summary> + /// <returns>ConcurrentBag{BaseItem}.</returns> + protected virtual ConcurrentBag<BaseItem> LoadChildren() + { + //just load our children from the repo - the library will be validated and maintained in other processes + return new ConcurrentBag<BaseItem>(GetCachedChildren()); + } + + /// <summary> + /// Gets or sets the current validation cancellation token source. + /// </summary> + /// <value>The current validation cancellation token source.</value> + private CancellationTokenSource CurrentValidationCancellationTokenSource { get; set; } + + /// <summary> + /// Validates that the children of the folder still exist + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="recursive">if set to <c>true</c> [recursive].</param> + /// <returns>Task.</returns> + public async Task ValidateChildren(IProgress<TaskProgress> progress, CancellationToken cancellationToken, bool? recursive = null) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Cancel the current validation, if any + if (CurrentValidationCancellationTokenSource != null) + { + CurrentValidationCancellationTokenSource.Cancel(); + } + + // Create an inner cancellation token. This can cancel all validations from this level on down, + // but nothing above this + var innerCancellationTokenSource = new CancellationTokenSource(); + + try + { + CurrentValidationCancellationTokenSource = innerCancellationTokenSource; + + var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(innerCancellationTokenSource.Token, cancellationToken); + + await ValidateChildrenInternal(progress, linkedCancellationTokenSource.Token, recursive).ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + Logger.LogInfo("ValidateChildren cancelled for " + Name); + + // If the outer cancelletion token in the cause for the cancellation, throw it + if (cancellationToken.IsCancellationRequested && ex.CancellationToken == cancellationToken) + { + throw; + } + } + finally + { + // Null out the token source + if (CurrentValidationCancellationTokenSource == innerCancellationTokenSource) + { + CurrentValidationCancellationTokenSource = null; + } + + innerCancellationTokenSource.Dispose(); + } + } + + /// <summary> + /// Compare our current children (presumably just read from the repo) with the current state of the file system and adjust for any changes + /// ***Currently does not contain logic to maintain items that are unavailable in the file system*** + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="recursive">if set to <c>true</c> [recursive].</param> + /// <returns>Task.</returns> + protected async virtual Task ValidateChildrenInternal(IProgress<TaskProgress> progress, CancellationToken cancellationToken, bool? recursive = null) + { + // Nothing to do here + if (LocationType != LocationType.FileSystem) + { + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var changedArgs = new ChildrenChangedEventArgs(this); + + //get the current valid children from filesystem (or wherever) + var nonCachedChildren = GetNonCachedChildren(); + + if (nonCachedChildren == null) return; //nothing to validate + + progress.Report(new TaskProgress { PercentComplete = 5 }); + + //build a dictionary of the current children we have now by Id so we can compare quickly and easily + var currentChildren = ActualChildren.ToDictionary(i => i.Id); + + //create a list for our validated children + var validChildren = new ConcurrentBag<Tuple<BaseItem, bool>>(); + + cancellationToken.ThrowIfCancellationRequested(); + + Parallel.ForEach(nonCachedChildren, child => + { + BaseItem currentChild; + + if (currentChildren.TryGetValue(child.Id, out currentChild)) + { + currentChild.ResolveArgs = child.ResolveArgs; + + //existing item - check if it has changed + if (currentChild.HasChanged(child)) + { + EntityResolutionHelper.EnsureDates(currentChild, child.ResolveArgs); + + changedArgs.AddUpdatedItem(currentChild); + validChildren.Add(new Tuple<BaseItem, bool>(currentChild, true)); + } + else + { + validChildren.Add(new Tuple<BaseItem, bool>(currentChild, false)); + } + } + else + { + //brand new item - needs to be added + changedArgs.AddNewItem(child); + + validChildren.Add(new Tuple<BaseItem, bool>(child, true)); + } + }); + + // If any items were added or removed.... + if (!changedArgs.ItemsAdded.IsEmpty || currentChildren.Count != validChildren.Count) + { + var newChildren = validChildren.Select(c => c.Item1).ToList(); + + //that's all the new and changed ones - now see if there are any that are missing + changedArgs.ItemsRemoved = currentChildren.Values.Except(newChildren).ToList(); + + foreach (var item in changedArgs.ItemsRemoved) + { + Logger.LogInfo("** " + item.Name + " Removed from library."); + } + + var childrenReplaced = false; + + if (changedArgs.ItemsRemoved.Count > 0) + { + ActualChildren = new ConcurrentBag<BaseItem>(newChildren); + childrenReplaced = true; + } + + var saveTasks = new List<Task>(); + + foreach (var item in changedArgs.ItemsAdded) + { + Logger.LogInfo("** " + item.Name + " Added to library."); + + if (!childrenReplaced) + { + _children.Add(item); + } + + saveTasks.Add(Kernel.Instance.ItemRepository.SaveItem(item, CancellationToken.None)); + } + + await Task.WhenAll(saveTasks).ConfigureAwait(false); + + //and save children in repo... + Logger.LogInfo("*** Saving " + newChildren.Count + " children for " + Name); + await Kernel.Instance.ItemRepository.SaveChildren(Id, newChildren, CancellationToken.None).ConfigureAwait(false); + } + + if (changedArgs.HasChange) + { + //force the indexes to rebuild next time + IndexCache.Clear(); + + //and fire event + Kernel.Instance.LibraryManager.OnLibraryChanged(changedArgs); + } + + progress.Report(new TaskProgress { PercentComplete = 15 }); + + cancellationToken.ThrowIfCancellationRequested(); + + await RefreshChildren(validChildren, progress, cancellationToken, recursive).ConfigureAwait(false); + + progress.Report(new TaskProgress { PercentComplete = 100 }); + } + + /// <summary> + /// Refreshes the children. + /// </summary> + /// <param name="children">The children.</param> + /// <param name="progress">The progress.</param> + /// <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<TaskProgress> progress, CancellationToken cancellationToken, bool? recursive) + { + var numComplete = 0; + + var list = children.ToList(); + + var tasks = list.Select(tuple => Task.Run(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + var child = tuple.Item1; + + //refresh it + await child.RefreshMetadata(cancellationToken, resetResolveArgs: child.IsFolder).ConfigureAwait(false); + + //and add it to our valid children + //fire an added event...? + //if it is a folder we need to validate its children as well + + // 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)); + + if (refreshChildren) + { + // Don't refresh children if explicitly set to false + if (recursive.HasValue && recursive.Value == false) + { + refreshChildren = false; + } + } + + if (refreshChildren) + { + cancellationToken.ThrowIfCancellationRequested(); + + await ((Folder)child).ValidateChildren(new Progress<TaskProgress> { }, cancellationToken, recursive: recursive).ConfigureAwait(false); + } + + lock (progress) + { + numComplete++; + + double percent = numComplete; + percent /= list.Count; + + progress.Report(new TaskProgress { PercentComplete = (85 * percent) + 15 }); + } + })); + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Get the children of this folder from the actual file system + /// </summary> + /// <returns>IEnumerable{BaseItem}.</returns> + protected virtual IEnumerable<BaseItem> GetNonCachedChildren() + { + IEnumerable<WIN32_FIND_DATA> fileSystemChildren; + + try + { + fileSystemChildren = ResolveArgs.FileSystemChildren; + } + catch (IOException ex) + { + Logger.LogException("Error getting ResolveArgs for {0}", ex, Path); + return new List<BaseItem> { }; + } + + return Kernel.Instance.LibraryManager.GetItems<BaseItem>(fileSystemChildren, this); + } + + /// <summary> + /// Get our children from the repo - stubbed for now + /// </summary> + /// <returns>IEnumerable{BaseItem}.</returns> + protected virtual IEnumerable<BaseItem> GetCachedChildren() + { + return Kernel.Instance.ItemRepository.RetrieveChildren(this); + } + + /// <summary> + /// Gets allowed children of an item + /// </summary> + /// <param name="user">The user.</param> + /// <param name="indexBy">The index by.</param> + /// <param name="sortBy">The sort by.</param> + /// <param name="sortOrder">The sort order.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public virtual IEnumerable<BaseItem> GetChildren(User user, string indexBy = null, string sortBy = null, Model.Entities.SortOrder? sortOrder = null) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + //the true root should return our users root folder children + if (IsPhysicalRoot) return user.RootFolder.GetChildren(user, indexBy, sortBy, sortOrder); + + IEnumerable<BaseItem> result = null; + + if (!string.IsNullOrEmpty(indexBy)) + { + result = GetIndexedChildren(user, indexBy); + } + + // If indexed is false or the indexing function is null + if (result == null) + { + result = ActualChildren.Where(c => c.IsVisible(user)); + } + + if (string.IsNullOrEmpty(sortBy)) + { + return result; + } + + return sortOrder.HasValue && sortOrder.Value == Model.Entities.SortOrder.Descending + ? result.OrderByDescending(i => i, GetSortingFunction(sortBy)) + : result.OrderBy(i => i, GetSortingFunction(sortBy)); + } + + /// <summary> + /// Gets allowed recursive children of an item + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public IEnumerable<BaseItem> GetRecursiveChildren(User user) + { + if (user == null) + { + throw new ArgumentNullException(); + } + + foreach (var item in GetChildren(user)) + { + yield return item; + + var subFolder = item as Folder; + + if (subFolder != null) + { + foreach (var subitem in subFolder.GetRecursiveChildren(user)) + { + yield return subitem; + } + } + } + } + + /// <summary> + /// Folders need to validate and refresh + /// </summary> + /// <returns>Task.</returns> + public override async Task ChangedExternally() + { + await base.ChangedExternally().ConfigureAwait(false); + + var progress = new Progress<TaskProgress> { }; + + await ValidateChildren(progress, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Marks the item as either played or unplayed + /// </summary> + /// <param name="user">The user.</param> + /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> + /// <returns>Task.</returns> + public override async Task SetPlayedStatus(User user, bool wasPlayed) + { + await base.SetPlayedStatus(user, wasPlayed).ConfigureAwait(false); + + // Now sweep through recursively and update status + var tasks = GetChildren(user).Select(c => c.SetPlayedStatus(user, wasPlayed)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + /// <summary> + /// Finds an item by ID, recursively + /// </summary> + /// <param name="id">The id.</param> + /// <param name="user">The user.</param> + /// <returns>BaseItem.</returns> + public override BaseItem FindItemById(Guid id, User user) + { + var result = base.FindItemById(id, user); + + if (result != null) + { + return result; + } + + var children = user == null ? ActualChildren : GetChildren(user); + + foreach (var child in children) + { + result = child.FindItemById(id, user); + + if (result != null) + { + return result; + } + } + + return null; + } + + /// <summary> + /// Finds an item by path, recursively + /// </summary> + /// <param name="path">The path.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public BaseItem FindByPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + try + { + if (ResolveArgs.PhysicalLocations.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + return this; + } + } + catch (IOException ex) + { + Logger.LogException("Error getting ResolveArgs for {0}", ex, Path); + } + + //this should be functionally equivilent to what was here since it is IEnum and works on a thread-safe copy + return RecursiveChildren.FirstOrDefault(i => + { + try + { + return i.ResolveArgs.PhysicalLocations.Contains(path, StringComparer.OrdinalIgnoreCase); + } + catch (IOException ex) + { + Logger.LogException("Error getting ResolveArgs for {0}", ex, Path); + return false; + } + }); + } + } +} diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index ba343a2bc6..d5e8afb202 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -1,7 +1,10 @@ -
-namespace MediaBrowser.Controller.Entities
-{
- public class Genre : BaseEntity
- {
- }
-}
+ +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Genre + /// </summary> + public class Genre : BaseItem + { + } +} diff --git a/MediaBrowser.Controller/Entities/ICollectionFolder.cs b/MediaBrowser.Controller/Entities/ICollectionFolder.cs new file mode 100644 index 0000000000..4ea531331e --- /dev/null +++ b/MediaBrowser.Controller/Entities/ICollectionFolder.cs @@ -0,0 +1,10 @@ + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// This is just a marker interface to denote top level folders + /// </summary> + public interface ICollectionFolder + { + } +} diff --git a/MediaBrowser.Controller/Entities/ISupportsSpecialFeatures.cs b/MediaBrowser.Controller/Entities/ISupportsSpecialFeatures.cs new file mode 100644 index 0000000000..9b52c2f7fc --- /dev/null +++ b/MediaBrowser.Controller/Entities/ISupportsSpecialFeatures.cs @@ -0,0 +1,105 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Library; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Allows some code sharing between entities that support special features + /// </summary> + public interface ISupportsSpecialFeatures + { + /// <summary> + /// Gets the path. + /// </summary> + /// <value>The path.</value> + string Path { get; } + + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + string Name { get; } + + /// <summary> + /// Gets the resolve args. + /// </summary> + /// <value>The resolve args.</value> + ItemResolveArgs ResolveArgs { get; } + + /// <summary> + /// Gets the special features. + /// </summary> + /// <value>The special features.</value> + List<Video> SpecialFeatures { get; } + } + + /// <summary> + /// Class SpecialFeatures + /// </summary> + public static class SpecialFeatures + { + /// <summary> + /// Loads special features from the file system + /// </summary> + /// <param name="entity">The entity.</param> + /// <returns>List{Video}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public static IEnumerable<Video> LoadSpecialFeatures(ISupportsSpecialFeatures entity) + { + if (entity == null) + { + throw new ArgumentNullException(); + } + + WIN32_FIND_DATA? folder; + + try + { + folder = entity.ResolveArgs.GetFileSystemEntryByName("specials"); + } + catch (IOException ex) + { + Logger.LogException("Error getting ResolveArgs for {0}", ex, entity.Path); + return new List<Video> { }; + } + + // Path doesn't exist. No biggie + if (folder == null) + { + return new List<Video> {}; + } + + IEnumerable<WIN32_FIND_DATA> files; + + try + { + files = FileSystem.GetFiles(folder.Value.Path); + } + catch (IOException ex) + { + Logger.LogException("Error loading trailers for {0}", ex, entity.Name); + return new List<Video> { }; + } + + return Kernel.Instance.LibraryManager.GetItems<Video>(files, null).Select(video => + { + // Try to retrieve it from the db. If we don't find it, use the resolved version + var dbItem = Kernel.Instance.ItemRepository.RetrieveItem(video.Id) as Video; + + if (dbItem != null) + { + dbItem.ResolveArgs = video.ResolveArgs; + video = dbItem; + } + + return video; + }); + } + } +} diff --git a/MediaBrowser.Controller/Entities/IndexFolder.cs b/MediaBrowser.Controller/Entities/IndexFolder.cs new file mode 100644 index 0000000000..013db48536 --- /dev/null +++ b/MediaBrowser.Controller/Entities/IndexFolder.cs @@ -0,0 +1,204 @@ +using MediaBrowser.Common.Extensions; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class IndexFolder + /// </summary> + public class IndexFolder : Folder + { + /// <summary> + /// Initializes a new instance of the <see cref="IndexFolder" /> class. + /// </summary> + /// <param name="parent">The parent.</param> + /// <param name="shadow">The shadow.</param> + /// <param name="children">The children.</param> + /// <param name="indexName">Name of the index.</param> + /// <param name="groupContents">if set to <c>true</c> [group contents].</param> + public IndexFolder(Folder parent, BaseItem shadow, IEnumerable<BaseItem> children, string indexName, bool groupContents = true) + { + ChildSource = children; + ShadowItem = shadow; + GroupContents = groupContents; + if (shadow == null) + { + Name = SortName = "<Unknown>"; + } + else + { + SetShadowValues(); + } + Id = (parent.Id.ToString() + Name).GetMBId(typeof(IndexFolder)); + + IndexName = indexName; + Parent = parent; + } + + /// <summary> + /// Resets the parent. + /// </summary> + /// <param name="parent">The parent.</param> + public void ResetParent(Folder parent) + { + Parent = parent; + Id = (parent.Id.ToString() + Name).GetMBId(typeof(IndexFolder)); + } + + /// <summary> + /// Override this to true if class should be grouped under a container in indicies + /// The container class should be defined via IndexContainer + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool GroupInIndex + { + get + { + return ShadowItem != null && ShadowItem.GroupInIndex; + } + } + + /// <summary> + /// Override this to return the folder that should be used to construct a container + /// for this item in an index. GroupInIndex should be true as well. + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public override Folder IndexContainer + { + get { return ShadowItem != null ? ShadowItem.IndexContainer : new IndexFolder(this, null, null, "<Unknown>", false); } + } + + /// <summary> + /// Gets or sets a value indicating whether [group contents]. + /// </summary> + /// <value><c>true</c> if [group contents]; otherwise, <c>false</c>.</value> + protected bool GroupContents { get; set; } + /// <summary> + /// Gets or sets the child source. + /// </summary> + /// <value>The child source.</value> + protected IEnumerable<BaseItem> ChildSource { get; set; } + /// <summary> + /// Gets or sets our children. + /// </summary> + /// <value>Our children.</value> + protected ConcurrentBag<BaseItem> OurChildren { get; set; } + /// <summary> + /// Gets the name of the index. + /// </summary> + /// <value>The name of the index.</value> + public string IndexName { get; private set; } + + /// <summary> + /// Override to return the children defined to us when we were created + /// </summary> + /// <value>The actual children.</value> + protected override ConcurrentBag<BaseItem> LoadChildren() + { + var originalChildSource = ChildSource.ToList(); + + var kids = originalChildSource; + if (GroupContents) + { + // Recursively group up the chain + var group = true; + var isSubsequentLoop = false; + + while (group) + { + kids = isSubsequentLoop || kids.Any(i => i.GroupInIndex) + ? GroupedSource(kids).ToList() + : originalChildSource; + + group = kids.Any(i => i.GroupInIndex); + isSubsequentLoop = true; + } + } + + // Now - since we built the index grouping from the bottom up - we now need to properly set Parents from the top down + SetParents(this, kids.OfType<IndexFolder>()); + + return new ConcurrentBag<BaseItem>(kids); + } + + /// <summary> + /// Sets the parents. + /// </summary> + /// <param name="parent">The parent.</param> + /// <param name="kids">The kids.</param> + private void SetParents(Folder parent, IEnumerable<IndexFolder> kids) + { + foreach (var child in kids) + { + child.ResetParent(parent); + child.SetParents(child, child.Children.OfType<IndexFolder>()); + } + } + + /// <summary> + /// Groupeds the source. + /// </summary> + /// <param name="source">The source.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + protected IEnumerable<BaseItem> GroupedSource(IEnumerable<BaseItem> source) + { + return source.GroupBy(i => i.IndexContainer).Select(container => new IndexFolder(this, container.Key, container, null, false)); + } + + /// <summary> + /// The item we are shadowing as a folder (Genre, Actor, etc.) + /// We inherit the images and other meta from this item + /// </summary> + /// <value>The shadow item.</value> + protected BaseItem ShadowItem { get; set; } + + /// <summary> + /// Sets the shadow values. + /// </summary> + protected void SetShadowValues() + { + if (ShadowItem != null) + { + Name = ShadowItem.Name; + SortName = ShadowItem.SortName; + Genres = ShadowItem.Genres; + Studios = ShadowItem.Studios; + OfficialRating = ShadowItem.OfficialRating; + BackdropImagePaths = ShadowItem.BackdropImagePaths; + Images = ShadowItem.Images; + Overview = ShadowItem.Overview; + DisplayMediaType = ShadowItem.GetType().Name; + } + } + + /// <summary> + /// Overrides the base implementation to refresh metadata for local trailers + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="forceSave">if set to <c>true</c> [is new item].</param> + /// <param name="forceRefresh">if set to <c>true</c> [force].</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <param name="resetResolveArgs">if set to <c>true</c> [reset resolve args].</param> + /// <returns>Task{System.Boolean}.</returns> + public override async Task<bool> RefreshMetadata(CancellationToken cancellationToken, bool forceSave = false, bool forceRefresh = false, bool allowSlowProviders = true, bool resetResolveArgs = true) + { + if (ShadowItem != null) + { + var changed = await ShadowItem.RefreshMetadata(cancellationToken, forceSave, forceRefresh, allowSlowProviders, resetResolveArgs).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + SetShadowValues(); + return changed; + } + return false; + } + } +} diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index cb841530ee..34f09b4b09 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -1,7 +1,11 @@ -
-namespace MediaBrowser.Controller.Entities.Movies
-{
- public class BoxSet : Folder
- {
- }
-}
+ +namespace MediaBrowser.Controller.Entities.Movies +{ + /// <summary> + /// Class BoxSet + /// </summary> + public class BoxSet : Folder + { + + } +} diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 2d98fa06e8..114e10d37a 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -1,31 +1,144 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace MediaBrowser.Controller.Entities.Movies
-{
- public class Movie : Video
- {
- public IEnumerable<Video> SpecialFeatures { get; set; }
-
- /// <summary>
- /// Finds an item by ID, recursively
- /// </summary>
- public override BaseItem FindItemById(Guid id)
- {
- var item = base.FindItemById(id);
-
- if (item != null)
- {
- return item;
- }
-
- if (SpecialFeatures != null)
- {
- return SpecialFeatures.FirstOrDefault(i => i.Id == id);
- }
-
- return null;
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Entities.Movies +{ + /// <summary> + /// Class Movie + /// </summary> + public class Movie : Video, ISupportsSpecialFeatures + { + /// <summary> + /// Should be overridden to return the proper folder where metadata lives + /// </summary> + /// <value>The meta location.</value> + [IgnoreDataMember] + public override string MetaLocation + { + get + { + return VideoType == VideoType.VideoFile || VideoType == VideoType.Iso ? System.IO.Path.GetDirectoryName(Path) : Path; + } + } + + /// <summary> + /// Override to use tmdb or imdb id so it will stick if the item moves physical locations + /// </summary> + /// <value>The user data id.</value> + [IgnoreDataMember] + public override Guid UserDataId + { + get + { + if (_userDataId == Guid.Empty) + { + var baseId = this.GetProviderId(MetadataProviders.Tmdb) ?? this.GetProviderId(MetadataProviders.Imdb); + _userDataId = baseId != null ? baseId.GetMD5() : Id; + } + return _userDataId; + } + } + + /// <summary> + /// The _special features + /// </summary> + private List<Video> _specialFeatures; + /// <summary> + /// The _special features initialized + /// </summary> + private bool _specialFeaturesInitialized; + /// <summary> + /// The _special features sync lock + /// </summary> + private object _specialFeaturesSyncLock = new object(); + /// <summary> + /// Gets the special features. + /// </summary> + /// <value>The special features.</value> + [IgnoreDataMember] + public List<Video> SpecialFeatures + { + get + { + LazyInitializer.EnsureInitialized(ref _specialFeatures, ref _specialFeaturesInitialized, ref _specialFeaturesSyncLock, () => Entities.SpecialFeatures.LoadSpecialFeatures(this).ToList()); + return _specialFeatures; + } + private set + { + _specialFeatures = value; + + if (value == null) + { + _specialFeaturesInitialized = false; + } + } + } + + /// <summary> + /// Needed because the resolver stops at the movie folder and we find the video inside. + /// </summary> + /// <value><c>true</c> if [use parent path to create resolve args]; otherwise, <c>false</c>.</value> + protected override bool UseParentPathToCreateResolveArgs + { + get + { + return VideoType == VideoType.VideoFile || VideoType == VideoType.Iso; + } + } + + /// <summary> + /// Overrides the base implementation to refresh metadata for special features + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="forceSave">if set to <c>true</c> [is new item].</param> + /// <param name="forceRefresh">if set to <c>true</c> [force].</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <param name="resetResolveArgs">if set to <c>true</c> [reset resolve args].</param> + /// <returns>Task{System.Boolean}.</returns> + public override async Task<bool> RefreshMetadata(CancellationToken cancellationToken, bool forceSave = false, bool forceRefresh = false, bool allowSlowProviders = true, bool resetResolveArgs = true) + { + // Lazy load these again + SpecialFeatures = null; + + // Kick off a task to refresh the main item + var result = await base.RefreshMetadata(cancellationToken, forceSave, forceRefresh, allowSlowProviders, resetResolveArgs).ConfigureAwait(false); + + var tasks = SpecialFeatures.Select(item => item.RefreshMetadata(cancellationToken, forceSave, forceRefresh, allowSlowProviders)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + return result; + } + + /// <summary> + /// Finds an item by ID, recursively + /// </summary> + /// <param name="id">The id.</param> + /// <param name="user">The user.</param> + /// <returns>BaseItem.</returns> + public override BaseItem FindItemById(Guid id, User user) + { + var item = base.FindItemById(id, user); + + if (item != null) + { + return item; + } + + if (SpecialFeatures != null) + { + return SpecialFeatures.FirstOrDefault(i => i.Id == id); + } + + return null; + } + } +} diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index a12b9e38e2..5c92c019b8 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -1,25 +1,41 @@ -
-namespace MediaBrowser.Controller.Entities
-{
- /// <summary>
- /// This is the full Person object that can be retrieved with all of it's data.
- /// </summary>
- public class Person : BaseEntity
- {
- }
-
- /// <summary>
- /// This is the small Person stub that is attached to BaseItems
- /// </summary>
- public class PersonInfo
- {
- public string Name { get; set; }
- public string Overview { get; set; }
- public string Type { get; set; }
-
- public override string ToString()
- {
- return Name;
- }
- }
-}
+ +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// This is the full Person object that can be retrieved with all of it's data. + /// </summary> + public class Person : BaseItem + { + } + + /// <summary> + /// This is the small Person stub that is attached to BaseItems + /// </summary> + public class PersonInfo + { + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string Name { get; set; } + /// <summary> + /// Gets or sets the role. + /// </summary> + /// <value>The role.</value> + public string Role { get; set; } + /// <summary> + /// Gets or sets the type. + /// </summary> + /// <value>The type.</value> + public string Type { get; set; } + + /// <summary> + /// Returns a <see cref="System.String" /> that represents this instance. + /// </summary> + /// <returns>A <see cref="System.String" /> that represents this instance.</returns> + public override string ToString() + { + return Name; + } + } +} diff --git a/MediaBrowser.Controller/Entities/PlaybackProgressEventArgs.cs b/MediaBrowser.Controller/Entities/PlaybackProgressEventArgs.cs new file mode 100644 index 0000000000..bbec606ae2 --- /dev/null +++ b/MediaBrowser.Controller/Entities/PlaybackProgressEventArgs.cs @@ -0,0 +1,13 @@ +using MediaBrowser.Common.Events; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Holds information about a playback progress event + /// </summary> + public class PlaybackProgressEventArgs : GenericEventArgs<BaseItem> + { + public User User { get; set; } + public long? PlaybackPositionTicks { get; set; } + } +} diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index b7c6e6aa43..a255849e61 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -1,7 +1,10 @@ -
-namespace MediaBrowser.Controller.Entities
-{
- public class Studio : BaseEntity
- {
- }
-}
+ +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Studio + /// </summary> + public class Studio : BaseItem + { + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 5d599fca7f..854b9d0183 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -1,7 +1,163 @@ -
-namespace MediaBrowser.Controller.Entities.TV
-{
- public class Episode : Video
- {
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities.TV +{ + /// <summary> + /// Class Episode + /// </summary> + public class Episode : Video + { + /// <summary> + /// Episodes have a special Metadata folder + /// </summary> + /// <value>The meta location.</value> + [IgnoreDataMember] + public override string MetaLocation + { + get + { + return System.IO.Path.Combine(Parent.Path, "metadata"); + } + } + + /// <summary> + /// We want to group into series not show individually in an index + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool GroupInIndex + { + get { return true; } + } + + /// <summary> + /// We roll up into series + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public override Folder IndexContainer + { + get + { + return Season; + } + } + + /// <summary> + /// Override to use the provider Ids + season and episode number so it will be portable + /// </summary> + /// <value>The user data id.</value> + [IgnoreDataMember] + public override Guid UserDataId + { + get + { + if (_userDataId == Guid.Empty) + { + var baseId = Series != null ? Series.GetProviderId(MetadataProviders.Tvdb) ?? Series.GetProviderId(MetadataProviders.Tvcom) : null; + if (baseId != null) + { + var seasonNo = Season != null ? Season.IndexNumber ?? 0 : 0; + var epNo = IndexNumber ?? 0; + baseId = baseId + seasonNo.ToString("000") + epNo.ToString("000"); + } + _userDataId = baseId != null ? baseId.GetMD5() : Id; + } + return _userDataId; + } + } + + /// <summary> + /// Override this if you need to combine/collapse person information + /// </summary> + /// <value>All people.</value> + [IgnoreDataMember] + public override IEnumerable<PersonInfo> AllPeople + { + get + { + if (People == null) return Series != null ? Series.People : People; + return Series != null && Series.People != null ? People.Concat(Series.People) : base.AllPeople; + } + } + + /// <summary> + /// Gets or sets the studios. + /// </summary> + /// <value>The studios.</value> + [IgnoreDataMember] + public override List<string> Studios + { + get + { + return Series != null ? Series.Studios : null; + } + set + { + base.Studios = value; + } + } + + /// <summary> + /// Gets or sets the genres. + /// </summary> + /// <value>The genres.</value> + [IgnoreDataMember] + public override List<string> Genres + { + get { return Series != null ? Series.Genres : null; } + set + { + base.Genres = value; + } + } + + /// <summary> + /// We persist the MB Id of our series object so we can always find it no matter + /// what context we happen to be loaded from. + /// </summary> + /// <value>The series item id.</value> + public Guid SeriesItemId { get; set; } + + /// <summary> + /// We persist the MB Id of our season object so we can always find it no matter + /// what context we happen to be loaded from. + /// </summary> + /// <value>The season item id.</value> + public Guid SeasonItemId { get; set; } + + /// <summary> + /// The _series + /// </summary> + private Series _series; + /// <summary> + /// This Episode's Series Instance + /// </summary> + /// <value>The series.</value> + [IgnoreDataMember] + public Series Series + { + get { return _series ?? (_series = FindParent<Series>()); } + } + + /// <summary> + /// The _season + /// </summary> + private Season _season; + /// <summary> + /// This Episode's Season Instance + /// </summary> + /// <value>The season.</value> + [IgnoreDataMember] + public Season Season + { + get { return _season ?? (_season = FindParent<Season>()); } + } + + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index f9c7fecb32..140c90814e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -1,34 +1,143 @@ -using System;
-
-namespace MediaBrowser.Controller.Entities.TV
-{
- public class Season : Folder
- {
- /// <summary>
- /// Store these to reduce disk access in Episode Resolver
- /// </summary>
- public string[] MetadataFiles
- {
- get
- {
- return ResolveArgs.MetadataFiles ?? new string[] { };
- }
- }
-
- /// <summary>
- /// Determines if the metafolder contains a given file
- /// </summary>
- public bool ContainsMetadataFile(string file)
- {
- for (int i = 0; i < MetadataFiles.Length; i++)
- {
- if (MetadataFiles[i].Equals(file, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
-
- return false;
- }
- }
-}
+using System.Collections.Generic; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Localization; +using MediaBrowser.Model.Entities; +using System; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities.TV +{ + /// <summary> + /// Class Season + /// </summary> + public class Season : Folder + { + + /// <summary> + /// Seasons are just containers + /// </summary> + /// <value><c>true</c> if [include in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool IncludeInIndex + { + get + { + return false; + } + } + + /// <summary> + /// We want to group into our Series + /// </summary> + /// <value><c>true</c> if [group in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool GroupInIndex + { + get + { + return true; + } + } + + /// <summary> + /// Override this to return the folder that should be used to construct a container + /// for this item in an index. GroupInIndex should be true as well. + /// </summary> + /// <value>The index container.</value> + [IgnoreDataMember] + public override Folder IndexContainer + { + get + { + return Series; + } + } + + // Genre, Rating and Stuido will all be the same + protected override Dictionary<string, Func<User, IEnumerable<BaseItem>>> GetIndexByOptions() + { + return new Dictionary<string, Func<User, IEnumerable<BaseItem>>> { + {LocalizedStrings.Instance.GetString("NoneDispPref"), null}, + {LocalizedStrings.Instance.GetString("PerformerDispPref"), GetIndexByPerformer}, + {LocalizedStrings.Instance.GetString("DirectorDispPref"), GetIndexByDirector}, + {LocalizedStrings.Instance.GetString("YearDispPref"), GetIndexByYear}, + }; + } + + /// <summary> + /// Override to use the provider Ids + season number so it will be portable + /// </summary> + /// <value>The user data id.</value> + [IgnoreDataMember] + public override Guid UserDataId + { + get + { + if (_userDataId == Guid.Empty) + { + var baseId = Series != null ? Series.GetProviderId(MetadataProviders.Tvdb) ?? Series.GetProviderId(MetadataProviders.Tvcom) : null; + if (baseId != null) + { + var seasonNo = IndexNumber ?? 0; + baseId = baseId + seasonNo.ToString("000"); + } + + _userDataId = baseId != null ? baseId.GetMD5() : Id; + } + return _userDataId; + } + } + + /// <summary> + /// We persist the MB Id of our series object so we can always find it no matter + /// what context we happen to be loaded from. + /// </summary> + /// <value>The series item id.</value> + public Guid SeriesItemId { get; set; } + + /// <summary> + /// The _series + /// </summary> + private Series _series; + /// <summary> + /// This Episode's Series Instance + /// </summary> + /// <value>The series.</value> + [IgnoreDataMember] + public Series Series + { + get { return _series ?? (_series = FindParent<Series>()); } + } + + /// <summary> + /// Add files from the metadata folder to ResolveArgs + /// </summary> + /// <param name="args">The args.</param> + internal static void AddMetadataFiles(ItemResolveArgs args) + { + var folder = args.GetFileSystemEntryByName("metadata"); + + if (folder.HasValue) + { + args.AddMetadataFiles(FileSystem.GetFiles(folder.Value.Path)); + } + } + + /// <summary> + /// Creates ResolveArgs on demand + /// </summary> + /// <param name="pathInfo">The path info.</param> + /// <returns>ItemResolveArgs.</returns> + protected internal override ItemResolveArgs CreateResolveArgs(WIN32_FIND_DATA? pathInfo = null) + { + var args = base.CreateResolveArgs(pathInfo); + + AddMetadataFiles(args); + + return args; + } + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 7c228a53df..152dcbac87 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -1,12 +1,89 @@ -using System;
-using System.Collections.Generic;
-
-namespace MediaBrowser.Controller.Entities.TV
-{
- public class Series : Folder
- {
- public string Status { get; set; }
- public IEnumerable<DayOfWeek> AirDays { get; set; }
- public string AirTime { get; set; }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Localization; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities.TV +{ + /// <summary> + /// Class Series + /// </summary> + public class Series : Folder + { + /// <summary> + /// Gets or sets the status. + /// </summary> + /// <value>The status.</value> + public SeriesStatus? Status { get; set; } + /// <summary> + /// Gets or sets the air days. + /// </summary> + /// <value>The air days.</value> + public List<DayOfWeek> AirDays { get; set; } + /// <summary> + /// Gets or sets the air time. + /// </summary> + /// <value>The air time.</value> + public string AirTime { get; set; } + + /// <summary> + /// Series aren't included directly in indices - Their Episodes will roll up to them + /// </summary> + /// <value><c>true</c> if [include in index]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public override bool IncludeInIndex + { + get + { + return false; + } + } + + /// <summary> + /// Override to use the provider Ids so it will be portable + /// </summary> + /// <value>The user data id.</value> + [IgnoreDataMember] + public override Guid UserDataId + { + get + { + if (_userDataId == Guid.Empty) + { + var baseId = this.GetProviderId(MetadataProviders.Tvdb) ?? this.GetProviderId(MetadataProviders.Tvcom); + _userDataId = baseId != null ? baseId.GetMD5() : Id; + } + return _userDataId; + } + } + + // Studio, Genre and Rating will all be the same so makes no sense to index by these + protected override Dictionary<string, Func<User, IEnumerable<BaseItem>>> GetIndexByOptions() + { + return new Dictionary<string, Func<User, IEnumerable<BaseItem>>> { + {LocalizedStrings.Instance.GetString("NoneDispPref"), null}, + {LocalizedStrings.Instance.GetString("PerformerDispPref"), GetIndexByPerformer}, + {LocalizedStrings.Instance.GetString("DirectorDispPref"), GetIndexByDirector}, + {LocalizedStrings.Instance.GetString("YearDispPref"), GetIndexByYear}, + }; + } + + /// <summary> + /// Creates ResolveArgs on demand + /// </summary> + /// <param name="pathInfo">The path info.</param> + /// <returns>ItemResolveArgs.</returns> + protected internal override ItemResolveArgs CreateResolveArgs(WIN32_FIND_DATA? pathInfo = null) + { + var args = base.CreateResolveArgs(pathInfo); + + Season.AddMetadataFiles(args); + + return args; + } + } +} diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs new file mode 100644 index 0000000000..ed20d05b04 --- /dev/null +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -0,0 +1,51 @@ +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Trailer + /// </summary> + public class Trailer : Video + { + /// <summary> + /// Gets a value indicating whether this instance is local trailer. + /// </summary> + /// <value><c>true</c> if this instance is local trailer; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public bool IsLocalTrailer + { + get + { + // Local trailers are not part of children + return Parent == null; + } + } + + /// <summary> + /// Should be overridden to return the proper folder where metadata lives + /// </summary> + /// <value>The meta location.</value> + [IgnoreDataMember] + public override string MetaLocation + { + get + { + if (!IsLocalTrailer) + { + return System.IO.Path.GetDirectoryName(Path); + } + + return base.MetaLocation; + } + } + + /// <summary> + /// Needed because the resolver stops at the trailer folder and we find the video inside. + /// </summary> + /// <value><c>true</c> if [use parent path to create resolve args]; otherwise, <c>false</c>.</value> + protected override bool UseParentPathToCreateResolveArgs + { + get { return !IsLocalTrailer; } + } + } +} diff --git a/MediaBrowser.Controller/Entities/User.cs b/MediaBrowser.Controller/Entities/User.cs index 01eadfafb2..92e268d4c5 100644 --- a/MediaBrowser.Controller/Entities/User.cs +++ b/MediaBrowser.Controller/Entities/User.cs @@ -1,21 +1,446 @@ -using System;
-
-namespace MediaBrowser.Controller.Entities
-{
- public class User : BaseEntity
- {
- public string Password { get; set; }
-
- public string MaxParentalRating { get; set; }
-
- public int RecentItemDays { get; set; }
-
- public User()
- {
- RecentItemDays = 14;
- }
-
- public DateTime? LastLoginDate { get; set; }
- public DateTime? LastActivityDate { get; set; }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using System; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class User + /// </summary> + public class User : BaseItem + { + /// <summary> + /// The _root folder path + /// </summary> + private string _rootFolderPath; + /// <summary> + /// Gets the root folder path. + /// </summary> + /// <value>The root folder path.</value> + [IgnoreDataMember] + public string RootFolderPath + { + get + { + if (_rootFolderPath == null) + { + if (Configuration.UseCustomLibrary) + { + _rootFolderPath = GetRootFolderPath(Name); + + if (!Directory.Exists(_rootFolderPath)) + { + Directory.CreateDirectory(_rootFolderPath); + } + } + else + { + _rootFolderPath = Kernel.Instance.ApplicationPaths.DefaultUserViewsPath; + } + } + return _rootFolderPath; + } + } + + /// <summary> + /// Gets the root folder path based on a given username + /// </summary> + /// <param name="username">The username.</param> + /// <returns>System.String.</returns> + private string GetRootFolderPath(string username) + { + var safeFolderName = FileSystem.GetValidFilename(username); + + return System.IO.Path.Combine(Kernel.Instance.ApplicationPaths.RootFolderPath, safeFolderName); + } + + /// <summary> + /// Gets or sets the password. + /// </summary> + /// <value>The password.</value> + public string Password { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public override string Path + { + get + { + // Return this so that metadata providers will look in here + return ConfigurationDirectoryPath; + } + set + { + base.Path = value; + } + } + + /// <summary> + /// Ensure this has a value + /// </summary> + /// <value>The display type of the media.</value> + public override string DisplayMediaType + { + get + { + return base.DisplayMediaType ?? GetType().Name; + } + set + { + base.DisplayMediaType = value; + } + } + + /// <summary> + /// The _root folder + /// </summary> + private UserRootFolder _rootFolder; + /// <summary> + /// The _user root folder initialized + /// </summary> + private bool _userRootFolderInitialized; + /// <summary> + /// The _user root folder sync lock + /// </summary> + private object _userRootFolderSyncLock = new object(); + /// <summary> + /// Gets the root folder. + /// </summary> + /// <value>The root folder.</value> + [IgnoreDataMember] + public UserRootFolder RootFolder + { + get + { + LazyInitializer.EnsureInitialized(ref _rootFolder, ref _userRootFolderInitialized, ref _userRootFolderSyncLock, () => (UserRootFolder)Kernel.Instance.LibraryManager.GetItem(RootFolderPath)); + return _rootFolder; + } + private set + { + _rootFolder = value; + + if (_rootFolder == null) + { + _userRootFolderInitialized = false; + } + } + } + + /// <summary> + /// Gets or sets the last login date. + /// </summary> + /// <value>The last login date.</value> + public DateTime? LastLoginDate { get; set; } + /// <summary> + /// Gets or sets the last activity date. + /// </summary> + /// <value>The last activity date.</value> + public DateTime? LastActivityDate { get; set; } + + /// <summary> + /// The _configuration + /// </summary> + private UserConfiguration _configuration; + /// <summary> + /// The _configuration initialized + /// </summary> + private bool _configurationInitialized; + /// <summary> + /// The _configuration sync lock + /// </summary> + private object _configurationSyncLock = new object(); + /// <summary> + /// Gets the user's configuration + /// </summary> + /// <value>The configuration.</value> + [IgnoreDataMember] + public UserConfiguration Configuration + { + get + { + // Lazy load + LazyInitializer.EnsureInitialized(ref _configuration, ref _configurationInitialized, ref _configurationSyncLock, () => XmlSerializer.GetXmlConfiguration<UserConfiguration>(ConfigurationFilePath)); + return _configuration; + } + private set + { + _configuration = value; + + if (value == null) + { + _configurationInitialized = false; + } + } + } + + /// <summary> + /// Gets the last date modified of the configuration + /// </summary> + /// <value>The configuration date last modified.</value> + [IgnoreDataMember] + public DateTime ConfigurationDateLastModified + { + get + { + // Ensure it's been lazy loaded + var config = Configuration; + + return File.GetLastWriteTimeUtc(ConfigurationFilePath); + } + } + + /// <summary> + /// Reloads the root media folder + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ValidateMediaLibrary(IProgress<TaskProgress> progress, CancellationToken cancellationToken) + { + Logger.LogInfo("Validating media library for {0}", Name); + await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + await RootFolder.ValidateChildren(progress, cancellationToken).ConfigureAwait(false); + } + + /// <summary> + /// Validates only the collection folders for a User and goes no further + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ValidateCollectionFolders(IProgress<TaskProgress> progress, CancellationToken cancellationToken) + { + Logger.LogInfo("Validating collection folders for {0}", Name); + await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + await RootFolder.ValidateChildren(progress, cancellationToken, recursive: false).ConfigureAwait(false); + } + + /// <summary> + /// Renames the user. + /// </summary> + /// <param name="newName">The new name.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + internal Task Rename(string newName) + { + if (string.IsNullOrEmpty(newName)) + { + throw new ArgumentNullException(); + } + + // If only the casing is changing, leave the file system alone + if (!newName.Equals(Name, StringComparison.OrdinalIgnoreCase)) + { + // Move configuration + var newConfigDirectory = GetConfigurationDirectoryPath(newName); + + // Exceptions will be thrown if these paths already exist + if (Directory.Exists(newConfigDirectory)) + { + Directory.Delete(newConfigDirectory, true); + } + Directory.Move(ConfigurationDirectoryPath, newConfigDirectory); + + var customLibraryPath = GetRootFolderPath(Name); + + // Move the root folder path if using a custom library + if (Directory.Exists(customLibraryPath)) + { + var newRootFolderPath = GetRootFolderPath(newName); + if (Directory.Exists(newRootFolderPath)) + { + Directory.Delete(newRootFolderPath, true); + } + Directory.Move(customLibraryPath, newRootFolderPath); + } + } + + Name = newName; + + // Force these to be lazy loaded again + _configurationDirectoryPath = null; + _rootFolderPath = null; + RootFolder = null; + + // Kick off a task to validate the media library + Task.Run(() => ValidateMediaLibrary(new Progress<TaskProgress> { }, CancellationToken.None)); + + return RefreshMetadata(CancellationToken.None, forceSave: true, forceRefresh: true); + } + + /// <summary> + /// The _configuration directory path + /// </summary> + private string _configurationDirectoryPath; + /// <summary> + /// Gets the path to the user's configuration directory + /// </summary> + /// <value>The configuration directory path.</value> + private string ConfigurationDirectoryPath + { + get + { + if (_configurationDirectoryPath == null) + { + _configurationDirectoryPath = GetConfigurationDirectoryPath(Name); + + if (!Directory.Exists(_configurationDirectoryPath)) + { + Directory.CreateDirectory(_configurationDirectoryPath); + } + } + + return _configurationDirectoryPath; + } + } + + /// <summary> + /// Gets the configuration directory path. + /// </summary> + /// <param name="username">The username.</param> + /// <returns>System.String.</returns> + private string GetConfigurationDirectoryPath(string username) + { + var safeFolderName = FileSystem.GetValidFilename(username); + + return System.IO.Path.Combine(Kernel.Instance.ApplicationPaths.UserConfigurationDirectoryPath, safeFolderName); + } + + /// <summary> + /// Gets the path to the user's configuration file + /// </summary> + /// <value>The configuration file path.</value> + private string ConfigurationFilePath + { + get + { + return System.IO.Path.Combine(ConfigurationDirectoryPath, "config.xml"); + } + } + + /// <summary> + /// Saves the current configuration to the file system + /// </summary> + public void SaveConfiguration() + { + XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath); + } + + /// <summary> + /// Refresh metadata on us by execution our provider chain + /// The item will be persisted if a change is made by a provider, or if it's new or changed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="forceSave">if set to <c>true</c> [is new item].</param> + /// <param name="forceRefresh">if set to <c>true</c> [force].</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <param name="resetResolveArgs">if set to <c>true</c> [reset resolve args].</param> + /// <returns>true if a provider reports we changed</returns> + public override async Task<bool> RefreshMetadata(CancellationToken cancellationToken, bool forceSave = false, bool forceRefresh = false, bool allowSlowProviders = true, bool resetResolveArgs = true) + { + if (resetResolveArgs) + { + ResolveArgs = null; + } + + var changed = await Kernel.Instance.ProviderManager.ExecuteMetadataProviders(this, cancellationToken, forceRefresh, allowSlowProviders).ConfigureAwait(false); + + if (changed || forceSave) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Kernel.Instance.UserManager.UpdateUser(this).ConfigureAwait(false); + } + + return changed; + } + + /// <summary> + /// Updates the configuration. + /// </summary> + /// <param name="config">The config.</param> + /// <exception cref="System.ArgumentNullException">config</exception> + public void UpdateConfiguration(UserConfiguration config) + { + if (config == null) + { + throw new ArgumentNullException("config"); + } + + var customLibraryChanged = config.UseCustomLibrary != Configuration.UseCustomLibrary; + + Configuration = config; + SaveConfiguration(); + + // Force these to be lazy loaded again + if (customLibraryChanged) + { + _rootFolderPath = null; + RootFolder = null; + + if (config.UseCustomLibrary) + { + CopyDefaultLibraryPathsIfNeeded(); + } + } + } + + /// <summary> + /// Copies the default library paths if needed. + /// </summary> + private void CopyDefaultLibraryPathsIfNeeded() + { + var userPath = RootFolderPath; + + var defaultPath = Kernel.Instance.ApplicationPaths.DefaultUserViewsPath; + + if (userPath.Equals(defaultPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!Directory.EnumerateFileSystemEntries(userPath, "*.lnk", SearchOption.AllDirectories).Any()) + { + FileSystem.CopyAll(defaultPath, userPath); + } + } + + /// <summary> + /// Resets the password by clearing it. + /// </summary> + /// <returns>Task.</returns> + public Task ResetPassword() + { + return ChangePassword(string.Empty); + } + + /// <summary> + /// Changes the password. + /// </summary> + /// <param name="newPassword">The new password.</param> + /// <returns>Task.</returns> + public Task ChangePassword(string newPassword) + { + Password = string.IsNullOrEmpty(newPassword) ? string.Empty : newPassword.GetMD5().ToString(); + + return Kernel.Instance.UserManager.UpdateUser(this); + } + } +} diff --git a/MediaBrowser.Controller/Entities/UserItemData.cs b/MediaBrowser.Controller/Entities/UserItemData.cs index bb4950046b..3e960d5275 100644 --- a/MediaBrowser.Controller/Entities/UserItemData.cs +++ b/MediaBrowser.Controller/Entities/UserItemData.cs @@ -1,67 +1,116 @@ -using System;
-using System.Runtime.Serialization;
-
-namespace MediaBrowser.Controller.Entities
-{
- public class UserItemData
- {
- private float? _rating;
- /// <summary>
- /// Gets or sets the users 0-10 rating
- /// </summary>
- public float? Rating
- {
- get
- {
- return _rating;
- }
- set
- {
- if (value.HasValue)
- {
- if (value.Value < 0 || value.Value > 10)
- {
- throw new InvalidOperationException("A 0-10 rating is required for UserItemData.");
- }
- }
-
- _rating = value;
- }
- }
-
- public long PlaybackPositionTicks { get; set; }
-
- public int PlayCount { get; set; }
-
- public bool IsFavorite { get; set; }
-
- /// <summary>
- /// This is an interpreted property to indicate likes or dislikes
- /// This should never be serialized.
- /// </summary>
- [IgnoreDataMember]
- public bool? Likes
- {
- get
- {
- if (Rating != null)
- {
- return Rating >= 6.5;
- }
-
- return null;
- }
- set
- {
- if (value.HasValue)
- {
- Rating = value.Value ? 10 : 1;
- }
- else
- {
- Rating = null;
- }
- }
- }
- }
-}
+using ProtoBuf; +using System; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class UserItemData + /// </summary> + [ProtoContract] + public class UserItemData + { + /// <summary> + /// Gets or sets the user id. + /// </summary> + /// <value>The user id.</value> + [ProtoMember(1)] + public Guid UserId { get; set; } + + /// <summary> + /// The _rating + /// </summary> + private float? _rating; + /// <summary> + /// Gets or sets the users 0-10 rating + /// </summary> + /// <value>The rating.</value> + /// <exception cref="System.ArgumentOutOfRangeException">A 0-10 rating is required for UserItemData.</exception> + /// <exception cref="System.InvalidOperationException">A 0-10 rating is required for UserItemData.</exception> + [ProtoMember(2)] + public float? Rating + { + get + { + return _rating; + } + set + { + if (value.HasValue) + { + if (value.Value < 0 || value.Value > 10) + { + throw new ArgumentOutOfRangeException("A 0-10 rating is required for UserItemData."); + } + } + + _rating = value; + } + } + + /// <summary> + /// Gets or sets the playback position ticks. + /// </summary> + /// <value>The playback position ticks.</value> + [ProtoMember(3)] + public long PlaybackPositionTicks { get; set; } + + /// <summary> + /// Gets or sets the play count. + /// </summary> + /// <value>The play count.</value> + [ProtoMember(4)] + public int PlayCount { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is favorite. + /// </summary> + /// <value><c>true</c> if this instance is favorite; otherwise, <c>false</c>.</value> + [ProtoMember(5)] + public bool IsFavorite { get; set; } + + /// <summary> + /// Gets or sets the last played date. + /// </summary> + /// <value>The last played date.</value> + [ProtoMember(6)] + public DateTime? LastPlayedDate { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this <see cref="UserItemData" /> is played. + /// </summary> + /// <value><c>true</c> if played; otherwise, <c>false</c>.</value> + [ProtoMember(7)] + public bool Played { get; set; } + + /// <summary> + /// This is an interpreted property to indicate likes or dislikes + /// This should never be serialized. + /// </summary> + /// <value><c>null</c> if [likes] contains no value, <c>true</c> if [likes]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public bool? Likes + { + get + { + if (Rating != null) + { + return Rating >= 6.5; + } + + return null; + } + set + { + if (value.HasValue) + { + Rating = value.Value ? 10 : 1; + } + else + { + Rating = null; + } + } + } + } +} diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs new file mode 100644 index 0000000000..4ffd3468d9 --- /dev/null +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Special class used for User Roots. Children contain actual ones defined for this user + /// PLUS the virtual folders from the physical root (added by plug-ins). + /// </summary> + public class UserRootFolder : Folder + { + /// <summary> + /// Get the children of this folder from the actual file system + /// </summary> + /// <returns>IEnumerable{BaseItem}.</returns> + protected override IEnumerable<BaseItem> GetNonCachedChildren() + { + return base.GetNonCachedChildren().Concat(Kernel.Instance.RootFolder.VirtualChildren); + } + } +} diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 8dd82fab99..b3ed21b19a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -1,20 +1,109 @@ -using MediaBrowser.Model.Entities;
-using System.Collections.Generic;
-
-namespace MediaBrowser.Controller.Entities
-{
- public class Video : BaseItem
- {
- public VideoType VideoType { get; set; }
-
- public List<SubtitleStream> Subtitles { get; set; }
- public List<AudioStream> AudioStreams { get; set; }
-
- public int Height { get; set; }
- public int Width { get; set; }
- public string ScanType { get; set; }
- public float FrameRate { get; set; }
- public int BitRate { get; set; }
- public string Codec { get; set; }
- }
-}
+using MediaBrowser.Model.Entities; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; + +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Video + /// </summary> + public class Video : BaseItem, IHasMediaStreams + { + /// <summary> + /// Gets or sets the type of the video. + /// </summary> + /// <value>The type of the video.</value> + public VideoType VideoType { get; set; } + + /// <summary> + /// Gets or sets the type of the iso. + /// </summary> + /// <value>The type of the iso.</value> + public IsoType? IsoType { get; set; } + + /// <summary> + /// Gets or sets the format of the video. + /// </summary> + /// <value>The format of the video.</value> + public VideoFormat VideoFormat { get; set; } + + /// <summary> + /// Gets or sets the media streams. + /// </summary> + /// <value>The media streams.</value> + public List<MediaStream> MediaStreams { get; set; } + + /// <summary> + /// Gets or sets the chapters. + /// </summary> + /// <value>The chapters.</value> + public List<ChapterInfo> Chapters { get; set; } + + /// <summary> + /// If the video is a folder-rip, this will hold the file list for the largest playlist + /// </summary> + public List<string> PlayableStreamFileNames { get; set; } + + /// <summary> + /// Gets the playable stream files. + /// </summary> + /// <returns>List{System.String}.</returns> + public List<string> GetPlayableStreamFiles() + { + return GetPlayableStreamFiles(Path); + } + + /// <summary> + /// Gets the playable stream files. + /// </summary> + /// <param name="rootPath">The root path.</param> + /// <returns>List{System.String}.</returns> + public List<string> GetPlayableStreamFiles(string rootPath) + { + if (PlayableStreamFileNames == null) + { + return null; + } + + var allFiles = Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories).ToList(); + + return PlayableStreamFileNames.Select(name => allFiles.FirstOrDefault(f => string.Equals(System.IO.Path.GetFileName(f), name, System.StringComparison.OrdinalIgnoreCase))) + .Where(f => !string.IsNullOrEmpty(f)) + .ToList(); + } + + /// <summary> + /// The default video stream for this video. Use this to determine media info for this item. + /// </summary> + /// <value>The default video stream.</value> + [IgnoreDataMember] + public MediaStream DefaultVideoStream + { + get { return MediaStreams != null ? MediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video) : null; } + } + + /// <summary> + /// Gets a value indicating whether [is3 D]. + /// </summary> + /// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value> + [IgnoreDataMember] + public bool Is3D + { + get { return VideoFormat > 0; } + } + + /// <summary> + /// Gets the type of the media. + /// </summary> + /// <value>The type of the media.</value> + public override string MediaType + { + get + { + return Model.Entities.MediaType.Video; + } + } + } +} diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index d0b29de56c..9150057c82 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -1,7 +1,10 @@ -
-namespace MediaBrowser.Controller.Entities
-{
- public class Year : BaseEntity
- {
- }
-}
+ +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// Class Year + /// </summary> + public class Year : BaseItem + { + } +} diff --git a/MediaBrowser.Controller/FFMpeg/FFProbe.cs b/MediaBrowser.Controller/FFMpeg/FFProbe.cs deleted file mode 100644 index f16f0142d6..0000000000 --- a/MediaBrowser.Controller/FFMpeg/FFProbe.cs +++ /dev/null @@ -1,137 +0,0 @@ -using MediaBrowser.Common.Logging;
-using MediaBrowser.Common.Serialization;
-using MediaBrowser.Controller.Entities;
-using System;
-using System.Diagnostics;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.FFMpeg
-{
- /// <summary>
- /// Runs FFProbe against a media file and returns metadata.
- /// </summary>
- public static class FFProbe
- {
- /// <summary>
- /// Runs FFProbe against an Audio file, caches the result and returns the output
- /// </summary>
- public static FFProbeResult Run(BaseItem item, string cacheDirectory)
- {
- string cachePath = GetFfProbeCachePath(item, cacheDirectory);
-
- // Use try catch to avoid having to use File.Exists
- try
- {
- return GetCachedResult(cachePath);
- }
- catch (FileNotFoundException)
- {
- }
- catch (Exception ex)
- {
- Logger.LogException(ex);
- }
-
- FFProbeResult result = Run(item.Path);
-
- if (result != null)
- {
- // Fire and forget
- CacheResult(result, cachePath);
- }
-
- return result;
- }
-
- /// <summary>
- /// Gets the cached result of an FFProbe operation
- /// </summary>
- private static FFProbeResult GetCachedResult(string path)
- {
- return ProtobufSerializer.DeserializeFromFile<FFProbeResult>(path);
- }
-
- /// <summary>
- /// Caches the result of an FFProbe operation
- /// </summary>
- private static async void CacheResult(FFProbeResult result, string outputCachePath)
- {
- await Task.Run(() =>
- {
- try
- {
- ProtobufSerializer.SerializeToFile(result, outputCachePath);
- }
- catch (Exception ex)
- {
- Logger.LogException(ex);
- }
- }).ConfigureAwait(false);
- }
-
- private static FFProbeResult Run(string input)
- {
- var startInfo = new ProcessStartInfo { };
-
- startInfo.CreateNoWindow = true;
-
- startInfo.UseShellExecute = false;
-
- // Must consume both or ffmpeg may hang due to deadlocks. See comments below.
- startInfo.RedirectStandardOutput = true;
- startInfo.RedirectStandardError = true;
-
- startInfo.FileName = Kernel.Instance.ApplicationPaths.FFProbePath;
- startInfo.WorkingDirectory = Kernel.Instance.ApplicationPaths.FFMpegDirectory;
- startInfo.Arguments = string.Format("\"{0}\" -v quiet -print_format json -show_streams -show_format", input);
-
- //Logger.LogInfo(startInfo.FileName + " " + startInfo.Arguments);
-
- var process = new Process { };
- process.StartInfo = startInfo;
-
- process.EnableRaisingEvents = true;
-
- process.Exited += ProcessExited;
-
- try
- {
- process.Start();
-
- // MUST read both stdout and stderr asynchronously or a deadlock may occurr
- // If we ever decide to disable the ffmpeg log then you must uncomment the below line.
- process.BeginErrorReadLine();
-
- return JsonSerializer.DeserializeFromStream<FFProbeResult>(process.StandardOutput.BaseStream);
- }
- catch (Exception ex)
- {
- Logger.LogException(ex);
-
- // Hate having to do this
- try
- {
- process.Kill();
- }
- catch
- {
- }
-
- return null;
- }
- }
-
- static void ProcessExited(object sender, EventArgs e)
- {
- (sender as Process).Dispose();
- }
-
- private static string GetFfProbeCachePath(BaseItem item, string cacheDirectory)
- {
- string outputDirectory = Path.Combine(cacheDirectory, item.Id.ToString().Substring(0, 1));
-
- return Path.Combine(outputDirectory, item.Id + "-" + item.DateModified.Ticks + ".pb");
- }
- }
-}
diff --git a/MediaBrowser.Controller/FFMpeg/FFProbeResult.cs b/MediaBrowser.Controller/FFMpeg/FFProbeResult.cs deleted file mode 100644 index db7c9dd3c5..0000000000 --- a/MediaBrowser.Controller/FFMpeg/FFProbeResult.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Collections.Generic;
-using ProtoBuf;
-
-namespace MediaBrowser.Controller.FFMpeg
-{
- /// <summary>
- /// Provides a class that we can use to deserialize the ffprobe json output
- /// Sample output:
- /// http://stackoverflow.com/questions/7708373/get-ffmpeg-information-in-friendly-way
- /// </summary>
- [ProtoContract]
- public class FFProbeResult
- {
- [ProtoMember(1)]
- public MediaStream[] streams { get; set; }
-
- [ProtoMember(2)]
- public MediaFormat format { get; set; }
- }
-
- /// <summary>
- /// Represents a stream within the output
- /// A number of properties are commented out to improve deserialization performance
- /// Enable them as needed.
- /// </summary>
- [ProtoContract]
- public class MediaStream
- {
- [ProtoMember(1)]
- public int index { get; set; }
-
- [ProtoMember(2)]
- public string profile { get; set; }
-
- [ProtoMember(3)]
- public string codec_name { get; set; }
-
- [ProtoMember(4)]
- public string codec_long_name { get; set; }
-
- [ProtoMember(5)]
- public string codec_type { get; set; }
-
- //public string codec_time_base { get; set; }
- //public string codec_tag { get; set; }
- //public string codec_tag_string { get; set; }
- //public string sample_fmt { get; set; }
-
- [ProtoMember(6)]
- public string sample_rate { get; set; }
-
- [ProtoMember(7)]
- public int channels { get; set; }
-
- //public int bits_per_sample { get; set; }
- //public string r_frame_rate { get; set; }
-
- [ProtoMember(8)]
- public string avg_frame_rate { get; set; }
-
- //public string time_base { get; set; }
- //public string start_time { get; set; }
-
- [ProtoMember(9)]
- public string duration { get; set; }
-
- [ProtoMember(10)]
- public string bit_rate { get; set; }
-
- [ProtoMember(11)]
- public int width { get; set; }
-
- [ProtoMember(12)]
- public int height { get; set; }
-
- //public int has_b_frames { get; set; }
- //public string sample_aspect_ratio { get; set; }
-
- [ProtoMember(13)]
- public string display_aspect_ratio { get; set; }
-
- //public string pix_fmt { get; set; }
- //public int level { get; set; }
-
- [ProtoMember(14)]
- public Dictionary<string, string> tags { get; set; }
- }
-
- [ProtoContract]
- public class MediaFormat
- {
- [ProtoMember(1)]
- public string filename { get; set; }
-
- [ProtoMember(2)]
- public int nb_streams { get; set; }
-
- [ProtoMember(3)]
- public string format_name { get; set; }
-
- [ProtoMember(4)]
- public string format_long_name { get; set; }
-
- [ProtoMember(5)]
- public string start_time { get; set; }
-
- [ProtoMember(6)]
- public string duration { get; set; }
-
- [ProtoMember(7)]
- public string size { get; set; }
-
- [ProtoMember(8)]
- public string bit_rate { get; set; }
-
- [ProtoMember(9)]
- public Dictionary<string, string> tags { get; set; }
- }
-}
diff --git a/MediaBrowser.Controller/FFMpeg/ffmpeg.exe.REMOVED.git-id b/MediaBrowser.Controller/FFMpeg/ffmpeg.exe.REMOVED.git-id deleted file mode 100644 index 73a37bd556..0000000000 --- a/MediaBrowser.Controller/FFMpeg/ffmpeg.exe.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -84ac1c51e84cfbfb20e7b96c9f1a4442a8cfadf2
\ No newline at end of file diff --git a/MediaBrowser.Controller/FFMpeg/ffprobe.exe.REMOVED.git-id b/MediaBrowser.Controller/FFMpeg/ffprobe.exe.REMOVED.git-id deleted file mode 100644 index 682ead74d1..0000000000 --- a/MediaBrowser.Controller/FFMpeg/ffprobe.exe.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -331e241e29f1b015e303b301c17c37883e39f39d
\ No newline at end of file diff --git a/MediaBrowser.Controller/FFMpeg/readme.txt b/MediaBrowser.Controller/FFMpeg/readme.txt deleted file mode 100644 index cdb039bdca..0000000000 --- a/MediaBrowser.Controller/FFMpeg/readme.txt +++ /dev/null @@ -1,3 +0,0 @@ -This is the 32-bit static build of ffmpeg, located at:
-
-http://ffmpeg.zeranoe.com/builds/
\ No newline at end of file diff --git a/MediaBrowser.Controller/IO/DirectoryWatchers.cs b/MediaBrowser.Controller/IO/DirectoryWatchers.cs index eb1358e16f..29353bc251 100644 --- a/MediaBrowser.Controller/IO/DirectoryWatchers.cs +++ b/MediaBrowser.Controller/IO/DirectoryWatchers.cs @@ -1,172 +1,525 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Common.Logging;
-using MediaBrowser.Common.Extensions;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.IO
-{
- public class DirectoryWatchers
- {
- private readonly List<FileSystemWatcher> FileSystemWatchers = new List<FileSystemWatcher>();
- private Timer updateTimer;
- private List<string> affectedPaths = new List<string>();
-
- private const int TimerDelayInSeconds = 30;
-
- public void Start()
- {
- var pathsToWatch = new List<string>();
-
- var rootFolder = Kernel.Instance.RootFolder;
-
- pathsToWatch.Add(rootFolder.Path);
-
- foreach (Folder folder in rootFolder.Children.OfType<Folder>())
- {
- foreach (string path in folder.PhysicalLocations)
- {
- if (Path.IsPathRooted(path) && !pathsToWatch.ContainsParentFolder(path))
- {
- pathsToWatch.Add(path);
- }
- }
- }
-
- foreach (string path in pathsToWatch)
- {
- Logger.LogInfo("Watching directory " + path + " for changes.");
-
- var watcher = new FileSystemWatcher(path, "*") { };
- watcher.IncludeSubdirectories = true;
-
- //watcher.Changed += watcher_Changed;
-
- // All the others seem to trigger change events on the parent, so let's keep it simple for now.
- // Actually, we really need to only watch created, deleted and renamed as changed fires too much -ebr
- watcher.Created += watcher_Changed;
- watcher.Deleted += watcher_Changed;
- watcher.Renamed += watcher_Changed;
-
- watcher.EnableRaisingEvents = true;
- FileSystemWatchers.Add(watcher);
- }
- }
-
- void watcher_Changed(object sender, FileSystemEventArgs e)
- {
- Logger.LogDebugInfo("****** Watcher sees change of type " + e.ChangeType.ToString() + " to " + e.FullPath);
- lock (affectedPaths)
- {
- //Since we're watching created, deleted and renamed we always want the parent of the item to be the affected path
- var affectedPath = Path.GetDirectoryName(e.FullPath);
-
- if (e.ChangeType == WatcherChangeTypes.Renamed)
- {
- var renamedArgs = e as RenamedEventArgs;
- if (affectedPaths.Contains(renamedArgs.OldFullPath))
- {
- Logger.LogDebugInfo("****** Removing " + renamedArgs.OldFullPath + " from affected paths.");
- affectedPaths.Remove(renamedArgs.OldFullPath);
- }
- }
-
- //If anything underneath this path was already marked as affected - remove it as it will now get captured by this one
- affectedPaths.RemoveAll(p => p.StartsWith(e.FullPath, StringComparison.OrdinalIgnoreCase));
-
- if (!affectedPaths.ContainsParentFolder(affectedPath))
- {
- Logger.LogDebugInfo("****** Adding " + affectedPath + " to affected paths.");
- affectedPaths.Add(affectedPath);
- }
- }
-
- if (updateTimer == null)
- {
- updateTimer = new Timer(TimerStopped, null, TimeSpan.FromSeconds(TimerDelayInSeconds), TimeSpan.FromMilliseconds(-1));
- }
- else
- {
- updateTimer.Change(TimeSpan.FromSeconds(TimerDelayInSeconds), TimeSpan.FromMilliseconds(-1));
- }
- }
-
- private async void TimerStopped(object stateInfo)
- {
- updateTimer.Dispose();
- updateTimer = null;
- List<string> paths;
- lock (affectedPaths)
- {
- paths = affectedPaths;
- affectedPaths = new List<string>();
- }
-
- await ProcessPathChanges(paths).ConfigureAwait(false);
- }
-
- private Task ProcessPathChanges(IEnumerable<string> paths)
- {
- var itemsToRefresh = new List<BaseItem>();
-
- foreach (BaseItem item in paths.Select(p => GetAffectedBaseItem(p)))
- {
- if (item != null && !itemsToRefresh.Contains(item))
- {
- itemsToRefresh.Add(item);
- }
- }
-
- if (itemsToRefresh.Any(i =>
- {
- var folder = i as Folder;
-
- return folder != null && folder.IsRoot;
- }))
- {
- return Kernel.Instance.ReloadRoot();
- }
-
- foreach (var p in paths) Logger.LogDebugInfo("********* "+ p + " reports change.");
- foreach (var i in itemsToRefresh) Logger.LogDebugInfo("********* "+i.Name + " ("+ i.Path + ") will be refreshed.");
- return Task.WhenAll(itemsToRefresh.Select(i => i.ChangedExternally()));
- }
-
- private BaseItem GetAffectedBaseItem(string path)
- {
- BaseItem item = null;
-
- while (item == null && !string.IsNullOrEmpty(path))
- {
- item = Kernel.Instance.RootFolder.FindByPath(path);
-
- path = Path.GetDirectoryName(path);
- }
-
- return item;
- }
-
- public void Stop()
- {
- foreach (FileSystemWatcher watcher in FileSystemWatchers)
- {
- watcher.Changed -= watcher_Changed;
- watcher.EnableRaisingEvents = false;
- watcher.Dispose();
- }
-
- if (updateTimer != null)
- {
- updateTimer.Dispose();
- updateTimer = null;
- }
-
- FileSystemWatchers.Clear();
- affectedPaths.Clear();
- }
- }
-}
+using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.ScheduledTasks; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.IO +{ + /// <summary> + /// Class DirectoryWatchers + /// </summary> + public class DirectoryWatchers : IDisposable + { + /// <summary> + /// The file system watchers + /// </summary> + private ConcurrentBag<FileSystemWatcher> FileSystemWatchers = new ConcurrentBag<FileSystemWatcher>(); + /// <summary> + /// The update timer + /// </summary> + private Timer updateTimer; + /// <summary> + /// The affected paths + /// </summary> + private readonly ConcurrentDictionary<string, string> affectedPaths = new ConcurrentDictionary<string, string>(); + + /// <summary> + /// A dynamic list of paths that should be ignored. Added to during our own file sytem modifications. + /// </summary> + private readonly ConcurrentDictionary<string,string> TempIgnoredPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The timer lock + /// </summary> + private readonly object timerLock = new object(); + + /// <summary> + /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. + /// </summary> + /// <param name="path">The path.</param> + public void TemporarilyIgnore(string path) + { + TempIgnoredPaths[path] = path; + } + + /// <summary> + /// Removes the temp ignore. + /// </summary> + /// <param name="path">The path.</param> + public void RemoveTempIgnore(string path) + { + string val; + TempIgnoredPaths.TryRemove(path, out val); + } + + /// <summary> + /// Gets or sets the logger. + /// </summary> + /// <value>The logger.</value> + private ILogger Logger { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="DirectoryWatchers" /> class. + /// </summary> + public DirectoryWatchers() + { + Logger = LogManager.GetLogger(GetType().Name); + } + + /// <summary> + /// Starts this instance. + /// </summary> + internal void Start() + { + Kernel.Instance.LibraryManager.LibraryChanged += Instance_LibraryChanged; + + var pathsToWatch = new List<string> { Kernel.Instance.RootFolder.Path }; + + var paths = Kernel.Instance.RootFolder.Children.OfType<Folder>() + .SelectMany(f => + { + try + { + // Accessing ResolveArgs could involve file system access + return f.ResolveArgs.PhysicalLocations; + } + catch (IOException) + { + return new string[] {}; + } + + }) + .Where(Path.IsPathRooted); + + foreach (var path in paths) + { + if (!ContainsParentFolder(pathsToWatch, path)) + { + pathsToWatch.Add(path); + } + } + + foreach (var path in pathsToWatch) + { + StartWatchingPath(path); + } + } + + /// <summary> + /// Examine a list of strings assumed to be file paths to see if it contains a parent of + /// the provided path. + /// </summary> + /// <param name="lst">The LST.</param> + /// <param name="path">The path.</param> + /// <returns><c>true</c> if [contains parent folder] [the specified LST]; otherwise, <c>false</c>.</returns> + /// <exception cref="System.ArgumentNullException">path</exception> + private static bool ContainsParentFolder(IEnumerable<string> lst, string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException("path"); + } + + path = path.TrimEnd(Path.DirectorySeparatorChar); + + return lst.Any(str => + { + //this should be a little quicker than examining each actual parent folder... + var compare = str.TrimEnd(Path.DirectorySeparatorChar); + + return (path.Equals(compare, StringComparison.OrdinalIgnoreCase) || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar)); + }); + } + + /// <summary> + /// Starts the watching path. + /// </summary> + /// <param name="path">The path.</param> + private void StartWatchingPath(string path) + { + // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel + Task.Run(() => + { + var newWatcher = new FileSystemWatcher(path, "*") { IncludeSubdirectories = true, InternalBufferSize = 32767 }; + + newWatcher.Created += watcher_Changed; + newWatcher.Deleted += watcher_Changed; + newWatcher.Renamed += watcher_Changed; + newWatcher.Changed += watcher_Changed; + + newWatcher.Error += watcher_Error; + + try + { + newWatcher.EnableRaisingEvents = true; + FileSystemWatchers.Add(newWatcher); + + Logger.Info("Watching directory " + path); + } + catch (IOException ex) + { + Logger.ErrorException("Error watching path: {0}", ex, path); + } + catch (PlatformNotSupportedException ex) + { + Logger.ErrorException("Error watching path: {0}", ex, path); + } + }); + } + + /// <summary> + /// Stops the watching path. + /// </summary> + /// <param name="path">The path.</param> + private void StopWatchingPath(string path) + { + var watcher = FileSystemWatchers.FirstOrDefault(f => f.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + + if (watcher != null) + { + DisposeWatcher(watcher); + } + } + + /// <summary> + /// Disposes the watcher. + /// </summary> + /// <param name="watcher">The watcher.</param> + private void DisposeWatcher(FileSystemWatcher watcher) + { + Logger.Info("Stopping directory watching for path {0}", watcher.Path); + + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + + var watchers = FileSystemWatchers.ToList(); + + watchers.Remove(watcher); + + FileSystemWatchers = new ConcurrentBag<FileSystemWatcher>(watchers); + } + + /// <summary> + /// Handles the LibraryChanged event of the Kernel + /// </summary> + /// <param name="sender">The source of the event.</param> + /// <param name="e">The <see cref="Library.ChildrenChangedEventArgs" /> instance containing the event data.</param> + void Instance_LibraryChanged(object sender, ChildrenChangedEventArgs e) + { + if (e.Folder is AggregateFolder && e.HasAddOrRemoveChange) + { + if (e.ItemsRemoved != null) + { + foreach (var item in e.ItemsRemoved.OfType<Folder>()) + { + StopWatchingPath(item.Path); + } + } + if (e.ItemsAdded != null) + { + foreach (var item in e.ItemsAdded.OfType<Folder>()) + { + StartWatchingPath(item.Path); + } + } + } + } + + /// <summary> + /// Handles the Error event of the watcher control. + /// </summary> + /// <param name="sender">The source of the event.</param> + /// <param name="e">The <see cref="ErrorEventArgs" /> instance containing the event data.</param> + async void watcher_Error(object sender, ErrorEventArgs e) + { + var ex = e.GetException(); + var dw = (FileSystemWatcher) sender; + + Logger.ErrorException("Error in Directory watcher for: "+dw.Path, ex); + + if (ex.Message.Contains("network name is no longer available")) + { + //Network either dropped or, we are coming out of sleep and it hasn't reconnected yet - wait and retry + Logger.Warn("Network connection lost - will retry..."); + var retries = 0; + var success = false; + while (!success && retries < 10) + { + await Task.Delay(500).ConfigureAwait(false); + + try + { + dw.EnableRaisingEvents = false; + dw.EnableRaisingEvents = true; + success = true; + } + catch (IOException) + { + Logger.Warn("Network still unavailable..."); + retries++; + } + } + if (!success) + { + Logger.Warn("Unable to access network. Giving up."); + DisposeWatcher(dw); + } + + } + else + { + if (!ex.Message.Contains("BIOS command limit")) + { + Logger.Info("Attempting to re-start watcher."); + + dw.EnableRaisingEvents = false; + dw.EnableRaisingEvents = true; + } + + } + } + + /// <summary> + /// Handles the Changed event of the watcher control. + /// </summary> + /// <param name="sender">The source of the event.</param> + /// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing the event data.</param> + void watcher_Changed(object sender, FileSystemEventArgs e) + { + if (e.ChangeType == WatcherChangeTypes.Created && e.Name == "New folder") + { + return; + } + if (TempIgnoredPaths.ContainsKey(e.FullPath)) + { + Logger.Info("Watcher requested to ignore change to " + e.FullPath); + return; + } + + Logger.Info("Watcher sees change of type " + e.ChangeType.ToString() + " to " + e.FullPath); + + //Since we're watching created, deleted and renamed we always want the parent of the item to be the affected path + var affectedPath = e.FullPath; + + affectedPaths.AddOrUpdate(affectedPath, affectedPath, (key, oldValue) => affectedPath); + + lock (timerLock) + { + if (updateTimer == null) + { + updateTimer = new Timer(TimerStopped, null, TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1)); + } + else + { + updateTimer.Change(TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1)); + } + } + } + + /// <summary> + /// Timers the stopped. + /// </summary> + /// <param name="stateInfo">The state info.</param> + private async void TimerStopped(object stateInfo) + { + lock (timerLock) + { + // Extend the timer as long as any of the paths are still being written to. + if (affectedPaths.Any(p => IsFileLocked(p.Key))) + { + Logger.Info("Timer extended."); + updateTimer.Change(TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1)); + return; + } + + Logger.Info("Timer stopped."); + + updateTimer.Dispose(); + updateTimer = null; + } + + var paths = affectedPaths.Keys.ToList(); + affectedPaths.Clear(); + + await ProcessPathChanges(paths).ConfigureAwait(false); + } + + /// <summary> + /// Try and determine if a file is locked + /// This is not perfect, and is subject to race conditions, so I'd rather not make this a re-usable library method. + /// </summary> + /// <param name="path">The path.</param> + /// <returns><c>true</c> if [is file locked] [the specified path]; otherwise, <c>false</c>.</returns> + private bool IsFileLocked(string path) + { + try + { + var data = FileSystem.GetFileData(path); + + if (!data.HasValue || data.Value.IsDirectory) + { + return false; + } + } + catch (IOException) + { + return false; + } + + FileStream stream = null; + + try + { + stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + } + catch + { + //the file is unavailable because it is: + //still being written to + //or being processed by another thread + //or does not exist (has already been processed) + return true; + } + finally + { + if (stream != null) + stream.Close(); + } + + //file is not locked + return false; + } + + /// <summary> + /// Processes the path changes. + /// </summary> + /// <param name="paths">The paths.</param> + /// <returns>Task.</returns> + private async Task ProcessPathChanges(List<string> paths) + { + var itemsToRefresh = paths.Select(Path.GetDirectoryName) + .Select(GetAffectedBaseItem) + .Where(item => item != null) + .Distinct() + .ToList(); + + foreach (var p in paths) Logger.Info(p + " reports change."); + + // If the root folder changed, run the library task so the user can see it + if (itemsToRefresh.Any(i => i is AggregateFolder)) + { + Kernel.Instance.TaskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(); + return; + } + + await Task.WhenAll(itemsToRefresh.Select(i => Task.Run(async () => + { + Logger.Info(i.Name + " (" + i.Path + ") will be refreshed."); + + try + { + await i.ChangedExternally().ConfigureAwait(false); + } + catch (IOException ex) + { + // For now swallow and log. + // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable) + // Should we remove it from it's parent? + Logger.ErrorException("Error refreshing {0}", ex, i.Name); + } + + }))).ConfigureAwait(false); + } + + /// <summary> + /// Gets the affected base item. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>BaseItem.</returns> + private BaseItem GetAffectedBaseItem(string path) + { + BaseItem item = null; + + while (item == null && !string.IsNullOrEmpty(path)) + { + item = Kernel.Instance.RootFolder.FindByPath(path); + + path = Path.GetDirectoryName(path); + } + + if (item != null) + { + // If the item has been deleted find the first valid parent that still exists + while (!Directory.Exists(item.Path) && !File.Exists(item.Path)) + { + item = item.Parent; + + if (item == null) + { + break; + } + } + } + + return item; + } + + /// <summary> + /// Stops this instance. + /// </summary> + private void Stop() + { + Kernel.Instance.LibraryManager.LibraryChanged -= Instance_LibraryChanged; + + FileSystemWatcher watcher; + + while (FileSystemWatchers.TryTake(out watcher)) + { + watcher.Changed -= watcher_Changed; + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + + lock (timerLock) + { + if (updateTimer != null) + { + updateTimer.Dispose(); + updateTimer = null; + } + } + + affectedPaths.Clear(); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + Stop(); + } + } + } +} diff --git a/MediaBrowser.Controller/IO/FileData.cs b/MediaBrowser.Controller/IO/FileData.cs index 4ae2ee72f3..74d0a7e6fe 100644 --- a/MediaBrowser.Controller/IO/FileData.cs +++ b/MediaBrowser.Controller/IO/FileData.cs @@ -1,251 +1,130 @@ -using MediaBrowser.Common.Logging;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Runtime.InteropServices;
-
-namespace MediaBrowser.Controller.IO
-{
- /// <summary>
- /// Provides low level File access that is much faster than the File/Directory api's
- /// </summary>
- public static class FileData
- {
- public const int MAX_PATH = 260;
- public const int MAX_ALTERNATE = 14;
- public static IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
-
- /// <summary>
- /// Gets information about a path
- /// </summary>
- public static WIN32_FIND_DATA GetFileData(string path)
- {
- WIN32_FIND_DATA data;
- IntPtr handle = FindFirstFile(path, out data);
- bool getFilename = false;
-
- if (handle == INVALID_HANDLE_VALUE && !Path.HasExtension(path))
- {
- if (!path.EndsWith("*"))
- {
- Logger.LogInfo("Handle came back invalid for {0}. Since this is a directory we'll try appending \\*.", path);
-
- FindClose(handle);
-
- handle = FindFirstFile(Path.Combine(path, "*"), out data);
-
- getFilename = true;
- }
- }
-
- if (handle == IntPtr.Zero)
- {
- throw new IOException("FindFirstFile failed");
- }
-
- if (getFilename)
- {
- data.cFileName = Path.GetFileName(path);
- }
-
- FindClose(handle);
-
- data.Path = path;
- return data;
- }
-
- /// <summary>
- /// Gets all file system entries within a foler
- /// </summary>
- public static IEnumerable<WIN32_FIND_DATA> GetFileSystemEntries(string path, string searchPattern)
- {
- return GetFileSystemEntries(path, searchPattern, true, true);
- }
-
- /// <summary>
- /// Gets all files within a folder
- /// </summary>
- public static IEnumerable<WIN32_FIND_DATA> GetFiles(string path, string searchPattern)
- {
- return GetFileSystemEntries(path, searchPattern, true, false);
- }
-
- /// <summary>
- /// Gets all sub-directories within a folder
- /// </summary>
- public static IEnumerable<WIN32_FIND_DATA> GetDirectories(string path, string searchPattern)
- {
- return GetFileSystemEntries(path, searchPattern, false, true);
- }
-
- /// <summary>
- /// Gets all file system entries within a foler
- /// </summary>
- public static IEnumerable<WIN32_FIND_DATA> GetFileSystemEntries(string path, string searchPattern, bool includeFiles, bool includeDirectories)
- {
- string lpFileName = Path.Combine(path, searchPattern);
-
- WIN32_FIND_DATA lpFindFileData;
- var handle = FindFirstFile(lpFileName, out lpFindFileData);
-
- if (handle == IntPtr.Zero)
- {
- int hr = Marshal.GetLastWin32Error();
- if (hr != 2 && hr != 0x12)
- {
- throw new IOException("GetFileSystemEntries failed");
- }
- yield break;
- }
-
- if (IncludeInOutput(lpFindFileData.cFileName, lpFindFileData.dwFileAttributes, includeFiles, includeDirectories))
- {
- yield return lpFindFileData;
- }
-
- while (FindNextFile(handle, out lpFindFileData) != IntPtr.Zero)
- {
- if (IncludeInOutput(lpFindFileData.cFileName, lpFindFileData.dwFileAttributes, includeFiles, includeDirectories))
- {
- lpFindFileData.Path = Path.Combine(path, lpFindFileData.cFileName);
- yield return lpFindFileData;
- }
- }
-
- FindClose(handle);
- }
-
- private static bool IncludeInOutput(string cFileName, FileAttributes attributes, bool includeFiles, bool includeDirectories)
- {
- if (cFileName.Equals(".", StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
- if (cFileName.Equals("..", StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- if (!includeFiles && !attributes.HasFlag(FileAttributes.Directory))
- {
- return false;
- }
-
- if (!includeDirectories && attributes.HasFlag(FileAttributes.Directory))
- {
- return false;
- }
-
- return true;
- }
-
- [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
- private static extern IntPtr FindFirstFile(string fileName, out WIN32_FIND_DATA data);
-
- [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
- private static extern IntPtr FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA data);
-
- [DllImport("kernel32")]
- private static extern bool FindClose(IntPtr hFindFile);
-
- private const char SpaceChar = ' ';
- private static readonly char[] InvalidFileNameChars = Path.GetInvalidFileNameChars();
-
- /// <summary>
- /// Takes a filename and removes invalid characters
- /// </summary>
- public static string GetValidFilename(string filename)
- {
- foreach (char c in InvalidFileNameChars)
- {
- filename = filename.Replace(c, SpaceChar);
- }
-
- return filename;
- }
- }
-
- [StructLayout(LayoutKind.Sequential)]
- public struct FILETIME
- {
- public uint dwLowDateTime;
- public uint dwHighDateTime;
- }
-
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
- public struct WIN32_FIND_DATA
- {
- public FileAttributes dwFileAttributes;
- public FILETIME ftCreationTime;
- public FILETIME ftLastAccessTime;
- public FILETIME ftLastWriteTime;
- public int nFileSizeHigh;
- public int nFileSizeLow;
- public int dwReserved0;
- public int dwReserved1;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = FileData.MAX_PATH)]
- public string cFileName;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = FileData.MAX_ALTERNATE)]
- public string cAlternate;
-
- public bool IsHidden
- {
- get
- {
- return dwFileAttributes.HasFlag(FileAttributes.Hidden);
- }
- }
-
- public bool IsSystemFile
- {
- get
- {
- return dwFileAttributes.HasFlag(FileAttributes.System);
- }
- }
-
- public bool IsDirectory
- {
- get
- {
- return dwFileAttributes.HasFlag(FileAttributes.Directory);
- }
- }
-
- public DateTime CreationTimeUtc
- {
- get
- {
- return ParseFileTime(ftCreationTime);
- }
- }
-
- public DateTime LastAccessTimeUtc
- {
- get
- {
- return ParseFileTime(ftLastAccessTime);
- }
- }
-
- public DateTime LastWriteTimeUtc
- {
- get
- {
- return ParseFileTime(ftLastWriteTime);
- }
- }
-
- private DateTime ParseFileTime(FILETIME filetime)
- {
- long highBits = filetime.dwHighDateTime;
- highBits = highBits << 32;
- return DateTime.FromFileTimeUtc(highBits + (long)filetime.dwLowDateTime);
- }
-
- public string Path { get; set; }
- }
-
-}
+using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Library; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; + +namespace MediaBrowser.Controller.IO +{ + /// <summary> + /// Provides low level File access that is much faster than the File/Directory api's + /// </summary> + public static class FileData + { + /// <summary> + /// Gets all file system entries within a foler + /// </summary> + /// <param name="path">The path.</param> + /// <param name="searchPattern">The search pattern.</param> + /// <param name="includeFiles">if set to <c>true</c> [include files].</param> + /// <param name="includeDirectories">if set to <c>true</c> [include directories].</param> + /// <param name="flattenFolderDepth">The flatten folder depth.</param> + /// <param name="args">The args.</param> + /// <returns>Dictionary{System.StringWIN32_FIND_DATA}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + /// <exception cref="System.IO.IOException">GetFileSystemEntries failed</exception> + public static Dictionary<string, WIN32_FIND_DATA> GetFilteredFileSystemEntries(string path, string searchPattern = "*", bool includeFiles = true, bool includeDirectories = true, int flattenFolderDepth = 0, ItemResolveArgs args = null) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + var lpFileName = Path.Combine(path, searchPattern); + + WIN32_FIND_DATA lpFindFileData; + var handle = NativeMethods.FindFirstFileEx(lpFileName, FINDEX_INFO_LEVELS.FindExInfoBasic, out lpFindFileData, + FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, FindFirstFileExFlags.FIND_FIRST_EX_LARGE_FETCH); + + if (handle == IntPtr.Zero) + { + int hr = Marshal.GetLastWin32Error(); + if (hr != 2 && hr != 0x12) + { + throw new IOException("GetFileSystemEntries failed"); + } + return new Dictionary<string, WIN32_FIND_DATA>(StringComparer.OrdinalIgnoreCase); + } + + var dict = new Dictionary<string, WIN32_FIND_DATA>(StringComparer.OrdinalIgnoreCase); + + if (FileSystem.IncludeInFindFileOutput(lpFindFileData.cFileName, lpFindFileData.dwFileAttributes, includeFiles, includeDirectories)) + { + if (!string.IsNullOrEmpty(lpFindFileData.cFileName)) + { + lpFindFileData.Path = Path.Combine(path, lpFindFileData.cFileName); + + dict[lpFindFileData.Path] = lpFindFileData; + } + } + + while (NativeMethods.FindNextFile(handle, out lpFindFileData) != IntPtr.Zero) + { + // This is the one circumstance where we can completely disregard a file + if (lpFindFileData.IsSystemFile) + { + continue; + } + + // Filter out invalid entries + if (lpFindFileData.cFileName.Equals(".", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (lpFindFileData.cFileName.Equals("..", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + lpFindFileData.Path = Path.Combine(path, lpFindFileData.cFileName); + + if (FileSystem.IsShortcut(lpFindFileData.Path)) + { + var newPath = FileSystem.ResolveShortcut(lpFindFileData.Path); + if (string.IsNullOrWhiteSpace(newPath)) + { + //invalid shortcut - could be old or target could just be unavailable + Logger.LogWarning("Encountered invalid shortuct: "+lpFindFileData.Path); + continue; + } + var data = FileSystem.GetFileData(newPath); + + if (data.HasValue) + { + lpFindFileData = data.Value; + + // Find out if the shortcut is pointing to a directory or file + if (lpFindFileData.IsDirectory) + { + // add to our physical locations + if (args != null) + { + args.AddAdditionalLocation(newPath); + } + } + + dict[lpFindFileData.Path] = lpFindFileData; + } + } + else if (flattenFolderDepth > 0 && lpFindFileData.IsDirectory) + { + foreach (var child in GetFilteredFileSystemEntries(lpFindFileData.Path, flattenFolderDepth: flattenFolderDepth - 1)) + { + dict[child.Key] = child.Value; + } + } + else + { + dict[lpFindFileData.Path] = lpFindFileData; + } + } + + NativeMethods.FindClose(handle); + return dict; + } + } + +} diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs deleted file mode 100644 index 732cf0803e..0000000000 --- a/MediaBrowser.Controller/IO/FileSystemHelper.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.IO;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Controller.Library;
-
-namespace MediaBrowser.Controller.IO
-{
- public static class FileSystemHelper
- {
- /// <summary>
- /// Transforms shortcuts into their actual paths and filters out items that should be ignored
- /// </summary>
- public static ItemResolveEventArgs FilterChildFileSystemEntries(ItemResolveEventArgs args, bool flattenShortcuts)
- {
-
- List<WIN32_FIND_DATA> returnChildren = new List<WIN32_FIND_DATA>();
- List<WIN32_FIND_DATA> resolvedShortcuts = new List<WIN32_FIND_DATA>();
-
- foreach (var file in args.FileSystemChildren)
- {
- // If it's a shortcut, resolve it
- if (Shortcut.IsShortcut(file.Path))
- {
- string newPath = Shortcut.ResolveShortcut(file.Path);
- WIN32_FIND_DATA newPathData = FileData.GetFileData(newPath);
-
- // Find out if the shortcut is pointing to a directory or file
- if (newPathData.IsDirectory)
- {
- // add to our physical locations
- args.AdditionalLocations.Add(newPath);
-
- // If we're flattening then get the shortcut's children
- if (flattenShortcuts)
- {
- returnChildren.Add(file);
- ItemResolveEventArgs newArgs = new ItemResolveEventArgs()
- {
- FileSystemChildren = FileData.GetFileSystemEntries(newPath, "*").ToArray()
- };
-
- resolvedShortcuts.AddRange(FilterChildFileSystemEntries(newArgs, false).FileSystemChildren);
- }
- else
- {
- returnChildren.Add(newPathData);
- }
- }
- else
- {
- returnChildren.Add(newPathData);
- }
- }
- else
- {
- //not a shortcut check to see if we should filter it out
- if (EntityResolutionHelper.ShouldResolvePath(file))
- {
- returnChildren.Add(file);
- }
- else
- {
- //filtered - see if it is one of our "indicator" folders and mark it now - no reason to search for it again
- args.IsBDFolder |= file.cFileName.Equals("bdmv", StringComparison.OrdinalIgnoreCase);
- args.IsDVDFolder |= file.cFileName.Equals("video_ts", StringComparison.OrdinalIgnoreCase);
- args.IsHDDVDFolder |= file.cFileName.Equals("hvdvd_ts", StringComparison.OrdinalIgnoreCase);
-
- //and check to see if it is a metadata folder and collect contents now if so
- if (IsMetadataFolder(file.cFileName))
- {
- args.MetadataFiles = Directory.GetFiles(Path.Combine(args.Path, "metadata"), "*", SearchOption.TopDirectoryOnly);
- }
- }
- }
- }
-
- if (resolvedShortcuts.Count > 0)
- {
- resolvedShortcuts.InsertRange(0, returnChildren);
- args.FileSystemChildren = resolvedShortcuts.ToArray();
- }
- else
- {
- args.FileSystemChildren = returnChildren.ToArray();
- }
- return args;
- }
-
- public static bool IsMetadataFolder(string path)
- {
- return path.TrimEnd('\\').EndsWith("metadata", StringComparison.OrdinalIgnoreCase);
- }
-
- public static bool IsVideoFile(string path)
- {
- string extension = System.IO.Path.GetExtension(path).ToLower();
-
- switch (extension)
- {
- case ".mkv":
- case ".m2ts":
- case ".iso":
- case ".ts":
- case ".rmvb":
- case ".mov":
- case ".avi":
- case ".mpg":
- case ".mpeg":
- case ".wmv":
- case ".mp4":
- case ".divx":
- case ".dvr-ms":
- case ".wtv":
- case ".ogm":
- case ".ogv":
- case ".asf":
- case ".m4v":
- case ".flv":
- case ".f4v":
- case ".3gp":
- return true;
-
- default:
- return false;
- }
- }
- }
-}
diff --git a/MediaBrowser.Controller/IO/FileSystemManager.cs b/MediaBrowser.Controller/IO/FileSystemManager.cs new file mode 100644 index 0000000000..e33983489b --- /dev/null +++ b/MediaBrowser.Controller/IO/FileSystemManager.cs @@ -0,0 +1,112 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Controller.Entities; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.IO +{ + /// <summary> + /// This class will manage our file system watching and modifications. Any process that needs to + /// modify the directories that the system is watching for changes should use the methods of + /// this class to do so. This way we can have the watchers correctly respond to only external changes. + /// </summary> + public class FileSystemManager : BaseManager<Kernel> + { + /// <summary> + /// Gets or sets the directory watchers. + /// </summary> + /// <value>The directory watchers.</value> + private DirectoryWatchers DirectoryWatchers { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="FileSystemManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public FileSystemManager(Kernel kernel) + : base(kernel) + { + DirectoryWatchers = new DirectoryWatchers(); + } + + /// <summary> + /// Start the directory watchers on our library folders + /// </summary> + public void StartWatchers() + { + DirectoryWatchers.Start(); + } + + /// <summary> + /// Saves to library filesystem. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="path">The path.</param> + /// <param name="dataToSave">The data to save.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task SaveToLibraryFilesystem(BaseItem item, string path, Stream dataToSave, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException(); + } + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + if (dataToSave == null) + { + throw new ArgumentNullException(); + } + if (cancellationToken == null) + { + throw new ArgumentNullException(); + } + + cancellationToken.ThrowIfCancellationRequested(); + + //Tell the watchers to ignore + DirectoryWatchers.TemporarilyIgnore(path); + + //Make the mod + + dataToSave.Position = 0; + + try + { + using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + await dataToSave.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + + dataToSave.Dispose(); + + // If this is ever used for something other than metadata we can add a file type param + item.ResolveArgs.AddMetadataFile(path); + } + } + finally + { + //Remove the ignore + DirectoryWatchers.RemoveTempIgnore(path); + } + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + DirectoryWatchers.Dispose(); + } + + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/IO/Shortcut.cs b/MediaBrowser.Controller/IO/Shortcut.cs deleted file mode 100644 index e9ea21f17a..0000000000 --- a/MediaBrowser.Controller/IO/Shortcut.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System;
-using System.IO;
-using System.Runtime.InteropServices;
-using System.Text;
-
-namespace MediaBrowser.Controller.IO
-{
- /// <summary>
- /// Contains helpers to interact with shortcut files (.lnk)
- /// </summary>
- public static class Shortcut
- {
- #region Signitures were imported from http://pinvoke.net
- [Flags()]
- enum SLGP_FLAGS
- {
- /// <summary>Retrieves the standard short (8.3 format) file name</summary>
- SLGP_SHORTPATH = 0x1,
- /// <summary>Retrieves the Universal Naming Convention (UNC) path name of the file</summary>
- SLGP_UNCPRIORITY = 0x2,
- /// <summary>Retrieves the raw path name. A raw path is something that might not exist and may include environment variables that need to be expanded</summary>
- SLGP_RAWPATH = 0x4
- }
-
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
- struct WIN32_FIND_DATAW
- {
- public uint dwFileAttributes;
- public long ftCreationTime;
- public long ftLastAccessTime;
- public long ftLastWriteTime;
- public uint nFileSizeHigh;
- public uint nFileSizeLow;
- public uint dwReserved0;
- public uint dwReserved1;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
- public string cFileName;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
- public string cAlternateFileName;
- }
-
- [Flags()]
-
- enum SLR_FLAGS
- {
- /// <summary>
- /// Do not display a dialog box if the link cannot be resolved. When SLR_NO_UI is set,
- /// the high-order word of fFlags can be set to a time-out value that specifies the
- /// maximum amount of time to be spent resolving the link. The function returns if the
- /// link cannot be resolved within the time-out duration. If the high-order word is set
- /// to zero, the time-out duration will be set to the default value of 3,000 milliseconds
- /// (3 seconds). To specify a value, set the high word of fFlags to the desired time-out
- /// duration, in milliseconds.
- /// </summary>
- SLR_NO_UI = 0x1,
- /// <summary>Obsolete and no longer used</summary>
- SLR_ANY_MATCH = 0x2,
- /// <summary>If the link object has changed, update its path and list of identifiers.
- /// If SLR_UPDATE is set, you do not need to call IPersistFile::IsDirty to determine
- /// whether or not the link object has changed.</summary>
- SLR_UPDATE = 0x4,
- /// <summary>Do not update the link information</summary>
- SLR_NOUPDATE = 0x8,
- /// <summary>Do not execute the search heuristics</summary>
- SLR_NOSEARCH = 0x10,
- /// <summary>Do not use distributed link tracking</summary>
- SLR_NOTRACK = 0x20,
- /// <summary>Disable distributed link tracking. By default, distributed link tracking tracks
- /// removable media across multiple devices based on the volume name. It also uses the
- /// Universal Naming Convention (UNC) path to track remote file systems whose drive letter
- /// has changed. Setting SLR_NOLINKINFO disables both types of tracking.</summary>
- SLR_NOLINKINFO = 0x40,
- /// <summary>Call the Microsoft Windows Installer</summary>
- SLR_INVOKE_MSI = 0x80
- }
-
-
- /// <summary>The IShellLink interface allows Shell links to be created, modified, and resolved</summary>
- [ComImport(), InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
- interface IShellLinkW
- {
- /// <summary>Retrieves the path and file name of a Shell link object</summary>
- void GetPath([Out(), MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, out WIN32_FIND_DATAW pfd, SLGP_FLAGS fFlags);
- /// <summary>Retrieves the list of item identifiers for a Shell link object</summary>
- void GetIDList(out IntPtr ppidl);
- /// <summary>Sets the pointer to an item identifier list (PIDL) for a Shell link object.</summary>
- void SetIDList(IntPtr pidl);
- /// <summary>Retrieves the description string for a Shell link object</summary>
- void GetDescription([Out(), MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
- /// <summary>Sets the description for a Shell link object. The description can be any application-defined string</summary>
- void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
- /// <summary>Retrieves the name of the working directory for a Shell link object</summary>
- void GetWorkingDirectory([Out(), MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
- /// <summary>Sets the name of the working directory for a Shell link object</summary>
- void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
- /// <summary>Retrieves the command-line arguments associated with a Shell link object</summary>
- void GetArguments([Out(), MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
- /// <summary>Sets the command-line arguments for a Shell link object</summary>
- void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
- /// <summary>Retrieves the hot key for a Shell link object</summary>
- void GetHotkey(out short pwHotkey);
- /// <summary>Sets a hot key for a Shell link object</summary>
- void SetHotkey(short wHotkey);
- /// <summary>Retrieves the show command for a Shell link object</summary>
- void GetShowCmd(out int piShowCmd);
- /// <summary>Sets the show command for a Shell link object. The show command sets the initial show state of the window.</summary>
- void SetShowCmd(int iShowCmd);
- /// <summary>Retrieves the location (path and index) of the icon for a Shell link object</summary>
- void GetIconLocation([Out(), MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath,
- int cchIconPath, out int piIcon);
- /// <summary>Sets the location (path and index) of the icon for a Shell link object</summary>
- void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
- /// <summary>Sets the relative path to the Shell link object</summary>
- void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
- /// <summary>Attempts to find the target of a Shell link, even if it has been moved or renamed</summary>
- void Resolve(IntPtr hwnd, SLR_FLAGS fFlags);
- /// <summary>Sets the path and file name of a Shell link object</summary>
- void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
-
- }
-
- [ComImport, Guid("0000010c-0000-0000-c000-000000000046"),
- InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
- public interface IPersist
- {
- [PreserveSig]
- void GetClassID(out Guid pClassID);
- }
-
-
- [ComImport, Guid("0000010b-0000-0000-C000-000000000046"),
- InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
- public interface IPersistFile : IPersist
- {
- new void GetClassID(out Guid pClassID);
- [PreserveSig]
- int IsDirty();
-
- [PreserveSig]
- void Load([In, MarshalAs(UnmanagedType.LPWStr)]
- string pszFileName, uint dwMode);
-
- [PreserveSig]
- void Save([In, MarshalAs(UnmanagedType.LPWStr)] string pszFileName,
- [In, MarshalAs(UnmanagedType.Bool)] bool remember);
-
- [PreserveSig]
- void SaveCompleted([In, MarshalAs(UnmanagedType.LPWStr)] string pszFileName);
-
- [PreserveSig]
- void GetCurFile([In, MarshalAs(UnmanagedType.LPWStr)] string ppszFileName);
- }
-
- const uint STGM_READ = 0;
- const int MAX_PATH = 260;
-
- // CLSID_ShellLink from ShlGuid.h
- [
- ComImport(),
- Guid("00021401-0000-0000-C000-000000000046")
- ]
- public class ShellLink
- {
- }
-
- #endregion
-
- public static string ResolveShortcut(string filename)
- {
- var link = new ShellLink();
- ((IPersistFile)link).Load(filename, STGM_READ);
- // TODO: if I can get hold of the hwnd call resolve first. This handles moved and renamed files.
- // ((IShellLinkW)link).Resolve(hwnd, 0)
- var sb = new StringBuilder(MAX_PATH);
- var data = new WIN32_FIND_DATAW();
- ((IShellLinkW)link).GetPath(sb, sb.Capacity, out data, 0);
- return sb.ToString();
- }
-
- public static bool IsShortcut(string filename)
- {
- return filename != null ? Path.GetExtension(filename).EndsWith("lnk", StringComparison.OrdinalIgnoreCase) : false;
- }
- }
-}
diff --git a/MediaBrowser.Controller/Kernel.cs b/MediaBrowser.Controller/Kernel.cs index 2430260ddc..aca7c4bd22 100644 --- a/MediaBrowser.Controller/Kernel.cs +++ b/MediaBrowser.Controller/Kernel.cs @@ -1,386 +1,599 @@ -using MediaBrowser.Common.Kernel;
-using MediaBrowser.Common.Logging;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Controller.Weather;
-using MediaBrowser.Model.Authentication;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Progress;
-using MediaBrowser.Common.Extensions;
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller
-{
- public class Kernel : BaseKernel<ServerConfiguration, ServerApplicationPaths>
- {
- #region Events
- /// <summary>
- /// Fires whenever any validation routine adds or removes items. The added and removed items are properties of the args.
- /// *** Will fire asynchronously. ***
- /// </summary>
- public event EventHandler<ChildrenChangedEventArgs> LibraryChanged;
- public void OnLibraryChanged(ChildrenChangedEventArgs args)
- {
- if (LibraryChanged != null)
- {
- Task.Run(() => LibraryChanged(this, args));
- }
- }
-
- #endregion
- public static Kernel Instance { get; private set; }
-
- public ItemController ItemController { get; private set; }
-
- public IEnumerable<User> Users { get; private set; }
- public Folder RootFolder { get; private set; }
-
- private DirectoryWatchers DirectoryWatchers { get; set; }
-
- private string MediaRootFolderPath
- {
- get
- {
- return ApplicationPaths.RootFolderPath;
- }
- }
-
- public override KernelContext KernelContext
- {
- get { return KernelContext.Server; }
- }
-
- /// <summary>
- /// Gets the list of currently registered weather prvoiders
- /// </summary>
- [ImportMany(typeof(BaseWeatherProvider))]
- public IEnumerable<BaseWeatherProvider> WeatherProviders { get; private set; }
-
- /// <summary>
- /// Gets the list of currently registered metadata prvoiders
- /// </summary>
- [ImportMany(typeof(BaseMetadataProvider))]
- private IEnumerable<BaseMetadataProvider> MetadataProvidersEnumerable { get; set; }
-
- /// <summary>
- /// Once MEF has loaded the resolvers, sort them by priority and store them in this array
- /// Given the sheer number of times they'll be iterated over it'll be faster to loop through an array
- /// </summary>
- private BaseMetadataProvider[] MetadataProviders { get; set; }
-
- /// <summary>
- /// Gets the list of currently registered entity resolvers
- /// </summary>
- [ImportMany(typeof(IBaseItemResolver))]
- private IEnumerable<IBaseItemResolver> EntityResolversEnumerable { get; set; }
-
- /// <summary>
- /// Once MEF has loaded the resolvers, sort them by priority and store them in this array
- /// Given the sheer number of times they'll be iterated over it'll be faster to loop through an array
- /// </summary>
- internal IBaseItemResolver[] EntityResolvers { get; private set; }
-
- /// <summary>
- /// Creates a kernel based on a Data path, which is akin to our current programdata path
- /// </summary>
- public Kernel()
- : base()
- {
- Instance = this;
- }
-
- /// <summary>
- /// Performs initializations that only occur once
- /// </summary>
- protected override void InitializeInternal(IProgress<TaskProgress> progress)
- {
- base.InitializeInternal(progress);
-
- ItemController = new ItemController();
- DirectoryWatchers = new DirectoryWatchers();
-
-
- ExtractFFMpeg();
- }
-
- /// <summary>
- /// Performs initializations that can be reloaded at anytime
- /// </summary>
- protected override async Task ReloadInternal(IProgress<TaskProgress> progress)
- {
- await base.ReloadInternal(progress).ConfigureAwait(false);
-
- ReportProgress(progress, "Loading Users");
- ReloadUsers();
-
- ReportProgress(progress, "Loading Media Library");
-
- await ReloadRoot(allowInternetProviders: false).ConfigureAwait(false);
-
- }
-
- /// <summary>
- /// Completely disposes the Kernel
- /// </summary>
- public override void Dispose()
- {
- base.Dispose();
-
- DirectoryWatchers.Stop();
-
- }
-
- protected override void OnComposablePartsLoaded()
- {
- // The base class will start up all the plugins
- base.OnComposablePartsLoaded();
-
- // Sort the resolvers by priority
- EntityResolvers = EntityResolversEnumerable.OrderBy(e => e.Priority).ToArray();
-
- // Sort the providers by priority
- MetadataProviders = MetadataProvidersEnumerable.OrderBy(e => e.Priority).ToArray();
- }
-
- public BaseItem ResolveItem(ItemResolveEventArgs args)
- {
- // Try first priority resolvers
- for (int i = 0; i < EntityResolvers.Length; i++)
- {
- var item = EntityResolvers[i].ResolvePath(args);
-
- if (item != null)
- {
- item.ResolveArgs = args;
- return item;
- }
- }
-
- return null;
- }
-
- private void ReloadUsers()
- {
- Users = GetAllUsers();
- }
-
- /// <summary>
- /// Reloads the root media folder
- /// </summary>
- public async Task ReloadRoot(bool allowInternetProviders = true)
- {
- if (!Directory.Exists(MediaRootFolderPath))
- {
- Directory.CreateDirectory(MediaRootFolderPath);
- }
-
- DirectoryWatchers.Stop();
-
- RootFolder = await ItemController.GetItem(MediaRootFolderPath, allowInternetProviders: allowInternetProviders).ConfigureAwait(false) as Folder;
- RootFolder.ChildrenChanged += RootFolder_ChildrenChanged;
-
- DirectoryWatchers.Start();
- }
-
- void RootFolder_ChildrenChanged(object sender, ChildrenChangedEventArgs e)
- {
- Logger.LogDebugInfo("Root Folder Children Changed. Added: " + e.ItemsAdded.Count + " Removed: " + e.ItemsRemoved.Count());
- //re-start the directory watchers
- DirectoryWatchers.Stop();
- DirectoryWatchers.Start();
- //Task.Delay(30000); //let's wait and see if more data gets filled in...
- var allChildren = RootFolder.RecursiveChildren;
- Logger.LogDebugInfo(string.Format("Loading complete. Movies: {0} Episodes: {1} Folders: {2}", allChildren.OfType<Entities.Movies.Movie>().Count(), allChildren.OfType<Entities.TV.Episode>().Count(), allChildren.Where(i => i is Folder && !(i is Series || i is Season)).Count()));
- //foreach (var child in allChildren)
- //{
- // Logger.LogDebugInfo("(" + child.GetType().Name + ") " + child.Name + " (" + child.Path + ")");
- //}
- }
-
- /// <summary>
- /// Gets the default user to use when EnableUserProfiles is false
- /// </summary>
- public User GetDefaultUser()
- {
- User user = Users.FirstOrDefault();
-
- return user;
- }
-
- /// <summary>
- /// Persists a User
- /// </summary>
- public void SaveUser(User user)
- {
-
- }
-
- /// <summary>
- /// Authenticates a User and returns a result indicating whether or not it succeeded
- /// </summary>
- public AuthenticationResult AuthenticateUser(User user, string password)
- {
- var result = new AuthenticationResult();
-
- // When EnableUserProfiles is false, only the default User can login
- if (!Configuration.EnableUserProfiles)
- {
- result.Success = user.Id == GetDefaultUser().Id;
- }
- else if (string.IsNullOrEmpty(user.Password))
- {
- result.Success = true;
- }
- else
- {
- password = password ?? string.Empty;
- result.Success = password.GetMD5().ToString().Equals(user.Password);
- }
-
- // Update LastActivityDate and LastLoginDate, then save
- if (result.Success)
- {
- user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
- SaveUser(user);
- }
-
- return result;
- }
-
- /// <summary>
- /// Finds a library item by Id
- /// </summary>
- public BaseItem GetItemById(Guid id)
- {
- if (id == Guid.Empty)
- {
- return RootFolder;
- }
-
- return RootFolder.FindItemById(id);
- }
-
- /// <summary>
- /// Gets all users within the system
- /// </summary>
- private IEnumerable<User> GetAllUsers()
- {
- var list = new List<User>();
-
- // Return a dummy user for now since all calls to get items requre a userId
- var user = new User { };
-
- user.Name = "Default User";
- user.Id = Guid.Parse("5d1cf7fce25943b790d140095457a42b");
- user.PrimaryImagePath = "D:\\Video\\TV\\Archer (2009)\\backdrop.jpg";
- list.Add(user);
-
- user = new User { };
- user.Name = "Abobader";
- user.Id = Guid.NewGuid();
- user.LastLoginDate = DateTime.UtcNow.AddDays(-1);
- user.LastActivityDate = DateTime.UtcNow.AddHours(-3);
- user.Password = ("1234").GetMD5().ToString();
- list.Add(user);
-
- user = new User { };
- user.Name = "Scottisafool";
- user.Id = Guid.NewGuid();
- list.Add(user);
-
- user = new User { };
- user.Name = "Redshirt";
- user.Id = Guid.NewGuid();
- list.Add(user);
-
- /*user = new User();
- user.Name = "Test User 4";
- user.Id = Guid.NewGuid();
- list.Add(user);
-
- user = new User();
- user.Name = "Test User 5";
- user.Id = Guid.NewGuid();
- list.Add(user);
-
- user = new User();
- user.Name = "Test User 6";
- user.Id = Guid.NewGuid();
- list.Add(user);*/
-
- return list;
- }
-
- /// <summary>
- /// Runs all metadata providers for an entity
- /// </summary>
- internal async Task ExecuteMetadataProviders(BaseEntity item, bool allowInternetProviders = true)
- {
- // Run them sequentially in order of priority
- for (int i = 0; i < MetadataProviders.Length; i++)
- {
- var provider = MetadataProviders[i];
-
- // Skip if internet providers are currently disabled
- if (provider.RequiresInternet && (!Configuration.EnableInternetProviders || !allowInternetProviders))
- {
- continue;
- }
-
- // Skip if the provider doesn't support the current item
- if (!provider.Supports(item))
- {
- continue;
- }
-
- try
- {
- await provider.FetchIfNeededAsync(item).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- Logger.LogException(ex);
- }
- }
- }
-
- private void ExtractFFMpeg()
- {
- ExtractFFMpeg(ApplicationPaths.FFMpegPath);
- ExtractFFMpeg(ApplicationPaths.FFProbePath);
- }
-
- /// <summary>
- /// Run these during Init.
- /// Can't run do this on-demand because there will be multiple workers accessing them at once and we'd have to lock them
- /// </summary>
- private void ExtractFFMpeg(string exe)
- {
- if (File.Exists(exe))
- {
- File.Delete(exe);
- }
-
- // Extract exe
- using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("MediaBrowser.Controller.FFMpeg." + Path.GetFileName(exe)))
- {
- using (var fileStream = new FileStream(exe, FileMode.Create))
- {
- stream.CopyTo(fileStream);
- }
- }
- }
- }
-}
+using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Persistence.SQLite; +using MediaBrowser.Controller.Playback; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.ScheduledTasks; +using MediaBrowser.Controller.Updates; +using MediaBrowser.Controller.Weather; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.System; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller +{ + /// <summary> + /// Class Kernel + /// </summary> + public class Kernel : BaseKernel<ServerConfiguration, ServerApplicationPaths> + { + /// <summary> + /// The MB admin URL + /// </summary> + public const string MBAdminUrl = "http://mb3admin.com/admin/"; + + /// <summary> + /// Gets the instance. + /// </summary> + /// <value>The instance.</value> + public static Kernel Instance { get; private set; } + + /// <summary> + /// Gets the library manager. + /// </summary> + /// <value>The library manager.</value> + public LibraryManager LibraryManager { get; private set; } + + /// <summary> + /// Gets the image manager. + /// </summary> + /// <value>The image manager.</value> + public ImageManager ImageManager { get; private set; } + + /// <summary> + /// Gets the user manager. + /// </summary> + /// <value>The user manager.</value> + public UserManager UserManager { get; private set; } + + /// <summary> + /// Gets the FFMPEG controller. + /// </summary> + /// <value>The FFMPEG controller.</value> + public FFMpegManager FFMpegManager { get; private set; } + + /// <summary> + /// Gets the installation manager. + /// </summary> + /// <value>The installation manager.</value> + public InstallationManager InstallationManager { get; private set; } + + /// <summary> + /// Gets or sets the file system manager. + /// </summary> + /// <value>The file system manager.</value> + public FileSystemManager FileSystemManager { get; private set; } + + /// <summary> + /// Gets the provider manager. + /// </summary> + /// <value>The provider manager.</value> + public ProviderManager ProviderManager { get; private set; } + + /// <summary> + /// Gets the user data manager. + /// </summary> + /// <value>The user data manager.</value> + public UserDataManager UserDataManager { get; private set; } + + /// <summary> + /// Gets the plug-in security manager. + /// </summary> + /// <value>The plug-in security manager.</value> + public PluginSecurityManager PluginSecurityManager { get; private set; } + + /// <summary> + /// The _users + /// </summary> + private IEnumerable<User> _users; + /// <summary> + /// The _user lock + /// </summary> + private object _usersSyncLock = new object(); + /// <summary> + /// The _users initialized + /// </summary> + private bool _usersInitialized; + /// <summary> + /// Gets the users. + /// </summary> + /// <value>The users.</value> + public IEnumerable<User> Users + { + get + { + // Call ToList to exhaust the stream because we'll be iterating over this multiple times + LazyInitializer.EnsureInitialized(ref _users, ref _usersInitialized, ref _usersSyncLock, UserManager.LoadUsers); + return _users; + } + internal set + { + _users = value; + + if (value == null) + { + _usersInitialized = false; + } + } + } + + /// <summary> + /// The _root folder + /// </summary> + private AggregateFolder _rootFolder; + /// <summary> + /// The _root folder sync lock + /// </summary> + private object _rootFolderSyncLock = new object(); + /// <summary> + /// The _root folder initialized + /// </summary> + private bool _rootFolderInitialized; + /// <summary> + /// Gets the root folder. + /// </summary> + /// <value>The root folder.</value> + public AggregateFolder RootFolder + { + get + { + LazyInitializer.EnsureInitialized(ref _rootFolder, ref _rootFolderInitialized, ref _rootFolderSyncLock, LibraryManager.CreateRootFolder); + return _rootFolder; + } + private set + { + _rootFolder = value; + + if (value == null) + { + _rootFolderInitialized = false; + } + } + } + + /// <summary> + /// Gets the kernel context. + /// </summary> + /// <value>The kernel context.</value> + public override KernelContext KernelContext + { + get { return KernelContext.Server; } + } + + /// <summary> + /// Gets the list of plugin configuration pages + /// </summary> + /// <value>The configuration pages.</value> + [ImportMany(typeof(BaseConfigurationPage))] + public IEnumerable<BaseConfigurationPage> PluginConfigurationPages { get; private set; } + + /// <summary> + /// Gets the intro providers. + /// </summary> + /// <value>The intro providers.</value> + [ImportMany(typeof(BaseIntroProvider))] + public IEnumerable<BaseIntroProvider> IntroProviders { get; private set; } + + /// <summary> + /// Gets the list of currently registered weather prvoiders + /// </summary> + /// <value>The weather providers.</value> + [ImportMany(typeof(BaseWeatherProvider))] + public IEnumerable<BaseWeatherProvider> WeatherProviders { get; private set; } + + /// <summary> + /// Gets the list of currently registered metadata prvoiders + /// </summary> + /// <value>The metadata providers enumerable.</value> + [ImportMany(typeof(BaseMetadataProvider))] + public BaseMetadataProvider[] MetadataProviders { get; private set; } + + /// <summary> + /// Gets the list of currently registered image processors + /// Image processors are specialized metadata providers that run after the normal ones + /// </summary> + /// <value>The image enhancers.</value> + [ImportMany(typeof(BaseImageEnhancer))] + public BaseImageEnhancer[] ImageEnhancers { get; private set; } + + /// <summary> + /// Gets the list of currently registered entity resolvers + /// </summary> + /// <value>The entity resolvers enumerable.</value> + [ImportMany(typeof(IBaseItemResolver))] + internal IBaseItemResolver[] EntityResolvers { get; private set; } + + /// <summary> + /// Gets the list of BasePluginFolders added by plugins + /// </summary> + /// <value>The plugin folders.</value> + [ImportMany(typeof(BasePluginFolder))] + internal IEnumerable<BasePluginFolder> PluginFolders { get; private set; } + + /// <summary> + /// Gets the list of available user repositories + /// </summary> + /// <value>The user repositories.</value> + [ImportMany(typeof(IUserRepository))] + private IEnumerable<IUserRepository> UserRepositories { get; set; } + + /// <summary> + /// Gets the active user repository + /// </summary> + /// <value>The user repository.</value> + public IUserRepository UserRepository { get; private set; } + + /// <summary> + /// Gets the active user repository + /// </summary> + /// <value>The display preferences repository.</value> + public IDisplayPreferencesRepository DisplayPreferencesRepository { get; private set; } + + /// <summary> + /// Gets the list of available item repositories + /// </summary> + /// <value>The item repositories.</value> + [ImportMany(typeof(IItemRepository))] + private IEnumerable<IItemRepository> ItemRepositories { get; set; } + + /// <summary> + /// Gets the active item repository + /// </summary> + /// <value>The item repository.</value> + public IItemRepository ItemRepository { get; private set; } + + /// <summary> + /// Gets the list of available item repositories + /// </summary> + /// <value>The user data repositories.</value> + [ImportMany(typeof(IUserDataRepository))] + private IEnumerable<IUserDataRepository> UserDataRepositories { get; set; } + + /// <summary> + /// Gets the list of available DisplayPreferencesRepositories + /// </summary> + /// <value>The display preferences repositories.</value> + [ImportMany(typeof(IDisplayPreferencesRepository))] + private IEnumerable<IDisplayPreferencesRepository> DisplayPreferencesRepositories { get; set; } + + /// <summary> + /// Gets the list of entity resolution ignore rules + /// </summary> + /// <value>The entity resolution ignore rules.</value> + [ImportMany(typeof(BaseResolutionIgnoreRule))] + internal IEnumerable<BaseResolutionIgnoreRule> EntityResolutionIgnoreRules { get; private set; } + + /// <summary> + /// Gets the active user data repository + /// </summary> + /// <value>The user data repository.</value> + public IUserDataRepository UserDataRepository { get; private set; } + + /// <summary> + /// Limits simultaneous access to various resources + /// </summary> + /// <value>The resource pools.</value> + public ResourcePool ResourcePools { get; set; } + + /// <summary> + /// Gets the UDP server port number. + /// </summary> + /// <value>The UDP server port number.</value> + public override int UdpServerPortNumber + { + get { return 7359; } + } + + /// <summary> + /// Creates a kernel based on a Data path, which is akin to our current programdata path + /// </summary> + public Kernel() + : base() + { + Instance = this; + } + + /// <summary> + /// Performs initializations that can be reloaded at anytime + /// </summary> + /// <returns>Task.</returns> + protected override async Task ReloadInternal() + { + Logger.Info("Extracting tools"); + + // Reset these so that they can be lazy loaded again + Users = null; + RootFolder = null; + + ReloadResourcePools(); + InstallationManager = new InstallationManager(this); + LibraryManager = new LibraryManager(this); + UserManager = new UserManager(this); + FFMpegManager = new FFMpegManager(this); + ImageManager = new ImageManager(this); + ProviderManager = new ProviderManager(this); + UserDataManager = new UserDataManager(this); + PluginSecurityManager = new PluginSecurityManager(this); + + await base.ReloadInternal().ConfigureAwait(false); + + ReloadFileSystemManager(); + + await UserManager.RefreshUsersMetadata(CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + DisposeResourcePools(); + + DisposeFileSystemManager(); + } + + base.Dispose(dispose); + } + + /// <summary> + /// Disposes the resource pools. + /// </summary> + private void DisposeResourcePools() + { + if (ResourcePools != null) + { + ResourcePools.Dispose(); + ResourcePools = null; + } + } + + /// <summary> + /// Reloads the resource pools. + /// </summary> + private void ReloadResourcePools() + { + DisposeResourcePools(); + ResourcePools = new ResourcePool(); + } + + /// <summary> + /// Called when [composable parts loaded]. + /// </summary> + /// <returns>Task.</returns> + protected override async Task OnComposablePartsLoaded() + { + // The base class will start up all the plugins + await base.OnComposablePartsLoaded().ConfigureAwait(false); + + // Get the current item repository + ItemRepository = GetRepository(ItemRepositories, Configuration.ItemRepository, SQLiteItemRepository.RepositoryName); + var itemRepoTask = ItemRepository.Initialize(); + + // Get the current user repository + UserRepository = GetRepository(UserRepositories, Configuration.UserRepository, SQLiteUserRepository.RepositoryName); + var userRepoTask = UserRepository.Initialize(); + + // Get the current item repository + UserDataRepository = GetRepository(UserDataRepositories, Configuration.UserDataRepository, SQLiteUserDataRepository.RepositoryName); + var userDataRepoTask = UserDataRepository.Initialize(); + + // Get the current display preferences repository + DisplayPreferencesRepository = GetRepository(DisplayPreferencesRepositories, Configuration.DisplayPreferencesRepository, SQLiteDisplayPreferencesRepository.RepositoryName); + var displayPreferencesRepoTask = DisplayPreferencesRepository.Initialize(); + + // Sort the resolvers by priority + EntityResolvers = EntityResolvers.OrderBy(e => e.Priority).ToArray(); + + // Sort the providers by priority + MetadataProviders = MetadataProviders.OrderBy(e => e.Priority).ToArray(); + + // Sort the image processors by priority + ImageEnhancers = ImageEnhancers.OrderBy(e => e.Priority).ToArray(); + + await Task.WhenAll(itemRepoTask, userRepoTask, userDataRepoTask, displayPreferencesRepoTask).ConfigureAwait(false); + } + + protected override IEnumerable<Assembly> GetComposablePartAssemblies() + { + var runningDirectory = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + + return base.GetComposablePartAssemblies().Concat(new[] { + + Assembly.Load(File.ReadAllBytes(Path.Combine(runningDirectory, "MediaBrowser.Api.dll"))), + Assembly.Load(File.ReadAllBytes(Path.Combine(runningDirectory, "MediaBrowser.ApiInteraction.Javascript.dll"))), + Assembly.Load(File.ReadAllBytes(Path.Combine(runningDirectory, "MediaBrowser.WebDashboard.dll"))) + }); + } + + /// <summary> + /// Gets a repository by name from a list, and returns the default if not found + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="repositories">The repositories.</param> + /// <param name="name">The name.</param> + /// <param name="defaultName">The default name.</param> + /// <returns>``0.</returns> + private T GetRepository<T>(IEnumerable<T> repositories, string name, string defaultName) + where T : class, IRepository + { + var enumerable = repositories as T[] ?? repositories.ToArray(); + + return enumerable.FirstOrDefault(r => r.Name.Equals(name ?? defaultName, StringComparison.OrdinalIgnoreCase)) ?? + enumerable.First(r => r.Name.Equals(defaultName, StringComparison.OrdinalIgnoreCase)); + } + + /// <summary> + /// Disposes the file system manager. + /// </summary> + private void DisposeFileSystemManager() + { + if (FileSystemManager != null) + { + FileSystemManager.Dispose(); + FileSystemManager = null; + } + } + + /// <summary> + /// Reloads the file system manager. + /// </summary> + private void ReloadFileSystemManager() + { + DisposeFileSystemManager(); + + FileSystemManager = new FileSystemManager(this); + FileSystemManager.StartWatchers(); + } + + /// <summary> + /// Gets a User by Id + /// </summary> + /// <param name="id">The id.</param> + /// <returns>User.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public User GetUserById(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException(); + } + + return Users.FirstOrDefault(u => u.Id == id); + } + + /// <summary> + /// Finds a library item by Id and UserId. + /// </summary> + /// <param name="id">The id.</param> + /// <param name="userId">The user id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException">id</exception> + public BaseItem GetItemById(Guid id, Guid userId) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + + if (userId == Guid.Empty) + { + throw new ArgumentNullException("userId"); + } + + var user = GetUserById(userId); + var userRoot = user.RootFolder; + + return userRoot.FindItemById(id, user); + } + + /// <summary> + /// Gets the item by id. + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException">id</exception> + public BaseItem GetItemById(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + + return RootFolder.FindItemById(id, null); + } + + /// <summary> + /// Completely overwrites the current configuration with a new copy + /// </summary> + /// <param name="config">The config.</param> + public void UpdateConfiguration(ServerConfiguration config) + { + var oldConfiguration = Configuration; + + var reloadLogger = config.ShowLogWindow != oldConfiguration.ShowLogWindow; + + // Figure out whether or not we should refresh people after the update is finished + var refreshPeopleAfterUpdate = !oldConfiguration.EnableInternetProviders && config.EnableInternetProviders; + + // This is true if internet providers has just been turned on, or if People have just been removed from InternetProviderExcludeTypes + if (!refreshPeopleAfterUpdate) + { + var oldConfigurationFetchesPeopleImages = oldConfiguration.InternetProviderExcludeTypes == null || !oldConfiguration.InternetProviderExcludeTypes.Contains(typeof(Person).Name, StringComparer.OrdinalIgnoreCase); + var newConfigurationFetchesPeopleImages = config.InternetProviderExcludeTypes == null || !config.InternetProviderExcludeTypes.Contains(typeof(Person).Name, StringComparer.OrdinalIgnoreCase); + + refreshPeopleAfterUpdate = newConfigurationFetchesPeopleImages && !oldConfigurationFetchesPeopleImages; + } + + Configuration = config; + SaveConfiguration(); + + if (reloadLogger) + { + ReloadLogger(); + } + + TcpManager.OnApplicationConfigurationChanged(oldConfiguration, config); + + // Validate currently executing providers, in the background + Task.Run(() => + { + ProviderManager.ValidateCurrentlyRunningProviders(); + + // Any number of configuration settings could change the way the library is refreshed, so do that now + TaskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(); + + if (refreshPeopleAfterUpdate) + { + TaskManager.CancelIfRunningAndQueue<PeopleValidationTask>(); + } + }); + } + + /// <summary> + /// Removes the plugin. + /// </summary> + /// <param name="plugin">The plugin.</param> + internal void RemovePlugin(IPlugin plugin) + { + var list = Plugins.ToList(); + list.Remove(plugin); + Plugins = list; + } + + /// <summary> + /// Gets the system info. + /// </summary> + /// <returns>SystemInfo.</returns> + public override SystemInfo GetSystemInfo() + { + var info = base.GetSystemInfo(); + + if (InstallationManager != null) + { + info.InProgressInstallations = InstallationManager.CurrentInstallations.Select(i => i.Item1).ToArray(); + info.CompletedInstallations = InstallationManager.CompletedInstallations.ToArray(); + } + + return info; + } + } +} diff --git a/MediaBrowser.Controller/Library/ChildrenChangedEventArgs.cs b/MediaBrowser.Controller/Library/ChildrenChangedEventArgs.cs index 462fcc6d69..94f4c540ff 100644 --- a/MediaBrowser.Controller/Library/ChildrenChangedEventArgs.cs +++ b/MediaBrowser.Controller/Library/ChildrenChangedEventArgs.cs @@ -1,34 +1,137 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Entities;
-
-namespace MediaBrowser.Controller.Library
-{
- public class ChildrenChangedEventArgs : EventArgs
- {
- public Folder Folder { get; set; }
- public List<BaseItem> ItemsAdded { get; set; }
- public IEnumerable<BaseItem> ItemsRemoved { get; set; }
-
- public ChildrenChangedEventArgs()
- {
- //initialize the list
- ItemsAdded = new List<BaseItem>();
- }
-
- /// <summary>
- /// Create the args and set the folder property
- /// </summary>
- /// <param name="folder"></param>
- public ChildrenChangedEventArgs(Folder folder)
- {
- //init the folder property
- this.Folder = folder;
- //init the list
- ItemsAdded = new List<BaseItem>();
- }
- }
-}
+using System.Collections.Concurrent; +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Class ChildrenChangedEventArgs + /// </summary> + public class ChildrenChangedEventArgs : EventArgs + { + /// <summary> + /// Gets or sets the folder. + /// </summary> + /// <value>The folder.</value> + public Folder Folder { get; set; } + /// <summary> + /// Gets or sets the items added. + /// </summary> + /// <value>The items added.</value> + public ConcurrentBag<BaseItem> ItemsAdded { get; set; } + /// <summary> + /// Gets or sets the items removed. + /// </summary> + /// <value>The items removed.</value> + public List<BaseItem> ItemsRemoved { get; set; } + /// <summary> + /// Gets or sets the items updated. + /// </summary> + /// <value>The items updated.</value> + public ConcurrentBag<BaseItem> ItemsUpdated { get; set; } + + /// <summary> + /// Create the args and set the folder property + /// </summary> + /// <param name="folder">The folder.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public ChildrenChangedEventArgs(Folder folder) + { + if (folder == null) + { + throw new ArgumentNullException(); + } + + //init the folder property + Folder = folder; + //init the list + ItemsAdded = new ConcurrentBag<BaseItem>(); + ItemsRemoved = new List<BaseItem>(); + ItemsUpdated = new ConcurrentBag<BaseItem>(); + } + + /// <summary> + /// Adds the new item. + /// </summary> + /// <param name="item">The item.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddNewItem(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + ItemsAdded.Add(item); + } + + /// <summary> + /// Adds the updated item. + /// </summary> + /// <param name="item">The item.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddUpdatedItem(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + ItemsUpdated.Add(item); + } + + /// <summary> + /// Adds the removed item. + /// </summary> + /// <param name="item">The item.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddRemovedItem(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + ItemsRemoved.Add(item); + } + + /// <summary> + /// Lists the has change. + /// </summary> + /// <param name="list">The list.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool ListHasChange(List<BaseItem> list) + { + return list != null && list.Count > 0; + } + + /// <summary> + /// Lists the has change. + /// </summary> + /// <param name="list">The list.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool ListHasChange(ConcurrentBag<BaseItem> list) + { + return list != null && !list.IsEmpty; + } + + /// <summary> + /// Gets a value indicating whether this instance has change. + /// </summary> + /// <value><c>true</c> if this instance has change; otherwise, <c>false</c>.</value> + public bool HasChange + { + get { return HasAddOrRemoveChange || ListHasChange(ItemsUpdated); } + } + + /// <summary> + /// Gets a value indicating whether this instance has add or remove change. + /// </summary> + /// <value><c>true</c> if this instance has add or remove change; otherwise, <c>false</c>.</value> + public bool HasAddOrRemoveChange + { + get { return ListHasChange(ItemsAdded) || ListHasChange(ItemsRemoved); } + } + } +} diff --git a/MediaBrowser.Controller/Library/DtoBuilder.cs b/MediaBrowser.Controller/Library/DtoBuilder.cs new file mode 100644 index 0000000000..b16671e9e2 --- /dev/null +++ b/MediaBrowser.Controller/Library/DtoBuilder.cs @@ -0,0 +1,934 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.DTO; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Generates DTO's from domain entities + /// </summary> + public static class DtoBuilder + { + /// <summary> + /// The index folder delimeter + /// </summary> + const string IndexFolderDelimeter = "-index-"; + + /// <summary> + /// Gets the dto base item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="fields">The fields.</param> + /// <returns>Task{DtoBaseItem}.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public async static Task<DtoBaseItem> GetDtoBaseItem(BaseItem item, List<ItemFields> fields) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + if (fields == null) + { + throw new ArgumentNullException("fields"); + } + + var dto = new DtoBaseItem(); + + var tasks = new List<Task>(); + + if (fields.Contains(ItemFields.PrimaryImageAspectRatio)) + { + try + { + tasks.Add(AttachPrimaryImageAspectRatio(dto, item)); + } + catch (Exception ex) + { + // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions + Logger.LogException("Error generating PrimaryImageAspectRatio for {0}", ex, item.Name); + } + } + + if (fields.Contains(ItemFields.Studios)) + { + dto.Studios = item.Studios; + } + + if (fields.Contains(ItemFields.People)) + { + tasks.Add(AttachPeople(dto, item)); + } + + AttachBasicFields(dto, item, fields); + + // Make sure all the tasks we kicked off have completed. + if (tasks.Count > 0) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + return dto; + } + + /// <summary> + /// Converts a BaseItem to a DTOBaseItem + /// </summary> + /// <param name="item">The item.</param> + /// <param name="user">The user.</param> + /// <param name="fields">The fields.</param> + /// <returns>Task{DtoBaseItem}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async static Task<DtoBaseItem> GetDtoBaseItem(BaseItem item, User user, List<ItemFields> fields) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + if (user == null) + { + throw new ArgumentNullException("user"); + } + if (fields == null) + { + throw new ArgumentNullException("fields"); + } + + var dto = new DtoBaseItem(); + + var tasks = new List<Task>(); + + if (fields.Contains(ItemFields.PrimaryImageAspectRatio)) + { + try + { + tasks.Add(AttachPrimaryImageAspectRatio(dto, item)); + } + catch (Exception ex) + { + // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions + Logger.LogException("Error generating PrimaryImageAspectRatio for {0}", ex, item.Name); + } + } + + if (fields.Contains(ItemFields.Studios)) + { + dto.Studios = item.Studios; + } + + if (fields.Contains(ItemFields.People)) + { + tasks.Add(AttachPeople(dto, item)); + } + + AttachBasicFields(dto, item, fields); + + AttachUserSpecificInfo(dto, item, user, fields); + + // Make sure all the tasks we kicked off have completed. + if (tasks.Count > 0) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + return dto; + } + + /// <summary> + /// Attaches the user specific info. + /// </summary> + /// <param name="dto">The dto.</param> + /// <param name="item">The item.</param> + /// <param name="user">The user.</param> + /// <param name="fields">The fields.</param> + private static void AttachUserSpecificInfo(DtoBaseItem dto, BaseItem item, User user, List<ItemFields> fields) + { + dto.IsNew = item.IsRecentlyAdded(user); + + if (fields.Contains(ItemFields.UserData)) + { + var userData = item.GetUserData(user, false); + + if (userData != null) + { + dto.UserData = GetDtoUserItemData(userData); + } + } + + if (item.IsFolder && fields.Contains(ItemFields.DisplayPreferences)) + { + dto.DisplayPreferences = ((Folder)item).GetDisplayPrefs(user, false) ?? new DisplayPreferences { UserId = user.Id }; + } + + if (item.IsFolder) + { + if (fields.Contains(ItemFields.ItemCounts)) + { + var folder = (Folder)item; + + // Skip sorting since all we want is a count + dto.ChildCount = folder.GetChildren(user).Count(); + + SetSpecialCounts(folder, user, dto); + } + } + } + + /// <summary> + /// Attaches the primary image aspect ratio. + /// </summary> + /// <param name="dto">The dto.</param> + /// <param name="item">The item.</param> + /// <returns>Task.</returns> + private static async Task AttachPrimaryImageAspectRatio(DtoBaseItem dto, BaseItem item) + { + var path = item.PrimaryImagePath; + + if (string.IsNullOrEmpty(path)) + { + return; + } + + var metaFileEntry = item.ResolveArgs.GetMetaFileByPath(path); + + // See if we can avoid a file system lookup by looking for the file in ResolveArgs + var dateModified = metaFileEntry == null ? File.GetLastWriteTimeUtc(path) : metaFileEntry.Value.LastWriteTimeUtc; + + ImageSize size; + + try + { + size = await Kernel.Instance.ImageManager.GetImageSize(path, dateModified).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + Logger.LogError("Image file does not exist: {0}", path); + return; + } + + foreach (var enhancer in Kernel.Instance.ImageEnhancers + .Where(i => i.Supports(item, ImageType.Primary))) + { + + size = enhancer.GetEnhancedImageSize(item, ImageType.Primary, 0, size); + } + + dto.PrimaryImageAspectRatio = size.Width / size.Height; + } + + /// <summary> + /// Sets simple property values on a DTOBaseItem + /// </summary> + /// <param name="dto">The dto.</param> + /// <param name="item">The item.</param> + /// <param name="fields">The fields.</param> + private static void AttachBasicFields(DtoBaseItem dto, BaseItem item, List<ItemFields> fields) + { + if (fields.Contains(ItemFields.DateCreated)) + { + dto.DateCreated = item.DateCreated; + } + + if (fields.Contains(ItemFields.DisplayMediaType)) + { + dto.DisplayMediaType = item.DisplayMediaType; + } + + dto.AspectRatio = item.AspectRatio; + + dto.BackdropImageTags = GetBackdropImageTags(item); + + if (fields.Contains(ItemFields.Genres)) + { + dto.Genres = item.Genres; + } + + if (item.Images != null) + { + dto.ImageTags = new Dictionary<ImageType, Guid>(); + + foreach (var image in item.Images) + { + ImageType type; + + if (Enum.TryParse(image.Key, true, out type)) + { + dto.ImageTags[type] = Kernel.Instance.ImageManager.GetImageCacheTag(item, type, image.Value); + } + } + } + + dto.Id = GetClientItemId(item); + dto.IndexNumber = item.IndexNumber; + dto.IsFolder = item.IsFolder; + dto.Language = item.Language; + dto.MediaType = item.MediaType; + dto.LocationType = item.LocationType; + + var localTrailerCount = item.LocalTrailers == null ? 0 : item.LocalTrailers.Count; + + if (localTrailerCount > 0) + { + dto.LocalTrailerCount = localTrailerCount; + } + + dto.Name = item.Name; + dto.OfficialRating = item.OfficialRating; + + if (fields.Contains(ItemFields.Overview)) + { + dto.Overview = item.Overview; + } + + // If there are no backdrops, indicate what parent has them in case the Ui wants to allow inheritance + if (dto.BackdropImageTags.Count == 0) + { + var parentWithBackdrop = GetParentBackdropItem(item); + + if (parentWithBackdrop != null) + { + dto.ParentBackdropItemId = GetClientItemId(parentWithBackdrop); + dto.ParentBackdropImageTags = GetBackdropImageTags(parentWithBackdrop); + } + } + + if (item.Parent != null && fields.Contains(ItemFields.ParentId)) + { + dto.ParentId = GetClientItemId(item.Parent); + } + + dto.ParentIndexNumber = item.ParentIndexNumber; + + // If there is no logo, indicate what parent has one in case the Ui wants to allow inheritance + if (!dto.HasLogo) + { + var parentWithLogo = GetParentLogoItem(item); + + if (parentWithLogo != null) + { + dto.ParentLogoItemId = GetClientItemId(parentWithLogo); + + dto.ParentLogoImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(parentWithLogo, ImageType.Logo, parentWithLogo.GetImage(ImageType.Logo)); + } + } + + if (fields.Contains(ItemFields.Path)) + { + dto.Path = item.Path; + } + + dto.PremiereDate = item.PremiereDate; + dto.ProductionYear = item.ProductionYear; + + if (fields.Contains(ItemFields.ProviderIds)) + { + dto.ProviderIds = item.ProviderIds; + } + + dto.RunTimeTicks = item.RunTimeTicks; + + if (fields.Contains(ItemFields.SortName)) + { + dto.SortName = item.SortName; + } + + if (fields.Contains(ItemFields.Taglines)) + { + dto.Taglines = item.Taglines; + } + + if (fields.Contains(ItemFields.TrailerUrls)) + { + dto.TrailerUrls = item.TrailerUrls; + } + + dto.Type = item.GetType().Name; + dto.CommunityRating = item.CommunityRating; + + if (item.IsFolder) + { + var folder = (Folder)item; + + dto.IsRoot = folder.IsRoot; + dto.IsVirtualFolder = folder.IsVirtualFolder; + + if (fields.Contains(ItemFields.IndexOptions)) + { + dto.IndexOptions = folder.IndexByOptionStrings.ToArray(); + } + + if (fields.Contains(ItemFields.SortOptions)) + { + dto.SortOptions = folder.SortByOptionStrings.ToArray(); + } + } + + // Add audio info + var audio = item as Audio; + if (audio != null) + { + if (fields.Contains(ItemFields.AudioInfo)) + { + dto.Album = audio.Album; + dto.AlbumArtist = audio.AlbumArtist; + dto.Artist = audio.Artist; + } + } + + // Add video info + var video = item as Video; + if (video != null) + { + dto.VideoType = video.VideoType; + dto.VideoFormat = video.VideoFormat; + dto.IsoType = video.IsoType; + + if (fields.Contains(ItemFields.Chapters) && video.Chapters != null) + { + dto.Chapters = video.Chapters.Select(c => GetChapterInfoDto(c, item)).ToList(); + } + } + + if (fields.Contains(ItemFields.MediaStreams)) + { + // Add VideoInfo + var iHasMediaStreams = item as IHasMediaStreams; + + if (iHasMediaStreams != null) + { + dto.MediaStreams = iHasMediaStreams.MediaStreams; + } + } + + // Add MovieInfo + var movie = item as Movie; + + if (movie != null) + { + var specialFeatureCount = movie.SpecialFeatures == null ? 0 : movie.SpecialFeatures.Count; + + if (specialFeatureCount > 0) + { + dto.SpecialFeatureCount = specialFeatureCount; + } + } + + if (fields.Contains(ItemFields.SeriesInfo)) + { + // Add SeriesInfo + var series = item as Series; + + if (series != null) + { + dto.AirDays = series.AirDays; + dto.AirTime = series.AirTime; + dto.Status = series.Status; + } + + // Add EpisodeInfo + var episode = item as Episode; + + if (episode != null) + { + series = item.FindParent<Series>(); + + dto.SeriesId = GetClientItemId(series); + dto.SeriesName = series.Name; + } + + // Add SeasonInfo + var season = item as Season; + + if (season != null) + { + series = item.FindParent<Series>(); + + dto.SeriesId = GetClientItemId(series); + dto.SeriesName = series.Name; + } + } + } + + /// <summary> + /// Since it can be slow to make all of these calculations independently, this method will provide a way to do them all at once + /// </summary> + /// <param name="folder">The folder.</param> + /// <param name="user">The user.</param> + /// <param name="dto">The dto.</param> + private static void SetSpecialCounts(Folder folder, User user, DtoBaseItem dto) + { + var utcNow = DateTime.UtcNow; + + var rcentlyAddedItemCount = 0; + var recursiveItemCount = 0; + var favoriteItemsCount = 0; + var recentlyAddedUnPlayedItemCount = 0; + var resumableItemCount = 0; + var recentlyPlayedItemCount = 0; + + double totalPercentPlayed = 0; + + // Loop through each recursive child + foreach (var child in folder.GetRecursiveChildren(user)) + { + var userdata = child.GetUserData(user, false); + + if (!child.IsFolder) + { + recursiveItemCount++; + + // Check is recently added + if (child.IsRecentlyAdded(user)) + { + rcentlyAddedItemCount++; + + // Check recently added unplayed + if (userdata == null || userdata.PlayCount == 0) + { + recentlyAddedUnPlayedItemCount++; + } + } + + // Incrememt totalPercentPlayed + if (userdata != null) + { + if (userdata.PlayCount > 0) + { + totalPercentPlayed += 100; + } + else if (userdata.PlaybackPositionTicks > 0 && child.RunTimeTicks.HasValue && child.RunTimeTicks.Value > 0) + { + double itemPercent = userdata.PlaybackPositionTicks; + itemPercent /= child.RunTimeTicks.Value; + totalPercentPlayed += itemPercent; + } + } + } + + if (userdata != null) + { + if (userdata.IsFavorite) + { + favoriteItemsCount++; + } + + if (userdata.PlaybackPositionTicks > 0) + { + resumableItemCount++; + } + + if (userdata.LastPlayedDate.HasValue && (utcNow - userdata.LastPlayedDate.Value).TotalDays < Kernel.Instance.Configuration.RecentlyPlayedDays) + { + recentlyPlayedItemCount++; + } + } + } + + dto.RecursiveItemCount = recursiveItemCount; + dto.RecentlyAddedItemCount = rcentlyAddedItemCount; + dto.RecentlyAddedUnPlayedItemCount = recentlyAddedUnPlayedItemCount; + dto.ResumableItemCount = resumableItemCount; + dto.FavoriteItemCount = favoriteItemsCount; + dto.RecentlyPlayedItemCount = recentlyPlayedItemCount; + + if (recursiveItemCount > 0) + { + dto.PlayedPercentage = totalPercentPlayed / recursiveItemCount; + } + } + + /// <summary> + /// Attaches People DTO's to a DTOBaseItem + /// </summary> + /// <param name="dto">The dto.</param> + /// <param name="item">The item.</param> + /// <returns>Task.</returns> + private static async Task AttachPeople(DtoBaseItem dto, BaseItem item) + { + if (item.People == null) + { + return; + } + + // Attach People by transforming them into BaseItemPerson (DTO) + dto.People = new BaseItemPerson[item.People.Count]; + + var entities = await Task.WhenAll(item.People.Select(c => + + Task.Run(async () => + { + try + { + return await Kernel.Instance.LibraryManager.GetPerson(c.Name).ConfigureAwait(false); + } + catch (IOException ex) + { + Logger.LogException("Error getting person {0}", ex, c.Name); + return null; + } + }) + + )).ConfigureAwait(false); + + for (var i = 0; i < item.People.Count; i++) + { + var person = item.People[i]; + + var baseItemPerson = new BaseItemPerson + { + Name = person.Name, + Role = person.Role, + Type = person.Type + }; + + var ibnObject = entities[i]; + + if (ibnObject != null) + { + var primaryImagePath = ibnObject.PrimaryImagePath; + + if (!string.IsNullOrEmpty(primaryImagePath)) + { + baseItemPerson.PrimaryImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(ibnObject, ImageType.Primary, primaryImagePath); + } + } + + dto.People[i] = baseItemPerson; + } + } + + /// <summary> + /// If an item does not any backdrops, this can be used to find the first parent that does have one + /// </summary> + /// <param name="item">The item.</param> + /// <returns>BaseItem.</returns> + private static BaseItem GetParentBackdropItem(BaseItem item) + { + var parent = item.Parent; + + while (parent != null) + { + if (parent.BackdropImagePaths != null && parent.BackdropImagePaths.Count > 0) + { + return parent; + } + + parent = parent.Parent; + } + + return null; + } + + /// <summary> + /// If an item does not have a logo, this can be used to find the first parent that does have one + /// </summary> + /// <param name="item">The item.</param> + /// <returns>BaseItem.</returns> + private static BaseItem GetParentLogoItem(BaseItem item) + { + var parent = item.Parent; + + while (parent != null) + { + if (parent.HasImage(ImageType.Logo)) + { + return parent; + } + + parent = parent.Parent; + } + + return null; + } + + /// <summary> + /// Gets the library update info. + /// </summary> + /// <param name="changeEvent">The <see cref="ChildrenChangedEventArgs" /> instance containing the event data.</param> + /// <returns>LibraryUpdateInfo.</returns> + internal static LibraryUpdateInfo GetLibraryUpdateInfo(ChildrenChangedEventArgs changeEvent) + { + return new LibraryUpdateInfo + { + Folder = GetBaseItemInfo(changeEvent.Folder), + ItemsAdded = changeEvent.ItemsAdded.Select(GetBaseItemInfo), + ItemsRemoved = changeEvent.ItemsRemoved.Select(i => i.Id), + ItemsUpdated = changeEvent.ItemsUpdated.Select(i => i.Id) + }; + } + + /// <summary> + /// Converts a UserItemData to a DTOUserItemData + /// </summary> + /// <param name="data">The data.</param> + /// <returns>DtoUserItemData.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public static DtoUserItemData GetDtoUserItemData(UserItemData data) + { + if (data == null) + { + throw new ArgumentNullException(); + } + + return new DtoUserItemData + { + IsFavorite = data.IsFavorite, + Likes = data.Likes, + PlaybackPositionTicks = data.PlaybackPositionTicks, + PlayCount = data.PlayCount, + Rating = data.Rating, + Played = data.Played + }; + } + + /// <summary> + /// Gets the chapter info dto. + /// </summary> + /// <param name="chapterInfo">The chapter info.</param> + /// <param name="item">The item.</param> + /// <returns>ChapterInfoDto.</returns> + private static ChapterInfoDto GetChapterInfoDto(ChapterInfo chapterInfo, BaseItem item) + { + var dto = new ChapterInfoDto + { + Name = chapterInfo.Name, + StartPositionTicks = chapterInfo.StartPositionTicks + }; + + if (!string.IsNullOrEmpty(chapterInfo.ImagePath)) + { + dto.ImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(item, ImageType.ChapterImage, chapterInfo.ImagePath); + } + + return dto; + } + + /// <summary> + /// Converts a BaseItem to a BaseItemInfo + /// </summary> + /// <param name="item">The item.</param> + /// <returns>BaseItemInfo.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public static BaseItemInfo GetBaseItemInfo(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var info = new BaseItemInfo + { + Id = GetClientItemId(item), + Name = item.Name, + Type = item.GetType().Name, + IsFolder = item.IsFolder, + RunTimeTicks = item.RunTimeTicks + }; + + var imagePath = item.PrimaryImagePath; + + if (!string.IsNullOrEmpty(imagePath)) + { + info.PrimaryImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(item, ImageType.Primary, imagePath); + } + + if (item.BackdropImagePaths != null && item.BackdropImagePaths.Count > 0) + { + imagePath = item.BackdropImagePaths[0]; + + if (!string.IsNullOrEmpty(imagePath)) + { + info.BackdropImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(item, ImageType.Backdrop, imagePath); + } + } + + return info; + } + + /// <summary> + /// Gets client-side Id of a server-side BaseItem + /// </summary> + /// <param name="item">The item.</param> + /// <returns>System.String.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public static string GetClientItemId(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var indexFolder = item as IndexFolder; + + if (indexFolder != null) + { + return GetClientItemId(indexFolder.Parent) + IndexFolderDelimeter + (indexFolder.IndexName ?? string.Empty) + IndexFolderDelimeter + indexFolder.Id; + } + + return item.Id.ToString(); + } + + /// <summary> + /// Converts a User to a DTOUser + /// </summary> + /// <param name="user">The user.</param> + /// <returns>DtoUser.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public static DtoUser GetDtoUser(User user) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + var dto = new DtoUser + { + Id = user.Id, + Name = user.Name, + HasPassword = !String.IsNullOrEmpty(user.Password), + LastActivityDate = user.LastActivityDate, + LastLoginDate = user.LastLoginDate, + Configuration = user.Configuration + }; + + var image = user.PrimaryImagePath; + + if (!string.IsNullOrEmpty(image)) + { + dto.PrimaryImageTag = Kernel.Instance.ImageManager.GetImageCacheTag(user, ImageType.Primary, image); + } + + return dto; + } + + /// <summary> + /// Gets a BaseItem based upon it's client-side item id + /// </summary> + /// <param name="id">The id.</param> + /// <param name="userId">The user id.</param> + /// <returns>BaseItem.</returns> + public static BaseItem GetItemByClientId(string id, Guid? userId = null) + { + var isIdEmpty = string.IsNullOrEmpty(id); + + // If the item is an indexed folder we have to do a special routine to get it + var isIndexFolder = !isIdEmpty && + id.IndexOf(IndexFolderDelimeter, StringComparison.OrdinalIgnoreCase) != -1; + + if (isIndexFolder) + { + if (userId.HasValue) + { + return GetIndexFolder(id, userId.Value); + } + } + + BaseItem item = null; + + if (userId.HasValue) + { + item = isIdEmpty + ? Kernel.Instance.GetUserById(userId.Value).RootFolder + : Kernel.Instance.GetItemById(new Guid(id), userId.Value); + } + else if (!isIndexFolder) + { + item = Kernel.Instance.GetItemById(new Guid(id)); + } + + // If we still don't find it, look within individual user views + if (item == null && !userId.HasValue) + { + foreach (var user in Kernel.Instance.Users) + { + item = GetItemByClientId(id, user.Id); + + if (item != null) + { + break; + } + } + } + + return item; + } + + /// <summary> + /// Finds an index folder based on an Id and userId + /// </summary> + /// <param name="id">The id.</param> + /// <param name="userId">The user id.</param> + /// <returns>BaseItem.</returns> + private static BaseItem GetIndexFolder(string id, Guid userId) + { + var user = Kernel.Instance.GetUserById(userId); + + var stringSeparators = new[] { IndexFolderDelimeter }; + + // Split using the delimeter + var values = id.Split(stringSeparators, StringSplitOptions.None).ToList(); + + // Get the top folder normally using the first id + var folder = GetItemByClientId(values[0], userId) as Folder; + + values.RemoveAt(0); + + // Get indexed folders using the remaining values in the id string + return GetIndexFolder(values, folder, user); + } + + /// <summary> + /// Gets indexed folders based on a list of index names and folder id's + /// </summary> + /// <param name="values">The values.</param> + /// <param name="parentFolder">The parent folder.</param> + /// <param name="user">The user.</param> + /// <returns>BaseItem.</returns> + private static BaseItem GetIndexFolder(List<string> values, Folder parentFolder, User user) + { + // The index name is first + var indexBy = values[0]; + + // The index folder id is next + var indexFolderId = new Guid(values[1]); + + // Remove them from the lst + values.RemoveRange(0, 2); + + // Get the IndexFolder + var indexFolder = parentFolder.GetChildren(user, indexBy).FirstOrDefault(i => i.Id == indexFolderId) as Folder; + + // Nested index folder + if (values.Count > 0) + { + return GetIndexFolder(values, indexFolder, user); + } + + return indexFolder; + } + + /// <summary> + /// Gets the backdrop image tags. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>List{System.String}.</returns> + private static List<Guid> GetBackdropImageTags(BaseItem item) + { + if (item.BackdropImagePaths == null) + { + return new List<Guid>(); + } + + return item.BackdropImagePaths.Select(p => Kernel.Instance.ImageManager.GetImageCacheTag(item, ImageType.Backdrop, p)).ToList(); + } + } +} diff --git a/MediaBrowser.Controller/Library/ItemController.cs b/MediaBrowser.Controller/Library/ItemController.cs deleted file mode 100644 index 54673e538d..0000000000 --- a/MediaBrowser.Controller/Library/ItemController.cs +++ /dev/null @@ -1,136 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Common.Extensions;
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Library
-{
- public class ItemController
- {
-
- /// <summary>
- /// Resolves a path into a BaseItem
- /// </summary>
- public async Task<BaseItem> GetItem(string path, Folder parent = null, WIN32_FIND_DATA? fileInfo = null, bool allowInternetProviders = true)
- {
- var args = new ItemResolveEventArgs
- {
- FileInfo = fileInfo ?? FileData.GetFileData(path),
- Parent = parent,
- Cancel = false,
- Path = path
- };
-
- // Gather child folder and files
- if (args.IsDirectory)
- {
- args.FileSystemChildren = FileData.GetFileSystemEntries(path, "*").ToArray();
-
- bool isVirtualFolder = parent != null && parent.IsRoot;
- args = FileSystemHelper.FilterChildFileSystemEntries(args, isVirtualFolder);
- }
- else
- {
- args.FileSystemChildren = new WIN32_FIND_DATA[] { };
- }
-
-
- // Check to see if we should resolve based on our contents
- if (!EntityResolutionHelper.ShouldResolvePathContents(args))
- {
- return null;
- }
-
- BaseItem item = Kernel.Instance.ResolveItem(args);
-
- return item;
- }
-
- /// <summary>
- /// Gets a Person
- /// </summary>
- public Task<Person> GetPerson(string name)
- {
- return GetImagesByNameItem<Person>(Kernel.Instance.ApplicationPaths.PeoplePath, name);
- }
-
- /// <summary>
- /// Gets a Studio
- /// </summary>
- public Task<Studio> GetStudio(string name)
- {
- return GetImagesByNameItem<Studio>(Kernel.Instance.ApplicationPaths.StudioPath, name);
- }
-
- /// <summary>
- /// Gets a Genre
- /// </summary>
- public Task<Genre> GetGenre(string name)
- {
- return GetImagesByNameItem<Genre>(Kernel.Instance.ApplicationPaths.GenrePath, name);
- }
-
- /// <summary>
- /// Gets a Year
- /// </summary>
- public Task<Year> GetYear(int value)
- {
- return GetImagesByNameItem<Year>(Kernel.Instance.ApplicationPaths.YearPath, value.ToString());
- }
-
- private readonly ConcurrentDictionary<string, object> ImagesByNameItemCache = new ConcurrentDictionary<string, object>(StringComparer.OrdinalIgnoreCase);
-
- /// <summary>
- /// Generically retrieves an IBN item
- /// </summary>
- private Task<T> GetImagesByNameItem<T>(string path, string name)
- where T : BaseEntity, new()
- {
- name = FileData.GetValidFilename(name);
-
- path = Path.Combine(path, name);
-
- // Look for it in the cache, if it's not there, create it
- if (!ImagesByNameItemCache.ContainsKey(path))
- {
- ImagesByNameItemCache[path] = CreateImagesByNameItem<T>(path, name);
- }
-
- return ImagesByNameItemCache[path] as Task<T>;
- }
-
- /// <summary>
- /// Creates an IBN item based on a given path
- /// </summary>
- private async Task<T> CreateImagesByNameItem<T>(string path, string name)
- where T : BaseEntity, new()
- {
- var item = new T { };
-
- item.Name = name;
- item.Id = path.GetMD5();
-
- if (!Directory.Exists(path))
- {
- Directory.CreateDirectory(path);
- }
-
- item.DateCreated = Directory.GetCreationTimeUtc(path);
- item.DateModified = Directory.GetLastWriteTimeUtc(path);
-
- var args = new ItemResolveEventArgs { };
- args.FileInfo = FileData.GetFileData(path);
- args.FileSystemChildren = FileData.GetFileSystemEntries(path, "*").ToArray();
-
- await Kernel.Instance.ExecuteMetadataProviders(item).ConfigureAwait(false);
-
- return item;
- }
- }
-}
diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs new file mode 100644 index 0000000000..c95300f745 --- /dev/null +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -0,0 +1,397 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// These are arguments relating to the file system that are collected once and then referred to + /// whenever needed. Primarily for entity resolution. + /// </summary> + public class ItemResolveArgs : EventArgs + { + /// <summary> + /// Gets the file system children. + /// </summary> + /// <value>The file system children.</value> + public IEnumerable<WIN32_FIND_DATA> FileSystemChildren + { + get { return FileSystemDictionary.Values; } + } + + /// <summary> + /// Gets or sets the file system dictionary. + /// </summary> + /// <value>The file system dictionary.</value> + public Dictionary<string, WIN32_FIND_DATA> FileSystemDictionary { get; set; } + + /// <summary> + /// Gets or sets the parent. + /// </summary> + /// <value>The parent.</value> + public Folder Parent { get; set; } + + /// <summary> + /// Gets or sets the file info. + /// </summary> + /// <value>The file info.</value> + public WIN32_FIND_DATA FileInfo { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public string Path { get; set; } + + /// <summary> + /// Gets a value indicating whether this instance is directory. + /// </summary> + /// <value><c>true</c> if this instance is directory; otherwise, <c>false</c>.</value> + public bool IsDirectory + { + get + { + return FileInfo.dwFileAttributes.HasFlag(FileAttributes.Directory); + } + } + + /// <summary> + /// Gets a value indicating whether this instance is hidden. + /// </summary> + /// <value><c>true</c> if this instance is hidden; otherwise, <c>false</c>.</value> + public bool IsHidden + { + get + { + return FileInfo.IsHidden; + } + } + + /// <summary> + /// Gets a value indicating whether this instance is system file. + /// </summary> + /// <value><c>true</c> if this instance is system file; otherwise, <c>false</c>.</value> + public bool IsSystemFile + { + get + { + return FileInfo.IsSystemFile; + } + } + + /// <summary> + /// Gets a value indicating whether this instance is vf. + /// </summary> + /// <value><c>true</c> if this instance is vf; otherwise, <c>false</c>.</value> + public bool IsVf + { + // we should be considered a virtual folder if we are a child of one of the children of the system root folder. + // this is a bit of a trick to determine that... the directory name of a sub-child of the root will start with + // the root but not be equal to it + get + { + if (!IsDirectory) + { + return false; + } + + var parentDir = FileInfo.Path != null ? System.IO.Path.GetDirectoryName(FileInfo.Path) ?? string.Empty : string.Empty; + + return (parentDir.Length > Kernel.Instance.ApplicationPaths.RootFolderPath.Length + && parentDir.StartsWith(Kernel.Instance.ApplicationPaths.RootFolderPath, StringComparison.OrdinalIgnoreCase)); + + } + } + + /// <summary> + /// Gets a value indicating whether this instance is physical root. + /// </summary> + /// <value><c>true</c> if this instance is physical root; otherwise, <c>false</c>.</value> + public bool IsPhysicalRoot + { + get + { + return IsDirectory && Path.Equals(Kernel.Instance.ApplicationPaths.RootFolderPath, StringComparison.OrdinalIgnoreCase); + } + } + + /// <summary> + /// Gets a value indicating whether this instance is root. + /// </summary> + /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value> + public bool IsRoot + { + get + { + return Parent == null; + } + } + + /// <summary> + /// Gets or sets the additional locations. + /// </summary> + /// <value>The additional locations.</value> + private List<string> AdditionalLocations { get; set; } + + /// <summary> + /// Adds the additional location. + /// </summary> + /// <param name="path">The path.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddAdditionalLocation(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + if (AdditionalLocations == null) + { + AdditionalLocations = new List<string>(); + } + + AdditionalLocations.Add(path); + } + + /// <summary> + /// Gets the physical locations. + /// </summary> + /// <value>The physical locations.</value> + public IEnumerable<string> PhysicalLocations + { + get + { + var paths = string.IsNullOrWhiteSpace(Path) ? new string[] {} : new[] {Path}; + return AdditionalLocations == null ? paths : paths.Concat(AdditionalLocations); + } + } + + /// <summary> + /// Store these to reduce disk access in Resolvers + /// </summary> + /// <value>The metadata file dictionary.</value> + private Dictionary<string, WIN32_FIND_DATA> MetadataFileDictionary { get; set; } + + /// <summary> + /// Gets the metadata files. + /// </summary> + /// <value>The metadata files.</value> + public IEnumerable<WIN32_FIND_DATA> MetadataFiles + { + get + { + if (MetadataFileDictionary != null) + { + return MetadataFileDictionary.Values; + } + + return new WIN32_FIND_DATA[] {}; + } + } + + /// <summary> + /// Adds the metadata file. + /// </summary> + /// <param name="path">The path.</param> + /// <exception cref="System.IO.FileNotFoundException"></exception> + public void AddMetadataFile(string path) + { + var file = FileSystem.GetFileData(path); + + if (!file.HasValue) + { + throw new FileNotFoundException(path); + } + + AddMetadataFile(file.Value); + } + + /// <summary> + /// Adds the metadata file. + /// </summary> + /// <param name="fileInfo">The file info.</param> + public void AddMetadataFile(WIN32_FIND_DATA fileInfo) + { + AddMetadataFiles(new[] { fileInfo }); + } + + /// <summary> + /// Adds the metadata files. + /// </summary> + /// <param name="files">The files.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void AddMetadataFiles(IEnumerable<WIN32_FIND_DATA> files) + { + if (files == null) + { + throw new ArgumentNullException(); + } + + if (MetadataFileDictionary == null) + { + MetadataFileDictionary = new Dictionary<string, WIN32_FIND_DATA>(StringComparer.OrdinalIgnoreCase); + } + foreach (var file in files) + { + MetadataFileDictionary[file.cFileName] = file; + } + } + + /// <summary> + /// Gets the name of the file system entry by. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public WIN32_FIND_DATA? GetFileSystemEntryByName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(); + } + + return GetFileSystemEntryByPath(System.IO.Path.Combine(Path, name)); + } + + /// <summary> + /// Gets the file system entry by path. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public WIN32_FIND_DATA? GetFileSystemEntryByPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + if (FileSystemDictionary != null) + { + WIN32_FIND_DATA entry; + + if (FileSystemDictionary.TryGetValue(path, out entry)) + { + return entry; + } + } + + return null; + } + + /// <summary> + /// Gets the meta file by path. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public WIN32_FIND_DATA? GetMetaFileByPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + if (MetadataFileDictionary != null) + { + WIN32_FIND_DATA entry; + + if (MetadataFileDictionary.TryGetValue(System.IO.Path.GetFileName(path), out entry)) + { + return entry; + } + } + + return GetFileSystemEntryByPath(path); + } + + /// <summary> + /// Gets the name of the meta file by. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public WIN32_FIND_DATA? GetMetaFileByName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(); + } + + if (MetadataFileDictionary != null) + { + WIN32_FIND_DATA entry; + + if (MetadataFileDictionary.TryGetValue(name, out entry)) + { + return entry; + } + } + + return GetFileSystemEntryByName(name); + } + + /// <summary> + /// Determines whether [contains meta file by name] [the specified name]. + /// </summary> + /// <param name="name">The name.</param> + /// <returns><c>true</c> if [contains meta file by name] [the specified name]; otherwise, <c>false</c>.</returns> + public bool ContainsMetaFileByName(string name) + { + return GetMetaFileByName(name).HasValue; + } + + /// <summary> + /// Determines whether [contains file system entry by name] [the specified name]. + /// </summary> + /// <param name="name">The name.</param> + /// <returns><c>true</c> if [contains file system entry by name] [the specified name]; otherwise, <c>false</c>.</returns> + public bool ContainsFileSystemEntryByName(string name) + { + return GetFileSystemEntryByName(name).HasValue; + } + + #region Equality Overrides + + /// <summary> + /// Determines whether the specified <see cref="System.Object" /> is equal to this instance. + /// </summary> + /// <param name="obj">The object to compare with the current object.</param> + /// <returns><c>true</c> if the specified <see cref="System.Object" /> is equal to this instance; otherwise, <c>false</c>.</returns> + public override bool Equals(object obj) + { + return (Equals(obj as ItemResolveArgs)); + } + + /// <summary> + /// Returns a hash code for this instance. + /// </summary> + /// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.</returns> + public override int GetHashCode() + { + return Path.GetHashCode(); + } + + /// <summary> + /// Equalses the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected bool Equals(ItemResolveArgs args) + { + if (args != null) + { + if (args.Path == null && Path == null) return true; + return args.Path != null && args.Path.Equals(Path, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + #endregion + } + +} diff --git a/MediaBrowser.Controller/Library/ItemResolveEventArgs.cs b/MediaBrowser.Controller/Library/ItemResolveEventArgs.cs deleted file mode 100644 index 32b8783dfd..0000000000 --- a/MediaBrowser.Controller/Library/ItemResolveEventArgs.cs +++ /dev/null @@ -1,104 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.IO;
-using System.Collections.Generic;
-using System.Linq;
-using System;
-using System.IO;
-
-namespace MediaBrowser.Controller.Library
-{
- /// <summary>
- /// This is an EventArgs object used when resolving a Path into a BaseItem
- /// </summary>
- public class ItemResolveEventArgs : PreBeginResolveEventArgs
- {
- public WIN32_FIND_DATA[] FileSystemChildren { get; set; }
-
- protected List<string> _additionalLocations = new List<string>();
- public List<string> AdditionalLocations
- {
- get
- {
- return _additionalLocations;
- }
- set
- {
- _additionalLocations = value;
- }
- }
-
- public IEnumerable<string> PhysicalLocations
- {
- get
- {
- return (new List<string>() {this.Path}).Concat(AdditionalLocations);
- }
- }
-
- public bool IsBDFolder { get; set; }
- public bool IsDVDFolder { get; set; }
- public bool IsHDDVDFolder { get; set; }
-
- /// <summary>
- /// Store these to reduce disk access in Resolvers
- /// </summary>
- public string[] MetadataFiles { get; set; }
-
- public WIN32_FIND_DATA? GetFileSystemEntry(string path)
- {
- WIN32_FIND_DATA entry = FileSystemChildren.FirstOrDefault(f => f.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
- return entry.cFileName != null ? (WIN32_FIND_DATA?)entry : null;
- }
-
- public bool ContainsFile(string name)
- {
- return FileSystemChildren.FirstOrDefault(f => f.cFileName.Equals(name, StringComparison.OrdinalIgnoreCase)).cFileName != null;
- }
-
- public bool ContainsFolder(string name)
- {
- return ContainsFile(name);
- }
- }
-
- /// <summary>
- /// This is an EventArgs object used before we begin resolving a Path into a BaseItem
- /// File system children have not been collected yet, but consuming events will
- /// have a chance to cancel resolution based on the Path, Parent and FileAttributes
- /// </summary>
- public class PreBeginResolveEventArgs : EventArgs
- {
- public Folder Parent { get; set; }
-
- public bool Cancel { get; set; }
-
- public WIN32_FIND_DATA FileInfo { get; set; }
-
- public string Path { get; set; }
-
- public bool IsDirectory
- {
- get
- {
- return FileInfo.dwFileAttributes.HasFlag(FileAttributes.Directory);
- }
- }
-
- public bool IsHidden
- {
- get
- {
- return FileInfo.IsHidden;
- }
- }
-
- public bool IsSystemFile
- {
- get
- {
- return FileInfo.IsSystemFile;
- }
- }
-
- }
-}
diff --git a/MediaBrowser.Controller/Library/LibraryManager.cs b/MediaBrowser.Controller/Library/LibraryManager.cs new file mode 100644 index 0000000000..7d3c764b2d --- /dev/null +++ b/MediaBrowser.Controller/Library/LibraryManager.cs @@ -0,0 +1,511 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Tasks; +using MoreLinq; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Class LibraryManager + /// </summary> + public class LibraryManager : BaseManager<Kernel> + { + #region LibraryChanged Event + /// <summary> + /// Fires whenever any validation routine adds or removes items. The added and removed items are properties of the args. + /// *** Will fire asynchronously. *** + /// </summary> + public event EventHandler<ChildrenChangedEventArgs> LibraryChanged; + + /// <summary> + /// Raises the <see cref="E:LibraryChanged" /> event. + /// </summary> + /// <param name="args">The <see cref="ChildrenChangedEventArgs" /> instance containing the event data.</param> + internal void OnLibraryChanged(ChildrenChangedEventArgs args) + { + EventHelper.QueueEventIfNotNull(LibraryChanged, this, args); + + // Had to put this in a separate method to avoid an implicitly captured closure + SendLibraryChangedWebSocketMessage(args); + } + + /// <summary> + /// Sends the library changed web socket message. + /// </summary> + /// <param name="args">The <see cref="ChildrenChangedEventArgs" /> instance containing the event data.</param> + private void SendLibraryChangedWebSocketMessage(ChildrenChangedEventArgs args) + { + // Notify connected ui's + Kernel.TcpManager.SendWebSocketMessage("LibraryChanged", () => DtoBuilder.GetLibraryUpdateInfo(args)); + } + #endregion + + /// <summary> + /// Initializes a new instance of the <see cref="LibraryManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public LibraryManager(Kernel kernel) + : base(kernel) + { + } + + /// <summary> + /// Resolves the item. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>BaseItem.</returns> + public BaseItem ResolveItem(ItemResolveArgs args) + { + return Kernel.EntityResolvers.Select(r => r.ResolvePath(args)).FirstOrDefault(i => i != null); + } + + /// <summary> + /// Resolves a path into a BaseItem + /// </summary> + /// <param name="path">The path.</param> + /// <param name="parent">The parent.</param> + /// <param name="fileInfo">The file info.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public BaseItem GetItem(string path, Folder parent = null, WIN32_FIND_DATA? fileInfo = null) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + fileInfo = fileInfo ?? FileSystem.GetFileData(path); + + if (!fileInfo.HasValue) + { + return null; + } + + var args = new ItemResolveArgs + { + Parent = parent, + Path = path, + FileInfo = fileInfo.Value + }; + + // Return null if ignore rules deem that we should do so + if (Kernel.EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(args))) + { + return null; + } + + // Gather child folder and files + if (args.IsDirectory) + { + // When resolving the root, we need it's grandchildren (children of user views) + var flattenFolderDepth = args.IsPhysicalRoot ? 2 : 0; + + args.FileSystemDictionary = FileData.GetFilteredFileSystemEntries(args.Path, flattenFolderDepth: flattenFolderDepth, args: args); + } + + // Check to see if we should resolve based on our contents + if (args.IsDirectory && !EntityResolutionHelper.ShouldResolvePathContents(args)) + { + return null; + } + + return ResolveItem(args); + } + + /// <summary> + /// Resolves a set of files into a list of BaseItem + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="files">The files.</param> + /// <param name="parent">The parent.</param> + /// <returns>List{``0}.</returns> + public List<T> GetItems<T>(IEnumerable<WIN32_FIND_DATA> files, Folder parent) + where T : BaseItem + { + var list = new List<T>(); + + Parallel.ForEach(files, f => + { + try + { + var item = GetItem(f.Path, parent, f) as T; + + if (item != null) + { + lock (list) + { + list.Add(item); + } + } + } + catch (Exception ex) + { + Logger.ErrorException("Error resolving path {0}", ex, f.Path); + } + }); + + return list; + } + + /// <summary> + /// Creates the root media folder + /// </summary> + /// <returns>AggregateFolder.</returns> + /// <exception cref="System.InvalidOperationException">Cannot create the root folder until plugins have loaded</exception> + internal AggregateFolder CreateRootFolder() + { + if (Kernel.Plugins == null) + { + throw new InvalidOperationException("Cannot create the root folder until plugins have loaded"); + } + + var rootFolderPath = Kernel.ApplicationPaths.RootFolderPath; + var rootFolder = Kernel.ItemRepository.RetrieveItem(rootFolderPath.GetMBId(typeof(AggregateFolder))) as AggregateFolder ?? (AggregateFolder)GetItem(rootFolderPath); + + // Add in the plug-in folders + foreach (var child in Kernel.PluginFolders) + { + rootFolder.AddVirtualChild(child); + } + + return rootFolder; + } + + /// <summary> + /// Gets a Person + /// </summary> + /// <param name="name">The name.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{Person}.</returns> + public Task<Person> GetPerson(string name, bool allowSlowProviders = false) + { + return GetPerson(name, CancellationToken.None, allowSlowProviders); + } + + /// <summary> + /// Gets a Person + /// </summary> + /// <param name="name">The name.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{Person}.</returns> + private Task<Person> GetPerson(string name, CancellationToken cancellationToken, bool allowSlowProviders = false) + { + return GetImagesByNameItem<Person>(Kernel.ApplicationPaths.PeoplePath, name, cancellationToken, allowSlowProviders); + } + + /// <summary> + /// Gets a Studio + /// </summary> + /// <param name="name">The name.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{Studio}.</returns> + public Task<Studio> GetStudio(string name, bool allowSlowProviders = false) + { + return GetImagesByNameItem<Studio>(Kernel.ApplicationPaths.StudioPath, name, CancellationToken.None, allowSlowProviders); + } + + /// <summary> + /// Gets a Genre + /// </summary> + /// <param name="name">The name.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{Genre}.</returns> + public Task<Genre> GetGenre(string name, bool allowSlowProviders = false) + { + return GetImagesByNameItem<Genre>(Kernel.ApplicationPaths.GenrePath, name, CancellationToken.None, allowSlowProviders); + } + + /// <summary> + /// The us culture + /// </summary> + private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + /// <summary> + /// Gets a Year + /// </summary> + /// <param name="value">The value.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{Year}.</returns> + /// <exception cref="System.ArgumentOutOfRangeException"></exception> + public Task<Year> GetYear(int value, bool allowSlowProviders = false) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(); + } + + return GetImagesByNameItem<Year>(Kernel.ApplicationPaths.YearPath, value.ToString(UsCulture), CancellationToken.None, allowSlowProviders); + } + + /// <summary> + /// The images by name item cache + /// </summary> + private readonly ConcurrentDictionary<string, object> ImagesByNameItemCache = new ConcurrentDictionary<string, object>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// Generically retrieves an IBN item + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="path">The path.</param> + /// <param name="name">The name.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{``0}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + private Task<T> GetImagesByNameItem<T>(string path, string name, CancellationToken cancellationToken, bool allowSlowProviders = true) + where T : BaseItem, new() + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentNullException(); + } + + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(); + } + + var key = Path.Combine(path, FileSystem.GetValidFilename(name)); + + var obj = ImagesByNameItemCache.GetOrAdd(key, keyname => CreateImagesByNameItem<T>(path, name, cancellationToken, allowSlowProviders)); + + return obj as Task<T>; + } + + /// <summary> + /// Creates an IBN item based on a given path + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="path">The path.</param> + /// <param name="name">The name.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{``0}.</returns> + /// <exception cref="System.IO.IOException">Path not created: + path</exception> + private async Task<T> CreateImagesByNameItem<T>(string path, string name, CancellationToken cancellationToken, bool allowSlowProviders = true) + where T : BaseItem, new() + { + cancellationToken.ThrowIfCancellationRequested(); + + Logger.Debug("Creating {0}: {1}", typeof(T).Name, name); + + path = Path.Combine(path, FileSystem.GetValidFilename(name)); + + var fileInfo = FileSystem.GetFileData(path); + + var isNew = false; + + if (!fileInfo.HasValue) + { + Directory.CreateDirectory(path); + fileInfo = FileSystem.GetFileData(path); + + if (!fileInfo.HasValue) + { + throw new IOException("Path not created: " + path); + } + + isNew = true; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var id = path.GetMBId(typeof(T)); + + var item = Kernel.ItemRepository.RetrieveItem(id) as T; + if (item == null) + { + item = new T + { + Name = name, + Id = id, + DateCreated = fileInfo.Value.CreationTimeUtc, + DateModified = fileInfo.Value.LastWriteTimeUtc, + Path = path + }; + isNew = true; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Set this now so we don't cause additional file system access during provider executions + item.ResetResolveArgs(fileInfo); + + await item.RefreshMetadata(cancellationToken, isNew, allowSlowProviders: allowSlowProviders).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + return item; + } + + /// <summary> + /// Validate and refresh the People sub-set of the IBN. + /// The items are stored in the db but not loaded into memory until actually requested by an operation. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + internal async Task ValidatePeople(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + // Clear the IBN cache + ImagesByNameItemCache.Clear(); + + const int maxTasks = 250; + + var tasks = new List<Task>(); + + var includedPersonTypes = new[] { PersonType.Actor, PersonType.Director }; + + var people = Kernel.RootFolder.RecursiveChildren + .Where(c => c.People != null) + .SelectMany(c => c.People.Where(p => includedPersonTypes.Contains(p.Type))) + .DistinctBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var numComplete = 0; + + foreach (var person in people) + { + if (tasks.Count > maxTasks) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + tasks.Clear(); + + // Safe cancellation point, when there are no pending tasks + cancellationToken.ThrowIfCancellationRequested(); + } + + // Avoid accessing the foreach variable within the closure + var currentPerson = person; + + tasks.Add(Task.Run(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await GetPerson(currentPerson.Name, cancellationToken, allowSlowProviders: true).ConfigureAwait(false); + } + catch (IOException ex) + { + Logger.ErrorException("Error validating IBN entry {0}", ex, currentPerson.Name); + } + + // Update progress + lock (progress) + { + numComplete++; + double percent = numComplete; + percent /= people.Count; + + progress.Report(new TaskProgress { PercentComplete = 100 * percent }); + } + })); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + progress.Report(new TaskProgress { PercentComplete = 100 }); + + Logger.Info("People validation complete"); + } + + /// <summary> + /// Reloads the root media folder + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + internal async Task ValidateMediaLibrary(IProgress<TaskProgress> progress, CancellationToken cancellationToken) + { + Logger.Info("Validating media library"); + + await Kernel.RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); + + // Start by just validating the children of the root, but go no further + await Kernel.RootFolder.ValidateChildren(new Progress<TaskProgress> { }, cancellationToken, recursive: false); + + // Validate only the collection folders for each user, just to make them available as quickly as possible + var userCollectionFolderTasks = Kernel.Users.AsParallel().Select(user => user.ValidateCollectionFolders(new Progress<TaskProgress> { }, cancellationToken)); + await Task.WhenAll(userCollectionFolderTasks).ConfigureAwait(false); + + // Now validate the entire media library + await Kernel.RootFolder.ValidateChildren(progress, cancellationToken, recursive: true).ConfigureAwait(false); + + foreach (var user in Kernel.Users) + { + await user.ValidateMediaLibrary(new Progress<TaskProgress> { }, cancellationToken).ConfigureAwait(false); + } + } + + /// <summary> + /// Saves display preferences for a Folder + /// </summary> + /// <param name="user">The user.</param> + /// <param name="folder">The folder.</param> + /// <param name="data">The data.</param> + /// <returns>Task.</returns> + public Task SaveDisplayPreferencesForFolder(User user, Folder folder, DisplayPreferences data) + { + // Need to update all items with the same DisplayPrefsId + foreach (var child in Kernel.RootFolder.GetRecursiveChildren(user) + .OfType<Folder>() + .Where(i => i.DisplayPrefsId == folder.DisplayPrefsId)) + { + child.AddOrUpdateDisplayPrefs(user, data); + } + + return Kernel.DisplayPreferencesRepository.SaveDisplayPrefs(folder, CancellationToken.None); + } + + /// <summary> + /// Gets the default view. + /// </summary> + /// <returns>IEnumerable{VirtualFolderInfo}.</returns> + public IEnumerable<VirtualFolderInfo> GetDefaultVirtualFolders() + { + return GetView(Kernel.ApplicationPaths.DefaultUserViewsPath); + } + + /// <summary> + /// Gets the view. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{VirtualFolderInfo}.</returns> + public IEnumerable<VirtualFolderInfo> GetVirtualFolders(User user) + { + return GetView(user.RootFolderPath); + } + + /// <summary> + /// Gets the view. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>IEnumerable{VirtualFolderInfo}.</returns> + private IEnumerable<VirtualFolderInfo> GetView(string path) + { + return Directory.EnumerateDirectories(path, "*", SearchOption.TopDirectoryOnly) + .Select(dir => new VirtualFolderInfo + { + Name = Path.GetFileName(dir), + Locations = Directory.EnumerateFiles(dir, "*.lnk", SearchOption.TopDirectoryOnly).Select(FileSystem.ResolveShortcut).ToList() + }); + } + } +} diff --git a/MediaBrowser.Controller/Library/Profiler.cs b/MediaBrowser.Controller/Library/Profiler.cs new file mode 100644 index 0000000000..4daa9d654c --- /dev/null +++ b/MediaBrowser.Controller/Library/Profiler.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics; +using MediaBrowser.Common.Logging; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Class Profiler + /// </summary> + public class Profiler : IDisposable + { + /// <summary> + /// The name + /// </summary> + readonly string name; + /// <summary> + /// The stopwatch + /// </summary> + readonly Stopwatch stopwatch; + + /// <summary> + /// Initializes a new instance of the <see cref="Profiler" /> class. + /// </summary> + /// <param name="name">The name.</param> + public Profiler(string name) + { + this.name = name; + + stopwatch = new Stopwatch(); + stopwatch.Start(); + } + #region IDisposable Members + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + stopwatch.Stop(); + string message; + if (stopwatch.ElapsedMilliseconds > 300000) + { + message = string.Format("{0} took {1} minutes.", + name, ((float)stopwatch.ElapsedMilliseconds / 60000).ToString("F")); + } + else + { + message = string.Format("{0} took {1} seconds.", + name, ((float)stopwatch.ElapsedMilliseconds / 1000).ToString("#0.000")); + } + Logger.LogInfo(message); + } + } + + #endregion + } +} diff --git a/MediaBrowser.Controller/Library/ResourcePool.cs b/MediaBrowser.Controller/Library/ResourcePool.cs new file mode 100644 index 0000000000..3e4d532041 --- /dev/null +++ b/MediaBrowser.Controller/Library/ResourcePool.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// This is just a collection of semaphores to control the number of concurrent executions of various resources + /// </summary> + public class ResourcePool : IDisposable + { + /// <summary> + /// You tube + /// </summary> + public readonly SemaphoreSlim YouTube = new SemaphoreSlim(5, 5); + + /// <summary> + /// The trakt + /// </summary> + public readonly SemaphoreSlim Trakt = new SemaphoreSlim(5, 5); + + /// <summary> + /// The tv db + /// </summary> + public readonly SemaphoreSlim TvDb = new SemaphoreSlim(5, 5); + + /// <summary> + /// The movie db + /// </summary> + public readonly SemaphoreSlim MovieDb = new SemaphoreSlim(5, 5); + + /// <summary> + /// The fan art + /// </summary> + public readonly SemaphoreSlim FanArt = new SemaphoreSlim(5, 5); + + /// <summary> + /// The mb + /// </summary> + public readonly SemaphoreSlim Mb = new SemaphoreSlim(5, 5); + + /// <summary> + /// Apple doesn't seem to like too many simulataneous requests. + /// </summary> + public readonly SemaphoreSlim AppleTrailerVideos = new SemaphoreSlim(1, 1); + + /// <summary> + /// The apple trailer images + /// </summary> + public readonly SemaphoreSlim AppleTrailerImages = new SemaphoreSlim(1, 1); + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + YouTube.Dispose(); + Trakt.Dispose(); + TvDb.Dispose(); + MovieDb.Dispose(); + FanArt.Dispose(); + Mb.Dispose(); + AppleTrailerVideos.Dispose(); + AppleTrailerImages.Dispose(); + } + } + } +} diff --git a/MediaBrowser.Controller/Library/UserDataManager.cs b/MediaBrowser.Controller/Library/UserDataManager.cs new file mode 100644 index 0000000000..dfa80483ec --- /dev/null +++ b/MediaBrowser.Controller/Library/UserDataManager.cs @@ -0,0 +1,219 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Connectivity; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Class UserDataManager + /// </summary> + public class UserDataManager : BaseManager<Kernel> + { + #region Events + /// <summary> + /// Occurs when [playback start]. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackStart; + /// <summary> + /// Occurs when [playback progress]. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress; + /// <summary> + /// Occurs when [playback stopped]. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackStopped; + #endregion + + /// <summary> + /// Initializes a new instance of the <see cref="UserDataManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public UserDataManager(Kernel kernel) + : base(kernel) + { + + } + + /// <summary> + /// Used to report that playback has started for an item + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void OnPlaybackStart(User user, BaseItem item, ClientType clientType, string deviceName) + { + if (user == null) + { + throw new ArgumentNullException(); + } + if (item == null) + { + throw new ArgumentNullException(); + } + + Kernel.UserManager.UpdateNowPlayingItemId(user, clientType, deviceName, item); + + // Nothing to save here + // Fire events to inform plugins + EventHelper.QueueEventIfNotNull(PlaybackStart, this, new PlaybackProgressEventArgs + { + Argument = item, + User = user + }); + } + + /// <summary> + /// Used to report playback progress for an item + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task OnPlaybackProgress(User user, BaseItem item, long? positionTicks, ClientType clientType, string deviceName) + { + if (user == null) + { + throw new ArgumentNullException(); + } + if (item == null) + { + throw new ArgumentNullException(); + } + + Kernel.UserManager.UpdateNowPlayingItemId(user, clientType, deviceName, item, positionTicks); + + if (positionTicks.HasValue) + { + var data = item.GetUserData(user, true); + + UpdatePlayState(item, data, positionTicks.Value, false); + await SaveUserDataForItem(user, item, data).ConfigureAwait(false); + } + + EventHelper.QueueEventIfNotNull(PlaybackProgress, this, new PlaybackProgressEventArgs + { + Argument = item, + User = user, + PlaybackPositionTicks = positionTicks + }); + } + + /// <summary> + /// Used to report that playback has ended for an item + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task OnPlaybackStopped(User user, BaseItem item, long? positionTicks, ClientType clientType, string deviceName) + { + if (user == null) + { + throw new ArgumentNullException(); + } + if (item == null) + { + throw new ArgumentNullException(); + } + + Kernel.UserManager.RemoveNowPlayingItemId(user, clientType, deviceName, item); + + var data = item.GetUserData(user, true); + + if (positionTicks.HasValue) + { + UpdatePlayState(item, data, positionTicks.Value, true); + } + else + { + // If the client isn't able to report this, then we'll just have to make an assumption + data.PlayCount++; + data.Played = true; + } + + await SaveUserDataForItem(user, item, data).ConfigureAwait(false); + + EventHelper.QueueEventIfNotNull(PlaybackStopped, this, new PlaybackProgressEventArgs + { + Argument = item, + User = user, + PlaybackPositionTicks = positionTicks + }); + } + + /// <summary> + /// Updates playstate position for an item but does not save + /// </summary> + /// <param name="item">The item</param> + /// <param name="data">User data for the item</param> + /// <param name="positionTicks">The current playback position</param> + /// <param name="incrementPlayCount">Whether or not to increment playcount</param> + private void UpdatePlayState(BaseItem item, UserItemData data, long positionTicks, bool incrementPlayCount) + { + // If a position has been reported, and if we know the duration + if (positionTicks > 0 && item.RunTimeTicks.HasValue && item.RunTimeTicks > 0) + { + var pctIn = Decimal.Divide(positionTicks, item.RunTimeTicks.Value) * 100; + + // Don't track in very beginning + if (pctIn < Kernel.Configuration.MinResumePct) + { + positionTicks = 0; + incrementPlayCount = false; + } + + // If we're at the end, assume completed + else if (pctIn > Kernel.Configuration.MaxResumePct || positionTicks >= item.RunTimeTicks.Value) + { + positionTicks = 0; + data.Played = true; + } + + else + { + // Enforce MinResumeDuration + var durationSeconds = TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds; + + if (durationSeconds < Kernel.Configuration.MinResumeDurationSeconds) + { + positionTicks = 0; + data.Played = true; + } + } + } + + data.PlaybackPositionTicks = positionTicks; + + if (incrementPlayCount) + { + data.PlayCount++; + data.LastPlayedDate = DateTime.UtcNow; + } + } + + /// <summary> + /// Saves user data for an item + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="data">The data.</param> + public Task SaveUserDataForItem(User user, BaseItem item, UserItemData data) + { + item.AddOrUpdateUserData(user, data); + + return Kernel.UserDataRepository.SaveUserData(item, CancellationToken.None); + } + } +} diff --git a/MediaBrowser.Controller/Library/UserManager.cs b/MediaBrowser.Controller/Library/UserManager.cs new file mode 100644 index 0000000000..af3239657b --- /dev/null +++ b/MediaBrowser.Controller/Library/UserManager.cs @@ -0,0 +1,395 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Connectivity; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Class UserManager + /// </summary> + public class UserManager : BaseManager<Kernel> + { + /// <summary> + /// The _active connections + /// </summary> + private readonly ConcurrentBag<ClientConnectionInfo> _activeConnections = + new ConcurrentBag<ClientConnectionInfo>(); + + /// <summary> + /// Gets all connections. + /// </summary> + /// <value>All connections.</value> + public IEnumerable<ClientConnectionInfo> AllConnections + { + get { return _activeConnections.Where(c => Kernel.GetUserById(c.UserId) != null).OrderByDescending(c => c.LastActivityDate); } + } + + /// <summary> + /// Gets the active connections. + /// </summary> + /// <value>The active connections.</value> + public IEnumerable<ClientConnectionInfo> ActiveConnections + { + get { return AllConnections.Where(c => (DateTime.UtcNow - c.LastActivityDate).TotalMinutes <= 10); } + } + + /// <summary> + /// Initializes a new instance of the <see cref="UserManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public UserManager(Kernel kernel) + : base(kernel) + { + } + + #region UserUpdated Event + /// <summary> + /// Occurs when [user updated]. + /// </summary> + public event EventHandler<GenericEventArgs<User>> UserUpdated; + + /// <summary> + /// Called when [user updated]. + /// </summary> + /// <param name="user">The user.</param> + internal void OnUserUpdated(User user) + { + EventHelper.QueueEventIfNotNull(UserUpdated, this, new GenericEventArgs<User> { Argument = user }); + + // Notify connected ui's + Kernel.TcpManager.SendWebSocketMessage("UserUpdated", DtoBuilder.GetDtoUser(user)); + } + #endregion + + #region UserDeleted Event + /// <summary> + /// Occurs when [user deleted]. + /// </summary> + public event EventHandler<GenericEventArgs<User>> UserDeleted; + /// <summary> + /// Called when [user deleted]. + /// </summary> + /// <param name="user">The user.</param> + internal void OnUserDeleted(User user) + { + EventHelper.QueueEventIfNotNull(UserDeleted, this, new GenericEventArgs<User> { Argument = user }); + + // Notify connected ui's + Kernel.TcpManager.SendWebSocketMessage("UserDeleted", user.Id.ToString()); + } + #endregion + + /// <summary> + /// Authenticates a User and returns a result indicating whether or not it succeeded + /// </summary> + /// <param name="user">The user.</param> + /// <param name="password">The password.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public async Task<bool> AuthenticateUser(User user, string password) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + password = password ?? string.Empty; + var existingPassword = string.IsNullOrEmpty(user.Password) ? string.Empty.GetMD5().ToString() : user.Password; + + var success = password.GetMD5().ToString().Equals(existingPassword); + + // Update LastActivityDate and LastLoginDate, then save + if (success) + { + user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + await UpdateUser(user).ConfigureAwait(false); + } + + Logger.Info("Authentication request for {0} {1}.", user.Name, (success ? "has succeeded" : "has been denied")); + + return success; + } + + /// <summary> + /// Logs the user activity. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public Task LogUserActivity(User user, ClientType clientType, string deviceName) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + var activityDate = DateTime.UtcNow; + + user.LastActivityDate = activityDate; + + LogConnection(user.Id, clientType, deviceName, activityDate); + + // Save this directly. No need to fire off all the events for this. + return Kernel.UserRepository.SaveUser(user, CancellationToken.None); + } + + /// <summary> + /// Updates the now playing item id. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <param name="item">The item.</param> + /// <param name="currentPositionTicks">The current position ticks.</param> + public void UpdateNowPlayingItemId(User user, ClientType clientType, string deviceName, BaseItem item, long? currentPositionTicks = null) + { + var conn = GetConnection(user.Id, clientType, deviceName); + + conn.NowPlayingPositionTicks = currentPositionTicks; + conn.NowPlayingItem = DtoBuilder.GetBaseItemInfo(item); + } + + /// <summary> + /// Removes the now playing item id. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <param name="item">The item.</param> + public void RemoveNowPlayingItemId(User user, ClientType clientType, string deviceName, BaseItem item) + { + var conn = GetConnection(user.Id, clientType, deviceName); + + if (conn.NowPlayingItem != null && conn.NowPlayingItem.Id.Equals(item.Id.ToString())) + { + conn.NowPlayingItem = null; + conn.NowPlayingPositionTicks = null; + } + } + + /// <summary> + /// Logs the connection. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <param name="lastActivityDate">The last activity date.</param> + private void LogConnection(Guid userId, ClientType clientType, string deviceName, DateTime lastActivityDate) + { + GetConnection(userId, clientType, deviceName).LastActivityDate = lastActivityDate; + } + + /// <summary> + /// Gets the connection. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="clientType">Type of the client.</param> + /// <param name="deviceName">Name of the device.</param> + /// <returns>ClientConnectionInfo.</returns> + private ClientConnectionInfo GetConnection(Guid userId, ClientType clientType, string deviceName) + { + var conn = _activeConnections.FirstOrDefault(c => c.UserId == userId && c.ClientType == clientType && string.Equals(deviceName, c.DeviceName, StringComparison.OrdinalIgnoreCase)); + + if (conn == null) + { + conn = new ClientConnectionInfo + { + UserId = userId, + ClientType = clientType, + DeviceName = deviceName + }; + + _activeConnections.Add(conn); + } + + return conn; + } + + /// <summary> + /// Loads the users from the repository + /// </summary> + /// <returns>IEnumerable{User}.</returns> + internal IEnumerable<User> LoadUsers() + { + var users = Kernel.UserRepository.RetrieveAllUsers().ToList(); + + // There always has to be at least one user. + if (users.Count == 0) + { + var name = Environment.UserName; + + var user = InstantiateNewUser(name); + + var task = Kernel.UserRepository.SaveUser(user, CancellationToken.None); + + // Hate having to block threads + Task.WaitAll(task); + + users.Add(user); + } + + return users; + } + + /// <summary> + /// Refreshes metadata for each user + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="force">if set to <c>true</c> [force].</param> + /// <returns>Task.</returns> + public Task RefreshUsersMetadata(CancellationToken cancellationToken, bool force = false) + { + var tasks = Kernel.Users.Select(user => user.RefreshMetadata(cancellationToken, forceRefresh: force)).ToList(); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Renames the user. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="newName">The new name.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + /// <exception cref="System.ArgumentException"></exception> + public async Task RenameUser(User user, string newName) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + if (string.IsNullOrEmpty(newName)) + { + throw new ArgumentNullException("newName"); + } + + if (Kernel.Users.Any(u => u.Id != user.Id && u.Name.Equals(newName, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", newName)); + } + + if (user.Name.Equals(newName, StringComparison.Ordinal)) + { + throw new ArgumentException("The new and old names must be different."); + } + + await user.Rename(newName); + + OnUserUpdated(user); + } + + /// <summary> + /// Updates the user. + /// </summary> + /// <param name="user">The user.</param> + /// <exception cref="System.ArgumentNullException">user</exception> + /// <exception cref="System.ArgumentException"></exception> + public async Task UpdateUser(User user) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + if (user.Id == Guid.Empty || !Kernel.Users.Any(u => u.Id.Equals(user.Id))) + { + throw new ArgumentException(string.Format("User with name '{0}' and Id {1} does not exist.", user.Name, user.Id)); + } + + user.DateModified = DateTime.UtcNow; + + await Kernel.UserRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false); + + OnUserUpdated(user); + } + + /// <summary> + /// Creates the user. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>User.</returns> + /// <exception cref="System.ArgumentNullException">name</exception> + /// <exception cref="System.ArgumentException"></exception> + public async Task<User> CreateUser(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException("name"); + } + + if (Kernel.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", name)); + } + + var user = InstantiateNewUser(name); + + var list = Kernel.Users.ToList(); + list.Add(user); + Kernel.Users = list; + + await Kernel.UserRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false); + + return user; + } + + /// <summary> + /// Deletes the user. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + /// <exception cref="System.ArgumentException"></exception> + public async Task DeleteUser(User user) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + if (Kernel.Users.FirstOrDefault(u => u.Id == user.Id) == null) + { + throw new ArgumentException(string.Format("The user cannot be deleted because there is no user with the Name {0} and Id {1}.", user.Name, user.Id)); + } + + if (Kernel.Users.Count() == 1) + { + throw new ArgumentException(string.Format("The user '{0}' be deleted because there must be at least one user in the system.", user.Name)); + } + + await Kernel.UserRepository.DeleteUser(user, CancellationToken.None).ConfigureAwait(false); + + OnUserDeleted(user); + + // Force this to be lazy loaded again + Kernel.Users = null; + } + + /// <summary> + /// Instantiates the new user. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>User.</returns> + private User InstantiateNewUser(string name) + { + return new User + { + Name = name, + Id = ("MBUser" + name).GetMD5(), + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + } + } +} diff --git a/MediaBrowser.Controller/Localization/AURatingsDictionary.cs b/MediaBrowser.Controller/Localization/AURatingsDictionary.cs new file mode 100644 index 0000000000..882302f103 --- /dev/null +++ b/MediaBrowser.Controller/Localization/AURatingsDictionary.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class AURatingsDictionary + /// </summary> + public class AURatingsDictionary : Dictionary<string, int> + { + /// <summary> + /// Initializes a new instance of the <see cref="AURatingsDictionary" /> class. + /// </summary> + public AURatingsDictionary() + { + Add("AU-G", 1); + Add("AU-PG", 5); + Add("AU-M", 6); + Add("AU-M15+", 7); + Add("AU-R18+", 9); + Add("AU-X18+", 10); + } + } +} diff --git a/MediaBrowser.Controller/Localization/BaseStrings.cs b/MediaBrowser.Controller/Localization/BaseStrings.cs new file mode 100644 index 0000000000..c76df7c7c8 --- /dev/null +++ b/MediaBrowser.Controller/Localization/BaseStrings.cs @@ -0,0 +1,290 @@ +using MediaBrowser.Common.Localization; +using System.ComponentModel.Composition; + +namespace MediaBrowser.Controller.Localization +{ + [Export(typeof(LocalizedStringData))] + public class BaseStrings : LocalizedStringData + { + public BaseStrings() + { + ThisVersion = "1.0002"; + Prefix = LocalizedStrings.BasePrefix; + } + + + + //Config Panel + public string ConfigConfig = "Configuration"; + public string VersionConfig = "Version"; + public string MediaOptionsConfig = "Media Options"; + public string ThemesConfig = "Theme Options"; + public string ParentalControlConfig = "Parental Control"; + public string ContinueConfig = "Continue"; + public string ResetDefaultsConfig = "Reset Defaults"; + public string ClearCacheConfig = "Clear Cache"; + public string UnlockConfig = "Unlock"; + public string GeneralConfig = "General"; + public string EnableScreenSaverConfig = "Screen Saver"; + public string SSTimeOutConfig = "Timeout (mins)"; + public string TrackingConfig = "Tracking"; + public string AssumeWatchedIfOlderThanConfig = "Assume Played If Older Than"; + public string MetadataConfig = "Metadata"; + public string EnableInternetProvidersConfig = "Allow Internet Providers"; + public string UpdatesConfig = "Updates"; + public string AutomaticUpdatesConfig = "Check For Updates"; + public string LoggingConfig = "Logging"; + public string BetaUpdatesConfig = "Beta Updates"; + public string GlobalConfig = "Global"; + public string EnableEHSConfig = "Enable EHS"; + public string ShowClockConfig = "Show Clock"; + public string DimUnselectedPostersConfig = "Dim Unselected Posters"; + public string HideFocusFrameConfig = "Hide Focus Frame"; + public string AlwaysShowDetailsConfig = "Always Show Details"; + public string ExcludeRemoteContentInSearchesConfig = "Exclude Remote Content In Searches"; + public string EnhancedMouseSupportConfig = "Enhanced Mouse Support"; + public string ViewsConfig = "Views"; + public string PosterGridSpacingConfig = "Poster Grid Spacing"; + public string ThumbWidthSplitConfig = "Thumb Width Split"; + public string BreadcrumbCountConfig = "Breadcrumb Count"; + public string ShowFanArtonViewsConfig = "Show Fan Art on Views"; + public string ShowInitialFolderBackgroundConfig = "Show Initial Folder Background"; + public string ShowThemeBackgroundConfig = "Show Theme Background"; + public string ShowHDOverlayonPostersConfig = "Show HD Overlay on Posters"; + public string ShowIcononRemoteContentConfig = "Show Icon on Remote Content"; + public string EnableAdvancedCmdsConfig = "Enable Advanced Commands"; + public string MediaTrackingConfig = "Media Tracking"; + public string RememberFolderIndexingConfig = "Remember Folder Indexing"; + public string ShowUnwatchedCountConfig = "Show Unplayed Count"; + public string WatchedIndicatoronFoldersConfig = "Played Indicator on Folders"; + public string HighlightUnwatchedItemsConfig = "Highlight Unplayed Items"; + public string WatchedIndicatoronVideosConfig = "Played Indicator on Items"; + public string WatchedIndicatorinDetailViewConfig = "Played Indicator in Detail View"; + public string DefaultToFirstUnwatchedItemConfig = "Default To First Unplayed Item"; + public string GeneralBehaviorConfig = "General Behavior"; + public string AllowNestedMovieFoldersConfig = "Allow Nested Movie Folders"; + public string AutoEnterSingleFolderItemsConfig = "Auto Enter Single Folder Items"; + public string MultipleFileBehaviorConfig = "Multiple File Behavior"; + public string TreatMultipleFilesAsSingleMovieConfig = "Treat Multiple Files As Single Movie"; + public string MultipleFileSizeLimitConfig = "Multiple File Size Limit"; + public string MBThemeConfig = "Media Browser Theme"; + public string VisualThemeConfig = "Visual Theme"; + public string ColorSchemeConfig = "Color Scheme *"; + public string FontSizeConfig = "Font Size *"; + public string RequiresRestartConfig = "* Requires a restart to take effect."; + public string ThemeSettingsConfig = "Theme Specific Settings"; + public string ShowConfigButtonConfig = "Show Config Button"; + public string AlphaBlendingConfig = "Alpha Blending"; + public string SecurityPINConfig = "Security PIN"; + public string PCUnlockedTxtConfig = "Parental Controls are Temporarily Unlocked. You cannot change values unless you re-lock."; + public string RelockBtnConfig = "Re-Lock"; + public string EnableParentalBlocksConfig = "Enable Parental Blocks"; + public string MaxAllowedRatingConfig = "Max Allowed Rating "; + public string BlockUnratedContentConfig = "Block Unrated Content"; + public string HideBlockedContentConfig = "Hide Blocked Content"; + public string UnlockonPINEntryConfig = "Unlock on PIN Entry"; + public string UnlockPeriodHoursConfig = "Unlock Period (Hours)"; + public string EnterNewPINConfig = "Enter New PIN"; + public string RandomizeBackdropConfig = "Randomize"; + public string RotateBackdropConfig = "Rotate"; + public string UpdateLibraryConfig = "Update Library"; + public string BackdropSettingsConfig = "Backdrop Settings"; + public string BackdropRotationIntervalConfig = "Rotation Time"; + public string BackdropTransitionIntervalConfig = "Transition Time"; + public string BackdropLoadDelayConfig = "Load Delay"; + public string AutoScrollTextConfig = "Auto Scroll Overview"; + public string SortYearsAscConfig = "Sort by Year in Ascending Order"; + public string AutoValidateConfig = "Automatically Validate Items"; + public string SaveLocalMetaConfig = "Save Locally"; + public string HideEmptyFoldersConfig = "Hide Empty TV Folders"; + + + //EHS + public string RecentlyWatchedEHS = "last played"; + public string RecentlyAddedEHS = "last added"; + public string RecentlyAddedUnwatchedEHS = "last added unplayed"; + public string WatchedEHS = "Played"; + public string AddedEHS = "Added"; + public string UnwatchedEHS = "Unplayed"; + public string AddedOnEHS = "Added on"; + public string OnEHS = "on"; + public string OfEHS = "of"; + public string NoItemsEHS = "No Items To Show"; + public string VariousEHS = "(various)"; + + //Context menu + public string CloseCMenu = "Close"; + public string PlayMenuCMenu = "Play Menu"; + public string ItemMenuCMenu = "Item Menu"; + public string PlayAllCMenu = "Play All"; + public string PlayAllFromHereCMenu = "Play All From Here"; + public string ResumeCMenu = "Resume"; + public string MarkUnwatchedCMenu = "Mark Unplayed"; + public string MarkWatchedCMenu = "Mark Played"; + public string ShufflePlayCMenu = "Shuffle Play"; + + //Media Detail Page + public string GeneralDetail = "General"; + public string ActorsDetail = "Actors"; + public string ArtistsDetail = "Artists"; + public string PlayDetail = "Play"; + public string ResumeDetail = "Resume"; + public string RefreshDetail = "Refresh"; + public string PlayTrailersDetail = "Trailer"; + public string CacheDetail = "Cache 2 xml"; + public string DeleteDetail = "Delete"; + public string TMDBRatingDetail = "TMDb Rating"; + public string OutOfDetail = "out of"; + public string DirectorDetail = "Director"; + public string ComposerDetail = "Composer"; + public string HostDetail = "Host"; + public string RuntimeDetail = "Runtime"; + public string NextItemDetail = "Next"; + public string PreviousItemDetail = "Previous"; + public string FirstAiredDetail = "First aired"; + public string LastPlayedDetail = "Last played"; + public string TrackNumberDetail = "Track"; + + public string DirectedByDetail = "Directed By: "; + public string WrittenByDetail = "Written By: "; + public string ComposedByDetail = "Composed By: "; + + //Display Prefs + public string ViewDispPref = "View"; + public string ViewSearch = "Search"; + public string CoverFlowDispPref = "Cover Flow"; + public string DetailDispPref = "Detail"; + public string PosterDispPref = "Poster"; + public string ThumbDispPref = "Thumb"; + public string ThumbStripDispPref = "Thumb Strip"; + public string ShowLabelsDispPref = "Show Labels"; + public string VerticalScrollDispPref = "Vertical Scroll"; + public string UseBannersDispPref = "Use Banners"; + public string UseCoverflowDispPref = "Use Coverflow Style"; + public string ThumbSizeDispPref = "Thumb Size"; + public string NameDispPref = "Name"; + public string DateDispPref = "Date"; + public string RatingDispPref = "User Rating"; + public string OfficialRatingDispPref = "Rating"; + public string RuntimeDispPref = "Runtime"; + public string UnWatchedDispPref = "Unplayed"; + public string YearDispPref = "Year"; + public string NoneDispPref = "None"; + public string PerformerDispPref = "Performer"; + public string ActorDispPref = "Actor"; + public string GenreDispPref = "Genre"; + public string DirectorDispPref = "Director"; + public string StudioDispPref = "Studio"; + + //Dialog boxes + //public string BrokenEnvironmentDial = "Application will now close due to broken MediaCenterEnvironment object, possibly due to 5 minutes of idle time and/or running with TVPack installed."; + //public string InitialConfigDial = "Initial configuration is complete, please restart Media Browser"; + //public string DeleteMediaDial = "Are you sure you wish to delete this media item?"; + //public string DeleteMediaCapDial = "Delete Confirmation"; + //public string NotDeletedDial = "Item NOT Deleted."; + //public string NotDeletedCapDial = "Delete Cancelled by User"; + //public string NotDelInvalidPathDial = "The selected media item cannot be deleted due to an invalid path. Or you may not have sufficient access rights to perform this command."; + //public string DelFailedDial = "Delete Failed"; + //public string NotDelUnknownDial = "The selected media item cannot be deleted due to an unknown error."; + //public string NotDelTypeDial = "The selected media item cannot be deleted due to its Item-Type or you have not enabled this feature in the configuration file."; + //public string FirstTimeDial = "As this is the first time you have run Media Browser please setup the inital configuration"; + //public string FirstTimeCapDial = "Configure"; + //public string EntryPointErrorDial = "Media Browser could not launch directly into "; + //public string EntryPointErrorCapDial = "Entrypoint Error"; + //public string CriticalErrorDial = "Media Browser encountered a critical error and had to shut down: "; + //public string CriticalErrorCapDial = "Critical Error"; + //public string ClearCacheErrorDial = "An error occured during the clearing of the cache, you may wish to manually clear it from {0} before restarting Media Browser"; + //public string RestartMBDial = "Please restart Media Browser"; + //public string ClearCacheDial = "Are you sure you wish to clear the cache?\nThis will erase all cached and downloaded information and images."; + //public string ClearCacheCapDial = "Clear Cache"; + //public string CacheClearedDial = "Cache Cleared"; + //public string ResetConfigDial = "Are you sure you wish to reset all configuration to defaults?"; + //public string ResetConfigCapDial = "Reset Configuration"; + //public string ConfigResetDial = "Configuration Reset"; + //public string UpdateMBDial = "Please visit www.mediabrowser.tv/download to install the new version."; + //public string UpdateMBCapDial = "Update Available"; + //public string UpdateMBExtDial = "There is an update available for Media Browser. Please update Media Browser next time you are at your MediaCenter PC."; + //public string DLUpdateFailDial = "Media Browser will operate normally and prompt you again the next time you load it."; + //public string DLUpdateFailCapDial = "Update Download Failed"; + //public string UpdateSuccessDial = "Media Browser must now exit to apply the update. It will restart automatically when it is done"; + //public string UpdateSuccessCapDial = "Update Downloaded"; + //public string CustomErrorDial = "Customisation Error"; + //public string ConfigErrorDial = "Reset to default?"; + //public string ConfigErrorCapDial = "Error in configuration file"; + //public string ContentErrorDial = "There was a problem playing the content. Check location exists"; + //public string ContentErrorCapDial = "Content Error"; + //public string CannotMaximizeDial = "We can not maximize the window! This is a known bug with Windows 7 and TV Pack, you will have to restart Media Browser!"; + //public string IncorrectPINDial = "Incorrect PIN Entered"; + //public string ContentProtected = "Content Protected"; + //public string CantChangePINDial = "Cannot Change PIN"; + //public string LibraryUnlockedDial = "Library Temporarily Unlocked. Will Re-Lock in {0} Hour(s) or on Application Re-Start"; + //public string LibraryUnlockedCapDial = "Unlock"; + //public string PINChangedDial = "PIN Successfully Changed"; + //public string PINChangedCapDial = "PIN Change"; + //public string EnterPINToViewDial = "Please Enter PIN to View Protected Content"; + //public string EnterPINToPlayDial = "Please Enter PIN to Play Protected Content"; + //public string EnterCurrentPINDial = "Please Enter CURRENT PIN."; + //public string EnterNewPINDial = "Please Enter NEW PIN (exactly 4 digits)."; + //public string EnterPINDial = "Please Enter PIN to Unlock Library"; + //public string NoContentDial = "No Content that can be played in this context."; + //public string FontsMissingDial = "CustomFonts.mcml as been patched with missing values"; + //public string StyleMissingDial = "{0} has been patched with missing values"; + //public string ManualRefreshDial = "Library Update Started. Will proceed in the background."; + //public string ForcedRebuildDial = "Your library is currently being migrated by the service. The service will re-start when it is finished and you may then run Media Browser."; + //public string ForcedRebuildCapDial = "Library Migration"; + //public string RefreshFailedDial = "The last service refresh process failed. Please run a manual refresh from the service."; + //public string RefreshFailedCapDial = "Service Refresh Failed"; + //public string RebuildNecDial = "This version of Media Browser requires a re-build of your library. It has started automatically in the service. Some information may be incomplete until this process finishes."; + //public string MigrateNecDial = "This version of Media Browser requires a migration of your library. It has started automatically in the service. The service will restart when it is complete and you may then run Media Browser."; + //public string RebuildFailedDial = "There was an error attempting to tell the service to re-build your library. Please run the service and do a manual refresh with the cache clear options selected."; + //public string MigrateFailedDial = "There was an error attempting to tell the service to re-build your library. Please run the service and do a manual refresh with the cache clear options selected."; + //public string RefreshFolderDial = "Refresh all contents too?"; + //public string RefreshFolderCapDial = "Refresh Folder"; + + //Generic + public string Restartstr = "Restart"; + public string Errorstr = "Error"; + public string Playstr = "Play"; + public string MinutesStr = "mins"; //Minutes abbreviation + public string HoursStr = "hrs"; //Hours abbreviation + public string EndsStr = "Ends"; + public string KBsStr = "Kbps"; //Kilobytes per second + public string FrameRateStr = "fps"; //Frames per second + public string AtStr = "at"; //x at y, e.g. 1920x1080 at 25 fps + public string Rated = "Rated"; + public string Or = "Or "; + public string Lower = "Lower"; + public string Higher = "Higher"; + public string Search = "Search"; + public string Cancel = "Cancel"; + public string TitleContains = "Title Contains "; + public string Any = "Any"; + + //Search + public string IncludeNested = "Include Subfolders"; + public string UnwatchedOnly = "Include Only Unwatched"; + public string FilterByRated = "Filter by Rating"; + + //Profiler + public string WelcomeProf = "Welcome to Media Browser"; + public string ProfilerTimeProf = "{1} took {2} seconds."; + public string RefreshProf = "Refresh"; + public string SetWatchedProf = "Set Played {0}"; + public string RefreshFolderProf = "Refresh Folder and all Contents of"; + public string ClearWatchedProf = "Clear Played {0}"; + public string FullRefreshProf = "Full Library Refresh"; + public string FullValidationProf = "Full Library Validation"; + public string FastRefreshProf = "Fast Metadata refresh"; + public string SlowRefresh = "Slow Metadata refresh"; + public string ImageRefresh = "Image refresh"; + public string PluginUpdateProf = "An update is available for plug-in {0}"; + public string NoPluginUpdateProf = "No Plugin Updates Currently Available."; + public string LibraryUnLockedProf = "Library Temporarily UnLocked. Will Re-Lock in {0} Hour(s)"; + public string LibraryReLockedProf = "Library Re-Locked"; + + //Messages + public string FullRefreshMsg = "Updating Media Library..."; + public string FullRefreshFinishedMsg = "Library update complete"; + + } +} diff --git a/MediaBrowser.Controller/Localization/GBRatingsDictionary.cs b/MediaBrowser.Controller/Localization/GBRatingsDictionary.cs new file mode 100644 index 0000000000..414abdd59d --- /dev/null +++ b/MediaBrowser.Controller/Localization/GBRatingsDictionary.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class GBRatingsDictionary + /// </summary> + public class GBRatingsDictionary : Dictionary<string, int> + { + /// <summary> + /// Initializes a new instance of the <see cref="GBRatingsDictionary" /> class. + /// </summary> + public GBRatingsDictionary() + { + Add("GB-U", 1); + Add("GB-PG", 5); + Add("GB-12", 6); + Add("GB-12A", 7); + Add("GB-15", 8); + Add("GB-18", 9); + Add("GB-R18", 15); + } + } +} diff --git a/MediaBrowser.Controller/Localization/LocalizedStrings.cs b/MediaBrowser.Controller/Localization/LocalizedStrings.cs new file mode 100644 index 0000000000..b7e66f0950 --- /dev/null +++ b/MediaBrowser.Controller/Localization/LocalizedStrings.cs @@ -0,0 +1,155 @@ +using MediaBrowser.Common.Localization; +using MediaBrowser.Common.Logging; +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Xml.Serialization; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class LocalizedStrings + /// </summary> + public class LocalizedStrings + { + /// <summary> + /// The base prefix + /// </summary> + public const string BasePrefix = "base-"; + /// <summary> + /// The local strings + /// </summary> + protected ConcurrentDictionary<string, string> LocalStrings = new ConcurrentDictionary<string, string>(); + /// <summary> + /// The _instance + /// </summary> + private static LocalizedStrings _instance; + + /// <summary> + /// Gets the instance. + /// </summary> + /// <value>The instance.</value> + public static LocalizedStrings Instance { get { return _instance ?? (_instance = new LocalizedStrings()); } } + + /// <summary> + /// Initializes a new instance of the <see cref="LocalizedStrings" /> class. + /// </summary> + public LocalizedStrings() + { + foreach (var stringObject in Kernel.Instance.StringFiles) + { + AddStringData(LoadFromFile(GetFileName(stringObject),stringObject.GetType())); + } + } + + /// <summary> + /// Gets the name of the file. + /// </summary> + /// <param name="stringObject">The string object.</param> + /// <returns>System.String.</returns> + protected string GetFileName(LocalizedStringData stringObject) + { + var path = Kernel.Instance.ApplicationPaths.LocalizationPath; + var name = Path.Combine(path, stringObject.Prefix + "strings-" + CultureInfo.CurrentCulture + ".xml"); + if (File.Exists(name)) + { + return name; + } + + name = Path.Combine(path, stringObject.Prefix + "strings-" + CultureInfo.CurrentCulture.Parent + ".xml"); + if (File.Exists(name)) + { + return name; + } + + //just return default + return Path.Combine(path, stringObject.Prefix + "strings-en.xml"); + } + + /// <summary> + /// Loads from file. + /// </summary> + /// <param name="file">The file.</param> + /// <param name="t">The t.</param> + /// <returns>LocalizedStringData.</returns> + protected LocalizedStringData LoadFromFile(string file, Type t) + { + var xs = new XmlSerializer(t); + var strings = (LocalizedStringData)Activator.CreateInstance(t); + strings.FileName = file; + Logger.LogInfo("Using String Data from {0}", file); + if (File.Exists(file)) + { + using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read)) + { + strings = (LocalizedStringData)xs.Deserialize(fs); + } + } + else + { + strings.Save(); //brand new - save it + } + + if (strings.ThisVersion != strings.Version && file.ToLower().Contains("-en.xml")) + { + //only re-save the english version as that is the one defined internally + strings = new BaseStrings {FileName = file}; + strings.Save(); + } + return strings; + + } + + /// <summary> + /// Adds the string data. + /// </summary> + /// <param name="stringData">The string data.</param> + public void AddStringData(object stringData ) + { + //translate our object definition into a dictionary for lookups + // and a reverse dictionary so we can lookup keys by value + foreach (var field in stringData.GetType().GetFields().Where(f => f != null && f.FieldType == typeof(string))) + { + string value; + + try + { + value = field.GetValue(stringData) as string; + } + catch (TargetException ex) + { + Logger.LogException("Error getting value for field: {0}", ex, field.Name); + continue; + } + catch (FieldAccessException ex) + { + Logger.LogException("Error getting value for field: {0}", ex, field.Name); + continue; + } + catch (NotSupportedException ex) + { + Logger.LogException("Error getting value for field: {0}", ex, field.Name); + continue; + } + + LocalStrings.TryAdd(field.Name, value); + } + } + + /// <summary> + /// Gets the string. + /// </summary> + /// <param name="key">The key.</param> + /// <returns>System.String.</returns> + public string GetString(string key) + { + string value; + + LocalStrings.TryGetValue(key, out value); + return value; + } + } +} diff --git a/MediaBrowser.Controller/Localization/NLRatingsDictionary.cs b/MediaBrowser.Controller/Localization/NLRatingsDictionary.cs new file mode 100644 index 0000000000..7a20f50ba4 --- /dev/null +++ b/MediaBrowser.Controller/Localization/NLRatingsDictionary.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class NLRatingsDictionary + /// </summary> + public class NLRatingsDictionary : Dictionary<string, int> + { + /// <summary> + /// Initializes a new instance of the <see cref="NLRatingsDictionary" /> class. + /// </summary> + public NLRatingsDictionary() + { + Add("NL-AL", 1); + Add("NL-MG6", 2); + Add("NL-6", 3); + Add("NL-9", 5); + Add("NL-12", 6); + Add("NL-16", 8); + } + } +} diff --git a/MediaBrowser.Controller/Localization/Ratings.cs b/MediaBrowser.Controller/Localization/Ratings.cs new file mode 100644 index 0000000000..946e25f536 --- /dev/null +++ b/MediaBrowser.Controller/Localization/Ratings.cs @@ -0,0 +1,162 @@ +using MediaBrowser.Common.Extensions; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class Ratings + /// </summary> + public static class Ratings + { + /// <summary> + /// The ratings def + /// </summary> + private static RatingsDefinition ratingsDef; + /// <summary> + /// The _ratings dict + /// </summary> + private static Dictionary<string, int> _ratingsDict; + /// <summary> + /// Gets the ratings dict. + /// </summary> + /// <value>The ratings dict.</value> + public static Dictionary<string, int> RatingsDict + { + get { return _ratingsDict ?? (_ratingsDict = Initialize(false)); } + } + /// <summary> + /// The ratings strings + /// </summary> + private static readonly Dictionary<int, string> ratingsStrings = new Dictionary<int, string>(); + + /// <summary> + /// Initializes the specified block unrated. + /// </summary> + /// <param name="blockUnrated">if set to <c>true</c> [block unrated].</param> + /// <returns>Dictionary{System.StringSystem.Int32}.</returns> + public static Dictionary<string, int> Initialize(bool blockUnrated) + { + //build our ratings dictionary from the combined local one and us one + ratingsDef = new RatingsDefinition(Path.Combine(Kernel.Instance.ApplicationPaths.LocalizationPath, "Ratings-" + Kernel.Instance.Configuration.MetadataCountryCode+".txt")); + //global value of None + var dict = new Dictionary<string, int> {{"None", -1}}; + foreach (var pair in ratingsDef.RatingsDict) + { + dict.TryAdd(pair.Key, pair.Value); + } + if (Kernel.Instance.Configuration.MetadataCountryCode.ToUpper() != "US") + { + foreach (var pair in new USRatingsDictionary()) + { + dict.TryAdd(pair.Key, pair.Value); + } + } + //global values of CS + dict.TryAdd("CS", 1000); + + dict.TryAdd("", blockUnrated ? 1000 : 0); + + //and rating reverse lookup dictionary (non-redundant ones) + ratingsStrings.Clear(); + var lastLevel = -10; + ratingsStrings.Add(-1,LocalizedStrings.Instance.GetString("Any")); + foreach (var pair in ratingsDef.RatingsDict.OrderBy(p => p.Value)) + { + if (pair.Value > lastLevel) + { + lastLevel = pair.Value; + ratingsStrings.TryAdd(pair.Value, pair.Key); + } + } + + ratingsStrings.TryAdd(999, "CS"); + + return dict; + } + + /// <summary> + /// Switches the unrated. + /// </summary> + /// <param name="block">if set to <c>true</c> [block].</param> + public static void SwitchUnrated(bool block) + { + RatingsDict.Remove(""); + RatingsDict.Add("", block ? 1000 : 0); + } + + /// <summary> + /// Levels the specified rating STR. + /// </summary> + /// <param name="ratingStr">The rating STR.</param> + /// <returns>System.Int32.</returns> + public static int Level(string ratingStr) + { + if (ratingStr == null) ratingStr = ""; + if (RatingsDict.ContainsKey(ratingStr)) + return RatingsDict[ratingStr]; + + string stripped = StripCountry(ratingStr); + if (RatingsDict.ContainsKey(stripped)) + return RatingsDict[stripped]; + + return RatingsDict[""]; //return "unknown" level + } + + /// <summary> + /// Strips the country. + /// </summary> + /// <param name="rating">The rating.</param> + /// <returns>System.String.</returns> + private static string StripCountry(string rating) + { + int start = rating.IndexOf('-'); + return start > 0 ? rating.Substring(start + 1) : rating; + } + + /// <summary> + /// Returns a <see cref="System.String" /> that represents this instance. + /// </summary> + /// <param name="level">The level.</param> + /// <returns>A <see cref="System.String" /> that represents this instance.</returns> + public static string ToString(int level) + { + //return the closest one + while (level > 0) + { + if (ratingsStrings.ContainsKey(level)) + return ratingsStrings[level]; + + level--; + } + return ratingsStrings.Values.FirstOrDefault(); //default to first one + } + /// <summary> + /// To the strings. + /// </summary> + /// <returns>List{System.String}.</returns> + public static List<string> ToStrings() + { + //return the whole list of ratings strings + return ratingsStrings.Values.ToList(); + } + + /// <summary> + /// To the values. + /// </summary> + /// <returns>List{System.Int32}.</returns> + public static List<int> ToValues() + { + //return the whole list of ratings values + return ratingsStrings.Keys.ToList(); + } + + //public Microsoft.MediaCenter.UI.Image RatingImage(string rating) + //{ + // return Helper.GetMediaInfoImage("Rated_" + rating); + //} + + + } +} diff --git a/MediaBrowser.Controller/Localization/RatingsDefinition.cs b/MediaBrowser.Controller/Localization/RatingsDefinition.cs new file mode 100644 index 0000000000..65bd3bcbb4 --- /dev/null +++ b/MediaBrowser.Controller/Localization/RatingsDefinition.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MediaBrowser.Common.Logging; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class RatingsDefinition + /// </summary> + public class RatingsDefinition + { + /// <summary> + /// Initializes a new instance of the <see cref="RatingsDefinition" /> class. + /// </summary> + /// <param name="file">The file.</param> + public RatingsDefinition(string file) + { + Logger.LogInfo("Loading Certification Ratings from file " + file); + this.file = file; + if (!Load()) + { + Init(Kernel.Instance.Configuration.MetadataCountryCode.ToUpper()); + } + } + + /// <summary> + /// Inits the specified country. + /// </summary> + /// <param name="country">The country.</param> + protected void Init(string country) + { + //intitialze based on country + switch (country) + { + case "US": + RatingsDict = new USRatingsDictionary(); + break; + case "GB": + RatingsDict = new GBRatingsDictionary(); + break; + case "NL": + RatingsDict = new NLRatingsDictionary(); + break; + case "AU": + RatingsDict = new AURatingsDictionary(); + break; + default: + RatingsDict = new USRatingsDictionary(); + break; + } + Save(); + } + + /// <summary> + /// The file + /// </summary> + readonly string file; + + /// <summary> + /// Save to file + /// </summary> + public void Save() + { + // Use simple text serialization - no need for xml + using (var fs = new StreamWriter(file)) + { + foreach (var pair in RatingsDict) + { + fs.WriteLine(pair.Key + "," + pair.Value); + } + } + } + + /// <summary> + /// Load from file + /// </summary> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected bool Load() + { + // Read back in our simple serialized format + RatingsDict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + try + { + using (var fs = new StreamReader(file)) + { + while (!fs.EndOfStream) + { + var line = fs.ReadLine() ?? ""; + var values = line.Split(','); + if (values.Length == 2) + { + + int value; + + if (int.TryParse(values[1], out value)) + { + RatingsDict[values[0].Trim()] = value; + } + else + { + Logger.LogError("Invalid line in ratings file " + file + "(" + line + ")"); + } + } + } + } + } + catch + { + // Couldn't load - probably just not there yet + return false; + } + return true; + } + + /// <summary> + /// The ratings dict + /// </summary> + public Dictionary<string, int> RatingsDict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + + } +} diff --git a/MediaBrowser.Controller/Localization/USRatingsDictionary.cs b/MediaBrowser.Controller/Localization/USRatingsDictionary.cs new file mode 100644 index 0000000000..2c7a69841c --- /dev/null +++ b/MediaBrowser.Controller/Localization/USRatingsDictionary.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Localization +{ + /// <summary> + /// Class USRatingsDictionary + /// </summary> + public class USRatingsDictionary : Dictionary<string,int> + { + /// <summary> + /// Initializes a new instance of the <see cref="USRatingsDictionary" /> class. + /// </summary> + public USRatingsDictionary() + { + Add("G", 1); + Add("E", 1); + Add("EC", 1); + Add("TV-G", 1); + Add("TV-Y", 2); + Add("TV-Y7", 3); + Add("TV-Y7-FV", 4); + Add("PG", 5); + Add("TV-PG", 5); + Add("PG-13", 7); + Add("T", 7); + Add("TV-14", 8); + Add("R", 9); + Add("M", 9); + Add("TV-MA", 9); + Add("NC-17", 10); + Add("AO", 15); + Add("RP", 15); + Add("UR", 15); + Add("NR", 15); + Add("X", 15); + Add("XXX", 100); + } + } +} diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index fc1e578e99..88e5ed551b 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -1,150 +1,279 @@ -<?xml version="1.0" encoding="utf-8"?>
-<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
- <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
- <PropertyGroup>
- <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
- <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
- <ProjectGuid>{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}</ProjectGuid>
- <OutputType>Library</OutputType>
- <AppDesignerFolder>Properties</AppDesignerFolder>
- <RootNamespace>MediaBrowser.Controller</RootNamespace>
- <AssemblyName>MediaBrowser.Controller</AssemblyName>
- <TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
- <FileAlignment>512</FileAlignment>
- </PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
- <DebugSymbols>true</DebugSymbols>
- <DebugType>full</DebugType>
- <Optimize>false</Optimize>
- <OutputPath>bin\Debug\</OutputPath>
- <DefineConstants>DEBUG;TRACE</DefineConstants>
- <ErrorReport>prompt</ErrorReport>
- <WarningLevel>4</WarningLevel>
- </PropertyGroup>
- <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
- <DebugType>pdbonly</DebugType>
- <Optimize>true</Optimize>
- <OutputPath>bin\Release\</OutputPath>
- <DefineConstants>TRACE</DefineConstants>
- <ErrorReport>prompt</ErrorReport>
- <WarningLevel>4</WarningLevel>
- </PropertyGroup>
- <ItemGroup>
- <Reference Include="protobuf-net">
- <HintPath>..\protobuf-net\Full\net30\protobuf-net.dll</HintPath>
- </Reference>
- <Reference Include="System" />
- <Reference Include="System.ComponentModel.Composition" />
- <Reference Include="System.Core" />
- <Reference Include="System.Drawing" />
- <Reference Include="System.Net.Http" />
- <Reference Include="System.Net.Http.WebRequest" />
- <Reference Include="System.Reactive.Core, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL">
- <SpecificVersion>False</SpecificVersion>
- <HintPath>..\packages\Rx-Core.2.0.20823\lib\Net45\System.Reactive.Core.dll</HintPath>
- </Reference>
- <Reference Include="System.Reactive.Interfaces, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL">
- <SpecificVersion>False</SpecificVersion>
- <HintPath>..\packages\Rx-Interfaces.2.0.20823\lib\Net45\System.Reactive.Interfaces.dll</HintPath>
- </Reference>
- <Reference Include="System.Reactive.Linq, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL">
- <SpecificVersion>False</SpecificVersion>
- <HintPath>..\packages\Rx-Linq.2.0.20823\lib\Net45\System.Reactive.Linq.dll</HintPath>
- </Reference>
- <Reference Include="System.Runtime.Serialization" />
- <Reference Include="System.Xml.Linq" />
- <Reference Include="System.Data.DataSetExtensions" />
- <Reference Include="Microsoft.CSharp" />
- <Reference Include="System.Data" />
- <Reference Include="System.Xml" />
- </ItemGroup>
- <ItemGroup>
- <Compile Include="Drawing\DrawingUtils.cs" />
- <Compile Include="Drawing\ImageProcessor.cs" />
- <Compile Include="Entities\Audio.cs" />
- <Compile Include="Entities\BaseEntity.cs" />
- <Compile Include="Entities\BaseItem.cs" />
- <Compile Include="Entities\Folder.cs" />
- <Compile Include="Entities\Genre.cs" />
- <Compile Include="Entities\Movies\BoxSet.cs" />
- <Compile Include="Entities\Movies\Movie.cs" />
- <Compile Include="Entities\Person.cs" />
- <Compile Include="Entities\Studio.cs" />
- <Compile Include="Entities\TV\Episode.cs" />
- <Compile Include="Entities\TV\Season.cs" />
- <Compile Include="Entities\TV\Series.cs" />
- <Compile Include="Entities\User.cs" />
- <Compile Include="Entities\UserItemData.cs" />
- <Compile Include="Entities\Video.cs" />
- <Compile Include="Entities\Year.cs" />
- <Compile Include="IO\FileSystemHelper.cs" />
- <Compile Include="Library\ChildrenChangedEventArgs.cs" />
- <Compile Include="Providers\BaseProviderInfo.cs" />
- <Compile Include="Providers\Movies\MovieProviderFromXml.cs" />
- <Compile Include="Providers\Movies\MovieSpecialFeaturesProvider.cs" />
- <Compile Include="Providers\TV\EpisodeImageFromMediaLocationProvider.cs" />
- <Compile Include="Providers\TV\EpisodeProviderFromXml.cs" />
- <Compile Include="Providers\TV\EpisodeXmlParser.cs" />
- <Compile Include="Providers\TV\SeriesProviderFromXml.cs" />
- <Compile Include="Providers\TV\SeriesXmlParser.cs" />
- <Compile Include="Resolvers\EntityResolutionHelper.cs" />
- <Compile Include="Resolvers\Movies\BoxSetResolver.cs" />
- <Compile Include="Resolvers\Movies\MovieResolver.cs" />
- <Compile Include="Resolvers\TV\EpisodeResolver.cs" />
- <Compile Include="Resolvers\TV\SeasonResolver.cs" />
- <Compile Include="Resolvers\TV\SeriesResolver.cs" />
- <Compile Include="Resolvers\TV\TVUtils.cs" />
- <Compile Include="ServerApplicationPaths.cs" />
- <Compile Include="Library\ItemResolveEventArgs.cs" />
- <Compile Include="FFMpeg\FFProbe.cs" />
- <Compile Include="FFMpeg\FFProbeResult.cs" />
- <Compile Include="IO\DirectoryWatchers.cs" />
- <Compile Include="IO\FileData.cs" />
- <Compile Include="IO\Shortcut.cs" />
- <Compile Include="Library\ItemController.cs" />
- <Compile Include="Kernel.cs" />
- <Compile Include="Properties\AssemblyInfo.cs" />
- <Compile Include="Providers\BaseMetadataProvider.cs" />
- <Compile Include="Providers\AudioInfoProvider.cs" />
- <Compile Include="Providers\FolderProviderFromXml.cs" />
- <Compile Include="Providers\ImageFromMediaLocationProvider.cs" />
- <Compile Include="Providers\LocalTrailerProvider.cs" />
- <Compile Include="Providers\VideoInfoProvider.cs" />
- <Compile Include="Resolvers\AudioResolver.cs" />
- <Compile Include="Resolvers\BaseItemResolver.cs" />
- <Compile Include="Resolvers\FolderResolver.cs" />
- <Compile Include="Resolvers\VideoResolver.cs" />
- <Compile Include="Weather\BaseWeatherProvider.cs" />
- <Compile Include="Weather\WeatherProvider.cs" />
- <Compile Include="Providers\BaseItemXmlParser.cs" />
- <Compile Include="Xml\XmlExtensions.cs" />
- </ItemGroup>
- <ItemGroup>
- <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj">
- <Project>{9142eefa-7570-41e1-bfcc-468bb571af2f}</Project>
- <Name>MediaBrowser.Common</Name>
- </ProjectReference>
- <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj">
- <Project>{7eeeb4bb-f3e8-48fc-b4c5-70f0fff8329b}</Project>
- <Name>MediaBrowser.Model</Name>
- </ProjectReference>
- </ItemGroup>
- <ItemGroup>
- <None Include="packages.config" />
- </ItemGroup>
- <ItemGroup>
- <EmbeddedResource Include="FFMpeg\ffmpeg.exe" />
- </ItemGroup>
- <ItemGroup>
- <EmbeddedResource Include="FFMpeg\ffprobe.exe" />
- <Content Include="FFMpeg\readme.txt" />
- </ItemGroup>
- <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
- <!-- To modify your build process, add your task inside one of the targets below and uncomment it.
- Other similar extension points exist, see Microsoft.Common.targets.
- <Target Name="BeforeBuild">
- </Target>
- <Target Name="AfterBuild">
- </Target>
- -->
+<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> + <ProjectGuid>{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}</ProjectGuid> + <OutputType>Library</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>MediaBrowser.Controller</RootNamespace> + <AssemblyName>MediaBrowser.Controller</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> + <RestorePackages>true</RestorePackages> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>bin\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>bin\Release\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup> + <RunPostBuildEvent>Always</RunPostBuildEvent> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> + <DebugSymbols>true</DebugSymbols> + <OutputPath>bin\x86\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <DebugType>full</DebugType> + <PlatformTarget>x86</PlatformTarget> + <ErrorReport>prompt</ErrorReport> + <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> + <OutputPath>bin\x86\Release\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <Optimize>true</Optimize> + <DebugType>pdbonly</DebugType> + <PlatformTarget>x86</PlatformTarget> + <ErrorReport>prompt</ErrorReport> + <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + <ItemGroup> + <Reference Include="Ionic.Zip"> + <HintPath>..\packages\DotNetZip.1.9.1.8\lib\net20\Ionic.Zip.dll</HintPath> + </Reference> + <Reference Include="Mediabrowser.PluginSecurity"> + <HintPath>Plugins\Mediabrowser.PluginSecurity.dll</HintPath> + </Reference> + <Reference Include="MoreLinq"> + <HintPath>..\packages\morelinq.1.0.15631-beta\lib\net35\MoreLinq.dll</HintPath> + </Reference> + <Reference Include="protobuf-net, Version=2.0.0.621, Culture=neutral, PublicKeyToken=257b51d87d2e4d67, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\protobuf-net.2.0.0.621\lib\net40\protobuf-net.dll</HintPath> + </Reference> + <Reference Include="System" /> + <Reference Include="System.ComponentModel.Composition" /> + <Reference Include="System.Core" /> + <Reference Include="System.Data.SQLite, Version=1.0.84.0, Culture=neutral, PublicKeyToken=db937bc2d44ff139, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\System.Data.SQLite.1.0.84.0\lib\net45\System.Data.SQLite.dll</HintPath> + </Reference> + <Reference Include="System.Data.SQLite.Linq, Version=1.0.84.0, Culture=neutral, PublicKeyToken=db937bc2d44ff139, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\System.Data.SQLite.1.0.84.0\lib\net45\System.Data.SQLite.Linq.dll</HintPath> + </Reference> + <Reference Include="System.Deployment" /> + <Reference Include="System.Drawing" /> + <Reference Include="System.Net" /> + <Reference Include="System.Reactive.Core, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\Rx-Core.2.0.21114\lib\Net45\System.Reactive.Core.dll</HintPath> + </Reference> + <Reference Include="System.Reactive.Interfaces, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\Rx-Interfaces.2.0.21114\lib\Net45\System.Reactive.Interfaces.dll</HintPath> + </Reference> + <Reference Include="System.Reactive.Linq, Version=2.0.20823.0, Culture=neutral, PublicKeyToken=f300afd708cefcd3, processorArchitecture=MSIL"> + <SpecificVersion>False</SpecificVersion> + <HintPath>..\packages\Rx-Linq.2.0.21114\lib\Net45\System.Reactive.Linq.dll</HintPath> + </Reference> + <Reference Include="System.Runtime.Serialization" /> + <Reference Include="System.Xml.Linq" /> + <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="Microsoft.CSharp" /> + <Reference Include="System.Data" /> + <Reference Include="System.Xml" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Drawing\ImageManager.cs" /> + <Compile Include="Entities\AggregateFolder.cs" /> + <Compile Include="Entities\Audio\Audio.cs" /> + <Compile Include="Entities\Audio\MusicAlbum.cs" /> + <Compile Include="Entities\Audio\MusicArtist.cs" /> + <Compile Include="Entities\BaseItem.cs" /> + <Compile Include="Entities\BasePluginFolder.cs" /> + <Compile Include="Entities\Folder.cs" /> + <Compile Include="Entities\Genre.cs" /> + <Compile Include="Entities\ICollectionFolder.cs" /> + <Compile Include="Entities\IndexFolder.cs" /> + <Compile Include="Entities\Movies\BoxSet.cs" /> + <Compile Include="Entities\ISupportsSpecialFeatures.cs" /> + <Compile Include="Entities\Movies\Movie.cs" /> + <Compile Include="Entities\Person.cs" /> + <Compile Include="Entities\PlaybackProgressEventArgs.cs" /> + <Compile Include="Entities\Studio.cs" /> + <Compile Include="Entities\Trailer.cs" /> + <Compile Include="Entities\TV\Episode.cs" /> + <Compile Include="Entities\TV\Season.cs" /> + <Compile Include="Entities\TV\Series.cs" /> + <Compile Include="Entities\User.cs" /> + <Compile Include="Entities\UserItemData.cs" /> + <Compile Include="Entities\UserRootFolder.cs" /> + <Compile Include="Entities\Video.cs" /> + <Compile Include="Entities\CollectionFolder.cs" /> + <Compile Include="Entities\Year.cs" /> + <Compile Include="IO\FileSystemManager.cs" /> + <Compile Include="Library\ChildrenChangedEventArgs.cs" /> + <Compile Include="Library\DtoBuilder.cs" /> + <Compile Include="Library\Profiler.cs" /> + <Compile Include="Library\UserDataManager.cs" /> + <Compile Include="Library\UserManager.cs" /> + <Compile Include="Localization\AURatingsDictionary.cs" /> + <Compile Include="Localization\BaseStrings.cs" /> + <Compile Include="Localization\GBRatingsDictionary.cs" /> + <Compile Include="Localization\LocalizedStrings.cs" /> + <Compile Include="Localization\NLRatingsDictionary.cs" /> + <Compile Include="Localization\Ratings.cs" /> + <Compile Include="Localization\RatingsDefinition.cs" /> + <Compile Include="Localization\USRatingsDictionary.cs" /> + <Compile Include="MediaInfo\BDInfoResult.cs" /> + <Compile Include="MediaInfo\FFMpegManager.cs" /> + <Compile Include="MediaInfo\FFProbeResult.cs" /> + <Compile Include="Persistence\IDisplayPreferencesRepository.cs" /> + <Compile Include="Persistence\IItemRepository.cs" /> + <Compile Include="Persistence\IRepository.cs" /> + <Compile Include="Persistence\IUserDataRepository.cs" /> + <Compile Include="Persistence\IUserRepository.cs" /> + <Compile Include="Persistence\SQLite\SQLiteDisplayPreferencesRepository.cs" /> + <Compile Include="Persistence\SQLite\SQLiteExtensions.cs" /> + <Compile Include="Persistence\SQLite\SQLiteItemRepository.cs" /> + <Compile Include="Persistence\SQLite\SQLiteRepository.cs" /> + <Compile Include="Persistence\SQLite\SQLiteUserDataRepository.cs" /> + <Compile Include="Persistence\SQLite\SQLiteUserRepository.cs" /> + <Compile Include="Persistence\TypeMapper.cs" /> + <Compile Include="Playback\BaseIntroProvider.cs" /> + <Compile Include="Plugins\BaseConfigurationPage.cs" /> + <Compile Include="Plugins\PluginSecurityManager.cs" /> + <Compile Include="Providers\BaseImageEnhancer.cs" /> + <Compile Include="Providers\FanartBaseProvider.cs" /> + <Compile Include="Providers\ImagesByNameProvider.cs" /> + <Compile Include="Providers\MediaInfo\BaseFFMpegImageProvider.cs" /> + <Compile Include="Providers\MediaInfo\BaseFFMpegProvider.cs" /> + <Compile Include="Providers\MediaInfo\BDInfoProvider.cs" /> + <Compile Include="Providers\MediaInfo\FFMpegAudioImageProvider.cs" /> + <Compile Include="Providers\MediaInfo\BaseFFProbeProvider.cs" /> + <Compile Include="Providers\BaseProviderInfo.cs" /> + <Compile Include="Providers\Movies\FanArtMovieProvider.cs" /> + <Compile Include="Providers\Movies\MovieDbProvider.cs" /> + <Compile Include="Providers\Movies\MovieProviderFromJson.cs" /> + <Compile Include="Providers\Movies\MovieProviderFromXml.cs" /> + <Compile Include="Providers\Movies\PersonProviderFromJson.cs" /> + <Compile Include="Providers\Movies\TmdbPersonProvider.cs" /> + <Compile Include="Providers\ProviderManager.cs" /> + <Compile Include="Providers\SortNameProvider.cs" /> + <Compile Include="Providers\TV\EpisodeImageFromMediaLocationProvider.cs" /> + <Compile Include="Providers\TV\EpisodeProviderFromXml.cs" /> + <Compile Include="Providers\TV\EpisodeXmlParser.cs" /> + <Compile Include="Providers\TV\FanArtTVProvider.cs" /> + <Compile Include="Providers\TV\RemoteEpisodeProvider.cs"> + <SubType>Code</SubType> + </Compile> + <Compile Include="Providers\TV\RemoteSeasonProvider.cs"> + <SubType>Code</SubType> + </Compile> + <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\Audio\MusicAlbumResolver.cs" /> + <Compile Include="Resolvers\Audio\MusicArtistResolver.cs" /> + <Compile Include="Resolvers\BaseResolutionIgnoreRule.cs" /> + <Compile Include="Resolvers\CoreResolutionIgnoreRule.cs" /> + <Compile Include="Resolvers\EntityResolutionHelper.cs" /> + <Compile Include="Resolvers\LocalTrailerResolver.cs" /> + <Compile Include="Resolvers\Movies\BoxSetResolver.cs" /> + <Compile Include="Resolvers\Movies\MovieResolver.cs" /> + <Compile Include="Resolvers\TV\EpisodeResolver.cs" /> + <Compile Include="Resolvers\TV\SeasonResolver.cs" /> + <Compile Include="Resolvers\TV\SeriesResolver.cs" /> + <Compile Include="Resolvers\TV\TVUtils.cs" /> + <Compile Include="Library\ResourcePool.cs" /> + <Compile Include="ScheduledTasks\ChapterImagesTask.cs" /> + <Compile Include="ScheduledTasks\ImageCleanupTask.cs" /> + <Compile Include="ScheduledTasks\PeopleValidationTask.cs" /> + <Compile Include="ScheduledTasks\PluginUpdateTask.cs" /> + <Compile Include="ScheduledTasks\RefreshMediaLibraryTask.cs" /> + <Compile Include="ServerApplicationPaths.cs" /> + <Compile Include="Library\ItemResolveArgs.cs" /> + <Compile Include="IO\DirectoryWatchers.cs" /> + <Compile Include="IO\FileData.cs" /> + <Compile Include="Library\LibraryManager.cs" /> + <Compile Include="Kernel.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Providers\BaseMetadataProvider.cs" /> + <Compile Include="Providers\MediaInfo\FFProbeAudioInfoProvider.cs" /> + <Compile Include="Providers\FolderProviderFromXml.cs" /> + <Compile Include="Providers\ImageFromMediaLocationProvider.cs" /> + <Compile Include="Providers\MediaInfo\FFProbeVideoInfoProvider.cs" /> + <Compile Include="Resolvers\Audio\AudioResolver.cs" /> + <Compile Include="Resolvers\BaseItemResolver.cs" /> + <Compile Include="Resolvers\FolderResolver.cs" /> + <Compile Include="Resolvers\VideoResolver.cs" /> + <Compile Include="Sorting\BaseItemComparer.cs" /> + <Compile Include="Sorting\SortOrder.cs" /> + <Compile Include="Updates\InstallationManager.cs" /> + <Compile Include="Weather\BaseWeatherProvider.cs" /> + <Compile Include="Weather\WeatherProvider.cs" /> + <Compile Include="Providers\BaseItemXmlParser.cs" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\BDInfo\BDInfo.csproj"> + <Project>{07b509c0-0c28-4f3f-8963-5263281f7e3d}</Project> + <Name>BDInfo</Name> + </ProjectReference> + <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj"> + <Project>{9142eefa-7570-41e1-bfcc-468bb571af2f}</Project> + <Name>MediaBrowser.Common</Name> + </ProjectReference> + <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj"> + <Project>{7eeeb4bb-f3e8-48fc-b4c5-70f0fff8329b}</Project> + <Name>MediaBrowser.Model</Name> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Include="MediaInfo\ffmpeg20130209.zip" /> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <Content Include="MediaInfo\readme.txt" /> + <Content Include="x64\SQLite.Interop.dll"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + <Content Include="x86\SQLite.Interop.dll"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> + <ItemGroup /> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <PropertyGroup> + <PostBuildEvent> + </PostBuildEvent> + </PropertyGroup> + <PropertyGroup> + <PreBuildEvent> + </PreBuildEvent> + </PropertyGroup> + <Import Project="$(SolutionDir)\.nuget\nuget.targets" /> + <!-- To modify your build process, add your task inside one of the targets below and uncomment it. + Other similar extension points exist, see Microsoft.Common.targets. + <Target Name="BeforeBuild"> + </Target> + <Target Name="AfterBuild"> + </Target> + --> </Project>
\ No newline at end of file diff --git a/MediaBrowser.Controller/MediaInfo/BDInfoResult.cs b/MediaBrowser.Controller/MediaInfo/BDInfoResult.cs new file mode 100644 index 0000000000..08d1ce8a0b --- /dev/null +++ b/MediaBrowser.Controller/MediaInfo/BDInfoResult.cs @@ -0,0 +1,41 @@ +using MediaBrowser.Model.Entities; +using ProtoBuf; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.MediaInfo +{ + /// <summary> + /// Represents the result of BDInfo output + /// </summary> + [ProtoContract] + public class BDInfoResult + { + /// <summary> + /// Gets or sets the media streams. + /// </summary> + /// <value>The media streams.</value> + [ProtoMember(1)] + public List<MediaStream> MediaStreams { get; set; } + + /// <summary> + /// Gets or sets the run time ticks. + /// </summary> + /// <value>The run time ticks.</value> + [ProtoMember(2)] + public long? RunTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the files. + /// </summary> + /// <value>The files.</value> + [ProtoMember(3)] + public List<string> Files { get; set; } + + /// <summary> + /// Gets or sets the chapters. + /// </summary> + /// <value>The chapters.</value> + [ProtoMember(4)] + public List<double> Chapters { get; set; } + } +} diff --git a/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs b/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs new file mode 100644 index 0000000000..ee7c87f901 --- /dev/null +++ b/MediaBrowser.Controller/MediaInfo/FFMpegManager.cs @@ -0,0 +1,1078 @@ +using Ionic.Zip; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.MediaInfo +{ + /// <summary> + /// Class FFMpegManager + /// </summary> + public class FFMpegManager : BaseManager<Kernel> + { + /// <summary> + /// Holds the list of new items to generate chapter image for when the NewItemTimer expires + /// </summary> + private readonly List<Video> _newlyAddedItems = new List<Video>(); + + /// <summary> + /// The amount of time to wait before generating chapter images + /// </summary> + private const int NewItemDelay = 300000; + + /// <summary> + /// The current new item timer + /// </summary> + /// <value>The new item timer.</value> + private Timer NewItemTimer { get; set; } + + /// <summary> + /// Gets or sets the video image cache. + /// </summary> + /// <value>The video image cache.</value> + 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> + internal FileSystemRepository SubtitleCache { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="FFMpegManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public FFMpegManager(Kernel kernel) + : base(kernel) + { + // Not crazy about this but it's the only way to suppress ffmpeg crash dialog boxes + SetErrorMode(ErrorModes.SEM_FAILCRITICALERRORS | ErrorModes.SEM_NOALIGNMENTFAULTEXCEPT | ErrorModes.SEM_NOGPFAULTERRORBOX | ErrorModes.SEM_NOOPENFILEERRORBOX); + + VideoImageCache = new FileSystemRepository(VideoImagesDataPath); + AudioImageCache = new FileSystemRepository(AudioImagesDataPath); + SubtitleCache = new FileSystemRepository(SubtitleCachePath); + + Kernel.LibraryManager.LibraryChanged += LibraryManager_LibraryChanged; + + Task.Run(() => VersionedDirectoryPath = GetVersionedDirectoryPath()); + } + + /// <summary> + /// Handles the LibraryChanged event of the LibraryManager control. + /// </summary> + /// <param name="sender">The source of the event.</param> + /// <param name="e">The <see cref="ChildrenChangedEventArgs" /> instance containing the event data.</param> + void LibraryManager_LibraryChanged(object sender, ChildrenChangedEventArgs e) + { + var videos = e.ItemsAdded.OfType<Video>().ToList(); + + // Use a timer to prevent lots of these notifications from showing in a short period of time + if (videos.Count > 0) + { + lock (_newlyAddedItems) + { + _newlyAddedItems.AddRange(videos); + + if (NewItemTimer == null) + { + NewItemTimer = new Timer(NewItemTimerCallback, null, NewItemDelay, Timeout.Infinite); + } + else + { + NewItemTimer.Change(NewItemDelay, Timeout.Infinite); + } + } + } + } + + /// <summary> + /// Called when the new item timer expires + /// </summary> + /// <param name="state">The state.</param> + private async void NewItemTimerCallback(object state) + { + List<Video> newItems; + + // Lock the list and release all resources + lock (_newlyAddedItems) + { + newItems = _newlyAddedItems.ToList(); + _newlyAddedItems.Clear(); + + NewItemTimer.Dispose(); + NewItemTimer = null; + } + + // Limit the number of videos we generate images for + // The idea is to catch new items that are added here and there + // Mass image generation can be left to the scheduled task + foreach (var video in newItems.Where(c => c.Chapters != null).Take(3)) + { + try + { + await PopulateChapterImages(video, CancellationToken.None, true, true).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.ErrorException("Error creating chapter images for {0}", ex, video.Name); + } + } + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + if (NewItemTimer != null) + { + NewItemTimer.Dispose(); + } + + SetErrorMode(ErrorModes.SYSTEM_DEFAULT); + + Kernel.LibraryManager.LibraryChanged -= LibraryManager_LibraryChanged; + + AudioImageCache.Dispose(); + VideoImageCache.Dispose(); + } + + base.Dispose(dispose); + } + + /// <summary> + /// The FF probe resource pool count + /// </summary> + private const int FFProbeResourcePoolCount = 4; + /// <summary> + /// The audio image resource pool count + /// </summary> + private const int AudioImageResourcePoolCount = 4; + /// <summary> + /// The video image resource pool count + /// </summary> + private const int VideoImageResourcePoolCount = 2; + + /// <summary> + /// The FF probe resource pool + /// </summary> + private readonly SemaphoreSlim FFProbeResourcePool = new SemaphoreSlim(FFProbeResourcePoolCount, FFProbeResourcePoolCount); + /// <summary> + /// The audio image resource pool + /// </summary> + private readonly SemaphoreSlim AudioImageResourcePool = new SemaphoreSlim(AudioImageResourcePoolCount, AudioImageResourcePoolCount); + /// <summary> + /// The video image resource pool + /// </summary> + private readonly SemaphoreSlim VideoImageResourcePool = new SemaphoreSlim(VideoImageResourcePoolCount, VideoImageResourcePoolCount); + + /// <summary> + /// Gets or sets the versioned directory path. + /// </summary> + /// <value>The versioned directory path.</value> + private string VersionedDirectoryPath { get; set; } + + /// <summary> + /// Gets the FFMPEG version. + /// </summary> + /// <value>The FFMPEG version.</value> + public string FFMpegVersion + { + get { return Path.GetFileNameWithoutExtension(VersionedDirectoryPath); } + } + + /// <summary> + /// The _ FF MPEG path + /// </summary> + private string _FFMpegPath; + /// <summary> + /// Gets the path to ffmpeg.exe + /// </summary> + /// <value>The FF MPEG path.</value> + public string FFMpegPath + { + get + { + return _FFMpegPath ?? (_FFMpegPath = Path.Combine(VersionedDirectoryPath, "ffmpeg.exe")); + } + } + + /// <summary> + /// The _ FF probe path + /// </summary> + private string _FFProbePath; + /// <summary> + /// Gets the path to ffprobe.exe + /// </summary> + /// <value>The FF probe path.</value> + public string FFProbePath + { + get + { + return _FFProbePath ?? (_FFProbePath = Path.Combine(VersionedDirectoryPath, "ffprobe.exe")); + } + } + + /// <summary> + /// The _video images data path + /// </summary> + private string _videoImagesDataPath; + /// <summary> + /// Gets the video images data path. + /// </summary> + /// <value>The video images data path.</value> + public string VideoImagesDataPath + { + get + { + if (_videoImagesDataPath == null) + { + _videoImagesDataPath = Path.Combine(Kernel.ApplicationPaths.DataPath, "ffmpeg-video-images"); + + if (!Directory.Exists(_videoImagesDataPath)) + { + Directory.CreateDirectory(_videoImagesDataPath); + } + } + + return _videoImagesDataPath; + } + } + + /// <summary> + /// The _audio images data path + /// </summary> + private string _audioImagesDataPath; + /// <summary> + /// Gets the audio images data path. + /// </summary> + /// <value>The audio images data path.</value> + public string AudioImagesDataPath + { + get + { + if (_audioImagesDataPath == null) + { + _audioImagesDataPath = Path.Combine(Kernel.ApplicationPaths.DataPath, "ffmpeg-audio-images"); + + if (!Directory.Exists(_audioImagesDataPath)) + { + Directory.CreateDirectory(_audioImagesDataPath); + } + } + + return _audioImagesDataPath; + } + } + + /// <summary> + /// The _subtitle cache path + /// </summary> + private string _subtitleCachePath; + /// <summary> + /// Gets the subtitle cache path. + /// </summary> + /// <value>The subtitle cache path.</value> + public string SubtitleCachePath + { + get + { + if (_subtitleCachePath == null) + { + _subtitleCachePath = Path.Combine(Kernel.ApplicationPaths.CachePath, "ffmpeg-subtitles"); + + if (!Directory.Exists(_subtitleCachePath)) + { + Directory.CreateDirectory(_subtitleCachePath); + } + } + + return _subtitleCachePath; + } + } + + /// <summary> + /// Gets the versioned directory path. + /// </summary> + /// <returns>System.String.</returns> + private string GetVersionedDirectoryPath() + { + var assembly = GetType().Assembly; + + const string prefix = "MediaBrowser.Controller.MediaInfo."; + const string srch = prefix + "ffmpeg"; + + var resource = assembly.GetManifestResourceNames().First(r => r.StartsWith(srch)); + + var filename = resource.Substring(resource.IndexOf(prefix, StringComparison.OrdinalIgnoreCase) + prefix.Length); + + var versionedDirectoryPath = Path.Combine(Kernel.ApplicationPaths.MediaToolsPath, Path.GetFileNameWithoutExtension(filename)); + + if (!Directory.Exists(versionedDirectoryPath)) + { + Directory.CreateDirectory(versionedDirectoryPath); + } + + ExtractTools(assembly, resource, versionedDirectoryPath); + + return versionedDirectoryPath; + } + + /// <summary> + /// Extracts the tools. + /// </summary> + /// <param name="assembly">The assembly.</param> + /// <param name="zipFileResourcePath">The zip file resource path.</param> + /// <param name="targetPath">The target path.</param> + private void ExtractTools(Assembly assembly, string zipFileResourcePath, string targetPath) + { + using (var resourceStream = assembly.GetManifestResourceStream(zipFileResourcePath)) + { + using (var zipFile = ZipFile.Read(resourceStream)) + { + zipFile.ExtractAll(targetPath, ExtractExistingFileAction.DoNotOverwrite); + } + } + } + + /// <summary> + /// Gets the probe size argument. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>System.String.</returns> + public string GetProbeSizeArgument(BaseItem item) + { + var video = item as Video; + + return video != null ? GetProbeSizeArgument(video.VideoType, video.IsoType) : string.Empty; + } + + /// <summary> + /// Gets the probe size argument. + /// </summary> + /// <param name="videoType">Type of the video.</param> + /// <param name="isoType">Type of the iso.</param> + /// <returns>System.String.</returns> + public string GetProbeSizeArgument(VideoType videoType, IsoType? isoType) + { + if (videoType == VideoType.Dvd || (isoType.HasValue && isoType.Value == IsoType.Dvd)) + { + return "-probesize 1G -analyzeduration 200M"; + } + + return string.Empty; + } + + /// <summary> + /// Runs FFProbe against a BaseItem + /// </summary> + /// <param name="item">The item.</param> + /// <param name="inputPath">The input path.</param> + /// <param name="lastDateModified">The last date modified.</param> + /// <param name="cache">The cache.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{FFProbeResult}.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Task<FFProbeResult> RunFFProbe(BaseItem item, string inputPath, DateTime lastDateModified, FileSystemRepository cache, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(inputPath)) + { + throw new ArgumentNullException("inputPath"); + } + + if (cache == null) + { + throw new ArgumentNullException("cache"); + } + + // Put the ffmpeg version into the cache name so that it's unique per-version + // We don't want to try and deserialize data based on an old version, which could potentially fail + var resourceName = item.Id + "_" + lastDateModified.Ticks + "_" + FFMpegVersion; + + // Forumulate the cache file path + var cacheFilePath = cache.GetResourcePath(resourceName, ".pb"); + + cancellationToken.ThrowIfCancellationRequested(); + + // Avoid File.Exists by just trying to deserialize + try + { + return Task.FromResult(Kernel.ProtobufSerializer.DeserializeFromFile<FFProbeResult>(cacheFilePath)); + } + catch (FileNotFoundException) + { + var extractChapters = false; + var video = item as Video; + var probeSizeArgument = string.Empty; + + if (video != null) + { + extractChapters = true; + probeSizeArgument = GetProbeSizeArgument(video.VideoType, video.IsoType); + } + + return RunFFProbeInternal(inputPath, extractChapters, cacheFilePath, probeSizeArgument, cancellationToken); + } + } + + /// <summary> + /// Runs FFProbe against a BaseItem + /// </summary> + /// <param name="inputPath">The input path.</param> + /// <param name="extractChapters">if set to <c>true</c> [extract chapters].</param> + /// <param name="cacheFile">The cache file.</param> + /// <param name="probeSizeArgument">The probe size argument.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{FFProbeResult}.</returns> + /// <exception cref="System.ApplicationException"></exception> + private async Task<FFProbeResult> RunFFProbeInternal(string inputPath, bool extractChapters, string cacheFile, string probeSizeArgument, CancellationToken cancellationToken) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both or ffmpeg may hang due to deadlocks. See comments below. + RedirectStandardOutput = true, + RedirectStandardError = true, + FileName = FFProbePath, + Arguments = string.Format("{0} -i {1} -threads 0 -v info -print_format json -show_streams -show_format", probeSizeArgument, inputPath).Trim(), + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + + EnableRaisingEvents = true + }; + + Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Exited += ProcessExited; + + await FFProbeResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + FFProbeResult result; + string standardError = null; + + try + { + process.Start(); + + Task<string> standardErrorReadTask = null; + + // MUST read both stdout and stderr asynchronously or a deadlock may occurr + if (extractChapters) + { + standardErrorReadTask = process.StandardError.ReadToEndAsync(); + } + else + { + process.BeginErrorReadLine(); + } + + result = JsonSerializer.DeserializeFromStream<FFProbeResult>(process.StandardOutput.BaseStream); + + if (extractChapters) + { + standardError = await standardErrorReadTask.ConfigureAwait(false); + } + } + catch + { + // Hate having to do this + try + { + process.Kill(); + } + catch (InvalidOperationException ex1) + { + Logger.ErrorException("Error killing ffprobe", ex1); + } + catch (Win32Exception ex1) + { + Logger.ErrorException("Error killing ffprobe", ex1); + } + + throw; + } + finally + { + FFProbeResourcePool.Release(); + } + + if (result == null) + { + throw new ApplicationException(string.Format("FFProbe failed for {0}", inputPath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (extractChapters && !string.IsNullOrEmpty(standardError)) + { + AddChapters(result, standardError); + } + + Kernel.ProtobufSerializer.SerializeToFile(result, cacheFile); + + return result; + } + + /// <summary> + /// Adds the chapters. + /// </summary> + /// <param name="result">The result.</param> + /// <param name="standardError">The standard error.</param> + private void AddChapters(FFProbeResult result, string standardError) + { + var lines = standardError.Split('\n').Select(l => l.TrimStart()); + + var chapters = new List<ChapterInfo> { }; + + ChapterInfo lastChapter = null; + + foreach (var line in lines) + { + if (line.StartsWith("Chapter", StringComparison.OrdinalIgnoreCase)) + { + // Example: + // Chapter #0.2: start 400.534, end 4565.435 + const string srch = "start "; + var start = line.IndexOf(srch, StringComparison.OrdinalIgnoreCase); + + if (start == -1) + { + continue; + } + + var subString = line.Substring(start + srch.Length); + subString = subString.Substring(0, subString.IndexOf(',')); + + double seconds; + + if (double.TryParse(subString, out seconds)) + { + lastChapter = new ChapterInfo + { + StartPositionTicks = TimeSpan.FromSeconds(seconds).Ticks + }; + + chapters.Add(lastChapter); + } + } + + else if (line.StartsWith("title", StringComparison.OrdinalIgnoreCase)) + { + if (lastChapter != null && string.IsNullOrEmpty(lastChapter.Name)) + { + var index = line.IndexOf(':'); + + if (index != -1) + { + lastChapter.Name = line.Substring(index + 1).Trim().TrimEnd('\r'); + } + } + } + } + + result.Chapters = chapters; + } + + /// <summary> + /// The first chapter ticks + /// </summary> + private static long FirstChapterTicks = TimeSpan.FromSeconds(15).Ticks; + + /// <summary> + /// Extracts the chapter images. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="extractImages">if set to <c>true</c> [extract images].</param> + /// <param name="saveItem">if set to <c>true</c> [save item].</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task PopulateChapterImages(Video video, CancellationToken cancellationToken, bool extractImages, bool saveItem) + { + if (video.Chapters == null) + { + throw new ArgumentNullException(); + } + + var changesMade = false; + + foreach (var chapter in video.Chapters) + { + var filename = video.Id + "_" + video.DateModified.Ticks + "_" + chapter.StartPositionTicks; + + var path = VideoImageCache.GetResourcePath(filename, ".jpg"); + + if (!VideoImageCache.ContainsFilePath(path)) + { + if (extractImages) + { + // Disable for now on folder rips + if (video.VideoType != VideoType.VideoFile) + { + continue; + } + + // Add some time for the first chapter to make sure we don't end up with a black image + var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(FirstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks); + + var success = await ExtractImage(GetInputArgument(video), time, path, cancellationToken).ConfigureAwait(false); + + if (success) + { + chapter.ImagePath = path; + changesMade = true; + } + } + } + else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase)) + { + chapter.ImagePath = path; + changesMade = true; + } + } + + if (saveItem && changesMade) + { + await Kernel.ItemRepository.SaveItem(video, CancellationToken.None).ConfigureAwait(false); + } + } + + /// <summary> + /// Extracts an image from an Audio file and returns a Task whose result indicates whether it was successful or not + /// </summary> + /// <param name="input">The input.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException">input</exception> + public async Task<bool> ExtractImage(Audio input, string outputPath, CancellationToken cancellationToken) + { + if (input == null) + { + throw new ArgumentNullException("input"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + throw new ArgumentNullException("outputPath"); + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = FFMpegPath, + Arguments = string.Format("-i {0} -threads 0 -v quiet -f image2 \"{1}\"", GetInputArgument(input), outputPath), + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + } + }; + + await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + await process.RunAsync().ConfigureAwait(false); + + AudioImageResourcePool.Release(); + + var exitCode = process.ExitCode; + + process.Dispose(); + + if (exitCode != -1 && File.Exists(outputPath)) + { + return true; + } + + Logger.Error("ffmpeg audio image extraction failed for {0}", input.Path); + return false; + } + + /// <summary> + /// Determines whether [is subtitle cached] [the specified input]. + /// </summary> + /// <param name="input">The input.</param> + /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param> + /// <param name="outputExtension">The output extension.</param> + /// <returns><c>true</c> if [is subtitle cached] [the specified input]; otherwise, <c>false</c>.</returns> + public bool IsSubtitleCached(Video input, int subtitleStreamIndex, string outputExtension) + { + return SubtitleCache.ContainsFilePath(GetSubtitleCachePath(input, subtitleStreamIndex, outputExtension)); + } + + /// <summary> + /// Gets the subtitle cache path. + /// </summary> + /// <param name="input">The input.</param> + /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param> + /// <param name="outputExtension">The output extension.</param> + /// <returns>System.String.</returns> + public string GetSubtitleCachePath(Video input, int subtitleStreamIndex, string outputExtension) + { + return SubtitleCache.GetResourcePath(input.Id + "_" + subtitleStreamIndex + "_" + input.DateModified.Ticks, outputExtension); + } + + /// <summary> + /// Extracts the text subtitle. + /// </summary> + /// <param name="input">The input.</param> + /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException">input</exception> + public async Task<bool> ExtractTextSubtitle(Video input, int subtitleStreamIndex, string outputPath, CancellationToken cancellationToken) + { + if (input == null) + { + throw new ArgumentNullException("input"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = FFMpegPath, + Arguments = string.Format("-i {0} -map 0:{1} -an -vn -c:s ass \"{2}\"", GetInputArgument(input), subtitleStreamIndex, outputPath), + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + } + }; + + Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + await process.RunAsync().ConfigureAwait(false); + + AudioImageResourcePool.Release(); + + var exitCode = process.ExitCode; + + process.Dispose(); + + if (exitCode != -1 && File.Exists(outputPath)) + { + return true; + } + + Logger.Error("ffmpeg subtitle extraction failed for {0}", input.Path); + return false; + } + + /// <summary> + /// Converts the text subtitle. + /// </summary> + /// <param name="mediaStream">The media stream.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException">mediaStream</exception> + /// <exception cref="System.ArgumentException">The given MediaStream is not an external subtitle stream</exception> + public async Task<bool> ConvertTextSubtitle(MediaStream mediaStream, string outputPath, CancellationToken cancellationToken) + { + if (mediaStream == null) + { + throw new ArgumentNullException("mediaStream"); + } + + if (!mediaStream.IsExternal || string.IsNullOrEmpty(mediaStream.Path)) + { + throw new ArgumentException("The given MediaStream is not an external subtitle stream"); + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = FFMpegPath, + Arguments = string.Format("-i \"{0}\" \"{1}\"", mediaStream.Path, outputPath), + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + } + }; + + Logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + await AudioImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + await process.RunAsync().ConfigureAwait(false); + + AudioImageResourcePool.Release(); + + var exitCode = process.ExitCode; + + process.Dispose(); + + if (exitCode != -1 && File.Exists(outputPath)) + { + return true; + } + + Logger.Error("ffmpeg subtitle conversion failed for {0}", mediaStream.Path); + return false; + } + + /// <summary> + /// Extracts an image from a Video and returns a Task whose result indicates whether it was successful or not + /// </summary> + /// <param name="inputPath">The input path.</param> + /// <param name="offset">The offset.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException">video</exception> + public async Task<bool> ExtractImage(string inputPath, TimeSpan offset, string outputPath, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(inputPath)) + { + throw new ArgumentNullException("inputPath"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + throw new ArgumentNullException("outputPath"); + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = FFMpegPath, + Arguments = string.Format("-ss {0} -i {1} -threads 0 -v quiet -t 1 -f image2 \"{2}\"", Convert.ToInt32(offset.TotalSeconds), inputPath, outputPath), + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + } + }; + + await VideoImageResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + process.Start(); + + var ranToCompletion = process.WaitForExit(10000); + + if (!ranToCompletion) + { + try + { + Logger.Info("Killing ffmpeg process"); + + process.Kill(); + process.WaitForExit(1000); + } + catch (Win32Exception ex) + { + Logger.ErrorException("Error killing process", ex); + } + catch (InvalidOperationException ex) + { + Logger.ErrorException("Error killing process", ex); + } + catch (NotSupportedException ex) + { + Logger.ErrorException("Error killing process", ex); + } + } + + VideoImageResourcePool.Release(); + + var exitCode = ranToCompletion ? process.ExitCode : -1; + + process.Dispose(); + + if (exitCode == -1) + { + if (File.Exists(outputPath)) + { + try + { + Logger.Info("Deleting extracted image due to failure: ", outputPath); + File.Delete(outputPath); + } + catch (IOException ex) + { + Logger.ErrorException("Error deleting extracted image {0}", ex, outputPath); + } + } + } + else + { + if (File.Exists(outputPath)) + { + return true; + } + } + + Logger.Error("ffmpeg video image extraction failed for {0}", inputPath); + return false; + } + + /// <summary> + /// Gets the input argument. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>System.String.</returns> + public string GetInputArgument(BaseItem item) + { + var video = item as Video; + + if (video != null) + { + if (video.VideoType == VideoType.BluRay) + { + return GetBlurayInputArgument(video.Path); + } + + if (video.VideoType == VideoType.Dvd) + { + return GetDvdInputArgument(video.GetPlayableStreamFiles()); + } + } + + return string.Format("file:\"{0}\"", item.Path); + } + + /// <summary> + /// Gets the input argument. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="mount">The mount.</param> + /// <returns>System.String.</returns> + public string GetInputArgument(Video item, IIsoMount mount) + { + if (item.VideoType == VideoType.Iso && item.IsoType.HasValue) + { + if (item.IsoType.Value == IsoType.BluRay) + { + return GetBlurayInputArgument(mount.MountedPath); + } + if (item.IsoType.Value == IsoType.Dvd) + { + return GetDvdInputArgument(item.GetPlayableStreamFiles(mount.MountedPath)); + } + } + + return GetInputArgument(item); + } + + /// <summary> + /// Gets the bluray input argument. + /// </summary> + /// <param name="blurayRoot">The bluray root.</param> + /// <returns>System.String.</returns> + public string GetBlurayInputArgument(string blurayRoot) + { + return string.Format("bluray:\"{0}\"", blurayRoot); + } + + /// <summary> + /// Gets the DVD input argument. + /// </summary> + /// <param name="playableStreamFiles">The playable stream files.</param> + /// <returns>System.String.</returns> + public string GetDvdInputArgument(IEnumerable<string> playableStreamFiles) + { + // Get all streams + var streamFilePaths = (playableStreamFiles ?? new string[] { }).ToArray(); + + // If there's more than one we'll need to use the concat command + if (streamFilePaths.Length > 1) + { + var files = string.Join("|", streamFilePaths); + + return string.Format("concat:\"{0}\"", files); + } + + // Determine the input path for video files + return string.Format("file:\"{0}\"", streamFilePaths[0]); + } + + /// <summary> + /// Processes the exited. + /// </summary> + /// <param name="sender">The sender.</param> + /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param> + void ProcessExited(object sender, EventArgs e) + { + ((Process)sender).Dispose(); + } + + /// <summary> + /// Sets the error mode. + /// </summary> + /// <param name="uMode">The u mode.</param> + /// <returns>ErrorModes.</returns> + [DllImport("kernel32.dll")] + static extern ErrorModes SetErrorMode(ErrorModes uMode); + + /// <summary> + /// Enum ErrorModes + /// </summary> + [Flags] + public enum ErrorModes : uint + { + /// <summary> + /// The SYSTE m_ DEFAULT + /// </summary> + SYSTEM_DEFAULT = 0x0, + /// <summary> + /// The SE m_ FAILCRITICALERRORS + /// </summary> + SEM_FAILCRITICALERRORS = 0x0001, + /// <summary> + /// The SE m_ NOALIGNMENTFAULTEXCEPT + /// </summary> + SEM_NOALIGNMENTFAULTEXCEPT = 0x0004, + /// <summary> + /// The SE m_ NOGPFAULTERRORBOX + /// </summary> + SEM_NOGPFAULTERRORBOX = 0x0002, + /// <summary> + /// The SE m_ NOOPENFILEERRORBOX + /// </summary> + SEM_NOOPENFILEERRORBOX = 0x8000 + } + } +} diff --git a/MediaBrowser.Controller/MediaInfo/FFProbeResult.cs b/MediaBrowser.Controller/MediaInfo/FFProbeResult.cs new file mode 100644 index 0000000000..06b4ff87d7 --- /dev/null +++ b/MediaBrowser.Controller/MediaInfo/FFProbeResult.cs @@ -0,0 +1,354 @@ +using MediaBrowser.Model.Entities; +using ProtoBuf; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.MediaInfo +{ + /// <summary> + /// Provides a class that we can use to deserialize the ffprobe json output + /// Sample output: + /// http://stackoverflow.com/questions/7708373/get-ffmpeg-information-in-friendly-way + /// </summary> + [ProtoContract] + public class FFProbeResult + { + /// <summary> + /// Gets or sets the streams. + /// </summary> + /// <value>The streams.</value> + [ProtoMember(1)] + public FFProbeMediaStreamInfo[] streams { get; set; } + + /// <summary> + /// Gets or sets the format. + /// </summary> + /// <value>The format.</value> + [ProtoMember(2)] + public FFProbeMediaFormatInfo format { get; set; } + + [ProtoMember(3)] + public List<ChapterInfo> Chapters { get; set; } + } + + /// <summary> + /// Represents a stream within the output + /// </summary> + [ProtoContract] + public class FFProbeMediaStreamInfo + { + /// <summary> + /// Gets or sets the index. + /// </summary> + /// <value>The index.</value> + [ProtoMember(1)] + public int index { get; set; } + + /// <summary> + /// Gets or sets the profile. + /// </summary> + /// <value>The profile.</value> + [ProtoMember(2)] + public string profile { get; set; } + + /// <summary> + /// Gets or sets the codec_name. + /// </summary> + /// <value>The codec_name.</value> + [ProtoMember(3)] + public string codec_name { get; set; } + + /// <summary> + /// Gets or sets the codec_long_name. + /// </summary> + /// <value>The codec_long_name.</value> + [ProtoMember(4)] + public string codec_long_name { get; set; } + + /// <summary> + /// Gets or sets the codec_type. + /// </summary> + /// <value>The codec_type.</value> + [ProtoMember(5)] + public string codec_type { get; set; } + + /// <summary> + /// Gets or sets the sample_rate. + /// </summary> + /// <value>The sample_rate.</value> + [ProtoMember(6)] + public string sample_rate { get; set; } + + /// <summary> + /// Gets or sets the channels. + /// </summary> + /// <value>The channels.</value> + [ProtoMember(7)] + public int channels { get; set; } + + /// <summary> + /// Gets or sets the avg_frame_rate. + /// </summary> + /// <value>The avg_frame_rate.</value> + [ProtoMember(8)] + public string avg_frame_rate { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + /// <value>The duration.</value> + [ProtoMember(9)] + public string duration { get; set; } + + /// <summary> + /// Gets or sets the bit_rate. + /// </summary> + /// <value>The bit_rate.</value> + [ProtoMember(10)] + public string bit_rate { get; set; } + + /// <summary> + /// Gets or sets the width. + /// </summary> + /// <value>The width.</value> + [ProtoMember(11)] + public int width { get; set; } + + /// <summary> + /// Gets or sets the height. + /// </summary> + /// <value>The height.</value> + [ProtoMember(12)] + public int height { get; set; } + + /// <summary> + /// Gets or sets the display_aspect_ratio. + /// </summary> + /// <value>The display_aspect_ratio.</value> + [ProtoMember(13)] + public string display_aspect_ratio { get; set; } + + /// <summary> + /// Gets or sets the tags. + /// </summary> + /// <value>The tags.</value> + [ProtoMember(14)] + public Dictionary<string, string> tags { get; set; } + + /// <summary> + /// Gets or sets the bits_per_sample. + /// </summary> + /// <value>The bits_per_sample.</value> + [ProtoMember(17)] + public int bits_per_sample { get; set; } + + /// <summary> + /// Gets or sets the r_frame_rate. + /// </summary> + /// <value>The r_frame_rate.</value> + [ProtoMember(18)] + public string r_frame_rate { get; set; } + + /// <summary> + /// Gets or sets the has_b_frames. + /// </summary> + /// <value>The has_b_frames.</value> + [ProtoMember(19)] + public int has_b_frames { get; set; } + + /// <summary> + /// Gets or sets the sample_aspect_ratio. + /// </summary> + /// <value>The sample_aspect_ratio.</value> + [ProtoMember(20)] + public string sample_aspect_ratio { get; set; } + + /// <summary> + /// Gets or sets the pix_fmt. + /// </summary> + /// <value>The pix_fmt.</value> + [ProtoMember(21)] + public string pix_fmt { get; set; } + + /// <summary> + /// Gets or sets the level. + /// </summary> + /// <value>The level.</value> + [ProtoMember(22)] + public int level { get; set; } + + /// <summary> + /// Gets or sets the time_base. + /// </summary> + /// <value>The time_base.</value> + [ProtoMember(23)] + public string time_base { get; set; } + + /// <summary> + /// Gets or sets the start_time. + /// </summary> + /// <value>The start_time.</value> + [ProtoMember(24)] + public string start_time { get; set; } + + /// <summary> + /// Gets or sets the codec_time_base. + /// </summary> + /// <value>The codec_time_base.</value> + [ProtoMember(25)] + public string codec_time_base { get; set; } + + /// <summary> + /// Gets or sets the codec_tag. + /// </summary> + /// <value>The codec_tag.</value> + [ProtoMember(26)] + public string codec_tag { get; set; } + + /// <summary> + /// Gets or sets the codec_tag_string. + /// </summary> + /// <value>The codec_tag_string.</value> + [ProtoMember(27)] + public string codec_tag_string { get; set; } + + /// <summary> + /// Gets or sets the sample_fmt. + /// </summary> + /// <value>The sample_fmt.</value> + [ProtoMember(28)] + public string sample_fmt { get; set; } + + /// <summary> + /// Gets or sets the dmix_mode. + /// </summary> + /// <value>The dmix_mode.</value> + [ProtoMember(29)] + public string dmix_mode { get; set; } + + /// <summary> + /// Gets or sets the start_pts. + /// </summary> + /// <value>The start_pts.</value> + [ProtoMember(30)] + public string start_pts { get; set; } + + /// <summary> + /// Gets or sets the is_avc. + /// </summary> + /// <value>The is_avc.</value> + [ProtoMember(31)] + public string is_avc { get; set; } + + /// <summary> + /// Gets or sets the nal_length_size. + /// </summary> + /// <value>The nal_length_size.</value> + [ProtoMember(32)] + public string nal_length_size { get; set; } + + /// <summary> + /// Gets or sets the ltrt_cmixlev. + /// </summary> + /// <value>The ltrt_cmixlev.</value> + [ProtoMember(33)] + public string ltrt_cmixlev { get; set; } + + /// <summary> + /// Gets or sets the ltrt_surmixlev. + /// </summary> + /// <value>The ltrt_surmixlev.</value> + [ProtoMember(34)] + public string ltrt_surmixlev { get; set; } + + /// <summary> + /// Gets or sets the loro_cmixlev. + /// </summary> + /// <value>The loro_cmixlev.</value> + [ProtoMember(35)] + public string loro_cmixlev { get; set; } + + /// <summary> + /// Gets or sets the loro_surmixlev. + /// </summary> + /// <value>The loro_surmixlev.</value> + [ProtoMember(36)] + public string loro_surmixlev { get; set; } + + /// <summary> + /// Gets or sets the disposition. + /// </summary> + /// <value>The disposition.</value> + [ProtoMember(37)] + public Dictionary<string, string> disposition { get; set; } + } + + /// <summary> + /// Class MediaFormat + /// </summary> + [ProtoContract] + public class FFProbeMediaFormatInfo + { + /// <summary> + /// Gets or sets the filename. + /// </summary> + /// <value>The filename.</value> + [ProtoMember(1)] + public string filename { get; set; } + + /// <summary> + /// Gets or sets the nb_streams. + /// </summary> + /// <value>The nb_streams.</value> + [ProtoMember(2)] + public int nb_streams { get; set; } + + /// <summary> + /// Gets or sets the format_name. + /// </summary> + /// <value>The format_name.</value> + [ProtoMember(3)] + public string format_name { get; set; } + + /// <summary> + /// Gets or sets the format_long_name. + /// </summary> + /// <value>The format_long_name.</value> + [ProtoMember(4)] + public string format_long_name { get; set; } + + /// <summary> + /// Gets or sets the start_time. + /// </summary> + /// <value>The start_time.</value> + [ProtoMember(5)] + public string start_time { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + /// <value>The duration.</value> + [ProtoMember(6)] + public string duration { get; set; } + + /// <summary> + /// Gets or sets the size. + /// </summary> + /// <value>The size.</value> + [ProtoMember(7)] + public string size { get; set; } + + /// <summary> + /// Gets or sets the bit_rate. + /// </summary> + /// <value>The bit_rate.</value> + [ProtoMember(8)] + public string bit_rate { get; set; } + + /// <summary> + /// Gets or sets the tags. + /// </summary> + /// <value>The tags.</value> + [ProtoMember(9)] + public Dictionary<string, string> tags { get; set; } + } +} diff --git a/MediaBrowser.Controller/MediaInfo/ffmpeg20130209.zip.REMOVED.git-id b/MediaBrowser.Controller/MediaInfo/ffmpeg20130209.zip.REMOVED.git-id new file mode 100644 index 0000000000..307afc51cb --- /dev/null +++ b/MediaBrowser.Controller/MediaInfo/ffmpeg20130209.zip.REMOVED.git-id @@ -0,0 +1 @@ +985770c0d2633a13719be2e5cf19554262415f62
\ No newline at end of file diff --git a/MediaBrowser.Controller/MediaInfo/readme.txt b/MediaBrowser.Controller/MediaInfo/readme.txt new file mode 100644 index 0000000000..b32dd9aec0 --- /dev/null +++ b/MediaBrowser.Controller/MediaInfo/readme.txt @@ -0,0 +1,5 @@ +This is the 32-bit static build of ffmpeg, located at: + +http://ffmpeg.zeranoe.com/builds/ + +The zip file contains both ffmpeg and ffprobe, and is suffixed with the date of the build.
\ No newline at end of file diff --git a/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs b/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs new file mode 100644 index 0000000000..b820f54a8d --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IDisplayPreferencesRepository.cs @@ -0,0 +1,29 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Interface IDisplayPreferencesRepository + /// </summary> + public interface IDisplayPreferencesRepository : IRepository + { + /// <summary> + /// Saves display preferences for an item + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SaveDisplayPrefs(Folder item, CancellationToken cancellationToken); + + /// <summary> + /// Gets display preferences for an item + /// </summary> + /// <param name="item">The item.</param> + /// <returns>IEnumerable{DisplayPreferences}.</returns> + IEnumerable<DisplayPreferences> RetrieveDisplayPrefs(Folder item); + } +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs new file mode 100644 index 0000000000..c7e51e38c3 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -0,0 +1,45 @@ +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Provides an interface to implement an Item repository + /// </summary> + public interface IItemRepository : IRepository + { + /// <summary> + /// Saves an item + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SaveItem(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Gets an item + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + BaseItem RetrieveItem(Guid id); + + /// <summary> + /// Gets children of a given Folder + /// </summary> + /// <param name="parent">The parent.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + IEnumerable<BaseItem> RetrieveChildren(Folder parent); + + /// <summary> + /// Saves children of a given Folder + /// </summary> + /// <param name="parentId">The parent id.</param> + /// <param name="children">The children.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SaveChildren(Guid parentId, IEnumerable<BaseItem> children, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/Persistence/IRepository.cs b/MediaBrowser.Controller/Persistence/IRepository.cs new file mode 100644 index 0000000000..2d051aa82c --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IRepository.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Provides a base interface for all the repository interfaces + /// </summary> + public interface IRepository : IDisposable + { + /// <summary> + /// Opens the connection to the repository + /// </summary> + /// <returns>Task.</returns> + Task Initialize(); + + /// <summary> + /// Gets the name of the repository + /// </summary> + /// <value>The name.</value> + string Name { get; } + } +} diff --git a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs new file mode 100644 index 0000000000..115bd411ab --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Controller.Entities; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Provides an interface to implement a UserData repository + /// </summary> + public interface IUserDataRepository : IRepository + { + /// <summary> + /// Saves user data for an item + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SaveUserData(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Gets user data for an item + /// </summary> + /// <param name="item">The item.</param> + /// <returns>IEnumerable{UserItemData}.</returns> + IEnumerable<UserItemData> RetrieveUserData(BaseItem item); + } +} diff --git a/MediaBrowser.Controller/Persistence/IUserRepository.cs b/MediaBrowser.Controller/Persistence/IUserRepository.cs new file mode 100644 index 0000000000..80961a369a --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IUserRepository.cs @@ -0,0 +1,35 @@ +using MediaBrowser.Controller.Entities; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Provides an interface to implement a User repository + /// </summary> + public interface IUserRepository : IRepository + { + /// <summary> + /// Deletes the user. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteUser(User user, CancellationToken cancellationToken); + + /// <summary> + /// Saves the user. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task SaveUser(User user, CancellationToken cancellationToken); + + /// <summary> + /// Retrieves all users. + /// </summary> + /// <returns>IEnumerable{User}.</returns> + IEnumerable<User> RetrieveAllUsers(); + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteDisplayPreferencesRepository.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteDisplayPreferencesRepository.cs new file mode 100644 index 0000000000..db1535b345 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteDisplayPreferencesRepository.cs @@ -0,0 +1,139 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Data; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SQLiteDisplayPreferencesRepository + /// </summary> + [Export(typeof(IDisplayPreferencesRepository))] + class SQLiteDisplayPreferencesRepository : SqliteRepository, IDisplayPreferencesRepository + { + /// <summary> + /// The repository name + /// </summary> + public const string RepositoryName = "SQLite"; + + /// <summary> + /// Gets the name of the repository + /// </summary> + /// <value>The name.</value> + public string Name + { + get + { + return RepositoryName; + } + } + + /// <summary> + /// Opens the connection to the database + /// </summary> + /// <returns>Task.</returns> + public async Task Initialize() + { + var dbFile = Path.Combine(Kernel.Instance.ApplicationPaths.DataPath, "displaypreferences.db"); + + await ConnectToDB(dbFile).ConfigureAwait(false); + + string[] queries = { + + "create table if not exists display_prefs (item_id GUID, user_id GUID, data BLOB)", + "create unique index if not exists idx_display_prefs on display_prefs (item_id, user_id)", + "create table if not exists schema_version (table_name primary key, version)", + //pragmas + "pragma temp_store = memory" + }; + + RunQueries(queries); + } + + /// <summary> + /// Save the display preferences associated with an item in the repo + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Task SaveDisplayPrefs(Folder item, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.Run(() => + { + var cmd = connection.CreateCommand(); + + cmd.CommandText = "delete from display_prefs where item_id = @guid"; + cmd.AddParam("@guid", item.DisplayPrefsId); + + QueueCommand(cmd); + + if (item.DisplayPrefs != null) + { + foreach (var data in item.DisplayPrefs) + { + cmd = connection.CreateCommand(); + cmd.CommandText = "insert into display_prefs (item_id, user_id, data) values (@1, @2, @3)"; + cmd.AddParam("@1", item.DisplayPrefsId); + cmd.AddParam("@2", data.UserId); + + cmd.AddParam("@3", Kernel.Instance.ProtobufSerializer.SerializeToBytes(data)); + + QueueCommand(cmd); + } + } + }); + } + + /// <summary> + /// Gets display preferences for an item + /// </summary> + /// <param name="item">The item.</param> + /// <returns>IEnumerable{DisplayPreferences}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public IEnumerable<DisplayPreferences> RetrieveDisplayPrefs(Folder item) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var cmd = connection.CreateCommand(); + cmd.CommandText = "select data from display_prefs where item_id = @guid"; + var guidParam = cmd.Parameters.Add("@guid", DbType.Guid); + guidParam.Value = item.DisplayPrefsId; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) + { + while (reader.Read()) + { + using (var stream = GetStream(reader, 0)) + { + var data = Kernel.Instance.ProtobufSerializer.DeserializeFromStream<DisplayPreferences>(stream); + if (data != null) + { + yield return data; + } + } + } + } + } + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteExtensions.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteExtensions.cs new file mode 100644 index 0000000000..f1ed774924 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteExtensions.cs @@ -0,0 +1,61 @@ +using System; +using System.Data; +using System.Data.SQLite; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SQLiteExtensions + /// </summary> + static class SQLiteExtensions + { + /// <summary> + /// Adds the param. + /// </summary> + /// <param name="cmd">The CMD.</param> + /// <param name="param">The param.</param> + /// <returns>SQLiteParameter.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public static SQLiteParameter AddParam(this SQLiteCommand cmd, string param) + { + if (string.IsNullOrEmpty(param)) + { + throw new ArgumentNullException(); + } + + var sqliteParam = new SQLiteParameter(param); + cmd.Parameters.Add(sqliteParam); + return sqliteParam; + } + + /// <summary> + /// Adds the param. + /// </summary> + /// <param name="cmd">The CMD.</param> + /// <param name="param">The param.</param> + /// <param name="data">The data.</param> + /// <returns>SQLiteParameter.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public static SQLiteParameter AddParam(this SQLiteCommand cmd, string param, object data) + { + if (string.IsNullOrEmpty(param)) + { + throw new ArgumentNullException(); + } + + var sqliteParam = AddParam(cmd, param); + sqliteParam.Value = data; + return sqliteParam; + } + + /// <summary> + /// Determines whether the specified conn is open. + /// </summary> + /// <param name="conn">The conn.</param> + /// <returns><c>true</c> if the specified conn is open; otherwise, <c>false</c>.</returns> + public static bool IsOpen(this SQLiteConnection conn) + { + return conn.State == ConnectionState.Open; + } + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteItemRepository.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteItemRepository.cs new file mode 100644 index 0000000000..08527f9c1f --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteItemRepository.cs @@ -0,0 +1,268 @@ +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Data; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SQLiteItemRepository + /// </summary> + [Export(typeof(IItemRepository))] + public class SQLiteItemRepository : SqliteRepository, IItemRepository + { + /// <summary> + /// The _type mapper + /// </summary> + private readonly TypeMapper _typeMapper = new TypeMapper(); + + /// <summary> + /// The repository name + /// </summary> + public const string RepositoryName = "SQLite"; + + /// <summary> + /// Gets the name of the repository + /// </summary> + /// <value>The name.</value> + public string Name + { + get + { + return RepositoryName; + } + } + + /// <summary> + /// Opens the connection to the database + /// </summary> + /// <returns>Task.</returns> + public async Task Initialize() + { + var dbFile = Path.Combine(Kernel.Instance.ApplicationPaths.DataPath, "library.db"); + + await ConnectToDB(dbFile).ConfigureAwait(false); + + string[] queries = { + + "create table if not exists items (guid GUID primary key, obj_type, data BLOB)", + "create index if not exists idx_items on items(guid)", + "create table if not exists children (guid GUID, child GUID)", + "create unique index if not exists idx_children on children(guid, child)", + "create table if not exists schema_version (table_name primary key, version)", + //triggers + TriggerSql, + //pragmas + "pragma temp_store = memory" + }; + + RunQueries(queries); + } + + //cascade delete triggers + /// <summary> + /// The trigger SQL + /// </summary> + protected string TriggerSql = + @"CREATE TRIGGER if not exists delete_item + AFTER DELETE + ON items + FOR EACH ROW + BEGIN + DELETE FROM children WHERE children.guid = old.child; + DELETE FROM children WHERE children.child = old.child; + END"; + + /// <summary> + /// Save a standard item in the repo + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Task SaveItem(BaseItem item, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.Run(() => + { + var serialized = JsonSerializer.SerializeToBytes(item); + + cancellationToken.ThrowIfCancellationRequested(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = "replace into items (guid, obj_type, data) values (@1, @2, @3)"; + cmd.AddParam("@1", item.Id); + cmd.AddParam("@2", item.GetType().FullName); + cmd.AddParam("@3", serialized); + QueueCommand(cmd); + }); + } + + /// <summary> + /// Retrieve a standard item from the repo + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentException"></exception> + public BaseItem RetrieveItem(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException(); + } + + return RetrieveItemInternal(id); + } + + /// <summary> + /// Internal retrieve from items or users table + /// </summary> + /// <param name="id">The id.</param> + /// <returns>BaseItem.</returns> + /// <exception cref="System.ArgumentException"></exception> + protected BaseItem RetrieveItemInternal(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException(); + } + + var cmd = connection.CreateCommand(); + cmd.CommandText = "select obj_type,data from items where guid = @guid"; + var guidParam = cmd.Parameters.Add("@guid", DbType.Guid); + guidParam.Value = id; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult | CommandBehavior.SingleRow)) + { + if (reader.Read()) + { + var type = reader.GetString(0); + using (var stream = GetStream(reader, 1)) + { + var itemType = _typeMapper.GetType(type); + + if (itemType == null) + { + Logger.Error("Cannot find type {0}. Probably belongs to plug-in that is no longer loaded.", type); + return null; + } + + var item = JsonSerializer.DeserializeFromStream(stream, itemType); + return item as BaseItem; + } + } + } + return null; + } + + /// <summary> + /// Retrieve all the children of the given folder + /// </summary> + /// <param name="parent">The parent.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public IEnumerable<BaseItem> RetrieveChildren(Folder parent) + { + if (parent == null) + { + throw new ArgumentNullException(); + } + + var cmd = connection.CreateCommand(); + cmd.CommandText = "select obj_type,data from items where guid in (select child from children where guid = @guid)"; + var guidParam = cmd.Parameters.Add("@guid", DbType.Guid); + guidParam.Value = parent.Id; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) + { + while (reader.Read()) + { + var type = reader.GetString(0); + + using (var stream = GetStream(reader, 1)) + { + var itemType = _typeMapper.GetType(type); + if (itemType == null) + { + Logger.Error("Cannot find type {0}. Probably belongs to plug-in that is no longer loaded.",type); + continue; + } + var item = JsonSerializer.DeserializeFromStream(stream, itemType) as BaseItem; + if (item != null) + { + item.Parent = parent; + yield return item; + } + } + } + } + } + + /// <summary> + /// Save references to all the children for the given folder + /// (Doesn't actually save the child entities) + /// </summary> + /// <param name="id">The id.</param> + /// <param name="children">The children.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">id</exception> + public Task SaveChildren(Guid id, IEnumerable<BaseItem> children, CancellationToken cancellationToken) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + + if (children == null) + { + throw new ArgumentNullException("children"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.Run(() => + { + var cmd = connection.CreateCommand(); + + cmd.CommandText = "delete from children where guid = @guid"; + cmd.AddParam("@guid", id); + + QueueCommand(cmd); + + foreach (var child in children) + { + var guid = child.Id; + cmd = connection.CreateCommand(); + cmd.AddParam("@guid", id); + cmd.CommandText = "replace into children (guid, child) values (@guid, @child)"; + var childParam = cmd.Parameters.Add("@child", DbType.Guid); + + childParam.Value = guid; + QueueCommand(cmd); + } + }); + } + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteRepository.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteRepository.cs new file mode 100644 index 0000000000..5cf57541b9 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteRepository.cs @@ -0,0 +1,301 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Concurrent; +using System.Data; +using System.Data.Common; +using System.Data.SQLite; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SqliteRepository + /// </summary> + public abstract class SqliteRepository : IDisposable + { + /// <summary> + /// The db file name + /// </summary> + protected string dbFileName; + /// <summary> + /// The connection + /// </summary> + protected SQLiteConnection connection; + /// <summary> + /// The delayed commands + /// </summary> + protected ConcurrentQueue<SQLiteCommand> delayedCommands = new ConcurrentQueue<SQLiteCommand>(); + /// <summary> + /// The flush interval + /// </summary> + private const int FlushInterval = 5000; + + /// <summary> + /// The flush timer + /// </summary> + private Timer FlushTimer; + + protected ILogger Logger { get; private set; } + + /// <summary> + /// Connects to DB. + /// </summary> + /// <param name="dbPath">The db path.</param> + /// <returns>Task{System.Boolean}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + protected async Task ConnectToDB(string dbPath) + { + if (string.IsNullOrEmpty(dbPath)) + { + throw new ArgumentNullException("dbPath"); + } + + Logger = LogManager.GetLogger(GetType().Name); + + dbFileName = dbPath; + var connectionstr = new SQLiteConnectionStringBuilder + { + PageSize = 4096, + CacheSize = 40960, + SyncMode = SynchronizationModes.Off, + DataSource = dbPath, + JournalMode = SQLiteJournalModeEnum.Memory + }; + + connection = new SQLiteConnection(connectionstr.ConnectionString); + + await connection.OpenAsync().ConfigureAwait(false); + + // Run once + FlushTimer = new Timer(Flush, null, TimeSpan.FromMilliseconds(FlushInterval), TimeSpan.FromMilliseconds(-1)); + } + + /// <summary> + /// Runs the queries. + /// </summary> + /// <param name="queries">The queries.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <exception cref="System.ArgumentNullException"></exception> + protected void RunQueries(string[] queries) + { + if (queries == null) + { + throw new ArgumentNullException("queries"); + } + + using (var tran = connection.BeginTransaction()) + { + try + { + var cmd = connection.CreateCommand(); + + foreach (var query in queries) + { + cmd.Transaction = tran; + cmd.CommandText = query; + cmd.ExecuteNonQuery(); + } + + tran.Commit(); + } + catch (Exception e) + { + Logger.ErrorException("Error running queries", e); + tran.Rollback(); + throw; + } + } + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + Logger.Info("Disposing " + GetType().Name); + + try + { + // If we're not already flushing, do it now + if (!IsFlushing) + { + Flush(null); + } + + // Don't dispose in the middle of a flush + while (IsFlushing) + { + Thread.Sleep(50); + } + + if (FlushTimer != null) + { + FlushTimer.Dispose(); + FlushTimer = null; + } + + if (connection.IsOpen()) + { + connection.Close(); + } + + connection.Dispose(); + } + catch (Exception ex) + { + Logger.ErrorException("Error disposing database", ex); + } + } + } + + /// <summary> + /// Queues the command. + /// </summary> + /// <param name="cmd">The CMD.</param> + /// <exception cref="System.ArgumentNullException"></exception> + protected void QueueCommand(SQLiteCommand cmd) + { + if (cmd == null) + { + throw new ArgumentNullException("cmd"); + } + + delayedCommands.Enqueue(cmd); + } + + /// <summary> + /// The is flushing + /// </summary> + private bool IsFlushing; + + /// <summary> + /// Flushes the specified sender. + /// </summary> + /// <param name="sender">The sender.</param> + private void Flush(object sender) + { + // Cannot call Count on a ConcurrentQueue since it's an O(n) operation + // Use IsEmpty instead + if (delayedCommands.IsEmpty) + { + FlushTimer.Change(TimeSpan.FromMilliseconds(FlushInterval), TimeSpan.FromMilliseconds(-1)); + return; + } + + if (IsFlushing) + { + return; + } + + IsFlushing = true; + var numCommands = 0; + + using (var tran = connection.BeginTransaction()) + { + try + { + while (!delayedCommands.IsEmpty) + { + SQLiteCommand command; + + delayedCommands.TryDequeue(out command); + + command.Connection = connection; + command.Transaction = tran; + + command.ExecuteNonQuery(); + numCommands++; + } + + tran.Commit(); + } + catch (Exception e) + { + Logger.ErrorException("Failed to commit transaction.", e); + tran.Rollback(); + } + } + + Logger.Info("SQL Delayed writer executed " + numCommands + " commands"); + + FlushTimer.Change(TimeSpan.FromMilliseconds(FlushInterval), TimeSpan.FromMilliseconds(-1)); + IsFlushing = false; + } + + /// <summary> + /// Executes the command. + /// </summary> + /// <param name="cmd">The CMD.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task ExecuteCommand(DbCommand cmd) + { + if (cmd == null) + { + throw new ArgumentNullException("cmd"); + } + + using (var tran = connection.BeginTransaction()) + { + try + { + cmd.Connection = connection; + cmd.Transaction = tran; + + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + + tran.Commit(); + } + catch (Exception e) + { + Logger.ErrorException("Failed to commit transaction.", e); + tran.Rollback(); + } + } + } + + /// <summary> + /// Gets a stream from a DataReader at a given ordinal + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="ordinal">The ordinal.</param> + /// <returns>Stream.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + protected static Stream GetStream(IDataReader reader, int ordinal) + { + if (reader == null) + { + throw new ArgumentNullException("reader"); + } + + var memoryStream = new MemoryStream(); + var num = 0L; + var array = new byte[4096]; + long bytes; + do + { + bytes = reader.GetBytes(ordinal, num, array, 0, array.Length); + memoryStream.Write(array, 0, (int)bytes); + num += bytes; + } + while (bytes > 0L); + memoryStream.Position = 0; + return memoryStream; + } + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserDataRepository.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserDataRepository.cs new file mode 100644 index 0000000000..a027e84751 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserDataRepository.cs @@ -0,0 +1,138 @@ +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Data; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SQLiteUserDataRepository + /// </summary> + [Export(typeof(IUserDataRepository))] + public class SQLiteUserDataRepository : SqliteRepository, IUserDataRepository + { + /// <summary> + /// The repository name + /// </summary> + public const string RepositoryName = "SQLite"; + + /// <summary> + /// Gets the name of the repository + /// </summary> + /// <value>The name.</value> + public string Name + { + get + { + return RepositoryName; + } + } + + /// <summary> + /// Opens the connection to the database + /// </summary> + /// <returns>Task.</returns> + public async Task Initialize() + { + var dbFile = Path.Combine(Kernel.Instance.ApplicationPaths.DataPath, "userdata.db"); + + await ConnectToDB(dbFile).ConfigureAwait(false); + + string[] queries = { + + "create table if not exists user_data (item_id GUID, user_id GUID, data BLOB)", + "create unique index if not exists idx_user_data on user_data (item_id, user_id)", + "create table if not exists schema_version (table_name primary key, version)", + //pragmas + "pragma temp_store = memory" + }; + + RunQueries(queries); + } + + /// <summary> + /// Save the user specific data associated with an item in the repo + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public Task SaveUserData(BaseItem item, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + var cmd = connection.CreateCommand(); + + cmd.CommandText = "delete from user_data where item_id = @guid"; + cmd.AddParam("@guid", item.UserDataId); + + QueueCommand(cmd); + + if (item.UserData != null) + { + foreach (var data in item.UserData) + { + cmd = connection.CreateCommand(); + cmd.CommandText = "insert into user_data (item_id, user_id, data) values (@1, @2, @3)"; + cmd.AddParam("@1", item.UserDataId); + cmd.AddParam("@2", data.UserId); + + cmd.AddParam("@3", Kernel.Instance.ProtobufSerializer.SerializeToBytes(data)); + + QueueCommand(cmd); + } + } + }); + } + + /// <summary> + /// Gets user data for an item + /// </summary> + /// <param name="item">The item.</param> + /// <returns>IEnumerable{UserItemData}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public IEnumerable<UserItemData> RetrieveUserData(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var cmd = connection.CreateCommand(); + cmd.CommandText = "select data from user_data where item_id = @guid"; + var guidParam = cmd.Parameters.Add("@guid", DbType.Guid); + guidParam.Value = item.UserDataId; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) + { + while (reader.Read()) + { + using (var stream = GetStream(reader, 0)) + { + var data = Kernel.Instance.ProtobufSerializer.DeserializeFromStream<UserItemData>(stream); + if (data != null) + { + yield return data; + } + } + } + } + } + } +} diff --git a/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserRepository.cs b/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserRepository.cs new file mode 100644 index 0000000000..9fe5e5624f --- /dev/null +++ b/MediaBrowser.Controller/Persistence/SQLite/SQLiteUserRepository.cs @@ -0,0 +1,147 @@ +using System.Threading; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Data; +using System.IO; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Persistence.SQLite +{ + /// <summary> + /// Class SQLiteUserRepository + /// </summary> + [Export(typeof(IUserRepository))] + public class SQLiteUserRepository : SqliteRepository, IUserRepository + { + /// <summary> + /// The repository name + /// </summary> + public const string RepositoryName = "SQLite"; + + /// <summary> + /// Gets the name of the repository + /// </summary> + /// <value>The name.</value> + public string Name + { + get + { + return RepositoryName; + } + } + + /// <summary> + /// Opens the connection to the database + /// </summary> + /// <returns>Task.</returns> + public async Task Initialize() + { + var dbFile = Path.Combine(Kernel.Instance.ApplicationPaths.DataPath, "users.db"); + + await ConnectToDB(dbFile).ConfigureAwait(false); + + string[] queries = { + + "create table if not exists users (guid GUID primary key, data BLOB)", + "create index if not exists idx_users on users(guid)", + "create table if not exists schema_version (table_name primary key, version)", + //pragmas + "pragma temp_store = memory" + }; + + RunQueries(queries); + } + + /// <summary> + /// Save a user in the repo + /// </summary> + /// <param name="user">The user.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public Task SaveUser(User user, CancellationToken cancellationToken) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + var serialized = JsonSerializer.SerializeToBytes(user); + + cancellationToken.ThrowIfCancellationRequested(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = "replace into users (guid, data) values (@1, @2)"; + cmd.AddParam("@1", user.Id); + cmd.AddParam("@2", serialized); + QueueCommand(cmd); + }); + } + + /// <summary> + /// Retrieve all users from the database + /// </summary> + /// <returns>IEnumerable{User}.</returns> + public IEnumerable<User> RetrieveAllUsers() + { + var cmd = connection.CreateCommand(); + cmd.CommandText = "select data from users"; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) + { + while (reader.Read()) + { + using (var stream = GetStream(reader, 0)) + { + var user = JsonSerializer.DeserializeFromStream<User>(stream); + yield return user; + } + } + } + } + + /// <summary> + /// Deletes the user. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + public Task DeleteUser(User user, CancellationToken cancellationToken) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = "delete from users where guid=@guid"; + var guidParam = cmd.Parameters.Add("@guid", DbType.Guid); + guidParam.Value = user.Id; + + return ExecuteCommand(cmd); + }); + } + } +} diff --git a/MediaBrowser.Controller/Persistence/TypeMapper.cs b/MediaBrowser.Controller/Persistence/TypeMapper.cs new file mode 100644 index 0000000000..2b9ec9e5ec --- /dev/null +++ b/MediaBrowser.Controller/Persistence/TypeMapper.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; + +namespace MediaBrowser.Controller.Persistence +{ + /// <summary> + /// Class TypeMapper + /// </summary> + public class TypeMapper + { + /// <summary> + /// This holds all the types in the running assemblies so that we can de-serialize properly when we don't have strong types + /// </summary> + private readonly ConcurrentDictionary<string, Type> _typeMap = new ConcurrentDictionary<string, Type>(); + + /// <summary> + /// Gets the type. + /// </summary> + /// <param name="typeName">Name of the type.</param> + /// <returns>Type.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public Type GetType(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + { + throw new ArgumentNullException(); + } + + return _typeMap.GetOrAdd(typeName, LookupType); + } + + /// <summary> + /// Lookups the type. + /// </summary> + /// <param name="typeName">Name of the type.</param> + /// <returns>Type.</returns> + private Type LookupType(string typeName) + { + return AppDomain + .CurrentDomain + .GetAssemblies() + .Select(a => a.GetType(typeName, false)) + .FirstOrDefault(t => t != null); + } + } +} diff --git a/MediaBrowser.Controller/Playback/BaseIntroProvider.cs b/MediaBrowser.Controller/Playback/BaseIntroProvider.cs new file mode 100644 index 0000000000..6e90baf3f3 --- /dev/null +++ b/MediaBrowser.Controller/Playback/BaseIntroProvider.cs @@ -0,0 +1,19 @@ +using MediaBrowser.Controller.Entities; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Playback +{ + /// <summary> + /// Class BaseIntroProvider + /// </summary> + public abstract class BaseIntroProvider + { + /// <summary> + /// Gets the intros. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="user">The user.</param> + /// <returns>IEnumerable{System.String}.</returns> + public abstract IEnumerable<string> GetIntros(BaseItem item, User user); + } +} diff --git a/MediaBrowser.Controller/Plugins/BaseConfigurationPage.cs b/MediaBrowser.Controller/Plugins/BaseConfigurationPage.cs new file mode 100644 index 0000000000..dc9405840a --- /dev/null +++ b/MediaBrowser.Controller/Plugins/BaseConfigurationPage.cs @@ -0,0 +1,81 @@ +using MediaBrowser.Common.Plugins; +using System.IO; + +namespace MediaBrowser.Controller.Plugins +{ + /// <summary> + /// Class BaseConfigurationPage + /// </summary> + public abstract class BaseConfigurationPage + { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public abstract string Name { get; } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public virtual string Description + { + get { return string.Empty; } + } + + /// <summary> + /// Gets the type of the configuration page. + /// </summary> + /// <value>The type of the configuration page.</value> + public virtual ConfigurationPageType ConfigurationPageType + { + get { return ConfigurationPageType.PluginConfiguration; } + } + + /// <summary> + /// Gets the HTML stream from manifest resource. + /// </summary> + /// <param name="resource">The resource.</param> + /// <returns>Stream.</returns> + protected Stream GetHtmlStreamFromManifestResource(string resource) + { + return GetType().Assembly.GetManifestResourceStream(resource); + } + + /// <summary> + /// Gets the HTML stream. + /// </summary> + /// <returns>Stream.</returns> + public abstract Stream GetHtmlStream(); + + /// <summary> + /// Gets the name of the plugin. + /// </summary> + /// <value>The name of the plugin.</value> + public virtual string OwnerPluginName + { + get { return GetOwnerPlugin().Name; } + } + + /// <summary> + /// Gets the owner plugin. + /// </summary> + /// <returns>BasePlugin.</returns> + public abstract IPlugin GetOwnerPlugin(); + } + + /// <summary> + /// Enum ConfigurationPageType + /// </summary> + public enum ConfigurationPageType + { + /// <summary> + /// The plugin configuration + /// </summary> + PluginConfiguration, + /// <summary> + /// The none + /// </summary> + None + } +} diff --git a/MediaBrowser.Controller/Plugins/PluginSecurityManager.cs b/MediaBrowser.Controller/Plugins/PluginSecurityManager.cs new file mode 100644 index 0000000000..341e3582bb --- /dev/null +++ b/MediaBrowser.Controller/Plugins/PluginSecurityManager.cs @@ -0,0 +1,65 @@ +using Mediabrowser.Model.Entities; +using Mediabrowser.PluginSecurity; +using MediaBrowser.Common.Kernel; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Plugins +{ + public class PluginSecurityManager : BaseManager<Kernel> + { + private bool? _isMBSupporter; + private bool _isMBSupporterInitialized; + private object _isMBSupporterSyncLock = new object(); + + public bool IsMBSupporter + { + get + { + LazyInitializer.EnsureInitialized(ref _isMBSupporter, ref _isMBSupporterInitialized, ref _isMBSupporterSyncLock, () => GetRegistrationStatus("MBSupporter").Result.IsRegistered); + return _isMBSupporter.Value; + } + } + + public PluginSecurityManager(Kernel kernel) : base(kernel) + { + } + + public async Task<MBRegistrationRecord> GetRegistrationStatus(string feature, string mb2Equivalent = null) + { + return await MBRegistration.GetRegistrationStatus(feature, mb2Equivalent).ConfigureAwait(false); + } + + public string SupporterKey + { + get { return MBRegistration.SupporterKey; } + set { + if (value != MBRegistration.SupporterKey) + { + MBRegistration.SupporterKey = value; + // Clear this so it will re-evaluate + ResetSupporterInfo(); + // And we'll need to restart to re-evaluate the status of plug-ins + Kernel.NotifyPendingRestart(); + + } + } + } + + public string LegacyKey + { + get { return MBRegistration.LegacyKey; } + set { + MBRegistration.LegacyKey = value; + // And we'll need to restart to re-evaluate the status of plug-ins + Kernel.NotifyPendingRestart(); + } + } + + private void ResetSupporterInfo() + { + _isMBSupporter = null; + _isMBSupporterInitialized = false; + } + } +} diff --git a/MediaBrowser.Controller/Properties/AssemblyInfo.cs b/MediaBrowser.Controller/Properties/AssemblyInfo.cs index 63cc65d7aa..f48500878d 100644 --- a/MediaBrowser.Controller/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Controller/Properties/AssemblyInfo.cs @@ -1,35 +1,34 @@ -using System.Reflection;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("MediaBrowser.Controller")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("MediaBrowser.Controller")]
-[assembly: AssemblyCopyright("Copyright © 2012")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("bc09905a-04ed-497d-b39b-27593401e715")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
+using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("MediaBrowser.Controller")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("MediaBrowser.Controller")] +[assembly: AssemblyCopyright("Copyright © 2012")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("bc09905a-04ed-497d-b39b-27593401e715")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("2.9.*")] diff --git a/MediaBrowser.Controller/Providers/AudioInfoProvider.cs b/MediaBrowser.Controller/Providers/AudioInfoProvider.cs deleted file mode 100644 index 302902646e..0000000000 --- a/MediaBrowser.Controller/Providers/AudioInfoProvider.cs +++ /dev/null @@ -1,262 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.FFMpeg;
-using MediaBrowser.Controller.Library;
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.Linq;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers
-{
- [Export(typeof(BaseMetadataProvider))]
- public class AudioInfoProvider : BaseMediaInfoProvider<Audio>
- {
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- protected override string CacheDirectory
- {
- get { return Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory; }
- }
-
- protected override void Fetch(Audio audio, FFProbeResult data)
- {
- MediaStream stream = data.streams.First(s => s.codec_type.Equals("audio", StringComparison.OrdinalIgnoreCase));
-
- audio.Channels = stream.channels;
-
- if (!string.IsNullOrEmpty(stream.sample_rate))
- {
- audio.SampleRate = int.Parse(stream.sample_rate);
- }
-
- string bitrate = stream.bit_rate;
- string duration = stream.duration;
-
- if (string.IsNullOrEmpty(bitrate))
- {
- bitrate = data.format.bit_rate;
- }
-
- if (string.IsNullOrEmpty(duration))
- {
- duration = data.format.duration;
- }
-
- if (!string.IsNullOrEmpty(bitrate))
- {
- audio.BitRate = int.Parse(bitrate);
- }
-
- if (!string.IsNullOrEmpty(duration))
- {
- audio.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration)).Ticks;
- }
-
- if (data.format.tags != null)
- {
- FetchDataFromTags(audio, data.format.tags);
- }
- }
-
- private void FetchDataFromTags(Audio audio, Dictionary<string, string> tags)
- {
- string title = GetDictionaryValue(tags, "title");
-
- if (!string.IsNullOrEmpty(title))
- {
- audio.Name = title;
- }
-
- string composer = GetDictionaryValue(tags, "composer");
-
- if (!string.IsNullOrEmpty(composer))
- {
- audio.AddPerson(new PersonInfo { Name = composer, Type = "Composer" });
- }
-
- audio.Album = GetDictionaryValue(tags, "album");
- audio.Artist = GetDictionaryValue(tags, "artist");
- audio.AlbumArtist = GetDictionaryValue(tags, "albumartist") ?? GetDictionaryValue(tags, "album artist") ?? GetDictionaryValue(tags, "album_artist");
-
- audio.IndexNumber = GetDictionaryNumericValue(tags, "track");
- audio.ParentIndexNumber = GetDictionaryDiscValue(tags);
-
- audio.Language = GetDictionaryValue(tags, "language");
-
- audio.ProductionYear = GetDictionaryNumericValue(tags, "date");
-
- audio.PremiereDate = GetDictionaryDateTime(tags, "retaildate") ?? GetDictionaryDateTime(tags, "retail date") ?? GetDictionaryDateTime(tags, "retail_date");
-
- FetchGenres(audio, tags);
-
- FetchStudios(audio, tags, "organization");
- FetchStudios(audio, tags, "ensemble");
- FetchStudios(audio, tags, "publisher");
- }
-
- private void FetchStudios(Audio audio, Dictionary<string, string> tags, string tagName)
- {
- string val = GetDictionaryValue(tags, tagName);
-
- if (!string.IsNullOrEmpty(val))
- {
- var list = audio.Studios ?? new List<string>();
- list.AddRange(val.Split('/'));
- audio.Studios = list;
- }
- }
-
- private void FetchGenres(Audio audio, Dictionary<string, string> tags)
- {
- string val = GetDictionaryValue(tags, "genre");
-
- if (!string.IsNullOrEmpty(val))
- {
- var list = audio.Genres ?? new List<string>();
- list.AddRange(val.Split('/'));
- audio.Genres = list;
- }
- }
-
- private int? GetDictionaryDiscValue(Dictionary<string, string> tags)
- {
- string disc = GetDictionaryValue(tags, "disc");
-
- if (!string.IsNullOrEmpty(disc))
- {
- disc = disc.Split('/')[0];
-
- int num;
-
- if (int.TryParse(disc, out num))
- {
- return num;
- }
- }
-
- return null;
- }
- }
-
- public abstract class BaseMediaInfoProvider<T> : BaseMetadataProvider
- where T : BaseItem
- {
- protected abstract string CacheDirectory { get; }
-
- public override bool Supports(BaseEntity item)
- {
- return item is T;
- }
-
- public override async Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- await Task.Run(() =>
- {
- /*T myItem = item as T;
-
- if (CanSkipFFProbe(myItem))
- {
- return;
- }
-
- FFProbeResult result = FFProbe.Run(myItem, CacheDirectory);
-
- if (result == null)
- {
- Logger.LogInfo("Null FFProbeResult for {0} {1}", item.Id, item.Name);
- return;
- }
-
- if (result.format != null && result.format.tags != null)
- {
- result.format.tags = ConvertDictionaryToCaseInSensitive(result.format.tags);
- }
-
- if (result.streams != null)
- {
- foreach (MediaStream stream in result.streams)
- {
- if (stream.tags != null)
- {
- stream.tags = ConvertDictionaryToCaseInSensitive(stream.tags);
- }
- }
- }
-
- Fetch(myItem, result);*/
- });
- }
-
- protected abstract void Fetch(T item, FFProbeResult result);
-
- protected virtual bool CanSkipFFProbe(T item)
- {
- return false;
- }
-
- protected string GetDictionaryValue(Dictionary<string, string> tags, string key)
- {
- if (tags == null)
- {
- return null;
- }
-
- if (!tags.ContainsKey(key))
- {
- return null;
- }
-
- return tags[key];
- }
-
- protected int? GetDictionaryNumericValue(Dictionary<string, string> tags, string key)
- {
- string val = GetDictionaryValue(tags, key);
-
- if (!string.IsNullOrEmpty(val))
- {
- int i;
-
- if (int.TryParse(val, out i))
- {
- return i;
- }
- }
-
- return null;
- }
-
- protected DateTime? GetDictionaryDateTime(Dictionary<string, string> tags, string key)
- {
- string val = GetDictionaryValue(tags, key);
-
- if (!string.IsNullOrEmpty(val))
- {
- DateTime i;
-
- if (DateTime.TryParse(val, out i))
- {
- return i.ToUniversalTime();
- }
- }
-
- return null;
- }
-
- private Dictionary<string, string> ConvertDictionaryToCaseInSensitive(Dictionary<string, string> dict)
- {
- var newDict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
- foreach (string key in dict.Keys)
- {
- newDict[key] = dict[key];
- }
-
- return newDict;
- }
- }
-}
diff --git a/MediaBrowser.Controller/Providers/BaseImageEnhancer.cs b/MediaBrowser.Controller/Providers/BaseImageEnhancer.cs new file mode 100644 index 0000000000..bd60003beb --- /dev/null +++ b/MediaBrowser.Controller/Providers/BaseImageEnhancer.cs @@ -0,0 +1,113 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using System; +using System.Drawing; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class BaseImageEnhancer + /// </summary> + public abstract class BaseImageEnhancer : IDisposable + { + /// <summary> + /// Return true only if the given image for the given item will be enhanced by this enhancer. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <returns><c>true</c> if this enhancer will enhance the supplied image for the supplied item, <c>false</c> otherwise</returns> + public abstract bool Supports(BaseItem item, ImageType imageType); + + /// <summary> + /// Gets the priority or order in which this enhancer should be run. + /// </summary> + /// <value>The priority.</value> + public abstract MetadataProviderPriority Priority { get; } + + /// <summary> + /// Return the date of the last configuration change affecting the provided baseitem and image type + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <returns>Date of last config change</returns> + public virtual DateTime LastConfigurationChange(BaseItem item, ImageType imageType) + { + return DateTime.MinValue; + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + } + + /// <summary> + /// Gets the size of the enhanced image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <param name="originalImageSize">Size of the original image.</param> + /// <returns>ImageSize.</returns> + public virtual ImageSize GetEnhancedImageSize(BaseItem item, ImageType imageType, int imageIndex, ImageSize originalImageSize) + { + return originalImageSize; + } + + /// <summary> + /// Enhances the supplied image and returns it + /// </summary> + /// <param name="item">The item.</param> + /// <param name="originalImage">The original image.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>Task{System.Drawing.Image}.</returns> + protected abstract Task<Image> EnhanceImageAsyncInternal(BaseItem item, Image originalImage, ImageType imageType, int imageIndex); + + /// <summary> + /// Enhances the image async. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="originalImage">The original image.</param> + /// <param name="imageType">Type of the image.</param> + /// <param name="imageIndex">Index of the image.</param> + /// <returns>Task{Image}.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task<Image> EnhanceImageAsync(BaseItem item, Image originalImage, ImageType imageType, int imageIndex) + { + if (item == null || originalImage == null) + { + throw new ArgumentNullException(); + } + + var typeName = GetType().Name; + + Logger.LogDebugInfo("Running {0} for {1}", typeName, item.Path ?? item.Name ?? "--Unknown--"); + + try + { + return await EnhanceImageAsyncInternal(item, originalImage, imageType, imageIndex).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogException("{0} failed enhancing {1}", ex, typeName, item.Name); + + throw; + } + } + } +} diff --git a/MediaBrowser.Controller/Providers/BaseItemXmlParser.cs b/MediaBrowser.Controller/Providers/BaseItemXmlParser.cs index 38afb2b524..0869b25bc4 100644 --- a/MediaBrowser.Controller/Providers/BaseItemXmlParser.cs +++ b/MediaBrowser.Controller/Providers/BaseItemXmlParser.cs @@ -1,724 +1,608 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Xml;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Common.Logging;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Xml;
-
-namespace MediaBrowser.Controller.Providers
-{
- /// <summary>
- /// Provides a base class for parsing metadata xml
- /// </summary>
- public class BaseItemXmlParser<T>
- where T : BaseItem, new()
- {
- /// <summary>
- /// Fetches metadata for an item from one xml file
- /// </summary>
- public void Fetch(T item, string metadataFile)
- {
- // Use XmlReader for best performance
- using (XmlReader reader = XmlReader.Create(metadataFile))
- {
- reader.MoveToContent();
-
- // Loop through each element
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- FetchDataFromXmlNode(reader, item);
- }
- }
- }
- }
-
- /// <summary>
- /// Fetches metadata from one Xml Element
- /// </summary>
- protected virtual void FetchDataFromXmlNode(XmlReader reader, T item)
- {
- switch (reader.Name)
- {
- // DateCreated
- case "Added":
- DateTime added;
- if (DateTime.TryParse(reader.ReadElementContentAsString() ?? string.Empty, out added))
- {
- item.DateCreated = added.ToUniversalTime();
- }
- break;
-
- // DisplayMediaType
- case "Type":
- {
- item.DisplayMediaType = reader.ReadElementContentAsString();
-
- switch (item.DisplayMediaType.ToLower())
- {
- case "blu-ray":
- item.DisplayMediaType = VideoType.BluRay.ToString();
- break;
- case "dvd":
- item.DisplayMediaType = VideoType.Dvd.ToString();
- break;
- case "":
- item.DisplayMediaType = null;
- break;
- }
-
- break;
- }
-
- // TODO: Do we still need this?
- case "banner":
- item.BannerImagePath = reader.ReadElementContentAsString();
- break;
-
- case "LocalTitle":
- item.Name = reader.ReadElementContentAsString();
- break;
-
- case "SortTitle":
- item.SortName = reader.ReadElementContentAsString();
- break;
-
- case "Overview":
- case "Description":
- item.Overview = reader.ReadElementContentAsString();
- break;
-
- case "TagLine":
- {
- var list = item.Taglines ?? new List<string>();
- var tagline = reader.ReadElementContentAsString();
-
- if (!list.Contains(tagline))
- {
- list.Add(tagline);
- }
-
- item.Taglines = list;
- break;
- }
-
- case "TagLines":
- {
- FetchFromTaglinesNode(reader.ReadSubtree(), item);
- break;
- }
-
- case "ContentRating":
- case "MPAARating":
- item.OfficialRating = reader.ReadElementContentAsString();
- break;
-
- case "CustomRating":
- item.CustomRating = reader.ReadElementContentAsString();
- break;
-
- case "CustomPin":
- item.CustomPin = reader.ReadElementContentAsString();
- break;
-
- case "Runtime":
- case "RunningTime":
- {
- string text = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(text))
- {
- int runtime;
- if (int.TryParse(text.Split(' ')[0], out runtime))
- {
- item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
- }
- }
- break;
- }
-
- case "Genre":
- {
- var list = item.Genres ?? new List<string>();
- list.AddRange(GetSplitValues(reader.ReadElementContentAsString(), '|'));
-
- item.Genres = list;
- break;
- }
-
- case "AspectRatio":
- item.AspectRatio = reader.ReadElementContentAsString();
- break;
-
- case "Network":
- {
- var list = item.Studios ?? new List<string>();
- list.AddRange(GetSplitValues(reader.ReadElementContentAsString(), '|'));
-
- item.Studios = list;
- break;
- }
-
- case "Director":
- {
- foreach (PersonInfo p in GetSplitValues(reader.ReadElementContentAsString(), '|').Select(v => new PersonInfo { Name = v, Type = "Director" }))
- {
- item.AddPerson(p);
- }
- break;
- }
- case "Writer":
- {
- foreach (PersonInfo p in GetSplitValues(reader.ReadElementContentAsString(), '|').Select(v => new PersonInfo { Name = v, Type = "Writer" }))
- {
- item.AddPerson(p);
- }
- break;
- }
-
- case "Actors":
- case "GuestStars":
- {
- foreach (PersonInfo p in GetSplitValues(reader.ReadElementContentAsString(), '|').Select(v => new PersonInfo { Name = v, Type = "Actor" }))
- {
- item.AddPerson(p);
- }
- break;
- }
-
- case "Trailer":
- item.TrailerUrl = reader.ReadElementContentAsString();
- break;
-
- case "ProductionYear":
- {
- string val = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(val))
- {
- int ProductionYear;
- if (int.TryParse(val, out ProductionYear) && ProductionYear > 1850)
- {
- item.ProductionYear = ProductionYear;
- }
- }
-
- break;
- }
-
- case "Rating":
- case "IMDBrating":
-
- string rating = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(rating))
- {
- float val;
-
- if (float.TryParse(rating, out val))
- {
- item.CommunityRating = val;
- }
- }
- break;
-
- case "FirstAired":
- {
- string firstAired = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(firstAired))
- {
- DateTime airDate;
-
- if (DateTime.TryParse(firstAired, out airDate) && airDate.Year > 1850)
- {
- item.PremiereDate = airDate.ToUniversalTime();
- item.ProductionYear = airDate.Year;
- }
- }
-
- break;
- }
-
- case "TMDbId":
- string tmdb = reader.ReadElementContentAsString();
- if (!string.IsNullOrWhiteSpace(tmdb))
- {
- item.SetProviderId(MetadataProviders.Tmdb, tmdb);
- }
- break;
-
- case "TVcomId":
- string TVcomId = reader.ReadElementContentAsString();
- if (!string.IsNullOrWhiteSpace(TVcomId))
- {
- item.SetProviderId(MetadataProviders.Tvcom, TVcomId);
- }
- break;
-
- case "IMDB_ID":
- case "IMDB":
- case "IMDbId":
- string IMDbId = reader.ReadElementContentAsString();
- if (!string.IsNullOrWhiteSpace(IMDbId))
- {
- item.SetProviderId(MetadataProviders.Imdb, IMDbId);
- }
- break;
-
- case "Genres":
- FetchFromGenresNode(reader.ReadSubtree(), item);
- break;
-
- case "Persons":
- FetchDataFromPersonsNode(reader.ReadSubtree(), item);
- break;
-
- case "ParentalRating":
- FetchFromParentalRatingNode(reader.ReadSubtree(), item);
- break;
-
- case "Studios":
- FetchFromStudiosNode(reader.ReadSubtree(), item);
- break;
-
- case "MediaInfo":
- {
- var video = item as Video;
-
- if (video != null)
- {
- FetchMediaInfo(reader.ReadSubtree(), video);
- }
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
-
- private void FetchMediaInfo(XmlReader reader, Video item)
- {
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Audio":
- {
- AudioStream stream = FetchMediaInfoAudio(reader.ReadSubtree());
-
- List<AudioStream> streams = item.AudioStreams ?? new List<AudioStream>();
- streams.Add(stream);
- item.AudioStreams = streams;
-
- break;
- }
-
- case "Video":
- FetchMediaInfoVideo(reader.ReadSubtree(), item);
- break;
-
- case "Subtitle":
- {
- SubtitleStream stream = FetchMediaInfoSubtitles(reader.ReadSubtree());
-
- List<SubtitleStream> streams = item.Subtitles ?? new List<SubtitleStream>();
- streams.Add(stream);
- item.Subtitles = streams;
-
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
- }
-
- private AudioStream FetchMediaInfoAudio(XmlReader reader)
- {
- var stream = new AudioStream();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Default":
- stream.IsDefault = reader.ReadElementContentAsString() == "True";
- break;
-
- case "SamplingRate":
- stream.SampleRate = reader.ReadIntSafe();
- break;
-
- case "BitRate":
- stream.BitRate = reader.ReadIntSafe();
- break;
-
- case "Channels":
- stream.Channels = reader.ReadIntSafe();
- break;
-
- case "Language":
- stream.Language = reader.ReadElementContentAsString();
- break;
-
- case "Codec":
- stream.Codec = reader.ReadElementContentAsString();
- break;
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- return stream;
- }
-
- private void FetchMediaInfoVideo(XmlReader reader, Video item)
- {
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Width":
- item.Width = reader.ReadIntSafe();
- break;
-
- case "Height":
- item.Height = reader.ReadIntSafe();
- break;
-
- case "BitRate":
- item.BitRate = reader.ReadIntSafe();
- break;
-
- case "FrameRate":
- item.FrameRate = reader.ReadFloatSafe();
- break;
-
- case "ScanType":
- item.ScanType = reader.ReadElementContentAsString();
- break;
-
- case "Duration":
- item.RunTimeTicks = TimeSpan.FromMinutes(reader.ReadIntSafe()).Ticks;
- break;
-
- case "DurationSeconds":
- int seconds = reader.ReadIntSafe();
- if (seconds > 0)
- {
- item.RunTimeTicks = TimeSpan.FromSeconds(seconds).Ticks;
- }
- break;
-
- case "Codec":
- {
- string videoCodec = reader.ReadElementContentAsString();
-
- switch (videoCodec.ToLower())
- {
- case "sorenson h.263":
- item.Codec = "Sorenson H263";
- break;
- case "h.262":
- item.Codec = "MPEG-2 Video";
- break;
- case "h.264":
- item.Codec = "AVC";
- break;
- default:
- item.Codec = videoCodec;
- break;
- }
-
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
- }
-
- private SubtitleStream FetchMediaInfoSubtitles(XmlReader reader)
- {
- var stream = new SubtitleStream();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Language":
- stream.Language = reader.ReadElementContentAsString();
- break;
-
- case "Default":
- stream.IsDefault = reader.ReadElementContentAsString() == "True";
- break;
-
- case "Forced":
- stream.IsForced = reader.ReadElementContentAsString() == "True";
- break;
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- return stream;
- }
-
- private void FetchFromTaglinesNode(XmlReader reader, T item)
- {
- var list = item.Taglines ?? new List<string>();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Tagline":
- {
- string val = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(val) && !list.Contains(val))
- {
- list.Add(val);
- }
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- item.Taglines = list;
- }
-
- private void FetchFromGenresNode(XmlReader reader, T item)
- {
- var list = item.Genres ?? new List<string>();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Genre":
- {
- string genre = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(genre))
- {
- list.Add(genre);
- }
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- item.Genres = list;
- }
-
- private void FetchDataFromPersonsNode(XmlReader reader, T item)
- {
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Person":
- {
- item.AddPerson(GetPersonFromXmlNode(reader.ReadSubtree()));
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
- }
-
- private void FetchFromStudiosNode(XmlReader reader, T item)
- {
- var list = item.Studios ?? new List<string>();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Studio":
- {
- string studio = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(studio))
- {
- list.Add(studio);
- }
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- item.Studios = list;
- }
-
- private void FetchFromParentalRatingNode(XmlReader reader, T item)
- {
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Value":
- {
- string ratingString = reader.ReadElementContentAsString();
-
- int rating = 7;
-
- if (!string.IsNullOrWhiteSpace(ratingString))
- {
- int.TryParse(ratingString, out rating);
- }
-
- switch (rating)
- {
- case -1:
- item.OfficialRating = "NR";
- break;
- case 0:
- item.OfficialRating = "UR";
- break;
- case 1:
- item.OfficialRating = "G";
- break;
- case 3:
- item.OfficialRating = "PG";
- break;
- case 4:
- item.OfficialRating = "PG-13";
- break;
- case 5:
- item.OfficialRating = "NC-17";
- break;
- case 6:
- item.OfficialRating = "R";
- break;
- default:
- break;
- }
- break;
- }
-
- default:
- reader.Skip();
- break;
- }
- }
- }
- }
-
- private PersonInfo GetPersonFromXmlNode(XmlReader reader)
- {
- var person = new PersonInfo();
-
- reader.MoveToContent();
-
- while (reader.Read())
- {
- if (reader.NodeType == XmlNodeType.Element)
- {
- switch (reader.Name)
- {
- case "Name":
- person.Name = reader.ReadElementContentAsString();
- break;
-
- case "Type":
- person.Type = reader.ReadElementContentAsString();
- break;
-
- case "Role":
- person.Overview = reader.ReadElementContentAsString();
- break;
-
- default:
- reader.Skip();
- break;
- }
- }
- }
-
- return person;
- }
-
- protected IEnumerable<string> GetSplitValues(string value, char deliminator)
- {
- value = (value ?? string.Empty).Trim(deliminator);
-
- return string.IsNullOrWhiteSpace(value) ? new string[] { } : value.Split(deliminator);
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Xml; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Provides a base class for parsing metadata xml + /// </summary> + /// <typeparam name="T"></typeparam> + public class BaseItemXmlParser<T> + where T : BaseItem, new() + { + /// <summary> + /// Fetches metadata for an item from one xml file + /// </summary> + /// <param name="item">The item.</param> + /// <param name="metadataFile">The metadata file.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <exception cref="System.ArgumentNullException"></exception> + public void Fetch(T item, string metadataFile, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + if (string.IsNullOrEmpty(metadataFile)) + { + throw new ArgumentNullException(); + } + + // Use XmlReader for best performance + using (var reader = XmlReader.Create(metadataFile)) + { + reader.MoveToContent(); + + // Loop through each element + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (reader.NodeType == XmlNodeType.Element) + { + FetchDataFromXmlNode(reader, item); + } + } + } + } + + /// <summary> + /// Fetches metadata from one Xml Element + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + protected virtual void FetchDataFromXmlNode(XmlReader reader, T item) + { + switch (reader.Name) + { + // DateCreated + case "Added": + DateTime added; + if (DateTime.TryParse(reader.ReadElementContentAsString() ?? string.Empty, out added)) + { + item.DateCreated = added.ToUniversalTime(); + } + break; + + case "LocalTitle": + item.Name = reader.ReadElementContentAsString(); + break; + + case "Type": + { + var type = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(type) && !type.Equals("none",StringComparison.OrdinalIgnoreCase)) + { + item.DisplayMediaType = type; + } + + break; + } + case "SortTitle": + item.SortName = reader.ReadElementContentAsString(); + break; + + case "Overview": + case "Description": + item.Overview = reader.ReadElementContentAsString(); + break; + + case "TagLine": + { + var tagline = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(tagline)) + { + item.AddTagline(tagline); + } + + break; + } + + case "TagLines": + { + FetchFromTaglinesNode(reader.ReadSubtree(), item); + break; + } + + case "ContentRating": + case "certification": + case "MPAARating": + { + var rating = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(rating)) + { + item.OfficialRating = rating; + } + break; + } + + case "CustomRating": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + item.CustomRating = val; + } + break; + } + + case "Runtime": + case "RunningTime": + { + var text = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(text)) + { + int runtime; + if (int.TryParse(text.Split(' ')[0], out runtime)) + { + item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; + } + } + break; + } + + case "Genre": + { + foreach (var name in SplitNames(reader.ReadElementContentAsString())) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + item.AddGenre(name); + } + break; + } + + case "AspectRatio": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + item.AspectRatio = val; + } + break; + } + + case "Network": + { + foreach (var name in SplitNames(reader.ReadElementContentAsString())) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + item.AddStudio(name); + } + break; + } + + case "Director": + { + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v, Type = PersonType.Director })) + { + if (string.IsNullOrWhiteSpace(p.Name)) + { + continue; + } + item.AddPerson(p); + } + break; + } + case "Writer": + { + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v, Type = PersonType.Writer })) + { + if (string.IsNullOrWhiteSpace(p.Name)) + { + continue; + } + item.AddPerson(p); + } + break; + } + + case "Actors": + case "GuestStars": + { + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Actor })) + { + if (string.IsNullOrWhiteSpace(p.Name)) + { + continue; + } + item.AddPerson(p); + } + break; + } + + case "Trailer": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + item.AddTrailerUrl(val); + } + break; + } + + case "ProductionYear": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + int ProductionYear; + if (int.TryParse(val, out ProductionYear) && ProductionYear > 1850) + { + item.ProductionYear = ProductionYear; + } + } + + break; + } + + case "Rating": + case "IMDBrating": + { + + var rating = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(rating)) + { + float val; + + if (float.TryParse(rating, out val)) + { + item.CommunityRating = val; + } + } + break; + } + + case "FirstAired": + { + var firstAired = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(firstAired)) + { + DateTime airDate; + + if (DateTime.TryParse(firstAired, out airDate) && airDate.Year > 1850) + { + item.PremiereDate = airDate.ToUniversalTime(); + item.ProductionYear = airDate.Year; + } + } + + break; + } + + case "TMDbId": + var tmdb = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(tmdb)) + { + item.SetProviderId(MetadataProviders.Tmdb, tmdb); + } + break; + + case "TVcomId": + var TVcomId = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(TVcomId)) + { + item.SetProviderId(MetadataProviders.Tvcom, TVcomId); + } + break; + + case "IMDB_ID": + case "IMDB": + case "IMDbId": + var IMDbId = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(IMDbId)) + { + item.SetProviderId(MetadataProviders.Imdb, IMDbId); + } + break; + + case "Genres": + FetchFromGenresNode(reader.ReadSubtree(), item); + break; + + case "Persons": + FetchDataFromPersonsNode(reader.ReadSubtree(), item); + break; + + case "ParentalRating": + FetchFromParentalRatingNode(reader.ReadSubtree(), item); + break; + + case "Studios": + FetchFromStudiosNode(reader.ReadSubtree(), item); + break; + + default: + reader.Skip(); + break; + } + } + + /// <summary> + /// Fetches from taglines node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + private void FetchFromTaglinesNode(XmlReader reader, T item) + { + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Tagline": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + item.AddTagline(val); + } + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + + /// <summary> + /// Fetches from genres node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + private void FetchFromGenresNode(XmlReader reader, T item) + { + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Genre": + { + var genre = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(genre)) + { + item.AddGenre(genre); + } + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + + /// <summary> + /// Fetches the data from persons node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + private void FetchDataFromPersonsNode(XmlReader reader, T item) + { + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Person": + { + item.AddPeople(GetPersonsFromXmlNode(reader.ReadSubtree())); + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + + /// <summary> + /// Fetches from studios node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + private void FetchFromStudiosNode(XmlReader reader, T item) + { + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Studio": + { + var studio = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(studio)) + { + item.AddStudio(studio); + } + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + + /// <summary> + /// Fetches from parental rating node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + private void FetchFromParentalRatingNode(XmlReader reader, T item) + { + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Value": + { + var ratingString = reader.ReadElementContentAsString(); + + int rating = 7; + + if (!string.IsNullOrWhiteSpace(ratingString)) + { + int.TryParse(ratingString, out rating); + } + + switch (rating) + { + case -1: + item.OfficialRating = "NR"; + break; + case 0: + item.OfficialRating = "UR"; + break; + case 1: + item.OfficialRating = "G"; + break; + case 3: + item.OfficialRating = "PG"; + break; + case 4: + item.OfficialRating = "PG-13"; + break; + case 5: + item.OfficialRating = "NC-17"; + break; + case 6: + item.OfficialRating = "R"; + break; + } + break; + } + + default: + reader.Skip(); + break; + } + } + } + } + + /// <summary> + /// Gets the persons from XML node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>IEnumerable{PersonInfo}.</returns> + private IEnumerable<PersonInfo> GetPersonsFromXmlNode(XmlReader reader) + { + var names = new List<string>(); + var type = string.Empty; + var role = string.Empty; + + reader.MoveToContent(); + + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "Name": + names.AddRange(SplitNames(reader.ReadElementContentAsString())); + break; + + case "Type": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + type = val; + } + break; + } + + case "Role": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + role = val; + } + break; + } + + default: + reader.Skip(); + break; + } + } + } + + return names.Select(n => new PersonInfo { Name = n, Role = role, Type = type }); + } + + /// <summary> + /// Used to split names of comma or pipe delimeted genres and people + /// </summary> + /// <param name="value">The value.</param> + /// <returns>IEnumerable{System.String}.</returns> + private IEnumerable<string> SplitNames(string value) + { + value = value ?? string.Empty; + + // Only split by comma if there is no pipe in the string + // We have to be careful to not split names like Matthew, Jr. + var separator = value.IndexOf('|') == -1 ? ',' : '|'; + + value = value.Trim().Trim(separator); + + return string.IsNullOrWhiteSpace(value) ? new string[] { } : value.Split(separator, StringSplitOptions.RemoveEmptyEntries); + } + } +} diff --git a/MediaBrowser.Controller/Providers/BaseMetadataProvider.cs b/MediaBrowser.Controller/Providers/BaseMetadataProvider.cs index 50004be442..3916c0f541 100644 --- a/MediaBrowser.Controller/Providers/BaseMetadataProvider.cs +++ b/MediaBrowser.Controller/Providers/BaseMetadataProvider.cs @@ -1,104 +1,408 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Common.Extensions;
-using System.Threading.Tasks;
-using System;
-
-namespace MediaBrowser.Controller.Providers
-{
- public abstract class BaseMetadataProvider
- {
- protected Guid _id;
- public virtual Guid Id
- {
- get
- {
- if (_id == null) _id = this.GetType().FullName.GetMD5();
- return _id;
- }
- }
-
- public abstract bool Supports(BaseEntity item);
-
- public virtual bool RequiresInternet
- {
- get
- {
- return false;
- }
- }
-
- /// <summary>
- /// Returns the last refresh time of this provider for this item. Providers that care should
- /// call SetLastRefreshed to update this value.
- /// </summary>
- /// <param name="item"></param>
- /// <returns></returns>
- protected virtual DateTime LastRefreshed(BaseEntity item)
- {
- return (item.ProviderData.GetValueOrDefault(this.Id, new BaseProviderInfo())).LastRefreshed;
- }
-
- /// <summary>
- /// Sets the persisted last refresh date on the item for this provider.
- /// </summary>
- /// <param name="item"></param>
- /// <param name="value"></param>
- protected virtual void SetLastRefreshed(BaseEntity item, DateTime value)
- {
- var data = item.ProviderData.GetValueOrDefault(this.Id, new BaseProviderInfo());
- data.LastRefreshed = value;
- item.ProviderData[this.Id] = data;
- }
-
- /// <summary>
- /// Returns whether or not this provider should be re-fetched. Default functionality can
- /// compare a provided date with a last refresh time. This can be overridden for more complex
- /// determinations.
- /// </summary>
- /// <returns></returns>
- public virtual bool NeedsRefresh(BaseEntity item)
- {
- return CompareDate(item) > LastRefreshed(item);
- }
-
- /// <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>
- protected virtual DateTime CompareDate(BaseEntity item)
- {
- return DateTime.MinValue.AddMinutes(1); // want this to be greater than mindate so new items will refresh
- }
-
- public virtual Task FetchIfNeededAsync(BaseEntity item)
- {
- if (this.NeedsRefresh(item))
- return FetchAsync(item, item.ResolveArgs);
- else
- return new Task(() => { });
- }
-
- public abstract Task FetchAsync(BaseEntity item, ItemResolveEventArgs args);
-
- public abstract MetadataProviderPriority Priority { get; }
- }
-
- /// <summary>
- /// Determines when a provider should execute, relative to others
- /// </summary>
- public enum MetadataProviderPriority
- {
- // Run this provider at the beginning
- First = 1,
-
- // Run this provider after all first priority providers
- Second = 2,
-
- // Run this provider after all second priority providers
- Third = 3,
-
- // Run this provider last
- Last = 4
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class BaseMetadataProvider + /// </summary> + public abstract class BaseMetadataProvider : IDisposable + { + /// <summary> + /// Gets the logger. + /// </summary> + /// <value>The logger.</value> + protected ILogger Logger { get; private set; } + + // Cache these since they will be used a lot + /// <summary> + /// The false task result + /// </summary> + protected static readonly Task<bool> FalseTaskResult = Task.FromResult(false); + /// <summary> + /// The true task result + /// </summary> + protected static readonly Task<bool> TrueTaskResult = Task.FromResult(true); + + /// <summary> + /// The _id + /// </summary> + protected Guid _id; + /// <summary> + /// Gets the id. + /// </summary> + /// <value>The id.</value> + public virtual Guid Id + { + get + { + if (_id == Guid.Empty) _id = GetType().FullName.GetMD5(); + return _id; + } + } + + /// <summary> + /// Supportses the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + public abstract bool Supports(BaseItem item); + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public virtual bool RequiresInternet + { + get + { + return false; + } + } + + /// <summary> + /// Gets the provider version. + /// </summary> + /// <value>The provider version.</value> + protected virtual string ProviderVersion + { + get + { + return null; + } + } + + /// <summary> + /// Gets a value indicating whether [refresh on version change]. + /// </summary> + /// <value><c>true</c> if [refresh on version change]; otherwise, <c>false</c>.</value> + protected virtual bool RefreshOnVersionChange + { + get + { + return false; + } + } + + /// <summary> + /// Determines if this provider is relatively slow and, therefore, should be skipped + /// in certain instances. Default is whether or not it requires internet. Can be overridden + /// for explicit designation. + /// </summary> + /// <value><c>true</c> if this instance is slow; otherwise, <c>false</c>.</value> + public virtual bool IsSlow + { + get { return RequiresInternet; } + } + + /// <summary> + /// Initializes a new instance of the <see cref="BaseMetadataProvider" /> class. + /// </summary> + protected BaseMetadataProvider() + { + Initialize(); + } + + /// <summary> + /// Initializes this instance. + /// </summary> + protected virtual void Initialize() + { + Logger = LogManager.GetLogger(GetType().Name); + } + + /// <summary> + /// Sets the persisted last refresh date on the item for this provider. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="value">The value.</param> + /// <param name="providerVersion">The provider version.</param> + /// <param name="status">The status.</param> + /// <exception cref="System.ArgumentNullException">item</exception> + protected virtual void SetLastRefreshed(BaseItem item, DateTime value, string providerVersion, ProviderRefreshStatus status = ProviderRefreshStatus.Success) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var data = item.ProviderData.GetValueOrDefault(Id, new BaseProviderInfo { ProviderId = Id }); + data.LastRefreshed = value; + data.LastRefreshStatus = status; + data.ProviderVersion = providerVersion; + + // Save the file system stamp for future comparisons + if (RefreshOnFileSystemStampChange) + { + data.FileSystemStamp = GetCurrentFileSystemStamp(item); + } + + item.ProviderData[Id] = data; + } + + /// <summary> + /// Sets the last refreshed. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="value">The value.</param> + /// <param name="status">The status.</param> + protected virtual void SetLastRefreshed(BaseItem item, DateTime value, ProviderRefreshStatus status = ProviderRefreshStatus.Success) + { + SetLastRefreshed(item, value, ProviderVersion, status); + } + + /// <summary> + /// Returns whether or not this provider should be re-fetched. Default functionality can + /// compare a provided date with a last refresh time. This can be overridden for more complex + /// determinations. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <exception cref="System.ArgumentNullException"></exception> + public bool NeedsRefresh(BaseItem item) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + var providerInfo = item.ProviderData.GetValueOrDefault(Id, new BaseProviderInfo()); + + return NeedsRefreshInternal(item, providerInfo); + } + + /// <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> + /// <exception cref="System.ArgumentNullException"></exception> + protected virtual bool NeedsRefreshInternal(BaseItem item, BaseProviderInfo providerInfo) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + if (providerInfo == null) + { + throw new ArgumentNullException("providerInfo"); + } + + if (CompareDate(item) > providerInfo.LastRefreshed) + { + return true; + } + + if (RefreshOnFileSystemStampChange && HasFileSystemStampChanged(item, providerInfo)) + { + return true; + } + + if (RefreshOnVersionChange && !string.Equals(ProviderVersion, providerInfo.ProviderVersion)) + { + return true; + } + + return false; + } + + /// <summary> + /// Determines if the item's file system stamp has changed from the last time the provider refreshed + /// </summary> + /// <param name="item">The item.</param> + /// <param name="providerInfo">The provider info.</param> + /// <returns><c>true</c> if [has file system stamp changed] [the specified item]; otherwise, <c>false</c>.</returns> + protected bool HasFileSystemStampChanged(BaseItem item, BaseProviderInfo providerInfo) + { + return GetCurrentFileSystemStamp(item) != providerInfo.FileSystemStamp; + } + + /// <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 virtual DateTime CompareDate(BaseItem item) + { + return DateTime.MinValue.AddMinutes(1); // want this to be greater than mindate so new items will refresh + } + + /// <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> + /// <exception cref="System.ArgumentNullException"></exception> + public async Task<bool> FetchAsync(BaseItem item, bool force, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException(); + } + + cancellationToken.ThrowIfCancellationRequested(); + + Logger.Info("Running for {0}", item.Path ?? item.Name ?? "--Unknown--"); + + // This provides the ability to cancel just this one provider + var innerCancellationTokenSource = new CancellationTokenSource(); + + Kernel.Instance.ProviderManager.OnProviderRefreshBeginning(this, item, innerCancellationTokenSource); + + try + { + var task = FetchAsyncInternal(item, force, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token); + + await task.ConfigureAwait(false); + + if (task.IsFaulted) + { + // Log the AggregateException + if (task.Exception != null) + { + Logger.ErrorException("AggregateException:", task.Exception); + } + + return false; + } + + return task.Result; + } + catch (OperationCanceledException ex) + { + Logger.Info("{0} cancelled for {1}", GetType().Name, item.Name); + + // If the outer cancellation token is the one that caused the cancellation, throw it + if (cancellationToken.IsCancellationRequested && ex.CancellationToken == cancellationToken) + { + throw; + } + + return false; + } + catch (Exception ex) + { + Logger.ErrorException("failed refreshing {0}", ex, item.Name); + + SetLastRefreshed(item, DateTime.UtcNow, ProviderRefreshStatus.Failure); + return true; + } + finally + { + innerCancellationTokenSource.Dispose(); + + Kernel.Instance.ProviderManager.OnProviderRefreshCompleted(this, item); + } + } + + /// <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> + protected abstract Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken); + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public abstract MetadataProviderPriority Priority { get; } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + } + + /// <summary> + /// Returns true or false indicating if the provider should refresh when the contents of it's directory changes + /// </summary> + /// <value><c>true</c> if [refresh on file system stamp change]; otherwise, <c>false</c>.</value> + protected virtual bool RefreshOnFileSystemStampChange + { + get + { + return false; + } + } + + /// <summary> + /// Determines if the parent's file system stamp should be used for comparison + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected virtual bool UseParentFileSystemStamp(BaseItem item) + { + // True when the current item is just a file + return !item.ResolveArgs.IsDirectory; + } + + /// <summary> + /// Gets the item's current file system stamp + /// </summary> + /// <param name="item">The item.</param> + /// <returns>Guid.</returns> + private Guid GetCurrentFileSystemStamp(BaseItem item) + { + if (UseParentFileSystemStamp(item) && item.Parent != null) + { + return item.Parent.FileSystemStamp; + } + + return item.FileSystemStamp; + } + } + + /// <summary> + /// Determines when a provider should execute, relative to others + /// </summary> + public enum MetadataProviderPriority + { + // Run this provider at the beginning + /// <summary> + /// The first + /// </summary> + First = 1, + + // Run this provider after all first priority providers + /// <summary> + /// The second + /// </summary> + Second = 2, + + // Run this provider after all second priority providers + /// <summary> + /// The third + /// </summary> + Third = 3, + + // Run this provider last + /// <summary> + /// The last + /// </summary> + Last = 4 + } +} diff --git a/MediaBrowser.Controller/Providers/BaseProviderInfo.cs b/MediaBrowser.Controller/Providers/BaseProviderInfo.cs index 1538b2262f..877ba5c4bf 100644 --- a/MediaBrowser.Controller/Providers/BaseProviderInfo.cs +++ b/MediaBrowser.Controller/Providers/BaseProviderInfo.cs @@ -1,15 +1,60 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers
-{
- public class BaseProviderInfo
- {
- public Guid ProviderId { get; set; }
- public DateTime LastRefreshed { get; set; }
-
- }
-}
+using System; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class BaseProviderInfo + /// </summary> + public class BaseProviderInfo + { + /// <summary> + /// Gets or sets the provider id. + /// </summary> + /// <value>The provider id.</value> + public Guid ProviderId { get; set; } + /// <summary> + /// Gets or sets the last refreshed. + /// </summary> + /// <value>The last refreshed.</value> + public DateTime LastRefreshed { get; set; } + /// <summary> + /// Gets or sets the file system stamp. + /// </summary> + /// <value>The file system stamp.</value> + public Guid FileSystemStamp { get; set; } + /// <summary> + /// Gets or sets the last refresh status. + /// </summary> + /// <value>The last refresh status.</value> + public ProviderRefreshStatus LastRefreshStatus { get; set; } + /// <summary> + /// Gets or sets the provider version. + /// </summary> + /// <value>The provider version.</value> + public string ProviderVersion { get; set; } + /// <summary> + /// Gets or sets the data hash. + /// </summary> + /// <value>The data hash.</value> + public Guid DataHash { get; set; } + } + + /// <summary> + /// Enum ProviderRefreshStatus + /// </summary> + public enum ProviderRefreshStatus + { + /// <summary> + /// The success + /// </summary> + Success, + /// <summary> + /// The failure + /// </summary> + Failure, + /// <summary> + /// The completed with errors + /// </summary> + CompletedWithErrors + } +} diff --git a/MediaBrowser.Controller/Providers/FanartBaseProvider.cs b/MediaBrowser.Controller/Providers/FanartBaseProvider.cs new file mode 100644 index 0000000000..3063f3c9e1 --- /dev/null +++ b/MediaBrowser.Controller/Providers/FanartBaseProvider.cs @@ -0,0 +1,84 @@ +using MediaBrowser.Controller.Entities; +using System; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class FanartBaseProvider + /// </summary> + public abstract class FanartBaseProvider : BaseMetadataProvider + { + /// <summary> + /// The LOG o_ FILE + /// </summary> + protected const string LOGO_FILE = "logo.png"; + /// <summary> + /// The AR t_ FILE + /// </summary> + protected const string ART_FILE = "clearart.png"; + /// <summary> + /// The THUM b_ FILE + /// </summary> + protected const string THUMB_FILE = "thumb.jpg"; + /// <summary> + /// The DIS c_ FILE + /// </summary> + protected const string DISC_FILE = "disc.png"; + /// <summary> + /// The BANNE r_ FILE + /// </summary> + protected const string BANNER_FILE = "banner.png"; + + /// <summary> + /// The API key + /// </summary> + protected const string APIKey = "5c6b04c68e904cfed1e6cbc9a9e683d4"; + + /// <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 (item.DontFetchMeta) return false; + + return DateTime.UtcNow > (providerInfo.LastRefreshed.AddDays(Kernel.Instance.Configuration.MetadataRefreshDays)) + && ShouldFetch(item, providerInfo); + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return true; + } + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Third; } + } + + /// <summary> + /// Shoulds the fetch. + /// </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 virtual bool ShouldFetch(BaseItem item, BaseProviderInfo providerInfo) + { + return false; + } + + } +} diff --git a/MediaBrowser.Controller/Providers/FolderProviderFromXml.cs b/MediaBrowser.Controller/Providers/FolderProviderFromXml.cs index b7d9b71893..110502bc1f 100644 --- a/MediaBrowser.Controller/Providers/FolderProviderFromXml.cs +++ b/MediaBrowser.Controller/Providers/FolderProviderFromXml.cs @@ -1,38 +1,83 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers
-{
- /// <summary>
- /// Provides metadata for Folders and all subclasses by parsing folder.xml
- /// </summary>
- [Export(typeof(BaseMetadataProvider))]
- public class FolderProviderFromXml : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Folder;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public async override Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.ContainsFile("folder.xml"))
- {
- await Task.Run(() => Fetch(item, args)).ConfigureAwait(false);
- }
- }
-
- private void Fetch(BaseEntity item, ItemResolveEventArgs args)
- {
- new BaseItemXmlParser<Folder>().Fetch(item as Folder, Path.Combine(args.Path, "folder.xml"));
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Provides metadata for Folders and all subclasses by parsing folder.xml + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class FolderProviderFromXml : BaseMetadataProvider + { + /// <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 is Folder && item.LocationType == LocationType.FileSystem; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <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) + { + var entry = item.MetaLocation != null ? item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "folder.xml")) : null; + return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue; + } + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return Task.Run(() => Fetch(item, cancellationToken)); + } + + /// <summary> + /// Fetches the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool Fetch(BaseItem item, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var metadataFile = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "folder.xml")); + + if (metadataFile.HasValue) + { + var path = metadataFile.Value.Path; + new BaseItemXmlParser<Folder>().Fetch((Folder)item, path, cancellationToken); + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + + return false; + } + } +} diff --git a/MediaBrowser.Controller/Providers/ImageFromMediaLocationProvider.cs b/MediaBrowser.Controller/Providers/ImageFromMediaLocationProvider.cs index d6fd26d1c4..9858764069 100644 --- a/MediaBrowser.Controller/Providers/ImageFromMediaLocationProvider.cs +++ b/MediaBrowser.Controller/Providers/ImageFromMediaLocationProvider.cs @@ -1,128 +1,231 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers
-{
- /// <summary>
- /// Provides images for all types by looking for standard images - folder, backdrop, logo, etc.
- /// </summary>
- [Export(typeof(BaseMetadataProvider))]
- public class ImageFromMediaLocationProvider : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return true;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public override Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.IsDirectory)
- {
- var baseItem = item as BaseItem;
-
- if (baseItem != null)
- {
- return Task.Run(() => PopulateBaseItemImages(baseItem, args));
- }
-
- return Task.Run(() => PopulateImages(item, args));
- }
-
- return Task.FromResult<object>(null);
- }
-
- /// <summary>
- /// Fills in image paths based on files win the folder
- /// </summary>
- private void PopulateImages(BaseEntity item, ItemResolveEventArgs args)
- {
- for (int i = 0; i < args.FileSystemChildren.Length; i++)
- {
- var file = args.FileSystemChildren[i];
-
- string filePath = file.Path;
-
- string ext = Path.GetExtension(filePath);
-
- // Only support png and jpg files
- if (!ext.EndsWith("png", StringComparison.OrdinalIgnoreCase) && !ext.EndsWith("jpg", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- string name = Path.GetFileNameWithoutExtension(filePath);
-
- if (name.Equals("folder", StringComparison.OrdinalIgnoreCase))
- {
- item.PrimaryImagePath = filePath;
- }
- }
- }
-
- /// <summary>
- /// Fills in image paths based on files win the folder
- /// </summary>
- private void PopulateBaseItemImages(BaseItem item, ItemResolveEventArgs args)
- {
- var backdropFiles = new List<string>();
-
- for (int i = 0; i < args.FileSystemChildren.Length; i++)
- {
- var file = args.FileSystemChildren[i];
-
- string filePath = file.Path;
-
- string ext = Path.GetExtension(filePath);
-
- // Only support png and jpg files
- if (!ext.EndsWith("png", StringComparison.OrdinalIgnoreCase) && !ext.EndsWith("jpg", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- string name = Path.GetFileNameWithoutExtension(filePath);
-
- if (name.Equals("folder", StringComparison.OrdinalIgnoreCase))
- {
- item.PrimaryImagePath = filePath;
- }
- else if (name.StartsWith("backdrop", StringComparison.OrdinalIgnoreCase))
- {
- backdropFiles.Add(filePath);
- }
- if (name.Equals("logo", StringComparison.OrdinalIgnoreCase))
- {
- item.LogoImagePath = filePath;
- }
- if (name.Equals("banner", StringComparison.OrdinalIgnoreCase))
- {
- item.BannerImagePath = filePath;
- }
- if (name.Equals("clearart", StringComparison.OrdinalIgnoreCase))
- {
- item.ArtImagePath = filePath;
- }
- if (name.Equals("thumb", StringComparison.OrdinalIgnoreCase))
- {
- item.ThumbnailImagePath = filePath;
- }
- }
-
- if (backdropFiles.Count > 0)
- {
- item.BackdropImagePaths = backdropFiles;
- }
- }
-
- }
-}
+using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Provides images for all types by looking for standard images - folder, backdrop, logo, etc. + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class ImageFromMediaLocationProvider : BaseMetadataProvider + { + /// <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.ResolveArgs.IsDirectory && item.LocationType == LocationType.FileSystem; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <summary> + /// Returns true or false indicating if the provider should refresh when the contents of it's directory changes + /// </summary> + /// <value><c>true</c> if [refresh on file system stamp change]; otherwise, <c>false</c>.</value> + protected override bool RefreshOnFileSystemStampChange + { + get + { + return 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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Make sure current image paths still exist + ValidateImages(item); + + cancellationToken.ThrowIfCancellationRequested(); + + // Make sure current backdrop paths still exist + ValidateBackdrops(item); + + cancellationToken.ThrowIfCancellationRequested(); + + PopulateBaseItemImages(item); + + SetLastRefreshed(item, DateTime.UtcNow); + return TrueTaskResult; + } + + /// <summary> + /// Validates that images within the item are still on the file system + /// </summary> + /// <param name="item">The item.</param> + private void ValidateImages(BaseItem item) + { + if (item.Images == null) + { + return; + } + + // Only validate paths from the same directory - need to copy to a list because we are going to potentially modify the collection below + var deletedKeys = item.Images.Keys.Where(image => + { + var path = item.Images[image]; + + return IsInSameDirectory(item, path) && !item.ResolveArgs.GetMetaFileByPath(path).HasValue; + }).ToList(); + + // Now remove them from the dictionary + foreach(var key in deletedKeys) + { + item.Images.Remove(key); + } + } + + /// <summary> + /// Validates that backdrops within the item are still on the file system + /// </summary> + /// <param name="item">The item.</param> + private void ValidateBackdrops(BaseItem item) + { + if (item.BackdropImagePaths == null) + { + return; + } + + // Only validate paths from the same directory - need to copy to a list because we are going to potentially modify the collection below + var deletedImages = item.BackdropImagePaths.Where(path => IsInSameDirectory(item, path) && !item.ResolveArgs.GetMetaFileByPath(path).HasValue).ToList(); + + // Now remove them from the dictionary + foreach (var path in deletedImages) + { + item.BackdropImagePaths.Remove(path); + } + } + + /// <summary> + /// Determines whether [is in same directory] [the specified item]. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="path">The path.</param> + /// <returns><c>true</c> if [is in same directory] [the specified item]; otherwise, <c>false</c>.</returns> + private bool IsInSameDirectory(BaseItem item, string path) + { + return string.Equals(Path.GetDirectoryName(path), item.Path, StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// Gets the image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="filenameWithoutExtension">The filename without extension.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + protected virtual WIN32_FIND_DATA? GetImage(BaseItem item, string filenameWithoutExtension) + { + return item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.ResolveArgs.Path, filenameWithoutExtension + ".png")) ?? item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.ResolveArgs.Path, filenameWithoutExtension + ".jpg")); + } + + /// <summary> + /// Fills in image paths based on files win the folder + /// </summary> + /// <param name="item">The item.</param> + private void PopulateBaseItemImages(BaseItem item) + { + var backdropFiles = new List<string>(); + + // Primary Image + var image = GetImage(item, "folder"); + + if (image.HasValue) + { + item.SetImage(ImageType.Primary, image.Value.Path); + } + + // Logo Image + image = GetImage(item, "logo"); + + if (image.HasValue) + { + item.SetImage(ImageType.Logo, image.Value.Path); + } + + // Banner Image + image = GetImage(item, "banner"); + + if (image.HasValue) + { + item.SetImage(ImageType.Banner, image.Value.Path); + } + + // Clearart + image = GetImage(item, "clearart"); + + if (image.HasValue) + { + item.SetImage(ImageType.Art, image.Value.Path); + } + + // Thumbnail Image + image = GetImage(item, "thumb"); + + if (image.HasValue) + { + item.SetImage(ImageType.Thumb, image.Value.Path); + } + + // Backdrop Image + image = GetImage(item, "backdrop"); + + if (image.HasValue) + { + backdropFiles.Add(image.Value.Path); + } + + var unfound = 0; + for (var i = 1; i <= 20; i++) + { + // Backdrop Image + image = GetImage(item, "backdrop" + i); + + if (image.HasValue) + { + backdropFiles.Add(image.Value.Path); + } + else + { + unfound++; + + if (unfound >= 3) + { + break; + } + } + } + + if (backdropFiles.Count > 0) + { + item.BackdropImagePaths = backdropFiles; + } + } + + } +} diff --git a/MediaBrowser.Controller/Providers/ImagesByNameProvider.cs b/MediaBrowser.Controller/Providers/ImagesByNameProvider.cs new file mode 100644 index 0000000000..114176e2c9 --- /dev/null +++ b/MediaBrowser.Controller/Providers/ImagesByNameProvider.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Provides images for generic types by looking for standard images in the IBN + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class ImagesByNameProvider : ImageFromMediaLocationProvider + { + /// <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) + { + //only run for these generic types since we are expensive in file i/o + return item is IndexFolder || item is BasePluginFolder; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get + { + return MetadataProviderPriority.Last; + } + } + + /// <summary> + /// Gets a value indicating whether [refresh on file system stamp change]. + /// </summary> + /// <value><c>true</c> if [refresh on file system stamp change]; otherwise, <c>false</c>.</value> + protected override bool RefreshOnFileSystemStampChange + { + get + { + return false; + } + } + + /// <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) + { + // If the IBN location exists return the last modified date of any file in it + var location = GetLocation(item); + return Directory.Exists(location) ? FileSystem.GetFiles(location).Select(f => f.CreationTimeUtc > f.LastWriteTimeUtc ? f.CreationTimeUtc : f.LastWriteTimeUtc).Max() : DateTime.MinValue; + } + + /// <summary> + /// The us culture + /// </summary> + private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + /// <summary> + /// Gets the location. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>System.String.</returns> + protected string GetLocation(BaseItem item) + { + var invalid = Path.GetInvalidFileNameChars(); + + var name = item.Name ?? string.Empty; + name = invalid.Aggregate(name, (current, c) => current.Replace(c.ToString(UsCulture), string.Empty)); + + return Path.Combine(Kernel.Instance.ApplicationPaths.GeneralPath, name); + } + + /// <summary> + /// Gets the image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="filenameWithoutExtension">The filename without extension.</param> + /// <returns>System.Nullable{WIN32_FIND_DATA}.</returns> + protected override WIN32_FIND_DATA? GetImage(BaseItem item, string filenameWithoutExtension) + { + var location = GetLocation(item); + + var result = FileSystem.GetFileData(Path.Combine(location, filenameWithoutExtension + ".png")); + if (!result.HasValue) + result = FileSystem.GetFileData(Path.Combine(location, filenameWithoutExtension + ".jpg")); + + return result; + } + } +} diff --git a/MediaBrowser.Controller/Providers/LocalTrailerProvider.cs b/MediaBrowser.Controller/Providers/LocalTrailerProvider.cs deleted file mode 100644 index 8823da6911..0000000000 --- a/MediaBrowser.Controller/Providers/LocalTrailerProvider.cs +++ /dev/null @@ -1,47 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers
-{
- /// <summary>
- /// Provides local trailers by checking the trailers subfolder
- /// </summary>
- [Export(typeof(BaseMetadataProvider))]
- public class LocalTrailerProvider : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is BaseItem;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public async override Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.ContainsFolder("trailers"))
- {
- var items = new List<Video>();
-
- foreach (WIN32_FIND_DATA file in FileData.GetFileSystemEntries(Path.Combine(args.Path, "trailers"), "*"))
- {
- var video = await Kernel.Instance.ItemController.GetItem(file.Path, fileInfo: file).ConfigureAwait(false) as Video;
-
- if (video != null)
- {
- items.Add(video);
- }
- }
-
- (item as BaseItem).LocalTrailers = items;
- }
- }
- }
-}
diff --git a/MediaBrowser.Controller/Providers/MediaInfo/BDInfoProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/BDInfoProvider.cs new file mode 100644 index 0000000000..6214cb0dae --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/BDInfoProvider.cs @@ -0,0 +1,265 @@ +using BDInfo; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Extracts dvd information using VgtMpeg + /// </summary> + internal static class BDInfoProvider + { + internal static void FetchBdInfo(BaseItem item, string inputPath, FileSystemRepository bdInfoCache, CancellationToken cancellationToken) + { + var video = (Video)item; + + // Get the path to the cache file + var cacheName = item.Id + "_" + item.DateModified.Ticks; + + var cacheFile = bdInfoCache.GetResourcePath(cacheName, ".pb"); + + BDInfoResult result; + + try + { + result = Kernel.Instance.ProtobufSerializer.DeserializeFromFile<BDInfoResult>(cacheFile); + } + catch (FileNotFoundException) + { + result = GetBDInfo(inputPath); + + Kernel.Instance.ProtobufSerializer.SerializeToFile(result, cacheFile); + } + + cancellationToken.ThrowIfCancellationRequested(); + + int? currentHeight = null; + int? currentWidth = null; + int? currentBitRate = null; + + var videoStream = video.MediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + + // Grab the values that ffprobe recorded + if (videoStream != null) + { + currentBitRate = videoStream.BitRate; + currentWidth = videoStream.Width; + currentHeight = videoStream.Height; + } + + // Fill video properties from the BDInfo result + Fetch(video, inputPath, result); + + videoStream = video.MediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + + // Use the ffprobe values if these are empty + if (videoStream != null) + { + videoStream.BitRate = IsEmpty(videoStream.BitRate) ? currentBitRate : videoStream.BitRate; + videoStream.Width = IsEmpty(videoStream.Width) ? currentWidth : videoStream.Width; + videoStream.Height = IsEmpty(videoStream.Height) ? currentHeight : videoStream.Height; + } + } + + /// <summary> + /// Determines whether the specified num is empty. + /// </summary> + /// <param name="num">The num.</param> + /// <returns><c>true</c> if the specified num is empty; otherwise, <c>false</c>.</returns> + private static bool IsEmpty(int? num) + { + return !num.HasValue || num.Value == 0; + } + + /// <summary> + /// Fills video properties from the VideoStream of the largest playlist + /// </summary> + /// <param name="video">The video.</param> + /// <param name="inputPath">The input path.</param> + /// <param name="stream">The stream.</param> + private static void Fetch(Video video, string inputPath, BDInfoResult stream) + { + // Check all input for null/empty/zero + + video.MediaStreams = stream.MediaStreams; + + if (stream.RunTimeTicks.HasValue && stream.RunTimeTicks.Value > 0) + { + video.RunTimeTicks = stream.RunTimeTicks; + } + + video.PlayableStreamFileNames = stream.Files.ToList(); + + if (stream.Chapters != null) + { + video.Chapters = stream.Chapters.Select(c => new ChapterInfo + { + StartPositionTicks = TimeSpan.FromSeconds(c).Ticks + + }).ToList(); + } + } + + /// <summary> + /// Gets information about the longest playlist on a bdrom + /// </summary> + /// <param name="path">The path.</param> + /// <returns>VideoStream.</returns> + private static BDInfoResult GetBDInfo(string path) + { + var bdrom = new BDROM(path); + + bdrom.Scan(); + + // Get the longest playlist + var playlist = bdrom.PlaylistFiles.Values.OrderByDescending(p => p.TotalLength).FirstOrDefault(p => p.IsValid); + + var outputStream = new BDInfoResult + { + MediaStreams = new List<MediaStream>() + }; + + if (playlist == null) + { + return outputStream; + } + + outputStream.Chapters = playlist.Chapters; + + outputStream.RunTimeTicks = TimeSpan.FromSeconds(playlist.TotalLength).Ticks; + + var mediaStreams = new List<MediaStream> {}; + + foreach (var stream in playlist.SortedStreams) + { + var videoStream = stream as TSVideoStream; + + if (videoStream != null) + { + AddVideoStream(mediaStreams, videoStream); + continue; + } + + var audioStream = stream as TSAudioStream; + + if (audioStream != null) + { + AddAudioStream(mediaStreams, audioStream); + continue; + } + + var textStream = stream as TSTextStream; + + if (textStream != null) + { + AddSubtitleStream(mediaStreams, textStream); + continue; + } + + var graphicsStream = stream as TSGraphicsStream; + + if (graphicsStream != null) + { + AddSubtitleStream(mediaStreams, graphicsStream); + } + } + + outputStream.MediaStreams = mediaStreams; + + if (playlist.StreamClips != null && playlist.StreamClips.Any()) + { + // Get the files in the playlist + outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToList(); + } + + return outputStream; + } + + /// <summary> + /// Adds the video stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="videoStream">The video stream.</param> + private static void AddVideoStream(List<MediaStream> streams, TSVideoStream videoStream) + { + var mediaStream = new MediaStream + { + BitRate = Convert.ToInt32(videoStream.BitRate), + Width = videoStream.Width, + Height = videoStream.Height, + Codec = videoStream.CodecShortName, + ScanType = videoStream.IsInterlaced ? "interlaced" : "progressive", + Type = MediaStreamType.Video, + Index = streams.Count + }; + + if (videoStream.FrameRateDenominator > 0) + { + float frameRateEnumerator = videoStream.FrameRateEnumerator; + float frameRateDenominator = videoStream.FrameRateDenominator; + + mediaStream.AverageFrameRate = mediaStream.RealFrameRate = frameRateEnumerator / frameRateDenominator; + } + + streams.Add(mediaStream); + } + + /// <summary> + /// Adds the audio stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="audioStream">The audio stream.</param> + private static void AddAudioStream(List<MediaStream> streams, TSAudioStream audioStream) + { + streams.Add(new MediaStream + { + BitRate = Convert.ToInt32(audioStream.BitRate), + Codec = audioStream.CodecShortName, + Language = audioStream.LanguageCode, + Channels = audioStream.ChannelCount, + SampleRate = audioStream.SampleRate, + Type = MediaStreamType.Audio, + Index = streams.Count + }); + } + + /// <summary> + /// Adds the subtitle stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="textStream">The text stream.</param> + private static void AddSubtitleStream(List<MediaStream> streams, TSTextStream textStream) + { + streams.Add(new MediaStream + { + Language = textStream.LanguageCode, + Codec = textStream.CodecShortName, + Type = MediaStreamType.Subtitle, + Index = streams.Count + }); + } + + /// <summary> + /// Adds the subtitle stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="textStream">The text stream.</param> + private static void AddSubtitleStream(List<MediaStream> streams, TSGraphicsStream textStream) + { + streams.Add(new MediaStream + { + Language = textStream.LanguageCode, + Codec = textStream.CodecShortName, + Type = MediaStreamType.Subtitle, + Index = streams.Count + }); + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegImageProvider.cs new file mode 100644 index 0000000000..95b70044a4 --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegImageProvider.cs @@ -0,0 +1,17 @@ +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + public abstract class BaseFFMpegImageProvider<T> : BaseFFMpegProvider<T> + where T : BaseItem + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Last; } + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegProvider.cs new file mode 100644 index 0000000000..605c03414f --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFMpegProvider.cs @@ -0,0 +1,74 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Class BaseFFMpegProvider + /// </summary> + /// <typeparam name="T"></typeparam> + public abstract class BaseFFMpegProvider<T> : BaseMetadataProvider + where T : BaseItem + { + /// <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 T; + } + + /// <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> + /// The null mount task result + /// </summary> + protected readonly Task<IIsoMount> NullMountTaskResult = Task.FromResult<IIsoMount>(null); + + /// <summary> + /// Gets the provider version. + /// </summary> + /// <value>The provider version.</value> + protected override string ProviderVersion + { + get + { + return Kernel.Instance.FFMpegManager.FFMpegVersion; + } + } + + /// <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 the last run wasn't successful, try again when there's a new version of ffmpeg + if (providerInfo.LastRefreshStatus != ProviderRefreshStatus.Success) + { + if (!string.Equals(ProviderVersion, providerInfo.ProviderVersion)) + { + return true; + } + } + + return base.NeedsRefreshInternal(item, providerInfo); + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/BaseFFProbeProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFProbeProvider.cs new file mode 100644 index 0000000000..bb2b82819c --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/BaseFFProbeProvider.cs @@ -0,0 +1,358 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Provides a base class for extracting media information through ffprobe + /// </summary> + /// <typeparam name="T"></typeparam> + public abstract class BaseFFProbeProvider<T> : BaseFFMpegProvider<T> + where T : BaseItem + { + /// <summary> + /// Gets or sets the FF probe cache. + /// </summary> + /// <value>The FF probe cache.</value> + protected FileSystemRepository FFProbeCache { get; set; } + + /// <summary> + /// Initializes this instance. + /// </summary> + protected override void Initialize() + { + base.Initialize(); + FFProbeCache = new FileSystemRepository(Path.Combine(Kernel.Instance.ApplicationPaths.CachePath, CacheDirectoryName)); + } + + /// <summary> + /// Gets the name of the cache directory. + /// </summary> + /// <value>The name of the cache directory.</value> + protected virtual string CacheDirectoryName + { + get + { + return "ffmpeg-video-info"; + } + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + // Give this second priority + // Give metadata xml providers a chance to fill in data first, so that we can skip this whenever possible + get { return MetadataProviderPriority.Second; } + } + + /// <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> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + var myItem = (T)item; + + var isoMount = await MountIsoIfNeeded(myItem, cancellationToken).ConfigureAwait(false); + + try + { + OnPreFetch(myItem, isoMount); + + var inputPath = isoMount == null ? + Kernel.Instance.FFMpegManager.GetInputArgument(myItem) : + Kernel.Instance.FFMpegManager.GetInputArgument((Video)item, isoMount); + + var result = await Kernel.Instance.FFMpegManager.RunFFProbe(item, inputPath, item.DateModified, FFProbeCache, cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + NormalizeFFProbeResult(result); + + cancellationToken.ThrowIfCancellationRequested(); + + await Fetch(myItem, cancellationToken, result, isoMount).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + SetLastRefreshed(item, DateTime.UtcNow); + } + finally + { + if (isoMount != null) + { + isoMount.Dispose(); + } + } + + return true; + } + + /// <summary> + /// Gets a value indicating whether [refresh on version change]. + /// </summary> + /// <value><c>true</c> if [refresh on version change]; otherwise, <c>false</c>.</value> + protected override bool RefreshOnVersionChange + { + get + { + return true; + } + } + + /// <summary> + /// Mounts the iso if needed. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>IsoMount.</returns> + protected virtual Task<IIsoMount> MountIsoIfNeeded(T item, CancellationToken cancellationToken) + { + return NullMountTaskResult; + } + + /// <summary> + /// Called when [pre fetch]. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="mount">The mount.</param> + protected virtual void OnPreFetch(T item, IIsoMount mount) + { + + } + + /// <summary> + /// Normalizes the FF probe result. + /// </summary> + /// <param name="result">The result.</param> + private void NormalizeFFProbeResult(FFProbeResult result) + { + if (result.format != null && result.format.tags != null) + { + result.format.tags = ConvertDictionaryToCaseInSensitive(result.format.tags); + } + + if (result.streams != null) + { + // Convert all dictionaries to case insensitive + foreach (var stream in result.streams) + { + if (stream.tags != null) + { + stream.tags = ConvertDictionaryToCaseInSensitive(stream.tags); + } + + if (stream.disposition != null) + { + stream.disposition = ConvertDictionaryToCaseInSensitive(stream.disposition); + } + } + } + } + + /// <summary> + /// Subclasses must set item values using this + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="result">The result.</param> + /// <param name="isoMount">The iso mount.</param> + /// <returns>Task.</returns> + protected abstract Task Fetch(T item, CancellationToken cancellationToken, FFProbeResult result, IIsoMount isoMount); + + /// <summary> + /// Converts ffprobe stream info to our MediaStream class + /// </summary> + /// <param name="streamInfo">The stream info.</param> + /// <param name="formatInfo">The format info.</param> + /// <returns>MediaStream.</returns> + protected MediaStream GetMediaStream(FFProbeMediaStreamInfo streamInfo, FFProbeMediaFormatInfo formatInfo) + { + var stream = new MediaStream + { + Codec = streamInfo.codec_name, + Language = GetDictionaryValue(streamInfo.tags, "language"), + Profile = streamInfo.profile, + Index = streamInfo.index + }; + + if (streamInfo.codec_type.Equals("audio", StringComparison.OrdinalIgnoreCase)) + { + stream.Type = MediaStreamType.Audio; + + stream.Channels = streamInfo.channels; + + if (!string.IsNullOrEmpty(streamInfo.sample_rate)) + { + stream.SampleRate = int.Parse(streamInfo.sample_rate); + } + } + else if (streamInfo.codec_type.Equals("subtitle", StringComparison.OrdinalIgnoreCase)) + { + stream.Type = MediaStreamType.Subtitle; + } + else + { + stream.Type = MediaStreamType.Video; + + stream.Width = streamInfo.width; + stream.Height = streamInfo.height; + stream.AspectRatio = streamInfo.display_aspect_ratio; + + stream.AverageFrameRate = GetFrameRate(streamInfo.avg_frame_rate); + stream.RealFrameRate = GetFrameRate(streamInfo.r_frame_rate); + } + + // Get stream bitrate + if (stream.Type != MediaStreamType.Subtitle) + { + if (!string.IsNullOrEmpty(streamInfo.bit_rate)) + { + stream.BitRate = int.Parse(streamInfo.bit_rate); + } + else if (formatInfo != null && !string.IsNullOrEmpty(formatInfo.bit_rate)) + { + // If the stream info doesn't have a bitrate get the value from the media format info + stream.BitRate = int.Parse(formatInfo.bit_rate); + } + } + + if (streamInfo.disposition != null) + { + var isDefault = GetDictionaryValue(streamInfo.disposition, "default"); + var isForced = GetDictionaryValue(streamInfo.disposition, "forced"); + + stream.IsDefault = string.Equals(isDefault, "1", StringComparison.OrdinalIgnoreCase); + + stream.IsForced = string.Equals(isForced, "1", StringComparison.OrdinalIgnoreCase); + } + + return stream; + } + + /// <summary> + /// Gets a frame rate from a string value in ffprobe output + /// This could be a number or in the format of 2997/125. + /// </summary> + /// <param name="value">The value.</param> + /// <returns>System.Nullable{System.Single}.</returns> + private float? GetFrameRate(string value) + { + if (!string.IsNullOrEmpty(value)) + { + var parts = value.Split('/'); + + if (parts.Length == 2) + { + return float.Parse(parts[0]) / float.Parse(parts[1]); + } + return float.Parse(parts[0]); + } + + return null; + } + + /// <summary> + /// Gets a string from an FFProbeResult tags dictionary + /// </summary> + /// <param name="tags">The tags.</param> + /// <param name="key">The key.</param> + /// <returns>System.String.</returns> + protected string GetDictionaryValue(Dictionary<string, string> tags, string key) + { + if (tags == null) + { + return null; + } + + string val; + + tags.TryGetValue(key, out val); + return val; + } + + /// <summary> + /// Gets an int from an FFProbeResult tags dictionary + /// </summary> + /// <param name="tags">The tags.</param> + /// <param name="key">The key.</param> + /// <returns>System.Nullable{System.Int32}.</returns> + protected int? GetDictionaryNumericValue(Dictionary<string, string> tags, string key) + { + var val = GetDictionaryValue(tags, key); + + if (!string.IsNullOrEmpty(val)) + { + int i; + + if (int.TryParse(val, out i)) + { + return i; + } + } + + return null; + } + + /// <summary> + /// Gets a DateTime from an FFProbeResult tags dictionary + /// </summary> + /// <param name="tags">The tags.</param> + /// <param name="key">The key.</param> + /// <returns>System.Nullable{DateTime}.</returns> + protected DateTime? GetDictionaryDateTime(Dictionary<string, string> tags, string key) + { + var val = GetDictionaryValue(tags, key); + + if (!string.IsNullOrEmpty(val)) + { + DateTime i; + + if (DateTime.TryParse(val, out i)) + { + return i.ToUniversalTime(); + } + } + + return null; + } + + /// <summary> + /// Converts a dictionary to case insensitive + /// </summary> + /// <param name="dict">The dict.</param> + /// <returns>Dictionary{System.StringSystem.String}.</returns> + private Dictionary<string, string> ConvertDictionaryToCaseInSensitive(Dictionary<string, string> dict) + { + return new Dictionary<string, string>(dict, StringComparer.OrdinalIgnoreCase); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + FFProbeCache.Dispose(); + } + + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs new file mode 100644 index 0000000000..523192d4e3 --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegAudioImageProvider.cs @@ -0,0 +1,84 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Uses ffmpeg to create video images + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class FFMpegAudioImageProvider : BaseFFMpegImageProvider<Audio> + { + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + var audio = (Audio)item; + + if (string.IsNullOrEmpty(audio.PrimaryImagePath)) + { + // First try to use the parent's image + audio.PrimaryImagePath = audio.ResolveArgs.Parent.PrimaryImagePath; + + // If it's still empty see if there's an embedded image + if (string.IsNullOrEmpty(audio.PrimaryImagePath)) + { + if (audio.MediaStreams != null && audio.MediaStreams.Any(s => s.Type == MediaStreamType.Video)) + { + var filename = item.Id + "_" + item.DateModified.Ticks + "_primary"; + + var path = Kernel.Instance.FFMpegManager.AudioImageCache.GetResourcePath(filename, ".jpg"); + + if (!Kernel.Instance.FFMpegManager.AudioImageCache.ContainsFilePath(path)) + { + return ExtractImage(audio, path, cancellationToken); + } + + // Image is already in the cache + audio.PrimaryImagePath = path; + } + + } + } + + SetLastRefreshed(item, DateTime.UtcNow); + return TrueTaskResult; + } + + /// <summary> + /// Extracts the image. + /// </summary> + /// <param name="audio">The audio.</param> + /// <param name="path">The path.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + private async Task<bool> ExtractImage(Audio audio, string path, CancellationToken cancellationToken) + { + var success = await Kernel.Instance.FFMpegManager.ExtractImage(audio, path, cancellationToken).ConfigureAwait(false); + + if (success) + { + audio.PrimaryImagePath = path; + SetLastRefreshed(audio, DateTime.UtcNow); + } + else + { + SetLastRefreshed(audio, DateTime.UtcNow, ProviderRefreshStatus.Failure); + } + + return true; + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs new file mode 100644 index 0000000000..2f617b5b18 --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/FFMpegVideoImageProvider.cs @@ -0,0 +1,137 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Uses ffmpeg to create video images + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class FFMpegVideoImageProvider : BaseFFMpegImageProvider<Video> + { + /// <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 && video.IsoType.HasValue && Kernel.Instance.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> + /// 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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(item.PrimaryImagePath)) + { + var video = (Video)item; + + 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 Kernel.Instance.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); + + var inputPath = isoMount == null ? + Kernel.Instance.FFMpegManager.GetInputArgument(video) : + Kernel.Instance.FFMpegManager.GetInputArgument(video, isoMount); + + var success = await Kernel.Instance.FFMpegManager.ExtractImage(inputPath, imageOffset, path, cancellationToken).ConfigureAwait(false); + + if (success) + { + video.PrimaryImagePath = path; + SetLastRefreshed(video, DateTime.UtcNow); + } + else + { + SetLastRefreshed(video, DateTime.UtcNow, ProviderRefreshStatus.Failure); + } + } + finally + { + if (isoMount != null) + { + isoMount.Dispose(); + } + } + + return true; + } + } +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFProbeAudioInfoProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFProbeAudioInfoProvider.cs new file mode 100644 index 0000000000..d8fd76805b --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/FFProbeAudioInfoProvider.cs @@ -0,0 +1,208 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Extracts audio information using ffprobe + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class FFProbeAudioInfoProvider : BaseFFProbeProvider<Audio> + { + /// <summary> + /// Gets the name of the cache directory. + /// </summary> + /// <value>The name of the cache directory.</value> + protected override string CacheDirectoryName + { + get + { + return "ffmpeg-audio-info"; + } + } + + /// <summary> + /// Fetches the specified audio. + /// </summary> + /// <param name="audio">The audio.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="data">The data.</param> + /// <param name="isoMount">The iso mount.</param> + /// <returns>Task.</returns> + protected override Task Fetch(Audio audio, CancellationToken cancellationToken, FFProbeResult data, IIsoMount isoMount) + { + return Task.Run(() => + { + if (data.streams == null) + { + Logger.Error("Audio item has no streams: " + audio.Path); + return; + } + + audio.MediaStreams = data.streams.Select(s => GetMediaStream(s, data.format)).ToList(); + + // Get the first audio stream + var stream = data.streams.First(s => s.codec_type.Equals("audio", StringComparison.OrdinalIgnoreCase)); + + // Get duration from stream properties + var duration = stream.duration; + + // If it's not there go into format properties + if (string.IsNullOrEmpty(duration)) + { + duration = data.format.duration; + } + + // If we got something, parse it + if (!string.IsNullOrEmpty(duration)) + { + audio.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration)).Ticks; + } + + if (data.format.tags != null) + { + FetchDataFromTags(audio, data.format.tags); + } + }); + } + + /// <summary> + /// Fetches data from the tags dictionary + /// </summary> + /// <param name="audio">The audio.</param> + /// <param name="tags">The tags.</param> + private void FetchDataFromTags(Audio audio, Dictionary<string, string> tags) + { + var title = GetDictionaryValue(tags, "title"); + + // Only set Name if title was found in the dictionary + if (!string.IsNullOrEmpty(title)) + { + audio.Name = title; + } + + var composer = GetDictionaryValue(tags, "composer"); + + if (!string.IsNullOrWhiteSpace(composer)) + { + // Only use the comma as a delimeter if there are no slashes or pipes. + // We want to be careful not to split names that have commas in them + var delimeter = composer.IndexOf('/') == -1 && composer.IndexOf('|') == -1 ? new[] { ',' } : new[] { '/', '|' }; + + foreach (var person in composer.Split(delimeter, StringSplitOptions.RemoveEmptyEntries)) + { + var name = person.Trim(); + + if (!string.IsNullOrEmpty(name)) + { + audio.AddPerson(new PersonInfo { Name = name, Type = PersonType.Composer }); + } + } + } + + audio.Album = GetDictionaryValue(tags, "album"); + audio.Artist = GetDictionaryValue(tags, "artist"); + + if (!string.IsNullOrWhiteSpace(audio.Artist)) + { + // Add to people too + audio.AddPerson(new PersonInfo {Name = audio.Artist, Type = PersonType.MusicArtist}); + } + + // Several different forms of albumartist + audio.AlbumArtist = GetDictionaryValue(tags, "albumartist") ?? GetDictionaryValue(tags, "album artist") ?? GetDictionaryValue(tags, "album_artist"); + + // Track number + audio.IndexNumber = GetDictionaryNumericValue(tags, "track"); + + // Disc number + audio.ParentIndexNumber = GetDictionaryDiscValue(tags); + + audio.Language = GetDictionaryValue(tags, "language"); + + audio.ProductionYear = GetDictionaryNumericValue(tags, "date"); + + // Several different forms of retaildate + audio.PremiereDate = GetDictionaryDateTime(tags, "retaildate") ?? GetDictionaryDateTime(tags, "retail date") ?? GetDictionaryDateTime(tags, "retail_date"); + + // If we don't have a ProductionYear try and get it from PremiereDate + if (audio.PremiereDate.HasValue && !audio.ProductionYear.HasValue) + { + audio.ProductionYear = audio.PremiereDate.Value.Year; + } + + FetchGenres(audio, tags); + + // There's several values in tags may or may not be present + FetchStudios(audio, tags, "organization"); + FetchStudios(audio, tags, "ensemble"); + FetchStudios(audio, tags, "publisher"); + } + + /// <summary> + /// Gets the studios from the tags collection + /// </summary> + /// <param name="audio">The audio.</param> + /// <param name="tags">The tags.</param> + /// <param name="tagName">Name of the tag.</param> + private void FetchStudios(Audio audio, Dictionary<string, string> tags, string tagName) + { + var val = GetDictionaryValue(tags, tagName); + + if (!string.IsNullOrEmpty(val)) + { + audio.AddStudios(val.Split(new[] { '/', '|' }, StringSplitOptions.RemoveEmptyEntries)); + } + } + + /// <summary> + /// Gets the genres from the tags collection + /// </summary> + /// <param name="audio">The audio.</param> + /// <param name="tags">The tags.</param> + private void FetchGenres(Audio audio, Dictionary<string, string> tags) + { + var val = GetDictionaryValue(tags, "genre"); + + if (!string.IsNullOrEmpty(val)) + { + audio.AddGenres(val.Split(new[] { '/', '|' }, StringSplitOptions.RemoveEmptyEntries)); + } + } + + /// <summary> + /// Gets the disc number, which is sometimes can be in the form of '1', or '1/3' + /// </summary> + /// <param name="tags">The tags.</param> + /// <returns>System.Nullable{System.Int32}.</returns> + private int? GetDictionaryDiscValue(Dictionary<string, string> tags) + { + var disc = GetDictionaryValue(tags, "disc"); + + if (!string.IsNullOrEmpty(disc)) + { + disc = disc.Split('/')[0]; + + int num; + + if (int.TryParse(disc, out num)) + { + return num; + } + } + + return null; + } + } + +} diff --git a/MediaBrowser.Controller/Providers/MediaInfo/FFProbeVideoInfoProvider.cs b/MediaBrowser.Controller/Providers/MediaInfo/FFProbeVideoInfoProvider.cs new file mode 100644 index 0000000000..5092429e84 --- /dev/null +++ b/MediaBrowser.Controller/Providers/MediaInfo/FFProbeVideoInfoProvider.cs @@ -0,0 +1,291 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.MediaInfo +{ + /// <summary> + /// Extracts video information using ffprobe + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class FFProbeVideoInfoProvider : BaseFFProbeProvider<Video> + { + /// <summary> + /// Gets or sets the bd info cache. + /// </summary> + /// <value>The bd info cache.</value> + private FileSystemRepository BdInfoCache { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="FFProbeVideoInfoProvider" /> class. + /// </summary> + public FFProbeVideoInfoProvider() + : base() + { + BdInfoCache = new FileSystemRepository(Path.Combine(Kernel.Instance.ApplicationPaths.CachePath, "bdinfo")); + } + + /// <summary> + /// Returns true or false indicating if the provider should refresh when the contents of it's directory changes + /// </summary> + /// <value><c>true</c> if [refresh on file system stamp change]; otherwise, <c>false</c>.</value> + protected override bool RefreshOnFileSystemStampChange + { + get + { + return true; + } + } + + /// <summary> + /// Supports video files and dvd structures + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + public override bool Supports(BaseItem item) + { + var video = item as Video; + + if (video != null) + { + if (video.VideoType == VideoType.Iso) + { + return Kernel.Instance.IsoManager.CanMount(item.Path); + } + + return video.VideoType == VideoType.VideoFile || video.VideoType == VideoType.Dvd || video.VideoType == VideoType.BluRay; + } + + return false; + } + + /// <summary> + /// Called when [pre fetch]. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="mount">The mount.</param> + protected override void OnPreFetch(Video item, IIsoMount mount) + { + if (item.VideoType == VideoType.Iso) + { + item.IsoType = DetermineIsoType(mount); + } + + if (item.VideoType == VideoType.Dvd || (item.IsoType.HasValue && item.IsoType == IsoType.Dvd)) + { + PopulateDvdStreamFiles(item, mount); + } + + base.OnPreFetch(item, mount); + } + + /// <summary> + /// Mounts the iso if needed. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>IsoMount.</returns> + protected override Task<IIsoMount> MountIsoIfNeeded(Video item, CancellationToken cancellationToken) + { + if (item.VideoType == VideoType.Iso) + { + return Kernel.Instance.IsoManager.Mount(item.Path, cancellationToken); + } + + return base.MountIsoIfNeeded(item, cancellationToken); + } + + /// <summary> + /// Determines the type of the iso. + /// </summary> + /// <param name="isoMount">The iso mount.</param> + /// <returns>System.Nullable{IsoType}.</returns> + private IsoType? DetermineIsoType(IIsoMount isoMount) + { + var folders = Directory.EnumerateDirectories(isoMount.MountedPath).Select(Path.GetFileName).ToList(); + + if (folders.Contains("video_ts", StringComparer.OrdinalIgnoreCase)) + { + return IsoType.Dvd; + } + if (folders.Contains("bdmv", StringComparer.OrdinalIgnoreCase)) + { + return IsoType.BluRay; + } + + return null; + } + + /// <summary> + /// Finds vob files and populates the dvd stream file properties + /// </summary> + /// <param name="video">The video.</param> + /// <param name="isoMount">The iso mount.</param> + private void PopulateDvdStreamFiles(Video video, IIsoMount isoMount) + { + // min size 300 mb + const long minPlayableSize = 314572800; + + var root = isoMount != null ? isoMount.MountedPath : video.Path; + + // Try to eliminate menus and intros by skipping all files at the front of the list that are less than the minimum size + // Once we reach a file that is at least the minimum, return all subsequent ones + video.PlayableStreamFileNames = Directory.EnumerateFiles(root, "*.vob", SearchOption.AllDirectories).SkipWhile(f => new FileInfo(f).Length < minPlayableSize).Select(Path.GetFileName).ToList(); + } + + /// <summary> + /// Fetches the specified video. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="data">The data.</param> + /// <param name="isoMount">The iso mount.</param> + /// <returns>Task.</returns> + protected override Task Fetch(Video video, CancellationToken cancellationToken, FFProbeResult data, IIsoMount isoMount) + { + return Task.Run(() => + { + if (data.format != null) + { + // For dvd's this may not always be accurate, so don't set the runtime if the item already has one + var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks == null || video.RunTimeTicks.Value == 0; + + if (needToSetRuntime && !string.IsNullOrEmpty(data.format.duration)) + { + video.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.format.duration)).Ticks; + } + } + + if (data.streams != null) + { + video.MediaStreams = data.streams.Select(s => GetMediaStream(s, data.format)).ToList(); + } + + if (data.Chapters != null) + { + video.Chapters = data.Chapters; + } + + if (video.Chapters == null || video.Chapters.Count == 0) + { + AddDummyChapters(video); + } + + if (video.VideoType == VideoType.BluRay || (video.IsoType.HasValue && video.IsoType.Value == IsoType.BluRay)) + { + var inputPath = isoMount != null ? isoMount.MountedPath : video.Path; + BDInfoProvider.FetchBdInfo(video, inputPath, BdInfoCache, cancellationToken); + } + + AddExternalSubtitles(video); + }); + } + + /// <summary> + /// Adds the external subtitles. + /// </summary> + /// <param name="video">The video.</param> + private void AddExternalSubtitles(Video video) + { + var useParent = (video.VideoType == VideoType.VideoFile || video.VideoType == VideoType.Iso) && !(video is Movie); + + if (useParent && video.Parent == null) + { + return; + } + + var fileSystemChildren = useParent + ? video.Parent.ResolveArgs.FileSystemChildren + : video.ResolveArgs.FileSystemChildren; + + var startIndex = video.MediaStreams == null ? 0 : video.MediaStreams.Count; + var streams = new List<MediaStream>(); + + foreach (var file in fileSystemChildren.Where(f => !f.IsDirectory)) + { + var extension = Path.GetExtension(file.Path); + + if (string.Equals(extension, ".srt", StringComparison.OrdinalIgnoreCase)) + { + streams.Add(new MediaStream + { + Index = startIndex, + Type = MediaStreamType.Subtitle, + IsExternal = true, + Path = file.Path, + Codec = "srt" + }); + + startIndex++; + } + } + + if (video.MediaStreams == null) + { + video.MediaStreams = new List<MediaStream>(); + } + video.MediaStreams.AddRange(streams); + } + + /// <summary> + /// The dummy chapter duration + /// </summary> + private static readonly long DummyChapterDuration = TimeSpan.FromMinutes(10).Ticks; + + /// <summary> + /// Adds the dummy chapters. + /// </summary> + /// <param name="video">The video.</param> + private void AddDummyChapters(Video video) + { + var runtime = video.RunTimeTicks ?? 0; + + if (runtime < DummyChapterDuration) + { + return; + } + + long currentChapterTicks = 0; + var index = 1; + + var chapters = new List<ChapterInfo> { }; + + while (currentChapterTicks < runtime) + { + chapters.Add(new ChapterInfo + { + Name = "Chapter " + index, + StartPositionTicks = currentChapterTicks + }); + + index++; + currentChapterTicks += DummyChapterDuration; + } + + video.Chapters = chapters; + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + BdInfoCache.Dispose(); + } + + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/FanArtMovieProvider.cs b/MediaBrowser.Controller/Providers/Movies/FanArtMovieProvider.cs new file mode 100644 index 0000000000..38e5475230 --- /dev/null +++ b/MediaBrowser.Controller/Providers/Movies/FanArtMovieProvider.cs @@ -0,0 +1,220 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.Movies +{ + /// <summary> + /// Class FanArtMovieProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + class FanArtMovieProvider : FanartBaseProvider + { + /// <summary> + /// The fan art base URL + /// </summary> + protected string FanArtBaseUrl = "http://api.fanart.tv/webservice/movie/{0}/{1}/xml/all/1/1"; + + /// <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 is Movie || item is BoxSet; + } + + /// <summary> + /// Shoulds the fetch. + /// </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 ShouldFetch(BaseItem item, BaseProviderInfo providerInfo) + { + var baseItem = item; + if (item.Path == null || item.DontFetchMeta || string.IsNullOrEmpty(baseItem.GetProviderId(MetadataProviders.Tmdb))) return false; //nothing to do + var artExists = item.ResolveArgs.ContainsMetaFileByName(ART_FILE); + var logoExists = item.ResolveArgs.ContainsMetaFileByName(LOGO_FILE); + var discExists = item.ResolveArgs.ContainsMetaFileByName(DISC_FILE); + + return (!artExists && Kernel.Instance.Configuration.DownloadMovieArt) + || (!logoExists && Kernel.Instance.Configuration.DownloadMovieLogo) + || (!discExists && Kernel.Instance.Configuration.DownloadMovieDisc); + } + + /// <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> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var movie = item; + if (ShouldFetch(movie, movie.ProviderData.GetValueOrDefault(Id, new BaseProviderInfo { ProviderId = Id }))) + { + var language = Kernel.Instance.Configuration.PreferredMetadataLanguage.ToLower(); + var url = string.Format(FanArtBaseUrl, APIKey, movie.GetProviderId(MetadataProviders.Tmdb)); + var doc = new XmlDocument(); + + try + { + using (var xml = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)) + { + doc.Load(xml); + } + } + catch (HttpException) + { + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (doc.HasChildNodes) + { + string path; + var hd = Kernel.Instance.Configuration.DownloadHDFanArt ? "hd" : ""; + if (Kernel.Instance.Configuration.DownloadMovieLogo && !item.ResolveArgs.ContainsMetaFileByName(LOGO_FILE)) + { + var node = + doc.SelectSingleNode("//fanart/movie/movielogos/" + hd + "movielogo[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/movielogos/movielogo[@lang = \"" + language + "\"]/@url"); + if (node == null && language != "en") + { + //maybe just couldn't find language - try just first one + node = doc.SelectSingleNode("//fanart/movie/movielogos/" + hd + "movielogo/@url"); + } + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting ClearLogo for " + movie.Name); + try + { + movie.SetImage(ImageType.Logo, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(movie, path, LOGO_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadMovieArt && !item.ResolveArgs.ContainsMetaFileByName(ART_FILE)) + { + var node = + doc.SelectSingleNode("//fanart/movie/moviearts/" + hd + "movieart[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviearts/" + hd + "movieart/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviearts/movieart[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviearts/movieart/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting ClearArt for " + movie.Name); + try + { + movie.SetImage(ImageType.Art, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(movie, path, ART_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadMovieDisc && !item.ResolveArgs.ContainsMetaFileByName(DISC_FILE)) + { + var node = doc.SelectSingleNode("//fanart/movie/moviediscs/moviedisc[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviediscs/moviedisc/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting DiscArt for " + movie.Name); + try + { + movie.SetImage(ImageType.Disc, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(movie, path, DISC_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadMovieBanner && !item.ResolveArgs.ContainsMetaFileByName(BANNER_FILE)) + { + var node = doc.SelectSingleNode("//fanart/movie/moviebanners/moviebanner[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviebanners/moviebanner/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting Banner for " + movie.Name); + try + { + movie.SetImage(ImageType.Banner, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(movie, path, BANNER_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadMovieThumb && !item.ResolveArgs.ContainsMetaFileByName(THUMB_FILE)) + { + var node = doc.SelectSingleNode("//fanart/movie/moviethumbs/moviethumb[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/movie/moviethumbs/moviethumb/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting Banner for " + movie.Name); + try + { + movie.SetImage(ImageType.Thumb, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(movie, path, THUMB_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + } + SetLastRefreshed(movie, DateTime.UtcNow); + return true; + } + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/MovieDbProvider.cs b/MediaBrowser.Controller/Providers/Movies/MovieDbProvider.cs new file mode 100644 index 0000000000..2319e5cfa4 --- /dev/null +++ b/MediaBrowser.Controller/Providers/Movies/MovieDbProvider.cs @@ -0,0 +1,1607 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.Movies +{ + class MovieDbProviderException : ApplicationException + { + public MovieDbProviderException(string msg) : base(msg) + { + } + + } + /// <summary> + /// Class MovieDbProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class MovieDbProvider : BaseMetadataProvider + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Second; } + } + + /// <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 is Movie || item is BoxSet; + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return true; + } + } + + /// <summary> + /// If we save locally, refresh if they delete something + /// </summary> + protected override bool RefreshOnFileSystemStampChange + { + get + { + return Kernel.Instance.Configuration.SaveLocalMeta; + } + } + + /// <summary> + /// The _TMDB settings task + /// </summary> + private Task<TmdbSettingsResult> _tmdbSettingsTask; + /// <summary> + /// The _TMDB settings task initialized + /// </summary> + private bool _tmdbSettingsTaskInitialized; + /// <summary> + /// The _TMDB settings task sync lock + /// </summary> + private object _tmdbSettingsTaskSyncLock = new object(); + + /// <summary> + /// Gets the TMDB settings. + /// </summary> + /// <value>The TMDB settings.</value> + public Task<TmdbSettingsResult> TmdbSettings + { + get + { + LazyInitializer.EnsureInitialized(ref _tmdbSettingsTask, ref _tmdbSettingsTaskInitialized, ref _tmdbSettingsTaskSyncLock, GetTmdbSettings); + return _tmdbSettingsTask; + } + } + + /// <summary> + /// Gets the TMDB settings. + /// </summary> + /// <returns>Task{TmdbSettingsResult}.</returns> + private static async Task<TmdbSettingsResult> GetTmdbSettings() + { + try + { + using (var json = await Kernel.Instance.HttpManager.Get(String.Format(TmdbConfigUrl, ApiKey), Kernel.Instance.ResourcePools.MovieDb, CancellationToken.None).ConfigureAwait(false)) + { + return JsonSerializer.DeserializeFromStream<TmdbSettingsResult>(json); + } + } + catch (HttpException e) + { + return new TmdbSettingsResult + { + images = new TmdbImageSettings + { + backdrop_sizes = + new List<string> + { + "w380", + "w780", + "w1280", + "original" + }, + poster_sizes = + new List<string> + { + "w92", + "w154", + "w185", + "w342", + "w500", + "original" + }, + profile_sizes = + new List<string> + { + "w45", + "w185", + "h632", + "original" + }, + base_url = "http://cf2.imgobject.com/t/p/" + + } + }; + } + } + + /// <summary> + /// The json provider + /// </summary> + protected MovieProviderFromJson JsonProvider; + /// <summary> + /// Sets the persisted last refresh date on the item for this provider. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="value">The value.</param> + /// <param name="status">The status.</param> + protected override void SetLastRefreshed(BaseItem item, DateTime value, ProviderRefreshStatus status = ProviderRefreshStatus.Success) + { + base.SetLastRefreshed(item, value, status); + + if (Kernel.Instance.Configuration.SaveLocalMeta) + { + //in addition to ours, we need to set the last refreshed time for the local data provider + //so it won't see the new files we download and process them all over again + if (JsonProvider == null) JsonProvider = new MovieProviderFromJson(); + var data = item.ProviderData.GetValueOrDefault(JsonProvider.Id, new BaseProviderInfo { ProviderId = JsonProvider.Id }); + data.LastRefreshed = value; + item.ProviderData[JsonProvider.Id] = data; + } + } + + private const string TmdbConfigUrl = "http://api.themoviedb.org/3/configuration?api_key={0}"; + private const string Search3 = @"http://api.themoviedb.org/3/search/movie?api_key={1}&query={0}&language={2}"; + private const string AltTitleSearch = @"http://api.themoviedb.org/3/movie/{0}/alternative_titles?api_key={1}&country={2}"; + private const string GetInfo3 = @"http://api.themoviedb.org/3/{3}/{0}?api_key={1}&language={2}"; + private const string CastInfo = @"http://api.themoviedb.org/3/movie/{0}/casts?api_key={1}"; + private const string ReleaseInfo = @"http://api.themoviedb.org/3/movie/{0}/releases?api_key={1}"; + private const string GetImages = @"http://api.themoviedb.org/3/{2}/{0}/images?api_key={1}"; + public static string ApiKey = "f6bd687ffa63cd282b6ff2c6877f2669"; + + static readonly Regex[] NameMatches = new[] { + new Regex(@"(?<name>.*)\((?<year>\d{4})\)"), // matches "My Movie (2001)" and gives us the name and the year + new Regex(@"(?<name>.*)") // last resort matches the whole string as the name + }; + + public const string LOCAL_META_FILE_NAME = "MBMovie.json"; + public const string ALT_META_FILE_NAME = "movie.xml"; + protected string ItemType = "movie"; + + protected override bool NeedsRefreshInternal(BaseItem item, BaseProviderInfo providerInfo) + { + if (item.DontFetchMeta) return false; + + if (Kernel.Instance.Configuration.SaveLocalMeta && HasFileSystemStampChanged(item, providerInfo)) + { + //If they deleted something from file system, chances are, this item was mis-identified the first time + item.SetProviderId(MetadataProviders.Tmdb, null); + Logger.Debug("MovieProvider reports file system stamp change..."); + return true; + + } + + if (providerInfo.LastRefreshStatus == ProviderRefreshStatus.CompletedWithErrors) + { + Logger.Debug("MovieProvider for {0} - last attempt had errors. Will try again.", item.Path); + return true; + } + + var downloadDate = providerInfo.LastRefreshed; + + if (Kernel.Instance.Configuration.MetadataRefreshDays == -1 && downloadDate != DateTime.MinValue) + { + return false; + } + + if (DateTime.Today.Subtract(item.DateCreated).TotalDays > 180 && downloadDate != DateTime.MinValue) + return false; // don't trigger a refresh data for item that are more than 6 months old and have been refreshed before + + if (DateTime.Today.Subtract(downloadDate).TotalDays < Kernel.Instance.Configuration.MetadataRefreshDays) // only refresh every n days + return false; + + if (HasAltMeta(item)) + return false; //never refresh if has meta from other source + + + + Logger.Debug("MovieDbProvider - " + item.Name + " needs refresh. Download date: " + downloadDate + " item created date: " + item.DateCreated + " Check for Update age: " + Kernel.Instance.Configuration.MetadataRefreshDays); + return 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> + /// <returns>Task{System.Boolean}.</returns> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + if (HasAltMeta(item)) + { + Logger.Info("MovieDbProvider - Not fetching because 3rd party meta exists for " + item.Name); + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + if (item.DontFetchMeta) + { + Logger.Info("MovieDbProvider - Not fetching because requested to ignore " + item.Name); + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!Kernel.Instance.Configuration.SaveLocalMeta || !HasLocalMeta(item) || (force && !HasLocalMeta(item))) + { + try + { + await FetchMovieData(item, cancellationToken).ConfigureAwait(false); + SetLastRefreshed(item, DateTime.UtcNow); + } + catch (MovieDbProviderException e) + { + SetLastRefreshed(item, DateTime.UtcNow, ProviderRefreshStatus.CompletedWithErrors); + } + + return true; + } + Logger.Debug("MovieDBProvider not fetching because local meta exists for " + item.Name); + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + + /// <summary> + /// Determines whether [has local meta] [the specified item]. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if [has local meta] [the specified item]; otherwise, <c>false</c>.</returns> + private bool HasLocalMeta(BaseItem item) + { + //need at least the xml and folder.jpg/png or a movie.xml put in by someone else + return item.ResolveArgs.ContainsMetaFileByName(LOCAL_META_FILE_NAME); + } + + /// <summary> + /// Determines whether [has alt meta] [the specified item]. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if [has alt meta] [the specified item]; otherwise, <c>false</c>.</returns> + private bool HasAltMeta(BaseItem item) + { + return item.ResolveArgs.ContainsMetaFileByName(ALT_META_FILE_NAME); + } + + /// <summary> + /// Fetches the movie data. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken"></param> + /// <returns>Task.</returns> + private async Task FetchMovieData(BaseItem item, CancellationToken cancellationToken) + { + string id = item.GetProviderId(MetadataProviders.Tmdb) ?? await FindId(item, item.ProductionYear, cancellationToken).ConfigureAwait(false); + if (id != null) + { + Logger.Debug("MovieDbProvider - getting movie info with id: " + id); + + cancellationToken.ThrowIfCancellationRequested(); + + await FetchMovieData(item, id, cancellationToken).ConfigureAwait(false); + } + else + { + Logger.Info("MovieDBProvider could not find " + item.Name + ". Check name on themoviedb.org."); + } + } + + /// <summary> + /// Parses the name. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="justName">Name of the just.</param> + /// <param name="year">The year.</param> + protected void ParseName(string name, out string justName, out int? year) + { + justName = null; + year = null; + foreach (var re in NameMatches) + { + Match m = re.Match(name); + if (m.Success) + { + justName = m.Groups["name"].Value.Trim(); + string y = m.Groups["year"] != null ? m.Groups["year"].Value : null; + int temp; + year = Int32.TryParse(y, out temp) ? temp : (int?)null; + break; + } + } + } + + /// <summary> + /// Finds the id. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="productionYear">The production year.</param> + /// <returns>Task{System.String}.</returns> + public async Task<string> FindId(BaseItem item, int? productionYear, CancellationToken cancellationToken) + { + string justName = item.Path != null ? item.Path.Substring(item.Path.LastIndexOf(Path.DirectorySeparatorChar)) : string.Empty; + var id = justName.GetAttributeValue("tmdbid"); + if (id != null) + { + Logger.Debug("Using tmdb id specified in path."); + return id; + } + + int? year; + string name = item.Name; + ParseName(name, out name, out year); + + if (year == null && productionYear != null) + { + year = productionYear; + } + + Logger.Info("MovieDbProvider: Finding id for movie: " + name); + string language = Kernel.Instance.Configuration.PreferredMetadataLanguage.ToLower(); + + //if we are a boxset - look at our first child + var boxset = item as BoxSet; + if (boxset != null) + { + if (!boxset.Children.IsEmpty) + { + var firstChild = boxset.Children.First(); + Logger.Debug("MovieDbProvider - Attempting to find boxset ID from: " + firstChild.Name); + string childName; + int? childYear; + ParseName(firstChild.Name, out childName, out childYear); + id = await GetBoxsetIdFromMovie(childName, childYear, language, cancellationToken).ConfigureAwait(false); + if (id != null) + { + Logger.Info("MovieDbProvider - Found Boxset ID: " + id); + } + } + + return id; + } + //nope - search for it + id = await AttemptFindId(name, year, language, cancellationToken).ConfigureAwait(false); + if (id == null) + { + //try in english if wasn't before + if (language != "en") + { + id = await AttemptFindId(name, year, "en", cancellationToken).ConfigureAwait(false); + } + else + { + // try with dot and _ turned to space + name = name.Replace(",", " "); + name = name.Replace(".", " "); + name = name.Replace(" ", " "); + name = name.Replace("_", " "); + name = name.Replace("-", ""); + id = await AttemptFindId(name, year, language, cancellationToken).ConfigureAwait(false); + if (id == null && language != "en") + { + //one more time, in english + id = await AttemptFindId(name, year, "en", cancellationToken).ConfigureAwait(false); + + } + if (id == null) + { + //last resort - try using the actual folder name + id = await AttemptFindId(Path.GetFileName(item.ResolveArgs.Path), year, "en", cancellationToken).ConfigureAwait(false); + } + } + } + return id; + } + + /// <summary> + /// Attempts the find id. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="year">The year.</param> + /// <param name="language">The language.</param> + /// <returns>Task{System.String}.</returns> + public virtual async Task<string> AttemptFindId(string name, int? year, string language, CancellationToken cancellationToken) + { + string url3 = string.Format(Search3, UrlEncode(name), ApiKey, language); + TmdbMovieSearchResults searchResult = null; + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url3, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + searchResult = JsonSerializer.DeserializeFromStream<TmdbMovieSearchResults>(json); + } + } + catch (HttpException) + { + } + if (searchResult == null || searchResult.results.Count == 0) + { + //try replacing numbers + foreach (var pair in ReplaceStartNumbers) + { + if (name.StartsWith(pair.Key)) + { + name = name.Remove(0, pair.Key.Length); + name = pair.Value + name; + } + } + foreach (var pair in ReplaceEndNumbers) + { + if (name.EndsWith(pair.Key)) + { + name = name.Remove(name.IndexOf(pair.Key), pair.Key.Length); + name = name + pair.Value; + } + } + Logger.Info("MovieDBProvider - No results. Trying replacement numbers: " + name); + url3 = string.Format(Search3, UrlEncode(name), ApiKey, language); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url3, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + searchResult = JsonSerializer.DeserializeFromStream<TmdbMovieSearchResults>(json); + } + } + catch (HttpException) + { + } + } + if (searchResult != null) + { + string compName = GetComparableName(name, Logger); + foreach (var possible in searchResult.results) + { + string matchedName = null; + string id = possible.id.ToString(); + string n = possible.title; + if (GetComparableName(n, Logger) == compName) + { + matchedName = n; + } + else + { + n = possible.original_title; + if (GetComparableName(n, Logger) == compName) + { + matchedName = n; + } + } + + Logger.Debug("MovieDbProvider - " + compName + " didn't match " + n); + //if main title matches we don't have to look for alternatives + if (matchedName == null) + { + //that title didn't match - look for alternatives + url3 = string.Format(AltTitleSearch, id, ApiKey, Kernel.Instance.Configuration.MetadataCountryCode); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url3, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + var response = JsonSerializer.DeserializeFromStream<TmdbAltTitleResults>(json); + + if (response != null && response.titles != null) + { + foreach (var title in response.titles) + { + var t = GetComparableName(title.title, Logger); + if (t == compName) + { + Logger.Debug("MovieDbProvider - " + compName + + " matched " + t); + matchedName = t; + break; + } + Logger.Debug("MovieDbProvider - " + compName + + " did not match " + t); + } + } + } + } + catch (HttpException) + { + } + } + + if (matchedName != null) + { + Logger.Debug("Match " + matchedName + " for " + name); + if (year != null) + { + DateTime r; + + if (DateTime.TryParse(possible.release_date, out r)) + { + if (Math.Abs(r.Year - year.Value) > 1) // allow a 1 year tolerance on release date + { + Logger.Debug("Result " + matchedName + " released on " + r + " did not match year " + year); + continue; + } + } + } + //matched name and year + return id; + } + + } + } + + return null; + } + + /// <summary> + /// URLs the encode. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.String.</returns> + private static string UrlEncode(string name) + { + return WebUtility.UrlEncode(name); + } + + /// <summary> + /// Gets the boxset id from movie. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="year">The year.</param> + /// <param name="language">The language.</param> + /// <returns>Task{System.String}.</returns> + protected async Task<string> GetBoxsetIdFromMovie(string name, int? year, string language, CancellationToken cancellationToken) + { + string id = null; + string childId = await AttemptFindId(name, year, language, cancellationToken).ConfigureAwait(false); + if (childId != null) + { + string url = string.Format(GetInfo3, childId, ApiKey, language, ItemType); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + var movieResult = JsonSerializer.DeserializeFromStream<CompleteMovieData>(json); + + if (movieResult != null && movieResult.belongs_to_collection != null) + { + id = movieResult.belongs_to_collection.id.ToString(); + } + else + { + Logger.Error("Unable to obtain boxset id."); + } + } + } + catch (HttpException) + { + } + } + return id; + } + + /// <summary> + /// Fetches the movie data. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="id">The id.</param> + /// <returns>Task.</returns> + protected async Task FetchMovieData(BaseItem item, string id, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (String.IsNullOrEmpty(id)) + { + Logger.Info("MoviedbProvider: Ignoring " + item.Name + " because ID forced blank."); + return; + } + if (item.GetProviderId(MetadataProviders.Tmdb) == null) item.SetProviderId(MetadataProviders.Tmdb, id); + var mainTask = FetchMainResult(item, id, cancellationToken); + var castTask = FetchCastInfo(item, id, cancellationToken); + var releaseTask = FetchReleaseInfo(item, id, cancellationToken); + var imageTask = FetchImageInfo(item, id, cancellationToken); + + await Task.WhenAll(mainTask, castTask, releaseTask).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + var mainResult = mainTask.Result; + if (mainResult == null) return; + + if (castTask.Result != null) + { + mainResult.cast = castTask.Result.cast; + mainResult.crew = castTask.Result.crew; + } + + if (releaseTask.Result != null) + { + mainResult.countries = releaseTask.Result.countries; + } + + ProcessMainInfo(item, mainResult); + + await Task.WhenAll(imageTask).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + if (imageTask.Result != null) + { + await ProcessImages(item, imageTask.Result, cancellationToken).ConfigureAwait(false); + } + + //and save locally + if (Kernel.Instance.Configuration.SaveLocalMeta) + { + var ms = new MemoryStream(); + JsonSerializer.SerializeToStream(mainResult, ms); + + cancellationToken.ThrowIfCancellationRequested(); + + await Kernel.Instance.FileSystemManager.SaveToLibraryFilesystem(item, Path.Combine(item.MetaLocation, LOCAL_META_FILE_NAME), ms, cancellationToken).ConfigureAwait(false); + } + } + + /// <summary> + /// Fetches the main result. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="id">The id.</param> + /// <returns>Task{CompleteMovieData}.</returns> + protected async Task<CompleteMovieData> FetchMainResult(BaseItem item, string id, CancellationToken cancellationToken) + { + ItemType = item is BoxSet ? "collection" : "movie"; + string url = string.Format(GetInfo3, id, ApiKey, Kernel.Instance.Configuration.PreferredMetadataLanguage, ItemType); + CompleteMovieData mainResult = null; + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using (var json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + mainResult = JsonSerializer.DeserializeFromStream<CompleteMovieData>(json); + } + } + catch (HttpException e) + { + if (e.IsTimedOut) + { + Logger.ErrorException("MovieDbProvider timed out attempting to retrieve main info for {0}", e, item.Path); + throw new MovieDbProviderException("Timed out on main info"); + } + if (e.StatusCode == HttpStatusCode.NotFound) + { + Logger.ErrorException("MovieDbProvider not found error attempting to retrieve main info for {0}", e, item.Path); + throw new MovieDbProviderException("Not Found"); + } + + throw; + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (mainResult != null && string.IsNullOrEmpty(mainResult.overview)) + { + if (Kernel.Instance.Configuration.PreferredMetadataLanguage.ToLower() != "en") + { + Logger.Info("MovieDbProvider couldn't find meta for language " + Kernel.Instance.Configuration.PreferredMetadataLanguage + ". Trying English..."); + url = string.Format(GetInfo3, id, ApiKey, "en", ItemType); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + mainResult = JsonSerializer.DeserializeFromStream<CompleteMovieData>(json); + } + } + catch (HttpException) + { + } + + if (String.IsNullOrEmpty(mainResult.overview)) + { + Logger.Error("MovieDbProvider - Unable to find information for " + item.Name + " (id:" + id + ")"); + return null; + } + } + } + return mainResult; + } + + /// <summary> + /// Fetches the cast info. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="id">The id.</param> + /// <returns>Task{TmdbCastResult}.</returns> + protected async Task<TmdbCastResult> FetchCastInfo(BaseItem item, string id, CancellationToken cancellationToken) + { + //get cast and crew info + var url = string.Format(CastInfo, id, ApiKey, ItemType); + TmdbCastResult cast = null; + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + cast = JsonSerializer.DeserializeFromStream<TmdbCastResult>(json); + } + } + catch (HttpException) + { + } + return cast; + } + + /// <summary> + /// Fetches the release info. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="id">The id.</param> + /// <returns>Task{TmdbReleasesResult}.</returns> + protected async Task<TmdbReleasesResult> FetchReleaseInfo(BaseItem item, string id, CancellationToken cancellationToken) + { + var url = string.Format(ReleaseInfo, id, ApiKey); + TmdbReleasesResult releases = null; + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + releases = JsonSerializer.DeserializeFromStream<TmdbReleasesResult>(json); + } + } + catch (HttpException) + { + } + + return releases; + } + + /// <summary> + /// Fetches the image info. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="id">The id.</param> + /// <returns>Task{TmdbImages}.</returns> + protected async Task<TmdbImages> FetchImageInfo(BaseItem item, string id, CancellationToken cancellationToken) + { + //fetch images + var url = string.Format(GetImages, id, ApiKey, ItemType); + TmdbImages images = null; + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + images = JsonSerializer.DeserializeFromStream<TmdbImages>(json); + } + } + catch (HttpException) + { + } + return images; + } + + /// <summary> + /// Processes the main info. + /// </summary> + /// <param name="movie">The movie.</param> + /// <param name="movieData">The movie data.</param> + protected virtual void ProcessMainInfo(BaseItem movie, CompleteMovieData movieData) + { + if (movie != null && movieData != null) + { + + movie.Name = movieData.title ?? movieData.original_title ?? movie.Name; + movie.Overview = movieData.overview; + movie.Overview = movie.Overview != null ? movie.Overview.Replace("\n\n", "\n") : null; + if (!string.IsNullOrEmpty(movieData.tagline)) movie.AddTagline(movieData.tagline); + movie.SetProviderId(MetadataProviders.Imdb, movieData.imdb_id); + float rating; + string voteAvg = movieData.vote_average.ToString(); + string cultureStr = Kernel.Instance.Configuration.PreferredMetadataLanguage + "-" + Kernel.Instance.Configuration.MetadataCountryCode; + CultureInfo culture; + try + { + culture = new CultureInfo(cultureStr); + } + catch + { + culture = CultureInfo.CurrentCulture; //default to windows settings if other was invalid + } + Logger.Debug("Culture for numeric conversion is: " + culture.Name); + if (float.TryParse(voteAvg, NumberStyles.AllowDecimalPoint, culture, out rating)) + movie.CommunityRating = rating; + + //release date and certification are retrieved based on configured country and we fall back on US if not there + if (movieData.countries != null) + { + var ourRelease = movieData.countries.FirstOrDefault(c => c.iso_3166_1.Equals(Kernel.Instance.Configuration.MetadataCountryCode, StringComparison.OrdinalIgnoreCase)) ?? new Country(); + var usRelease = movieData.countries.FirstOrDefault(c => c.iso_3166_1.Equals("US", StringComparison.OrdinalIgnoreCase)) ?? new Country(); + + movie.OfficialRating = ourRelease.certification ?? usRelease.certification; + if (ourRelease.release_date > new DateTime(1900, 1, 1)) + { + movie.PremiereDate = ourRelease.release_date; + movie.ProductionYear = ourRelease.release_date.Year; + } + else + { + movie.PremiereDate = usRelease.release_date; + movie.ProductionYear = usRelease.release_date.Year; + } + } + else + { + //no specific country release info at all + movie.PremiereDate = movieData.release_date; + movie.ProductionYear = movieData.release_date.Year; + } + + //if that didn't find a rating and we are a boxset, use the one from our first child + if (movie.OfficialRating == null && movie is BoxSet) + { + var boxset = movie as BoxSet; + Logger.Info("MovieDbProvider - Using rating of first child of boxset..."); + boxset.OfficialRating = !boxset.Children.IsEmpty ? boxset.Children.First().OfficialRating : null; + } + + if (movie.RunTimeTicks == null && movieData.runtime > 0) + movie.RunTimeTicks = TimeSpan.FromMinutes(movieData.runtime).Ticks; + + //studios + if (movieData.production_companies != null) + { + //always clear so they don't double up + movie.AddStudios(movieData.production_companies.Select(c => c.name)); + } + + //genres + if (movieData.genres != null) + { + movie.AddGenres(movieData.genres.Select(g => g.name)); + } + + //Actors, Directors, Writers - all in People + //actors come from cast + if (movieData.cast != null) + { + foreach (var actor in movieData.cast.OrderBy(a => a.order)) movie.AddPerson(new PersonInfo { Name = actor.name, Role = actor.character, Type = PersonType.Actor }); + } + //and the rest from crew + if (movieData.crew != null) + { + foreach (var person in movieData.crew) movie.AddPerson(new PersonInfo { Name = person.name, Role = person.job, Type = person.department }); + } + + + } + + } + + /// <summary> + /// Processes the images. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="images">The images.</param> + /// <returns>Task.</returns> + protected virtual async Task ProcessImages(BaseItem item, TmdbImages images, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // poster + if (images.posters != null && images.posters.Count > 0 && (Kernel.Instance.Configuration.RefreshItemImages || !item.HasLocalImage("folder"))) + { + var tmdbSettings = await TmdbSettings.ConfigureAwait(false); + + var tmdbImageUrl = tmdbSettings.images.base_url + Kernel.Instance.Configuration.TmdbFetchedPosterSize; + // get highest rated poster for our language + + var postersSortedByVote = images.posters.OrderByDescending(i => i.vote_average); + + var poster = postersSortedByVote.FirstOrDefault(p => p.iso_639_1 != null && p.iso_639_1.Equals(Kernel.Instance.Configuration.PreferredMetadataLanguage, StringComparison.OrdinalIgnoreCase)); + if (poster == null && !Kernel.Instance.Configuration.PreferredMetadataLanguage.Equals("en")) + { + // couldn't find our specific language, find english (if that wasn't our language) + poster = postersSortedByVote.FirstOrDefault(p => p.iso_639_1 != null && p.iso_639_1.Equals("en", StringComparison.OrdinalIgnoreCase)); + } + if (poster == null) + { + //still couldn't find it - try highest rated null one + poster = postersSortedByVote.FirstOrDefault(p => p.iso_639_1 == null); + } + if (poster == null) + { + //finally - just get the highest rated one + poster = postersSortedByVote.FirstOrDefault(); + } + if (poster != null) + { + try + { + item.PrimaryImagePath = await Kernel.Instance.ProviderManager.DownloadAndSaveImage(item, tmdbImageUrl + poster.file_path, "folder" + Path.GetExtension(poster.file_path), Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + // backdrops + if (images.backdrops != null && images.backdrops.Count > 0) + { + item.BackdropImagePaths = new List<string>(); + + var tmdbSettings = await TmdbSettings.ConfigureAwait(false); + + var tmdbImageUrl = tmdbSettings.images.base_url + Kernel.Instance.Configuration.TmdbFetchedBackdropSize; + //backdrops should be in order of rating. get first n ones + var numToFetch = Math.Min(Kernel.Instance.Configuration.MaxBackdrops, images.backdrops.Count); + for (var i = 0; i < numToFetch; i++) + { + var bdName = "backdrop" + (i == 0 ? "" : i.ToString()); + + if (Kernel.Instance.Configuration.RefreshItemImages || !item.HasLocalImage(bdName)) + { + try + { + item.BackdropImagePaths.Add(await Kernel.Instance.ProviderManager.DownloadAndSaveImage(item, tmdbImageUrl + images.backdrops[i].file_path, bdName + Path.GetExtension(images.backdrops[i].file_path), Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + } + + + /// <summary> + /// The remove + /// </summary> + const string remove = "\"'!`?"; + // "Face/Off" support. + /// <summary> + /// The spacers + /// </summary> + const string spacers = "/,.:;\\(){}[]+-_=–*"; // (there are not actually two - in the they are different char codes) + /// <summary> + /// The replace start numbers + /// </summary> + static readonly Dictionary<string, string> ReplaceStartNumbers = new Dictionary<string, string> { + {"1 ","one "}, + {"2 ","two "}, + {"3 ","three "}, + {"4 ","four "}, + {"5 ","five "}, + {"6 ","six "}, + {"7 ","seven "}, + {"8 ","eight "}, + {"9 ","nine "}, + {"10 ","ten "}, + {"11 ","eleven "}, + {"12 ","twelve "}, + {"13 ","thirteen "}, + {"100 ","one hundred "}, + {"101 ","one hundred one "} + }; + + /// <summary> + /// The replace end numbers + /// </summary> + static readonly Dictionary<string, string> ReplaceEndNumbers = new Dictionary<string, string> { + {" 1"," i"}, + {" 2"," ii"}, + {" 3"," iii"}, + {" 4"," iv"}, + {" 5"," v"}, + {" 6"," vi"}, + {" 7"," vii"}, + {" 8"," viii"}, + {" 9"," ix"}, + {" 10"," x"} + }; + + /// <summary> + /// Gets the name of the comparable. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="logger">The logger.</param> + /// <returns>System.String.</returns> + internal static string GetComparableName(string name, ILogger logger) + { + name = name.ToLower(); + name = name.Replace("á", "a"); + name = name.Replace("é", "e"); + name = name.Replace("í", "i"); + name = name.Replace("ó", "o"); + name = name.Replace("ú", "u"); + name = name.Replace("ü", "u"); + name = name.Replace("ñ", "n"); + foreach (var pair in ReplaceStartNumbers) + { + if (name.StartsWith(pair.Key)) + { + name = name.Remove(0, pair.Key.Length); + name = pair.Value + name; + logger.Info("MovieDbProvider - Replaced Start Numbers: " + name); + } + } + foreach (var pair in ReplaceEndNumbers) + { + if (name.EndsWith(pair.Key)) + { + name = name.Remove(name.IndexOf(pair.Key), pair.Key.Length); + name = name + pair.Value; + logger.Info("MovieDbProvider - Replaced End Numbers: " + name); + } + } + name = name.Normalize(NormalizationForm.FormKD); + var sb = new StringBuilder(); + foreach (var c in name) + { + if ((int)c >= 0x2B0 && (int)c <= 0x0333) + { + // skip char modifier and diacritics + } + else if (remove.IndexOf(c) > -1) + { + // skip chars we are removing + } + else if (spacers.IndexOf(c) > -1) + { + sb.Append(" "); + } + else if (c == '&') + { + sb.Append(" and "); + } + else + { + sb.Append(c); + } + } + name = sb.ToString(); + name = name.Replace(", the", ""); + name = name.Replace(" the ", " "); + name = name.Replace("the ", ""); + + string prev_name; + do + { + prev_name = name; + name = name.Replace(" ", " "); + } while (name.Length != prev_name.Length); + + return name.Trim(); + } + + #region Result Objects + + + /// <summary> + /// Class TmdbMovieSearchResult + /// </summary> + protected class TmdbMovieSearchResult + { + /// <summary> + /// Gets or sets a value indicating whether this <see cref="TmdbMovieSearchResult" /> is adult. + /// </summary> + /// <value><c>true</c> if adult; otherwise, <c>false</c>.</value> + public bool adult { get; set; } + /// <summary> + /// Gets or sets the backdrop_path. + /// </summary> + /// <value>The backdrop_path.</value> + public string backdrop_path { get; set; } + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the original_title. + /// </summary> + /// <value>The original_title.</value> + public string original_title { get; set; } + /// <summary> + /// Gets or sets the release_date. + /// </summary> + /// <value>The release_date.</value> + public string release_date { get; set; } + /// <summary> + /// Gets or sets the poster_path. + /// </summary> + /// <value>The poster_path.</value> + public string poster_path { get; set; } + /// <summary> + /// Gets or sets the popularity. + /// </summary> + /// <value>The popularity.</value> + public double popularity { get; set; } + /// <summary> + /// Gets or sets the title. + /// </summary> + /// <value>The title.</value> + public string title { get; set; } + /// <summary> + /// Gets or sets the vote_average. + /// </summary> + /// <value>The vote_average.</value> + public double vote_average { get; set; } + /// <summary> + /// Gets or sets the vote_count. + /// </summary> + /// <value>The vote_count.</value> + public int vote_count { get; set; } + } + + /// <summary> + /// Class TmdbMovieSearchResults + /// </summary> + protected class TmdbMovieSearchResults + { + /// <summary> + /// Gets or sets the page. + /// </summary> + /// <value>The page.</value> + public int page { get; set; } + /// <summary> + /// Gets or sets the results. + /// </summary> + /// <value>The results.</value> + public List<TmdbMovieSearchResult> results { get; set; } + /// <summary> + /// Gets or sets the total_pages. + /// </summary> + /// <value>The total_pages.</value> + public int total_pages { get; set; } + /// <summary> + /// Gets or sets the total_results. + /// </summary> + /// <value>The total_results.</value> + public int total_results { get; set; } + } + + /// <summary> + /// Class BelongsToCollection + /// </summary> + protected class BelongsToCollection + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + /// <summary> + /// Gets or sets the poster_path. + /// </summary> + /// <value>The poster_path.</value> + public string poster_path { get; set; } + /// <summary> + /// Gets or sets the backdrop_path. + /// </summary> + /// <value>The backdrop_path.</value> + public string backdrop_path { get; set; } + } + + /// <summary> + /// Class Genre + /// </summary> + protected class Genre + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + } + + /// <summary> + /// Class ProductionCompany + /// </summary> + protected class ProductionCompany + { + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + } + + /// <summary> + /// Class ProductionCountry + /// </summary> + protected class ProductionCountry + { + /// <summary> + /// Gets or sets the iso_3166_1. + /// </summary> + /// <value>The iso_3166_1.</value> + public string iso_3166_1 { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + } + + /// <summary> + /// Class SpokenLanguage + /// </summary> + protected class SpokenLanguage + { + /// <summary> + /// Gets or sets the iso_639_1. + /// </summary> + /// <value>The iso_639_1.</value> + public string iso_639_1 { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + } + + /// <summary> + /// Class Cast + /// </summary> + protected class Cast + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + /// <summary> + /// Gets or sets the character. + /// </summary> + /// <value>The character.</value> + public string character { get; set; } + /// <summary> + /// Gets or sets the order. + /// </summary> + /// <value>The order.</value> + public int order { get; set; } + /// <summary> + /// Gets or sets the profile_path. + /// </summary> + /// <value>The profile_path.</value> + public string profile_path { get; set; } + } + + /// <summary> + /// Class Crew + /// </summary> + protected class Crew + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string name { get; set; } + /// <summary> + /// Gets or sets the department. + /// </summary> + /// <value>The department.</value> + public string department { get; set; } + /// <summary> + /// Gets or sets the job. + /// </summary> + /// <value>The job.</value> + public string job { get; set; } + /// <summary> + /// Gets or sets the profile_path. + /// </summary> + /// <value>The profile_path.</value> + public object profile_path { get; set; } + } + + /// <summary> + /// Class Country + /// </summary> + protected class Country + { + /// <summary> + /// Gets or sets the iso_3166_1. + /// </summary> + /// <value>The iso_3166_1.</value> + public string iso_3166_1 { get; set; } + /// <summary> + /// Gets or sets the certification. + /// </summary> + /// <value>The certification.</value> + public string certification { get; set; } + /// <summary> + /// Gets or sets the release_date. + /// </summary> + /// <value>The release_date.</value> + public DateTime release_date { get; set; } + } + + //protected class TmdbMovieResult + //{ + // public bool adult { get; set; } + // public string backdrop_path { get; set; } + // public int belongs_to_collection { get; set; } + // public int budget { get; set; } + // public List<Genre> genres { get; set; } + // public string homepage { get; set; } + // public int id { get; set; } + // public string imdb_id { get; set; } + // public string original_title { get; set; } + // public string overview { get; set; } + // public double popularity { get; set; } + // public string poster_path { get; set; } + // public List<ProductionCompany> production_companies { get; set; } + // public List<ProductionCountry> production_countries { get; set; } + // public string release_date { get; set; } + // public int revenue { get; set; } + // public int runtime { get; set; } + // public List<SpokenLanguage> spoken_languages { get; set; } + // public string tagline { get; set; } + // public string title { get; set; } + // public double vote_average { get; set; } + // public int vote_count { get; set; } + //} + + /// <summary> + /// Class TmdbTitle + /// </summary> + protected class TmdbTitle + { + /// <summary> + /// Gets or sets the iso_3166_1. + /// </summary> + /// <value>The iso_3166_1.</value> + public string iso_3166_1 { get; set; } + /// <summary> + /// Gets or sets the title. + /// </summary> + /// <value>The title.</value> + public string title { get; set; } + } + + /// <summary> + /// Class TmdbAltTitleResults + /// </summary> + protected class TmdbAltTitleResults + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the titles. + /// </summary> + /// <value>The titles.</value> + public List<TmdbTitle> titles { get; set; } + } + + /// <summary> + /// Class TmdbCastResult + /// </summary> + protected class TmdbCastResult + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the cast. + /// </summary> + /// <value>The cast.</value> + public List<Cast> cast { get; set; } + /// <summary> + /// Gets or sets the crew. + /// </summary> + /// <value>The crew.</value> + public List<Crew> crew { get; set; } + } + + /// <summary> + /// Class TmdbReleasesResult + /// </summary> + protected class TmdbReleasesResult + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the countries. + /// </summary> + /// <value>The countries.</value> + public List<Country> countries { get; set; } + } + + /// <summary> + /// Class TmdbImage + /// </summary> + protected class TmdbImage + { + /// <summary> + /// Gets or sets the file_path. + /// </summary> + /// <value>The file_path.</value> + public string file_path { get; set; } + /// <summary> + /// Gets or sets the width. + /// </summary> + /// <value>The width.</value> + public int width { get; set; } + /// <summary> + /// Gets or sets the height. + /// </summary> + /// <value>The height.</value> + public int height { get; set; } + /// <summary> + /// Gets or sets the iso_639_1. + /// </summary> + /// <value>The iso_639_1.</value> + public string iso_639_1 { get; set; } + /// <summary> + /// Gets or sets the aspect_ratio. + /// </summary> + /// <value>The aspect_ratio.</value> + public double aspect_ratio { get; set; } + /// <summary> + /// Gets or sets the vote_average. + /// </summary> + /// <value>The vote_average.</value> + public double vote_average { get; set; } + /// <summary> + /// Gets or sets the vote_count. + /// </summary> + /// <value>The vote_count.</value> + public int vote_count { get; set; } + } + + /// <summary> + /// Class TmdbImages + /// </summary> + protected class TmdbImages + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int id { get; set; } + /// <summary> + /// Gets or sets the backdrops. + /// </summary> + /// <value>The backdrops.</value> + public List<TmdbImage> backdrops { get; set; } + /// <summary> + /// Gets or sets the posters. + /// </summary> + /// <value>The posters.</value> + public List<TmdbImage> posters { get; set; } + } + + /// <summary> + /// Class CompleteMovieData + /// </summary> + protected class CompleteMovieData + { + public bool adult { get; set; } + public string backdrop_path { get; set; } + public BelongsToCollection belongs_to_collection { get; set; } + public int budget { get; set; } + public List<Genre> genres { get; set; } + public string homepage { get; set; } + public int id { get; set; } + public string imdb_id { get; set; } + public string original_title { get; set; } + public string overview { get; set; } + public double popularity { get; set; } + public string poster_path { get; set; } + public List<ProductionCompany> production_companies { get; set; } + public List<ProductionCountry> production_countries { get; set; } + public DateTime release_date { get; set; } + public int revenue { get; set; } + public int runtime { get; set; } + public List<SpokenLanguage> spoken_languages { get; set; } + public string tagline { get; set; } + public string title { get; set; } + public double vote_average { get; set; } + public int vote_count { get; set; } + public List<Country> countries { get; set; } + public List<Cast> cast { get; set; } + public List<Crew> crew { get; set; } + } + + public class TmdbImageSettings + { + public List<string> backdrop_sizes { get; set; } + public string base_url { get; set; } + public List<string> poster_sizes { get; set; } + public List<string> profile_sizes { get; set; } + } + + public class TmdbSettingsResult + { + public TmdbImageSettings images { get; set; } + } + #endregion + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/MovieProviderFromJson.cs b/MediaBrowser.Controller/Providers/Movies/MovieProviderFromJson.cs new file mode 100644 index 0000000000..ad5f6626be --- /dev/null +++ b/MediaBrowser.Controller/Providers/Movies/MovieProviderFromJson.cs @@ -0,0 +1,100 @@ +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.Movies +{ + /// <summary> + /// Class MovieProviderFromJson + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class MovieProviderFromJson : MovieDbProvider + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get { return false; } + } + + /// <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) + { + var entry = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, LOCAL_META_FILE_NAME)); + return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue; + } + + /// <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 (item.ResolveArgs.ContainsMetaFileByName(ALT_META_FILE_NAME)) + { + return false; // don't read our file if 3rd party data exists + } + + if (!item.ResolveArgs.ContainsMetaFileByName(LOCAL_META_FILE_NAME)) + { + return false; // nothing to read + } + + // Need to re-override to jump over intermediate implementation + return CompareDate(item) > providerInfo.LastRefreshed; + } + + /// <summary> + /// Fetches the async. + /// </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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + // Since we don't have anything truly async, and since deserializing can be expensive, create a task to force parallelism + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + var entry = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, LOCAL_META_FILE_NAME)); + if (entry.HasValue) + { + // read in our saved meta and pass to processing function + var movieData = JsonSerializer.DeserializeFromFile<CompleteMovieData>(entry.Value.Path); + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessMainInfo(item, movieData); + + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + return false; + }); + } + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/MovieProviderFromXml.cs b/MediaBrowser.Controller/Providers/Movies/MovieProviderFromXml.cs index 7ef53d546a..b87c71df3d 100644 --- a/MediaBrowser.Controller/Providers/Movies/MovieProviderFromXml.cs +++ b/MediaBrowser.Controller/Providers/Movies/MovieProviderFromXml.cs @@ -1,43 +1,91 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-using System;
-
-namespace MediaBrowser.Controller.Providers.Movies
-{
- [Export(typeof(BaseMetadataProvider))]
- public class MovieProviderFromXml : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Movie;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- protected override DateTime CompareDate(BaseEntity item)
- {
- var entry = item.ResolveArgs.GetFileSystemEntry(Path.Combine(item.Path, "movie.xml"));
- return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue;
- }
-
- public override async Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- await Task.Run(() => Fetch(item, args)).ConfigureAwait(false);
- }
-
- private void Fetch(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.ContainsFile("movie.xml"))
- {
- new BaseItemXmlParser<Movie>().Fetch(item as Movie, Path.Combine(args.Path, "movie.xml"));
- }
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.Movies +{ + /// <summary> + /// Class MovieProviderFromXml + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class MovieProviderFromXml : BaseMetadataProvider + { + /// <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 is Movie || item is BoxSet; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <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) + { + var entry = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "movie.xml")); + return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue; + } + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return Task.Run(() => Fetch(item, cancellationToken)); + } + + /// <summary> + /// Fetches the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool Fetch(BaseItem item, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var metadataFile = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "movie.xml")); + + if (metadataFile.HasValue) + { + var path = metadataFile.Value.Path; + var boxset = item as BoxSet; + if (boxset != null) + { + new BaseItemXmlParser<BoxSet>().Fetch(boxset, path, cancellationToken); + } + else + { + new BaseItemXmlParser<Movie>().Fetch((Movie)item, path, cancellationToken); + } + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + + return false; + } + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/MovieSpecialFeaturesProvider.cs b/MediaBrowser.Controller/Providers/Movies/MovieSpecialFeaturesProvider.cs deleted file mode 100644 index b6b856d292..0000000000 --- a/MediaBrowser.Controller/Providers/Movies/MovieSpecialFeaturesProvider.cs +++ /dev/null @@ -1,45 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers.Movies
-{
- [Export(typeof(BaseMetadataProvider))]
- public class MovieSpecialFeaturesProvider : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Movie;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public async override Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.ContainsFolder("specials"))
- {
- var items = new List<Video>();
-
- foreach (WIN32_FIND_DATA file in FileData.GetFileSystemEntries(Path.Combine(args.Path, "specials"), "*"))
- {
- var video = await Kernel.Instance.ItemController.GetItem(file.Path, fileInfo: file).ConfigureAwait(false) as Video;
-
- if (video != null)
- {
- items.Add(video);
- }
- }
-
- (item as Movie).SpecialFeatures = items;
- }
- }
- }
-}
diff --git a/MediaBrowser.Controller/Providers/Movies/PersonProviderFromJson.cs b/MediaBrowser.Controller/Providers/Movies/PersonProviderFromJson.cs new file mode 100644 index 0000000000..19a707be30 --- /dev/null +++ b/MediaBrowser.Controller/Providers/Movies/PersonProviderFromJson.cs @@ -0,0 +1,113 @@ +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.Movies +{ + /// <summary> + /// Class PersonProviderFromJson + /// </summary> + [Export(typeof(BaseMetadataProvider))] + class PersonProviderFromJson : TmdbPersonProvider + { + /// <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 is Person; + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return false; + } + } + + // Need to re-override to jump over intermediate implementation + /// <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 (!item.ResolveArgs.ContainsMetaFileByName(MetaFileName)) + { + return false; + } + + return CompareDate(item) > providerInfo.LastRefreshed; + } + + /// <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) + { + var entry = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation,MetaFileName)); + return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get + { + return MetadataProviderPriority.Third; + } + } + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var personInfo = JsonSerializer.DeserializeFromFile<PersonResult>(Path.Combine(item.MetaLocation, MetaFileName)); + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessInfo((Person)item, personInfo); + + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + catch (FileNotFoundException) + { + // This is okay - just means we force refreshed and there isn't a json file + return false; + } + + }); + } + } +} diff --git a/MediaBrowser.Controller/Providers/Movies/TmdbPersonProvider.cs b/MediaBrowser.Controller/Providers/Movies/TmdbPersonProvider.cs new file mode 100644 index 0000000000..4cdfc58940 --- /dev/null +++ b/MediaBrowser.Controller/Providers/Movies/TmdbPersonProvider.cs @@ -0,0 +1,465 @@ +using MediaBrowser.Common.Serialization; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.Movies +{ + /// <summary> + /// Class TmdbPersonProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class TmdbPersonProvider : BaseMetadataProvider + { + /// <summary> + /// The meta file name + /// </summary> + protected const string MetaFileName = "MBPerson.json"; + + /// <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 is Person; + } + + /// <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) + { + //we fetch if either info or image needed and haven't already tried recently + return (string.IsNullOrEmpty(item.PrimaryImagePath) || !item.ResolveArgs.ContainsMetaFileByName(MetaFileName)) + && DateTime.Today.Subtract(providerInfo.LastRefreshed).TotalDays > Kernel.Instance.Configuration.MetadataRefreshDays; + } + + /// <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> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var person = (Person)item; + var tasks = new List<Task>(); + + var id = person.GetProviderId(MetadataProviders.Tmdb); + + // We don't already have an Id, need to fetch it + if (string.IsNullOrEmpty(id)) + { + id = await GetTmdbId(item, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.IsNullOrEmpty(id)) + { + //get info only if not already saved + if (!item.ResolveArgs.ContainsMetaFileByName(MetaFileName)) + { + tasks.Add(FetchInfo(person, id, cancellationToken)); + } + + //get image only if not already there + if (string.IsNullOrEmpty(item.PrimaryImagePath)) + { + tasks.Add(FetchImages(person, id, cancellationToken)); + } + + //and wait for them to complete + await Task.WhenAll(tasks).ConfigureAwait(false); + } + else + { + Logger.Debug("TmdbPersonProvider Unable to obtain id for " + item.Name); + } + + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Second; } + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return true; + } + } + + /// <summary> + /// Gets the TMDB id. + /// </summary> + /// <param name="person">The person.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + private async Task<string> GetTmdbId(BaseItem person, CancellationToken cancellationToken) + { + string url = string.Format(@"http://api.themoviedb.org/3/search/person?api_key={1}&query={0}", WebUtility.UrlEncode(person.Name), MovieDbProvider.ApiKey); + PersonSearchResults searchResult = null; + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + searchResult = JsonSerializer.DeserializeFromStream<PersonSearchResults>(json); + } + } + catch (HttpException) + { + } + + return searchResult != null && searchResult.Total_Results > 0 ? searchResult.Results[0].Id.ToString() : null; + } + + /// <summary> + /// Fetches the info. + /// </summary> + /// <param name="person">The person.</param> + /// <param name="id">The id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task FetchInfo(Person person, string id, CancellationToken cancellationToken) + { + string url = string.Format(@"http://api.themoviedb.org/3/person/{1}?api_key={0}", MovieDbProvider.ApiKey, id); + PersonResult searchResult = null; + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + if (json != null) + { + searchResult = JsonSerializer.DeserializeFromStream<PersonResult>(json); + } + } + } + catch (HttpException) + { + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (searchResult != null && searchResult.Biography != null) + { + ProcessInfo(person, searchResult); + + //save locally + var memoryStream = new MemoryStream(); + + JsonSerializer.SerializeToStream(searchResult, memoryStream); + + await Kernel.Instance.FileSystemManager.SaveToLibraryFilesystem(person, Path.Combine(person.MetaLocation, MetaFileName), memoryStream, cancellationToken); + + Logger.Debug("TmdbPersonProvider downloaded and saved information for {0}", person.Name); + } + } + + /// <summary> + /// Processes the info. + /// </summary> + /// <param name="person">The person.</param> + /// <param name="searchResult">The search result.</param> + protected void ProcessInfo(Person person, PersonResult searchResult) + { + person.Overview = searchResult.Biography; + + DateTime date; + + if (DateTime.TryParseExact(searchResult.Birthday, "yyyy-MM-dd", new CultureInfo("en-US"), DateTimeStyles.None, out date)) + { + person.PremiereDate = date; + } + + person.SetProviderId(MetadataProviders.Tmdb, searchResult.Id.ToString()); + } + + /// <summary> + /// Fetches the images. + /// </summary> + /// <param name="person">The person.</param> + /// <param name="id">The id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task FetchImages(Person person, string id, CancellationToken cancellationToken) + { + string url = string.Format(@"http://api.themoviedb.org/3/person/{1}/images?api_key={0}", MovieDbProvider.ApiKey, id); + + PersonImages searchResult = null; + + try + { + using (Stream json = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + if (json != null) + { + searchResult = JsonSerializer.DeserializeFromStream<PersonImages>(json); + } + } + } + catch (HttpException) + { + } + + if (searchResult != null && searchResult.Profiles.Count > 0) + { + //get our language + var profile = + searchResult.Profiles.FirstOrDefault( + p => + !string.IsNullOrEmpty(p.Iso_639_1) && + p.Iso_639_1.Equals(Kernel.Instance.Configuration.PreferredMetadataLanguage, + StringComparison.OrdinalIgnoreCase)); + if (profile == null) + { + //didn't find our language - try first null one + profile = + searchResult.Profiles.FirstOrDefault( + p => + !string.IsNullOrEmpty(p.Iso_639_1) && + p.Iso_639_1.Equals(Kernel.Instance.Configuration.PreferredMetadataLanguage, + StringComparison.OrdinalIgnoreCase)); + + } + if (profile == null) + { + //still nothing - just get first one + profile = searchResult.Profiles[0]; + } + if (profile != null) + { + var tmdbSettings = await Kernel.Instance.MetadataProviders.OfType<MovieDbProvider>().First().TmdbSettings.ConfigureAwait(false); + + var img = await DownloadAndSaveImage(person, tmdbSettings.images.base_url + Kernel.Instance.Configuration.TmdbFetchedProfileSize + profile.File_Path, + "folder" + Path.GetExtension(profile.File_Path), cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(img)) + { + person.PrimaryImagePath = img; + } + } + } + } + + /// <summary> + /// Downloads the and save image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="source">The source.</param> + /// <param name="targetName">Name of the target.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + private async Task<string> DownloadAndSaveImage(BaseItem item, string source, string targetName, CancellationToken cancellationToken) + { + if (source == null) return null; + + //download and save locally (if not already there) + var localPath = Path.Combine(item.MetaLocation, targetName); + if (!item.ResolveArgs.ContainsMetaFileByName(targetName)) + { + using (var sourceStream = await Kernel.Instance.HttpManager.FetchToMemoryStream(source, Kernel.Instance.ResourcePools.MovieDb, cancellationToken).ConfigureAwait(false)) + { + await Kernel.Instance.FileSystemManager.SaveToLibraryFilesystem(item, localPath, sourceStream, cancellationToken).ConfigureAwait(false); + + Logger.Debug("TmdbPersonProvider downloaded and saved image for {0}", item.Name); + } + } + return localPath; + } + + #region Result Objects + /// <summary> + /// Class PersonSearchResult + /// </summary> + public class PersonSearchResult + { + /// <summary> + /// Gets or sets a value indicating whether this <see cref="PersonSearchResult" /> is adult. + /// </summary> + /// <value><c>true</c> if adult; otherwise, <c>false</c>.</value> + public bool Adult { get; set; } + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int Id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string Name { get; set; } + /// <summary> + /// Gets or sets the profile_ path. + /// </summary> + /// <value>The profile_ path.</value> + public string Profile_Path { get; set; } + } + + /// <summary> + /// Class PersonSearchResults + /// </summary> + public class PersonSearchResults + { + /// <summary> + /// Gets or sets the page. + /// </summary> + /// <value>The page.</value> + public int Page { get; set; } + /// <summary> + /// Gets or sets the results. + /// </summary> + /// <value>The results.</value> + public List<PersonSearchResult> Results { get; set; } + /// <summary> + /// Gets or sets the total_ pages. + /// </summary> + /// <value>The total_ pages.</value> + public int Total_Pages { get; set; } + /// <summary> + /// Gets or sets the total_ results. + /// </summary> + /// <value>The total_ results.</value> + public int Total_Results { get; set; } + } + + /// <summary> + /// Class PersonResult + /// </summary> + public class PersonResult + { + /// <summary> + /// Gets or sets a value indicating whether this <see cref="PersonResult" /> is adult. + /// </summary> + /// <value><c>true</c> if adult; otherwise, <c>false</c>.</value> + public bool Adult { get; set; } + /// <summary> + /// Gets or sets the also_ known_ as. + /// </summary> + /// <value>The also_ known_ as.</value> + public List<object> Also_Known_As { get; set; } + /// <summary> + /// Gets or sets the biography. + /// </summary> + /// <value>The biography.</value> + public string Biography { get; set; } + /// <summary> + /// Gets or sets the birthday. + /// </summary> + /// <value>The birthday.</value> + public string Birthday { get; set; } + /// <summary> + /// Gets or sets the deathday. + /// </summary> + /// <value>The deathday.</value> + public string Deathday { get; set; } + /// <summary> + /// Gets or sets the homepage. + /// </summary> + /// <value>The homepage.</value> + public string Homepage { get; set; } + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int Id { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string Name { get; set; } + /// <summary> + /// Gets or sets the place_ of_ birth. + /// </summary> + /// <value>The place_ of_ birth.</value> + public string Place_Of_Birth { get; set; } + /// <summary> + /// Gets or sets the profile_ path. + /// </summary> + /// <value>The profile_ path.</value> + public string Profile_Path { get; set; } + } + + /// <summary> + /// Class PersonProfile + /// </summary> + public class PersonProfile + { + /// <summary> + /// Gets or sets the aspect_ ratio. + /// </summary> + /// <value>The aspect_ ratio.</value> + public double Aspect_Ratio { get; set; } + /// <summary> + /// Gets or sets the file_ path. + /// </summary> + /// <value>The file_ path.</value> + public string File_Path { get; set; } + /// <summary> + /// Gets or sets the height. + /// </summary> + /// <value>The height.</value> + public int Height { get; set; } + /// <summary> + /// Gets or sets the iso_639_1. + /// </summary> + /// <value>The iso_639_1.</value> + public string Iso_639_1 { get; set; } + /// <summary> + /// Gets or sets the width. + /// </summary> + /// <value>The width.</value> + public int Width { get; set; } + } + + /// <summary> + /// Class PersonImages + /// </summary> + public class PersonImages + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public int Id { get; set; } + /// <summary> + /// Gets or sets the profiles. + /// </summary> + /// <value>The profiles.</value> + public List<PersonProfile> Profiles { get; set; } + } + + #endregion + } +} diff --git a/MediaBrowser.Controller/Providers/ProviderManager.cs b/MediaBrowser.Controller/Providers/ProviderManager.cs new file mode 100644 index 0000000000..0d5d497e8f --- /dev/null +++ b/MediaBrowser.Controller/Providers/ProviderManager.cs @@ -0,0 +1,332 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class ProviderManager + /// </summary> + public class ProviderManager : BaseManager<Kernel> + { + /// <summary> + /// The remote image cache + /// </summary> + private readonly FileSystemRepository _remoteImageCache; + + /// <summary> + /// The currently running metadata providers + /// </summary> + private readonly ConcurrentDictionary<string, Tuple<BaseMetadataProvider, BaseItem, CancellationTokenSource>> _currentlyRunningProviders = + new ConcurrentDictionary<string, Tuple<BaseMetadataProvider, BaseItem, CancellationTokenSource>>(); + + /// <summary> + /// Initializes a new instance of the <see cref="ProviderManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public ProviderManager(Kernel kernel) + : base(kernel) + { + _remoteImageCache = new FileSystemRepository(ImagesDataPath); + } + + /// <summary> + /// The _images data path + /// </summary> + private string _imagesDataPath; + /// <summary> + /// Gets the images data path. + /// </summary> + /// <value>The images data path.</value> + public string ImagesDataPath + { + get + { + if (_imagesDataPath == null) + { + _imagesDataPath = Path.Combine(Kernel.ApplicationPaths.DataPath, "remote-images"); + + if (!Directory.Exists(_imagesDataPath)) + { + Directory.CreateDirectory(_imagesDataPath); + } + } + + return _imagesDataPath; + } + } + + /// <summary> + /// Gets or sets the supported providers key. + /// </summary> + /// <value>The supported providers key.</value> + private Guid SupportedProvidersKey { get; set; } + + /// <summary> + /// Runs all metadata providers for an entity, and returns true or false indicating if at least one was refreshed and requires persistence + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="force">if set to <c>true</c> [force].</param> + /// <param name="allowSlowProviders">if set to <c>true</c> [allow slow providers].</param> + /// <returns>Task{System.Boolean}.</returns> + internal async Task<bool> ExecuteMetadataProviders(BaseItem item, CancellationToken cancellationToken, bool force = false, bool allowSlowProviders = true) + { + // Allow providers of the same priority to execute in parallel + MetadataProviderPriority? currentPriority = null; + var currentTasks = new List<Task<bool>>(); + + var result = false; + + cancellationToken.ThrowIfCancellationRequested(); + + // Determine if supported providers have changed + var supportedProviders = Kernel.MetadataProviders.Where(p => p.Supports(item)).ToList(); + + BaseProviderInfo supportedProvidersInfo; + + if (SupportedProvidersKey == Guid.Empty) + { + SupportedProvidersKey = "SupportedProviders".GetMD5(); + } + + var supportedProvidersHash = string.Join("+", supportedProviders.Select(i => i.GetType().Name)).GetMD5(); + bool providersChanged; + + item.ProviderData.TryGetValue(SupportedProvidersKey, out supportedProvidersInfo); + if (supportedProvidersInfo == null) + { + // First time + supportedProvidersInfo = new BaseProviderInfo { ProviderId = SupportedProvidersKey, FileSystemStamp = supportedProvidersHash }; + providersChanged = force = true; + } + else + { + // Force refresh if the supported providers have changed + providersChanged = force = force || supportedProvidersInfo.FileSystemStamp != supportedProvidersHash; + } + + // If providers have changed, clear provider info and update the supported providers hash + if (providersChanged) + { + Logger.Debug("Providers changed for {0}. Clearing and forcing refresh.", item.Name); + item.ProviderData.Clear(); + supportedProvidersInfo.FileSystemStamp = supportedProvidersHash; + } + + if (force) item.ClearMetaValues(); + + // Run the normal providers sequentially in order of priority + foreach (var provider in supportedProviders) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Skip if internet providers are currently disabled + if (provider.RequiresInternet && !Kernel.Configuration.EnableInternetProviders) + { + continue; + } + + // Skip if is slow and we aren't allowing slow ones + if (provider.IsSlow && !allowSlowProviders) + { + continue; + } + + // Skip if internet provider and this type is not allowed + if (provider.RequiresInternet && Kernel.Configuration.EnableInternetProviders && Kernel.Configuration.InternetProviderExcludeTypes.Contains(item.GetType().Name, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + // When a new priority is reached, await the ones that are currently running and clear the list + if (currentPriority.HasValue && currentPriority.Value != provider.Priority && currentTasks.Count > 0) + { + var results = await Task.WhenAll(currentTasks).ConfigureAwait(false); + result |= results.Contains(true); + + currentTasks.Clear(); + } + + // Put this check below the await because the needs refresh of the next tier of providers may depend on the previous ones running + // This is the case for the fan art provider which depends on the movie and tv providers having run before them + if (!force && !provider.NeedsRefresh(item)) + { + continue; + } + + currentTasks.Add(provider.FetchAsync(item, force, cancellationToken)); + currentPriority = provider.Priority; + } + + if (currentTasks.Count > 0) + { + var results = await Task.WhenAll(currentTasks).ConfigureAwait(false); + result |= results.Contains(true); + } + + if (providersChanged) + { + item.ProviderData[SupportedProvidersKey] = supportedProvidersInfo; + } + + return result || providersChanged; + } + + /// <summary> + /// Notifies the kernal that a provider has begun refreshing + /// </summary> + /// <param name="provider">The provider.</param> + /// <param name="item">The item.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + internal void OnProviderRefreshBeginning(BaseMetadataProvider provider, BaseItem item, CancellationTokenSource cancellationTokenSource) + { + var key = item.Id + provider.GetType().Name; + + Tuple<BaseMetadataProvider, BaseItem, CancellationTokenSource> current; + + if (_currentlyRunningProviders.TryGetValue(key, out current)) + { + try + { + current.Item3.Cancel(); + } + catch (ObjectDisposedException) + { + + } + } + + var tuple = new Tuple<BaseMetadataProvider, BaseItem, CancellationTokenSource>(provider, item, cancellationTokenSource); + + _currentlyRunningProviders.AddOrUpdate(key, tuple, (k, v) => tuple); + } + + /// <summary> + /// Notifies the kernal that a provider has completed refreshing + /// </summary> + /// <param name="provider">The provider.</param> + /// <param name="item">The item.</param> + internal void OnProviderRefreshCompleted(BaseMetadataProvider provider, BaseItem item) + { + var key = item.Id + provider.GetType().Name; + + Tuple<BaseMetadataProvider, BaseItem, CancellationTokenSource> current; + + if (_currentlyRunningProviders.TryRemove(key, out current)) + { + current.Item3.Dispose(); + } + } + + /// <summary> + /// Validates the currently running providers and cancels any that should not be run due to configuration changes + /// </summary> + internal void ValidateCurrentlyRunningProviders() + { + Logger.Info("Validing currently running providers"); + + var enableInternetProviders = Kernel.Configuration.EnableInternetProviders; + var internetProviderExcludeTypes = Kernel.Configuration.InternetProviderExcludeTypes; + + foreach (var tuple in _currentlyRunningProviders.Values + .Where(p => p.Item1.RequiresInternet && (!enableInternetProviders || internetProviderExcludeTypes.Contains(p.Item2.GetType().Name, StringComparer.OrdinalIgnoreCase))) + .ToList()) + { + tuple.Item3.Cancel(); + } + } + + /// <summary> + /// Downloads the and save image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="source">The source.</param> + /// <param name="targetName">Name of the target.</param> + /// <param name="resourcePool">The resource pool.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + public async Task<string> DownloadAndSaveImage(BaseItem item, string source, string targetName, SemaphoreSlim resourcePool, CancellationToken cancellationToken) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + if (string.IsNullOrEmpty(source)) + { + throw new ArgumentNullException("source"); + } + if (string.IsNullOrEmpty(targetName)) + { + throw new ArgumentNullException("targetName"); + } + if (resourcePool == null) + { + throw new ArgumentNullException("resourcePool"); + } + + //download and save locally + var localPath = Kernel.Configuration.SaveLocalMeta ? + Path.Combine(item.MetaLocation, targetName) : + _remoteImageCache.GetResourcePath(item.GetType().FullName + item.Path.ToLower(), targetName); + + var img = await Kernel.HttpManager.FetchToMemoryStream(source, resourcePool, cancellationToken).ConfigureAwait(false); + + if (Kernel.Configuration.SaveLocalMeta) // queue to media directories + { + await Kernel.FileSystemManager.SaveToLibraryFilesystem(item, localPath, img, cancellationToken).ConfigureAwait(false); + } + else + { + // we can write directly here because it won't affect the watchers + + try + { + using (var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) + { + await img.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.ErrorException("Error downloading and saving image " + localPath, e); + throw; + } + finally + { + img.Dispose(); + } + + } + return localPath; + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + _remoteImageCache.Dispose(); + } + + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/Providers/SortNameProvider.cs b/MediaBrowser.Controller/Providers/SortNameProvider.cs new file mode 100644 index 0000000000..071732f3e3 --- /dev/null +++ b/MediaBrowser.Controller/Providers/SortNameProvider.cs @@ -0,0 +1,129 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Class SortNameProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class SortNameProvider : BaseMetadataProvider + { + /// <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 true; + } + + /// <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) + { + return !string.IsNullOrEmpty(item.Name) && string.IsNullOrEmpty(item.SortName); + } + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return SetSortName(item, cancellationToken) ? TrueTaskResult : FalseTaskResult; + } + + /// <summary> + /// Sets the name of the sort. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected bool SetSortName(BaseItem item, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(item.SortName)) return false; //let the earlier provider win + + cancellationToken.ThrowIfCancellationRequested(); + + if (item is Episode) + { + //special handling for TV episodes season and episode number + item.SortName = (item.ParentIndexNumber != null ? item.ParentIndexNumber.Value.ToString("000-") : "") + + (item.IndexNumber != null ? item.IndexNumber.Value.ToString("0000 - ") : "") + item.Name; + + } + else if (item is Season) + { + //sort seasons by season number - numerically + item.SortName = item.IndexNumber != null ? item.IndexNumber.Value.ToString("0000") : item.Name; + } + else if (item is Audio) + { + //sort tracks by production year and index no so they will sort in order if in a multi-album list + item.SortName = (item.ProductionYear != null ? item.ProductionYear.Value.ToString("000-") : "") + + (item.IndexNumber != null ? item.IndexNumber.Value.ToString("0000 - ") : "") + item.Name; + } + else if (item is MusicAlbum) + { + //sort albums by year + item.SortName = item.ProductionYear != null ? item.ProductionYear.Value.ToString("0000") : item.Name; + } + else + { + if (item.Name == null) return false; //some items may not have name filled in properly + + var sortable = item.Name.Trim().ToLower(); + sortable = Kernel.Instance.Configuration.SortRemoveCharacters.Aggregate(sortable, (current, search) => current.Replace(search.ToLower(), string.Empty)); + + sortable = Kernel.Instance.Configuration.SortReplaceCharacters.Aggregate(sortable, (current, search) => current.Replace(search.ToLower(), " ")); + + foreach (var search in Kernel.Instance.Configuration.SortRemoveWords) + { + cancellationToken.ThrowIfCancellationRequested(); + + var searchLower = search.ToLower(); + // Remove from beginning if a space follows + if (sortable.StartsWith(searchLower + " ")) + { + sortable = sortable.Remove(0, searchLower.Length + 1); + } + // Remove from middle if surrounded by spaces + sortable = sortable.Replace(" " + searchLower + " ", " "); + + // Remove from end if followed by a space + if (sortable.EndsWith(" " + searchLower)) + { + sortable = sortable.Remove(sortable.Length - (searchLower.Length + 1)); + } + } + item.SortName = sortable; + } + + return true; + } + + } +} diff --git a/MediaBrowser.Controller/Providers/TV/EpisodeImageFromMediaLocationProvider.cs b/MediaBrowser.Controller/Providers/TV/EpisodeImageFromMediaLocationProvider.cs index 0b9cf85ebe..a493ce746f 100644 --- a/MediaBrowser.Controller/Providers/TV/EpisodeImageFromMediaLocationProvider.cs +++ b/MediaBrowser.Controller/Providers/TV/EpisodeImageFromMediaLocationProvider.cs @@ -1,67 +1,127 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers.TV
-{
- [Export(typeof(BaseMetadataProvider))]
- public class EpisodeImageFromMediaLocationProvider : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Episode;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public override Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- return Task.Run(() =>
- {
- var episode = item as Episode;
-
- string metadataFolder = Path.Combine(args.Parent.Path, "metadata");
-
- string episodeFileName = Path.GetFileName(episode.Path);
-
- var season = args.Parent as Season;
-
- SetPrimaryImagePath(episode, season, metadataFolder, episodeFileName);
- });
- }
-
- private void SetPrimaryImagePath(Episode item, Season season, string metadataFolder, string episodeFileName)
- {
- // Look for the image file in the metadata folder, and if found, set PrimaryImagePath
- var imageFiles = new string[] {
- Path.Combine(metadataFolder, Path.ChangeExtension(episodeFileName, ".jpg")),
- Path.Combine(metadataFolder, Path.ChangeExtension(episodeFileName, ".png"))
- };
-
- string image;
-
- if (season == null)
- {
- // Epsiode directly in Series folder. Gotta do this the slow way
- image = imageFiles.FirstOrDefault(f => File.Exists(f));
- }
- else
- {
- image = imageFiles.FirstOrDefault(f => season.ContainsMetadataFile(f));
- }
-
- // If we found something, set PrimaryImagePath
- if (!string.IsNullOrEmpty(image))
- {
- item.PrimaryImagePath = image;
- }
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class EpisodeImageFromMediaLocationProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class EpisodeImageFromMediaLocationProvider : BaseMetadataProvider + { + /// <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 is Episode && item.LocationType == LocationType.FileSystem; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <summary> + /// Returns true or false indicating if the provider should refresh when the contents of it's directory changes + /// </summary> + /// <value><c>true</c> if [refresh on file system stamp change]; otherwise, <c>false</c>.</value> + protected override bool RefreshOnFileSystemStampChange + { + get + { + return 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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var episode = (Episode)item; + + var episodeFileName = Path.GetFileName(episode.Path); + + var parent = item.ResolveArgs.Parent; + + ValidateImage(episode, item.MetaLocation); + + cancellationToken.ThrowIfCancellationRequested(); + + SetPrimaryImagePath(episode, parent, item.MetaLocation, episodeFileName); + + SetLastRefreshed(item, DateTime.UtcNow); + return TrueTaskResult; + } + + /// <summary> + /// Validates the primary image path still exists + /// </summary> + /// <param name="episode">The episode.</param> + /// <param name="metadataFolderPath">The metadata folder path.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private void ValidateImage(Episode episode, string metadataFolderPath) + { + var path = episode.PrimaryImagePath; + + if (string.IsNullOrEmpty(path)) + { + return; + } + + // Only validate images in the season/metadata folder + if (!string.Equals(Path.GetDirectoryName(path), metadataFolderPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!episode.Parent.ResolveArgs.GetMetaFileByPath(path).HasValue) + { + episode.PrimaryImagePath = null; + } + } + + /// <summary> + /// Sets the primary image path. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="parent">The parent.</param> + /// <param name="metadataFolder">The metadata folder.</param> + /// <param name="episodeFileName">Name of the episode file.</param> + private void SetPrimaryImagePath(Episode item, Folder parent, string metadataFolder, string episodeFileName) + { + // Look for the image file in the metadata folder, and if found, set PrimaryImagePath + var imageFiles = new[] { + Path.Combine(metadataFolder, Path.ChangeExtension(episodeFileName, ".jpg")), + Path.Combine(metadataFolder, Path.ChangeExtension(episodeFileName, ".png")) + }; + + var file = parent.ResolveArgs.GetMetaFileByPath(imageFiles[0]) ?? + parent.ResolveArgs.GetMetaFileByPath(imageFiles[1]); + + if (file.HasValue) + { + item.PrimaryImagePath = file.Value.Path; + } + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/EpisodeProviderFromXml.cs b/MediaBrowser.Controller/Providers/TV/EpisodeProviderFromXml.cs index f3c19a7048..df46b7167f 100644 --- a/MediaBrowser.Controller/Providers/TV/EpisodeProviderFromXml.cs +++ b/MediaBrowser.Controller/Providers/TV/EpisodeProviderFromXml.cs @@ -1,59 +1,122 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers.TV
-{
- [Export(typeof(BaseMetadataProvider))]
- public class EpisodeProviderFromXml : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Episode;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public override async Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- await Task.Run(() => Fetch(item, args)).ConfigureAwait(false);
- }
-
- private void Fetch(BaseEntity item, ItemResolveEventArgs args)
- {
- string metadataFolder = Path.Combine(args.Parent.Path, "metadata");
-
- string metadataFile = Path.Combine(metadataFolder, Path.ChangeExtension(Path.GetFileName(args.Path), ".xml"));
-
- FetchMetadata(item as Episode, args.Parent as Season, metadataFile);
- }
-
- private void FetchMetadata(Episode item, Season season, string metadataFile)
- {
- if (season == null)
- {
- // Episode directly in Series folder
- // Need to validate it the slow way
- if (!File.Exists(metadataFile))
- {
- return;
- }
- }
- else
- {
- if (!season.ContainsMetadataFile(metadataFile))
- {
- return;
- }
- }
-
- new EpisodeXmlParser().Fetch(item, metadataFile);
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class EpisodeProviderFromXml + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class EpisodeProviderFromXml : BaseMetadataProvider + { + /// <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 is Episode && item.LocationType == LocationType.FileSystem; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + protected override bool RefreshOnFileSystemStampChange + { + get + { + return 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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return Task.Run(() => Fetch(item, cancellationToken)); + } + + /// <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) + { + var metadataFile = Path.Combine(item.MetaLocation, Path.ChangeExtension(Path.GetFileName(item.Path), ".xml")); + + var file = item.ResolveArgs.Parent.ResolveArgs.GetMetaFileByPath(metadataFile); + + if (!file.HasValue) + { + return base.CompareDate(item); + } + + return file.Value.LastWriteTimeUtc; + } + + /// <summary> + /// Fetches the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool Fetch(BaseItem item, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var metadataFile = Path.Combine(item.MetaLocation, Path.ChangeExtension(Path.GetFileName(item.Path), ".xml")); + + var episode = (Episode)item; + + if (!FetchMetadata(episode, item.ResolveArgs.Parent, metadataFile, cancellationToken)) + { + // Don't set last refreshed if we didn't do anything + return false; + } + + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + + /// <summary> + /// Fetches the metadata. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="parent">The parent.</param> + /// <param name="metadataFile">The metadata file.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool FetchMetadata(Episode item, Folder parent, string metadataFile, CancellationToken cancellationToken) + { + var file = parent.ResolveArgs.GetMetaFileByPath(metadataFile); + + if (!file.HasValue) + { + return false; + } + + new EpisodeXmlParser().Fetch(item, metadataFile, cancellationToken); + return true; + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/EpisodeXmlParser.cs b/MediaBrowser.Controller/Providers/TV/EpisodeXmlParser.cs index 1cb604a51c..7133d87451 100644 --- a/MediaBrowser.Controller/Providers/TV/EpisodeXmlParser.cs +++ b/MediaBrowser.Controller/Providers/TV/EpisodeXmlParser.cs @@ -1,60 +1,104 @@ -using MediaBrowser.Controller.Entities.TV;
-using System.IO;
-using System.Xml;
-
-namespace MediaBrowser.Controller.Providers.TV
-{
- public class EpisodeXmlParser : BaseItemXmlParser<Episode>
- {
- protected override void FetchDataFromXmlNode(XmlReader reader, Episode item)
- {
- switch (reader.Name)
- {
- case "filename":
- {
- string filename = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(filename))
- {
- // Strip off everything but the filename. Some metadata tools like MetaBrowser v1.0 will have an 'episodes' prefix
- // even though it's actually using the metadata folder.
- filename = Path.GetFileName(filename);
-
- string seasonFolder = Path.GetDirectoryName(item.Path);
- item.PrimaryImagePath = Path.Combine(seasonFolder, "metadata", filename);
- }
- break;
- }
- case "SeasonNumber":
- {
- string number = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(number))
- {
- item.ParentIndexNumber = int.Parse(number);
- }
- break;
- }
-
- case "EpisodeNumber":
- {
- string number = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(number))
- {
- item.IndexNumber = int.Parse(number);
- }
- break;
- }
-
- case "EpisodeName":
- item.Name = reader.ReadElementContentAsString();
- break;
-
- default:
- base.FetchDataFromXmlNode(reader, item);
- break;
- }
- }
- }
-}
+using MediaBrowser.Controller.Entities.TV; +using System.IO; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class EpisodeXmlParser + /// </summary> + public class EpisodeXmlParser : BaseItemXmlParser<Episode> + { + /// <summary> + /// Fetches the data from XML node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + protected override void FetchDataFromXmlNode(XmlReader reader, Episode item) + { + switch (reader.Name) + { + case "Episode": + //MB generated metadata is within an "Episode" node + using (var subTree = reader.ReadSubtree()) + { + subTree.MoveToContent(); + + // Loop through each element + while (subTree.Read()) + { + if (subTree.NodeType == XmlNodeType.Element) + { + FetchDataFromXmlNode(subTree, item); + } + } + + } + break; + + case "filename": + { + string filename = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(filename)) + { + // Strip off everything but the filename. Some metadata tools like MetaBrowser v1.0 will have an 'episodes' prefix + // even though it's actually using the metadata folder. + filename = Path.GetFileName(filename); + + string seasonFolder = Path.GetDirectoryName(item.Path); + item.PrimaryImagePath = Path.Combine(seasonFolder, "metadata", filename); + } + break; + } + case "SeasonNumber": + { + var number = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(number)) + { + int num; + + if (int.TryParse(number, out num)) + { + item.ParentIndexNumber = num; + } + } + break; + } + + case "EpisodeNumber": + { + var number = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(number)) + { + int num; + + if (int.TryParse(number, out num)) + { + item.IndexNumber = num; + } + } + break; + } + + case "EpisodeName": + { + var name = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(name)) + { + item.Name = name; + } + break; + } + + + default: + base.FetchDataFromXmlNode(reader, item); + break; + } + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/FanArtTVProvider.cs b/MediaBrowser.Controller/Providers/TV/FanArtTVProvider.cs new file mode 100644 index 0000000000..2640e04825 --- /dev/null +++ b/MediaBrowser.Controller/Providers/TV/FanArtTVProvider.cs @@ -0,0 +1,140 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + [Export(typeof(BaseMetadataProvider))] + class FanArtTVProvider : FanartBaseProvider + { + protected string FanArtBaseUrl = "http://api.fanart.tv/webservice/series/{0}/{1}/xml/all/1/1"; + + public override bool Supports(BaseItem item) + { + return item is Series; + } + + protected override bool ShouldFetch(BaseItem item, BaseProviderInfo providerInfo) + { + if (item.DontFetchMeta || string.IsNullOrEmpty(item.GetProviderId(MetadataProviders.Tvdb))) return false; //nothing to do + var artExists = item.ResolveArgs.ContainsMetaFileByName(ART_FILE); + var logoExists = item.ResolveArgs.ContainsMetaFileByName(LOGO_FILE); + var thumbExists = item.ResolveArgs.ContainsMetaFileByName(THUMB_FILE); + + + return (!artExists && Kernel.Instance.Configuration.DownloadTVArt) + || (!logoExists && Kernel.Instance.Configuration.DownloadTVLogo) + || (!thumbExists && Kernel.Instance.Configuration.DownloadTVThumb); + } + + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var series = (Series)item; + if (ShouldFetch(series, series.ProviderData.GetValueOrDefault(Id, new BaseProviderInfo { ProviderId = Id }))) + { + string language = Kernel.Instance.Configuration.PreferredMetadataLanguage.ToLower(); + string url = string.Format(FanArtBaseUrl, APIKey, series.GetProviderId(MetadataProviders.Tvdb)); + var doc = new XmlDocument(); + + try + { + using (var xml = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)) + { + doc.Load(xml); + } + } + catch (HttpException) + { + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (doc.HasChildNodes) + { + string path; + if (Kernel.Instance.Configuration.DownloadTVLogo && !series.ResolveArgs.ContainsMetaFileByName(LOGO_FILE)) + { + var node = doc.SelectSingleNode("//fanart/series/clearlogos/clearlogo[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/series/clearlogos/clearlogo/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting ClearLogo for " + series.Name); + try + { + series.SetImage(ImageType.Logo, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, path, LOGO_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadTVArt && !series.ResolveArgs.ContainsMetaFileByName(ART_FILE)) + { + var node = doc.SelectSingleNode("//fanart/series/cleararts/clearart[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/series/cleararts/clearart/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting ClearArt for " + series.Name); + try + { + series.SetImage(ImageType.Art, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, path, ART_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Kernel.Instance.Configuration.DownloadTVThumb && !series.ResolveArgs.ContainsMetaFileByName(THUMB_FILE)) + { + var node = doc.SelectSingleNode("//fanart/series/tvthumbs/tvthumb[@lang = \"" + language + "\"]/@url") ?? + doc.SelectSingleNode("//fanart/series/tvthumbs/tvthumb/@url"); + path = node != null ? node.Value : null; + if (!string.IsNullOrEmpty(path)) + { + Logger.Debug("FanArtProvider getting ThumbArt for " + series.Name); + try + { + series.SetImage(ImageType.Disc, await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, path, THUMB_FILE, Kernel.Instance.ResourcePools.FanArt, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + } + SetLastRefreshed(series, DateTime.UtcNow); + return true; + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/RemoteEpisodeProvider.cs b/MediaBrowser.Controller/Providers/TV/RemoteEpisodeProvider.cs new file mode 100644 index 0000000000..dbe744ed40 --- /dev/null +++ b/MediaBrowser.Controller/Providers/TV/RemoteEpisodeProvider.cs @@ -0,0 +1,288 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Resolvers.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + + /// <summary> + /// Class RemoteEpisodeProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + class RemoteEpisodeProvider : BaseMetadataProvider + { + + /// <summary> + /// The episode query + /// </summary> + private const string episodeQuery = "http://www.thetvdb.com/api/{0}/series/{1}/default/{2}/{3}/{4}.xml"; + /// <summary> + /// The abs episode query + /// </summary> + private const string absEpisodeQuery = "http://www.thetvdb.com/api/{0}/series/{1}/absolute/{2}/{3}.xml"; + + /// <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 is Episode; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Second; } + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get { return true; } + } + + protected override bool RefreshOnFileSystemStampChange + { + get + { + return true; + } + } + + /// <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) + { + bool fetch = false; + var episode = (Episode)item; + var downloadDate = providerInfo.LastRefreshed; + + if (Kernel.Instance.Configuration.MetadataRefreshDays == -1 && downloadDate != DateTime.MinValue) + { + return false; + } + + if (!item.DontFetchMeta && !HasLocalMeta(episode)) + { + fetch = Kernel.Instance.Configuration.MetadataRefreshDays != -1 && + DateTime.Today.Subtract(downloadDate).TotalDays > Kernel.Instance.Configuration.MetadataRefreshDays; + } + + return fetch; + } + + /// <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> + /// <returns>Task{System.Boolean}.</returns> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var episode = (Episode)item; + if (!item.DontFetchMeta && !HasLocalMeta(episode)) + { + var seriesId = episode.Series != null ? episode.Series.GetProviderId(MetadataProviders.Tvdb) : null; + + if (seriesId != null) + { + await FetchEpisodeData(episode, seriesId, cancellationToken).ConfigureAwait(false); + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + Logger.Info("Episode provider cannot determine Series Id for " + item.Path); + return false; + } + Logger.Info("Episode provider not fetching because local meta exists or requested to ignore: " + item.Name); + return false; + } + + + /// <summary> + /// Fetches the episode data. + /// </summary> + /// <param name="episode">The episode.</param> + /// <param name="seriesId">The series id.</param> + /// <returns>Task{System.Boolean}.</returns> + private async Task<bool> FetchEpisodeData(Episode episode, string seriesId, CancellationToken cancellationToken) + { + + string name = episode.Name; + string location = episode.Path; + + Logger.Debug("TvDbProvider: Fetching episode data for: " + name); + string epNum = TVUtils.EpisodeNumberFromFile(location, episode.Season != null); + + if (epNum == null) + { + Logger.Warn("TvDbProvider: Could not determine episode number for: " + episode.Path); + return false; + } + + var episodeNumber = Int32.Parse(epNum); + + episode.IndexNumber = episodeNumber; + var usingAbsoluteData = false; + + if (string.IsNullOrEmpty(seriesId)) return false; + + var seasonNumber = ""; + if (episode.Parent is Season) + { + seasonNumber = episode.Parent.IndexNumber.ToString(); + } + + if (string.IsNullOrEmpty(seasonNumber)) + seasonNumber = TVUtils.SeasonNumberFromEpisodeFile(location); // try and extract the season number from the file name for S1E1, 1x04 etc. + + if (!string.IsNullOrEmpty(seasonNumber)) + { + seasonNumber = seasonNumber.TrimStart('0'); + + if (string.IsNullOrEmpty(seasonNumber)) + { + seasonNumber = "0"; // Specials + } + + var url = string.Format(episodeQuery, TVUtils.TVDBApiKey, seriesId, seasonNumber, episodeNumber, Kernel.Instance.Configuration.PreferredMetadataLanguage); + var doc = new XmlDocument(); + + try + { + using (var result = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + doc.Load(result); + } + } + catch (HttpException) + { + } + + //episode does not exist under this season, try absolute numbering. + //still assuming it's numbered as 1x01 + //this is basicly just for anime. + if (!doc.HasChildNodes && Int32.Parse(seasonNumber) == 1) + { + url = string.Format(absEpisodeQuery, TVUtils.TVDBApiKey, seriesId, episodeNumber, Kernel.Instance.Configuration.PreferredMetadataLanguage); + + try + { + using (var result = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + if (result != null) doc.Load(result); + usingAbsoluteData = true; + } + } + catch (HttpException) + { + } + } + + if (doc.HasChildNodes) + { + var p = doc.SafeGetString("//filename"); + if (p != null) + { + if (!Directory.Exists(episode.MetaLocation)) Directory.CreateDirectory(episode.MetaLocation); + + try + { + episode.PrimaryImagePath = await Kernel.Instance.ProviderManager.DownloadAndSaveImage(episode, TVUtils.BannerUrl + p, Path.GetFileName(p), Kernel.Instance.ResourcePools.TvDb, cancellationToken); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + + episode.Overview = doc.SafeGetString("//Overview"); + if (usingAbsoluteData) + episode.IndexNumber = doc.SafeGetInt32("//absolute_number", -1); + if (episode.IndexNumber < 0) + episode.IndexNumber = doc.SafeGetInt32("//EpisodeNumber"); + + episode.Name = episode.IndexNumber.Value.ToString("000") + " - " + doc.SafeGetString("//EpisodeName"); + episode.CommunityRating = doc.SafeGetSingle("//Rating", -1, 10); + var firstAired = doc.SafeGetString("//FirstAired"); + DateTime airDate; + if (DateTime.TryParse(firstAired, out airDate) && airDate.Year > 1850) + { + episode.PremiereDate = airDate.ToUniversalTime(); + episode.ProductionYear = airDate.Year; + } + + var actors = doc.SafeGetString("//GuestStars"); + if (actors != null) + { + episode.AddPeople(actors.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).Select(str => new PersonInfo { Type = "Actor", Name = str })); + } + + + var directors = doc.SafeGetString("//Director"); + if (directors != null) + { + episode.AddPeople(directors.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).Select(str => new PersonInfo { Type = "Director", Name = str })); + } + + + var writers = doc.SafeGetString("//Writer"); + if (writers != null) + { + episode.AddPeople(writers.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).Select(str => new PersonInfo { Type = "Writer", Name = str })); + } + + if (Kernel.Instance.Configuration.SaveLocalMeta) + { + if (!Directory.Exists(episode.MetaLocation)) Directory.CreateDirectory(episode.MetaLocation); + var ms = new MemoryStream(); + doc.Save(ms); + + await Kernel.Instance.FileSystemManager.SaveToLibraryFilesystem(episode, Path.Combine(episode.MetaLocation, Path.GetFileNameWithoutExtension(episode.Path) + ".xml"), ms, cancellationToken).ConfigureAwait(false); + } + + return true; + } + + } + + return false; + } + + /// <summary> + /// Determines whether [has local meta] [the specified episode]. + /// </summary> + /// <param name="episode">The episode.</param> + /// <returns><c>true</c> if [has local meta] [the specified episode]; otherwise, <c>false</c>.</returns> + private bool HasLocalMeta(Episode episode) + { + return (episode.Parent.ResolveArgs.ContainsMetaFileByName(Path.GetFileNameWithoutExtension(episode.Path) + ".xml")); + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/RemoteSeasonProvider.cs b/MediaBrowser.Controller/Providers/TV/RemoteSeasonProvider.cs new file mode 100644 index 0000000000..0d6cc41b8d --- /dev/null +++ b/MediaBrowser.Controller/Providers/TV/RemoteSeasonProvider.cs @@ -0,0 +1,287 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Resolvers.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class RemoteSeasonProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + class RemoteSeasonProvider : BaseMetadataProvider + { + + /// <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 is Season; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Second; } + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return true; + } + } + + /// <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) + { + bool fetch = false; + var downloadDate = providerInfo.LastRefreshed; + + if (Kernel.Instance.Configuration.MetadataRefreshDays == -1 && downloadDate != DateTime.MinValue) + return false; + + if (!HasLocalMeta(item)) + { + fetch = Kernel.Instance.Configuration.MetadataRefreshDays != -1 && + DateTime.UtcNow.Subtract(downloadDate).TotalDays > Kernel.Instance.Configuration.MetadataRefreshDays; + } + + return fetch; + } + + /// <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> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var season = (Season)item; + + if (!HasLocalMeta(item)) + { + var seriesId = season.Series != null ? season.Series.GetProviderId(MetadataProviders.Tvdb) : null; + + if (seriesId != null) + { + await FetchSeasonData(season, seriesId, cancellationToken).ConfigureAwait(false); + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + Logger.Info("Season provider unable to obtain series id for {0}", item.Path); + } + else + { + Logger.Info("Season provider not fetching because local meta exists: " + season.Name); + } + return false; + } + + + /// <summary> + /// Fetches the season data. + /// </summary> + /// <param name="season">The season.</param> + /// <param name="seriesId">The series id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + private async Task<bool> FetchSeasonData(Season season, string seriesId, CancellationToken cancellationToken) + { + string name = season.Name; + + Logger.Debug("TvDbProvider: Fetching season data: " + name); + var seasonNumber = TVUtils.GetSeasonNumberFromPath(season.Path) ?? -1; + + season.IndexNumber = seasonNumber; + + if (seasonNumber == 0) + { + season.Name = "Specials"; + } + + if (!string.IsNullOrEmpty(seriesId)) + { + if ((season.PrimaryImagePath == null) || (!season.HasImage(ImageType.Banner)) || (season.BackdropImagePaths == null)) + { + var images = new XmlDocument(); + var url = string.Format("http://www.thetvdb.com/api/" + TVUtils.TVDBApiKey + "/series/{0}/banners.xml", seriesId); + + try + { + using (var imgs = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + images.Load(imgs); + } + } + catch (HttpException) + { + } + + if (images.HasChildNodes) + { + if (Kernel.Instance.Configuration.RefreshItemImages || !season.HasLocalImage("folder")) + { + var n = images.SelectSingleNode("//Banner[BannerType='season'][BannerType2='season'][Season='" + seasonNumber + "']"); + if (n != null) + { + n = n.SelectSingleNode("./BannerPath"); + + try + { + if (n != null) + season.PrimaryImagePath = await Kernel.Instance.ProviderManager.DownloadAndSaveImage(season, TVUtils.BannerUrl + n.InnerText, "folder" + Path.GetExtension(n.InnerText), Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + + if (Kernel.Instance.Configuration.DownloadTVSeasonBanner && (Kernel.Instance.Configuration.RefreshItemImages || !season.HasLocalImage("banner"))) + { + var n = images.SelectSingleNode("//Banner[BannerType='season'][BannerType2='seasonwide'][Season='" + seasonNumber + "']"); + if (n != null) + { + n = n.SelectSingleNode("./BannerPath"); + if (n != null) + { + try + { + var bannerImagePath = + await + Kernel.Instance.ProviderManager.DownloadAndSaveImage(season, + TVUtils.BannerUrl + n.InnerText, + "banner" + + Path.GetExtension(n.InnerText), + Kernel.Instance.ResourcePools.TvDb, cancellationToken). + ConfigureAwait(false); + + season.SetImage(ImageType.Banner, bannerImagePath); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + + if (Kernel.Instance.Configuration.DownloadTVSeasonBackdrops && (Kernel.Instance.Configuration.RefreshItemImages || !season.HasLocalImage("backdrop"))) + { + var n = images.SelectSingleNode("//Banner[BannerType='fanart'][Season='" + seasonNumber + "']"); + if (n != null) + { + n = n.SelectSingleNode("./BannerPath"); + if (n != null) + { + try + { + if (season.BackdropImagePaths == null) season.BackdropImagePaths = new List<string>(); + season.BackdropImagePaths.Add(await Kernel.Instance.ProviderManager.DownloadAndSaveImage(season, TVUtils.BannerUrl + n.InnerText, "backdrop" + Path.GetExtension(n.InnerText), Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + else if (!Kernel.Instance.Configuration.SaveLocalMeta) //if saving local - season will inherit from series + { + // not necessarily accurate but will give a different bit of art to each season + var lst = images.SelectNodes("//Banner[BannerType='fanart']"); + if (lst != null && lst.Count > 0) + { + var num = seasonNumber % lst.Count; + n = lst[num]; + n = n.SelectSingleNode("./BannerPath"); + if (n != null) + { + if (season.BackdropImagePaths == null) + season.BackdropImagePaths = new List<string>(); + + try + { + season.BackdropImagePaths.Add( + await + Kernel.Instance.ProviderManager.DownloadAndSaveImage(season, + TVUtils.BannerUrl + + n.InnerText, + "backdrop" + + Path.GetExtension( + n.InnerText), + Kernel.Instance.ResourcePools.TvDb, cancellationToken) + .ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + } + } + } + return true; + } + + return false; + } + + /// <summary> + /// Determines whether [has local meta] [the specified item]. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if [has local meta] [the specified item]; otherwise, <c>false</c>.</returns> + private bool HasLocalMeta(BaseItem item) + { + //just folder.jpg/png + return (item.ResolveArgs.ContainsMetaFileByName("folder.jpg") || + item.ResolveArgs.ContainsMetaFileByName("folder.png")); + } + + } +} diff --git a/MediaBrowser.Controller/Providers/TV/RemoteSeriesProvider.cs b/MediaBrowser.Controller/Providers/TV/RemoteSeriesProvider.cs new file mode 100644 index 0000000000..901d390407 --- /dev/null +++ b/MediaBrowser.Controller/Providers/TV/RemoteSeriesProvider.cs @@ -0,0 +1,545 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Resolvers.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class RemoteSeriesProvider + /// </summary> + [Export(typeof(BaseMetadataProvider))] + class RemoteSeriesProvider : BaseMetadataProvider + { + + /// <summary> + /// The root URL + /// </summary> + private const string rootUrl = "http://www.thetvdb.com/api/"; + /// <summary> + /// The series query + /// </summary> + private const string seriesQuery = "GetSeries.php?seriesname={0}"; + /// <summary> + /// The series get + /// </summary> + private const string seriesGet = "http://www.thetvdb.com/api/{0}/series/{1}/{2}.xml"; + /// <summary> + /// The get actors + /// </summary> + private const string getActors = "http://www.thetvdb.com/api/{0}/series/{1}/actors.xml"; + + /// <summary> + /// The LOCA l_ MET a_ FIL e_ NAME + /// </summary> + protected const string LOCAL_META_FILE_NAME = "Series.xml"; + + /// <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 is Series; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.Second; } + } + + /// <summary> + /// Gets a value indicating whether [requires internet]. + /// </summary> + /// <value><c>true</c> if [requires internet]; otherwise, <c>false</c>.</value> + public override bool RequiresInternet + { + get + { + return true; + } + } + + /// <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) + { + var downloadDate = providerInfo.LastRefreshed; + + if (Kernel.Instance.Configuration.MetadataRefreshDays == -1 && downloadDate != DateTime.MinValue) + { + return false; + } + + if (item.DontFetchMeta) return false; + + return !HasLocalMeta(item) && (Kernel.Instance.Configuration.MetadataRefreshDays != -1 && + DateTime.UtcNow.Subtract(downloadDate).TotalDays > Kernel.Instance.Configuration.MetadataRefreshDays); + } + + /// <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> + protected override async Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var series = (Series)item; + if (!item.DontFetchMeta && !HasLocalMeta(series)) + { + var path = item.Path ?? ""; + var seriesId = Path.GetFileName(path).GetAttributeValue("tvdbid") ?? await GetSeriesId(series, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.IsNullOrEmpty(seriesId)) + { + series.SetProviderId(MetadataProviders.Tvdb, seriesId); + if (!HasCompleteMetadata(series)) + { + await FetchSeriesData(series, seriesId, cancellationToken).ConfigureAwait(false); + } + } + SetLastRefreshed(item, DateTime.UtcNow); + return true; + } + Logger.Info("Series provider not fetching because local meta exists or requested to ignore: " + item.Name); + return false; + + } + + /// <summary> + /// Fetches the series data. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="seriesId">The series id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.Boolean}.</returns> + private async Task<bool> FetchSeriesData(Series series, string seriesId, CancellationToken cancellationToken) + { + var success = false; + + var name = series.Name; + Logger.Debug("TvDbProvider: Fetching series data: " + name); + + if (!string.IsNullOrEmpty(seriesId)) + { + + string url = string.Format(seriesGet, TVUtils.TVDBApiKey, seriesId, Kernel.Instance.Configuration.PreferredMetadataLanguage); + var doc = new XmlDocument(); + + try + { + using (var xml = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + doc.Load(xml); + } + } + catch (HttpException) + { + } + + if (doc.HasChildNodes) + { + //kick off the actor and image fetch simultaneously + var actorTask = FetchActors(series, seriesId, doc, cancellationToken); + var imageTask = FetchImages(series, seriesId, cancellationToken); + + success = true; + + series.Name = doc.SafeGetString("//SeriesName"); + series.Overview = doc.SafeGetString("//Overview"); + series.CommunityRating = doc.SafeGetSingle("//Rating", 0, 10); + series.AirDays = TVUtils.GetAirDays(doc.SafeGetString("//Airs_DayOfWeek")); + series.AirTime = doc.SafeGetString("//Airs_Time"); + + string n = doc.SafeGetString("//banner"); + if (!string.IsNullOrWhiteSpace(n)) + { + series.SetImage(ImageType.Banner, TVUtils.BannerUrl + n); + } + + string s = doc.SafeGetString("//Network"); + if (!string.IsNullOrWhiteSpace(s)) + series.AddStudios(new List<string>(s.Trim().Split('|'))); + + series.OfficialRating = doc.SafeGetString("//ContentRating"); + + string g = doc.SafeGetString("//Genre"); + + if (g != null) + { + string[] genres = g.Trim('|').Split('|'); + if (g.Length > 0) + { + series.AddGenres(genres); + } + } + + //wait for other tasks + await Task.WhenAll(actorTask, imageTask).ConfigureAwait(false); + + if (Kernel.Instance.Configuration.SaveLocalMeta) + { + var ms = new MemoryStream(); + doc.Save(ms); + + await Kernel.Instance.FileSystemManager.SaveToLibraryFilesystem(series, Path.Combine(series.MetaLocation, LOCAL_META_FILE_NAME), ms, cancellationToken).ConfigureAwait(false); + } + } + } + + + + return success; + } + + /// <summary> + /// Fetches the actors. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="seriesId">The series id.</param> + /// <param name="doc">The doc.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task FetchActors(Series series, string seriesId, XmlDocument doc, CancellationToken cancellationToken) + { + string urlActors = string.Format(getActors, TVUtils.TVDBApiKey, seriesId); + var docActors = new XmlDocument(); + + try + { + using (var actors = await Kernel.Instance.HttpManager.Get(urlActors, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + docActors.Load(actors); + } + } + catch (HttpException) + { + } + + if (docActors.HasChildNodes) + { + XmlNode actorsNode = null; + if (Kernel.Instance.Configuration.SaveLocalMeta) + { + //add to the main doc for saving + var seriesNode = doc.SelectSingleNode("//Series"); + if (seriesNode != null) + { + actorsNode = doc.CreateNode(XmlNodeType.Element, "Persons", null); + seriesNode.AppendChild(actorsNode); + } + } + + var xmlNodeList = docActors.SelectNodes("Actors/Actor"); + if (xmlNodeList != null) + foreach (XmlNode p in xmlNodeList) + { + string actorName = p.SafeGetString("Name"); + string actorRole = p.SafeGetString("Role"); + if (!string.IsNullOrWhiteSpace(actorName)) + { + series.AddPerson(new PersonInfo { Type = PersonType.Actor, Name = actorName, Role = actorRole }); + + if (Kernel.Instance.Configuration.SaveLocalMeta && actorsNode != null) + { + //create in main doc + var personNode = doc.CreateNode(XmlNodeType.Element, "Person", null); + foreach (XmlNode subNode in p.ChildNodes) + personNode.AppendChild(doc.ImportNode(subNode, true)); + //need to add the type + var typeNode = doc.CreateNode(XmlNodeType.Element, "Type", null); + typeNode.InnerText = "Actor"; + personNode.AppendChild(typeNode); + actorsNode.AppendChild(personNode); + } + + } + } + } + } + + /// <summary> + /// Fetches the images. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="seriesId">The series id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task FetchImages(Series series, string seriesId, CancellationToken cancellationToken) + { + if ((!string.IsNullOrEmpty(seriesId)) && ((series.PrimaryImagePath == null) || (series.BackdropImagePaths == null))) + { + string url = string.Format("http://www.thetvdb.com/api/" + TVUtils.TVDBApiKey + "/series/{0}/banners.xml", seriesId); + var images = new XmlDocument(); + + try + { + using (var imgs = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + images.Load(imgs); + } + } + catch (HttpException) + { + } + + if (images.HasChildNodes) + { + if (Kernel.Instance.Configuration.RefreshItemImages || !series.HasLocalImage("folder")) + { + var n = images.SelectSingleNode("//Banner[BannerType='poster']"); + if (n != null) + { + n = n.SelectSingleNode("./BannerPath"); + if (n != null) + { + try + { + series.PrimaryImagePath = await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, TVUtils.BannerUrl + n.InnerText, "folder" + Path.GetExtension(n.InnerText), Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + + if (Kernel.Instance.Configuration.DownloadTVBanner && (Kernel.Instance.Configuration.RefreshItemImages || !series.HasLocalImage("banner"))) + { + var n = images.SelectSingleNode("//Banner[BannerType='series']"); + if (n != null) + { + n = n.SelectSingleNode("./BannerPath"); + if (n != null) + { + try + { + var bannerImagePath = await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, TVUtils.BannerUrl + n.InnerText, "banner" + Path.GetExtension(n.InnerText), Kernel.Instance.ResourcePools.TvDb, cancellationToken); + + series.SetImage(ImageType.Banner, bannerImagePath); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + } + } + + var bdNo = 0; + var xmlNodeList = images.SelectNodes("//Banner[BannerType='fanart']"); + if (xmlNodeList != null) + foreach (XmlNode b in xmlNodeList) + { + series.BackdropImagePaths = new List<string>(); + var p = b.SelectSingleNode("./BannerPath"); + if (p != null) + { + var bdName = "backdrop" + (bdNo > 0 ? bdNo.ToString() : ""); + if (Kernel.Instance.Configuration.RefreshItemImages || !series.HasLocalImage(bdName)) + { + try + { + series.BackdropImagePaths.Add(await Kernel.Instance.ProviderManager.DownloadAndSaveImage(series, TVUtils.BannerUrl + p.InnerText, bdName + Path.GetExtension(p.InnerText), Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)); + } + catch (HttpException) + { + } + catch (IOException) + { + + } + } + bdNo++; + if (bdNo >= Kernel.Instance.Configuration.MaxBackdrops) break; + } + } + } + } + } + + /// <summary> + /// Determines whether [has complete metadata] [the specified series]. + /// </summary> + /// <param name="series">The series.</param> + /// <returns><c>true</c> if [has complete metadata] [the specified series]; otherwise, <c>false</c>.</returns> + private bool HasCompleteMetadata(Series series) + { + return (series.HasImage(ImageType.Banner)) && (series.CommunityRating != null) + && (series.Overview != null) && (series.Name != null) && (series.People != null) + && (series.Genres != null) && (series.OfficialRating != null); + } + + /// <summary> + /// Determines whether [has local meta] [the specified item]. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if [has local meta] [the specified item]; otherwise, <c>false</c>.</returns> + private bool HasLocalMeta(BaseItem item) + { + //need at least the xml and folder.jpg/png + return item.ResolveArgs.ContainsMetaFileByName(LOCAL_META_FILE_NAME) && (item.ResolveArgs.ContainsMetaFileByName("folder.jpg") || + item.ResolveArgs.ContainsMetaFileByName("folder.png")); + } + + /// <summary> + /// Gets the series id. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + private async Task<string> GetSeriesId(BaseItem item, CancellationToken cancellationToken) + { + var seriesId = item.GetProviderId(MetadataProviders.Tvdb); + if (string.IsNullOrEmpty(seriesId)) + { + seriesId = await FindSeries(item.Name, cancellationToken).ConfigureAwait(false); + } + return seriesId; + } + + + /// <summary> + /// Finds the series. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{System.String}.</returns> + public async Task<string> FindSeries(string name, CancellationToken cancellationToken) + { + + //nope - search for it + string url = string.Format(rootUrl + seriesQuery, WebUtility.UrlEncode(name)); + var doc = new XmlDocument(); + + try + { + using (var results = await Kernel.Instance.HttpManager.Get(url, Kernel.Instance.ResourcePools.TvDb, cancellationToken).ConfigureAwait(false)) + { + doc.Load(results); + } + } + catch (HttpException) + { + } + + if (doc.HasChildNodes) + { + XmlNodeList nodes = doc.SelectNodes("//Series"); + string comparableName = GetComparableName(name); + if (nodes != null) + foreach (XmlNode node in nodes) + { + var n = node.SelectSingleNode("./SeriesName"); + if (n != null && GetComparableName(n.InnerText) == comparableName) + { + n = node.SelectSingleNode("./seriesid"); + if (n != null) + return n.InnerText; + } + else + { + if (n != null) + Logger.Info("TVDb Provider - " + n.InnerText + " did not match " + comparableName); + } + } + } + + Logger.Info("TVDb Provider - Could not find " + name + ". Check name on Thetvdb.org."); + return null; + } + + /// <summary> + /// The remove + /// </summary> + const string remove = "\"'!`?"; + /// <summary> + /// The spacers + /// </summary> + const string spacers = "/,.:;\\(){}[]+-_=–*"; // (there are not actually two - in the they are different char codes) + + /// <summary> + /// Gets the name of the comparable. + /// </summary> + /// <param name="name">The name.</param> + /// <returns>System.String.</returns> + internal static string GetComparableName(string name) + { + name = name.ToLower(); + name = name.Normalize(NormalizationForm.FormKD); + var sb = new StringBuilder(); + foreach (var c in name) + { + if ((int)c >= 0x2B0 && (int)c <= 0x0333) + { + // skip char modifier and diacritics + } + else if (remove.IndexOf(c) > -1) + { + // skip chars we are removing + } + else if (spacers.IndexOf(c) > -1) + { + sb.Append(" "); + } + else if (c == '&') + { + sb.Append(" and "); + } + else + { + sb.Append(c); + } + } + name = sb.ToString(); + name = name.Replace(", the", ""); + name = name.Replace("the ", " "); + name = name.Replace(" the ", " "); + + string prevName; + do + { + prevName = name; + name = name.Replace(" ", " "); + } while (name.Length != prevName.Length); + + return name.Trim(); + } + + + + } +} diff --git a/MediaBrowser.Controller/Providers/TV/SeriesProviderFromXml.cs b/MediaBrowser.Controller/Providers/TV/SeriesProviderFromXml.cs index 76d7e7ac11..728ac0549c 100644 --- a/MediaBrowser.Controller/Providers/TV/SeriesProviderFromXml.cs +++ b/MediaBrowser.Controller/Providers/TV/SeriesProviderFromXml.cs @@ -1,36 +1,86 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Providers.TV
-{
- [Export(typeof(BaseMetadataProvider))]
- public class SeriesProviderFromXml : BaseMetadataProvider
- {
- public override bool Supports(BaseEntity item)
- {
- return item is Series;
- }
-
- public override MetadataProviderPriority Priority
- {
- get { return MetadataProviderPriority.First; }
- }
-
- public override async Task FetchAsync(BaseEntity item, ItemResolveEventArgs args)
- {
- await Task.Run(() => Fetch(item, args)).ConfigureAwait(false);
- }
-
- private void Fetch(BaseEntity item, ItemResolveEventArgs args)
- {
- if (args.ContainsFile("series.xml"))
- {
- new SeriesXmlParser().Fetch(item as Series, Path.Combine(args.Path, "series.xml"));
- }
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class SeriesProviderFromXml + /// </summary> + [Export(typeof(BaseMetadataProvider))] + public class SeriesProviderFromXml : BaseMetadataProvider + { + /// <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 is Series && item.LocationType == LocationType.FileSystem; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override MetadataProviderPriority Priority + { + get { return MetadataProviderPriority.First; } + } + + /// <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) + { + var entry = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "series.xml")); + return entry != null ? entry.Value.LastWriteTimeUtc : DateTime.MinValue; + } + + /// <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> + protected override Task<bool> FetchAsyncInternal(BaseItem item, bool force, CancellationToken cancellationToken) + { + return Task.Run(() => Fetch(item, cancellationToken)); + } + + /// <summary> + /// Fetches the specified item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + private bool Fetch(BaseItem item, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var metadataFile = item.ResolveArgs.GetMetaFileByPath(Path.Combine(item.MetaLocation, "series.xml")); + + if (metadataFile.HasValue) + { + var path = metadataFile.Value.Path; + + new SeriesXmlParser().Fetch((Series)item, path, cancellationToken); + SetLastRefreshed(item, DateTime.UtcNow); + + return true; + } + + return false; + } + } +} diff --git a/MediaBrowser.Controller/Providers/TV/SeriesXmlParser.cs b/MediaBrowser.Controller/Providers/TV/SeriesXmlParser.cs index 36c0a99efd..7516904255 100644 --- a/MediaBrowser.Controller/Providers/TV/SeriesXmlParser.cs +++ b/MediaBrowser.Controller/Providers/TV/SeriesXmlParser.cs @@ -1,69 +1,90 @@ -using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Model.Entities;
-using System;
-using System.Xml;
-
-namespace MediaBrowser.Controller.Providers.TV
-{
- public class SeriesXmlParser : BaseItemXmlParser<Series>
- {
- protected override void FetchDataFromXmlNode(XmlReader reader, Series item)
- {
- switch (reader.Name)
- {
- case "id":
- string id = reader.ReadElementContentAsString();
- if (!string.IsNullOrWhiteSpace(id))
- {
- item.SetProviderId(MetadataProviders.Tvdb, id);
- }
- break;
-
- case "Airs_DayOfWeek":
- {
- string day = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(day))
- {
- if (day.Equals("Daily", StringComparison.OrdinalIgnoreCase))
- {
- item.AirDays = new DayOfWeek[] {
- DayOfWeek.Sunday,
- DayOfWeek.Monday,
- DayOfWeek.Tuesday,
- DayOfWeek.Wednesday,
- DayOfWeek.Thursday,
- DayOfWeek.Friday,
- DayOfWeek.Saturday
- };
- }
- else
- {
- item.AirDays = new DayOfWeek[] {
- (DayOfWeek)Enum.Parse(typeof(DayOfWeek), day, true)
- };
- }
- }
-
- break;
- }
-
- case "Airs_Time":
- item.AirTime = reader.ReadElementContentAsString();
- break;
-
- case "SeriesName":
- item.Name = reader.ReadElementContentAsString();
- break;
-
- case "Status":
- item.Status = reader.ReadElementContentAsString();
- break;
-
- default:
- base.FetchDataFromXmlNode(reader, item);
- break;
- }
- }
- }
-}
+using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Resolvers.TV; +using MediaBrowser.Model.Entities; +using System; +using System.Xml; + +namespace MediaBrowser.Controller.Providers.TV +{ + /// <summary> + /// Class SeriesXmlParser + /// </summary> + public class SeriesXmlParser : BaseItemXmlParser<Series> + { + /// <summary> + /// Fetches the data from XML node. + /// </summary> + /// <param name="reader">The reader.</param> + /// <param name="item">The item.</param> + protected override void FetchDataFromXmlNode(XmlReader reader, Series item) + { + switch (reader.Name) + { + case "Series": + //MB generated metadata is within a "Series" node + using (var subTree = reader.ReadSubtree()) + { + subTree.MoveToContent(); + + // Loop through each element + while (subTree.Read()) + { + if (subTree.NodeType == XmlNodeType.Element) + { + FetchDataFromXmlNode(subTree, item); + } + } + + } + break; + + case "id": + string id = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(id)) + { + item.SetProviderId(MetadataProviders.Tvdb, id); + } + break; + + case "Airs_DayOfWeek": + { + item.AirDays = TVUtils.GetAirDays(reader.ReadElementContentAsString()); + break; + } + + case "Airs_Time": + item.AirTime = reader.ReadElementContentAsString(); + break; + + case "SeriesName": + item.Name = reader.ReadElementContentAsString(); + break; + + case "Status": + { + var status = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(status)) + { + SeriesStatus seriesStatus; + if (Enum.TryParse(status, true, out seriesStatus)) + { + item.Status = seriesStatus; + } + else + { + Logger.LogInfo("Unrecognized series status: " + status); + } + } + + break; + } + + default: + base.FetchDataFromXmlNode(reader, item); + break; + } + } + } +} diff --git a/MediaBrowser.Controller/Providers/VideoInfoProvider.cs b/MediaBrowser.Controller/Providers/VideoInfoProvider.cs deleted file mode 100644 index 264825fe08..0000000000 --- a/MediaBrowser.Controller/Providers/VideoInfoProvider.cs +++ /dev/null @@ -1,168 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.FFMpeg;
-using MediaBrowser.Model.Entities;
-using System;
-using System.Collections.Generic;
-using System.ComponentModel.Composition;
-using System.Linq;
-
-namespace MediaBrowser.Controller.Providers
-{
- [Export(typeof(BaseMetadataProvider))]
- public class VideoInfoProvider : BaseMediaInfoProvider<Video>
- {
- public override MetadataProviderPriority Priority
- {
- // Give this second priority
- // Give metadata xml providers a chance to fill in data first, so that we can skip this whenever possible
- get { return MetadataProviderPriority.Second; }
- }
-
- protected override string CacheDirectory
- {
- get { return Kernel.Instance.ApplicationPaths.FFProbeVideoCacheDirectory; }
- }
-
- protected override void Fetch(Video video, FFProbeResult data)
- {
- if (data.format != null)
- {
- if (!string.IsNullOrEmpty(data.format.duration))
- {
- video.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.format.duration)).Ticks;
- }
-
- if (!string.IsNullOrEmpty(data.format.bit_rate))
- {
- video.BitRate = int.Parse(data.format.bit_rate);
- }
- }
-
- if (data.streams != null)
- {
- // For now, only read info about first video stream
- // Files with multiple video streams are possible, but extremely rare
- bool foundVideo = false;
-
- foreach (MediaStream stream in data.streams)
- {
- if (stream.codec_type.Equals("video", StringComparison.OrdinalIgnoreCase))
- {
- if (!foundVideo)
- {
- FetchFromVideoStream(video, stream);
- }
-
- foundVideo = true;
- }
- else if (stream.codec_type.Equals("audio", StringComparison.OrdinalIgnoreCase))
- {
- FetchFromAudioStream(video, stream);
- }
- }
- }
- }
-
- private void FetchFromVideoStream(Video video, MediaStream stream)
- {
- video.Codec = stream.codec_name;
- video.Width = stream.width;
- video.Height = stream.height;
- video.AspectRatio = stream.display_aspect_ratio;
-
- if (!string.IsNullOrEmpty(stream.avg_frame_rate))
- {
- string[] parts = stream.avg_frame_rate.Split('/');
-
- if (parts.Length == 2)
- {
- video.FrameRate = float.Parse(parts[0]) / float.Parse(parts[1]);
- }
- else
- {
- video.FrameRate = float.Parse(parts[0]);
- }
- }
- }
-
- private void FetchFromAudioStream(Video video, MediaStream stream)
- {
- var audio = new AudioStream{};
-
- audio.Codec = stream.codec_name;
-
- if (!string.IsNullOrEmpty(stream.bit_rate))
- {
- audio.BitRate = int.Parse(stream.bit_rate);
- }
-
- audio.Channels = stream.channels;
-
- if (!string.IsNullOrEmpty(stream.sample_rate))
- {
- audio.SampleRate = int.Parse(stream.sample_rate);
- }
-
- audio.Language = GetDictionaryValue(stream.tags, "language");
-
- List<AudioStream> streams = video.AudioStreams ?? new List<AudioStream>();
- streams.Add(audio);
- video.AudioStreams = streams;
- }
-
- private void FetchFromSubtitleStream(Video video, MediaStream stream)
- {
- var subtitle = new SubtitleStream{};
-
- subtitle.Language = GetDictionaryValue(stream.tags, "language");
-
- List<SubtitleStream> streams = video.Subtitles ?? new List<SubtitleStream>();
- streams.Add(subtitle);
- video.Subtitles = streams;
- }
-
- /// <summary>
- /// Determines if there's already enough info in the Video object to allow us to skip running ffprobe
- /// </summary>
- protected override bool CanSkipFFProbe(Video video)
- {
- if (video.VideoType != VideoType.VideoFile)
- {
- // Not supported yet
- return true;
- }
-
- if (video.AudioStreams == null || !video.AudioStreams.Any())
- {
- return false;
- }
-
- if (string.IsNullOrEmpty(video.AspectRatio))
- {
- return false;
- }
-
- if (string.IsNullOrEmpty(video.Codec))
- {
- return false;
- }
-
- if (string.IsNullOrEmpty(video.ScanType))
- {
- return false;
- }
-
- if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value == 0)
- {
- return false;
- }
-
- if (Convert.ToInt32(video.FrameRate) == 0 || video.Height == 0 || video.Width == 0 || video.BitRate == 0)
- {
- return false;
- }
-
- return true;
- }
- }
-}
diff --git a/MediaBrowser.Controller/Resolvers/Audio/AudioResolver.cs b/MediaBrowser.Controller/Resolvers/Audio/AudioResolver.cs new file mode 100644 index 0000000000..f827bf0477 --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/Audio/AudioResolver.cs @@ -0,0 +1,29 @@ +using MediaBrowser.Controller.Library; +using System.ComponentModel.Composition; + +namespace MediaBrowser.Controller.Resolvers.Audio +{ + [Export(typeof(IBaseItemResolver))] + public class AudioResolver : BaseItemResolver<Entities.Audio.Audio> + { + public override ResolverPriority Priority + { + get { return ResolverPriority.Last; } + } + + protected override Entities.Audio.Audio Resolve(ItemResolveArgs args) + { + // Return audio if the path is a file and has a matching extension + + if (!args.IsDirectory) + { + if (EntityResolutionHelper.IsAudioFile(args)) + { + return new Entities.Audio.Audio(); + } + } + + return null; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/Audio/MusicAlbumResolver.cs b/MediaBrowser.Controller/Resolvers/Audio/MusicAlbumResolver.cs new file mode 100644 index 0000000000..8b2e49f313 --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/Audio/MusicAlbumResolver.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using System.ComponentModel.Composition; + +namespace MediaBrowser.Controller.Resolvers.Audio +{ + [Export(typeof(IBaseItemResolver))] + public class MusicAlbumResolver : BaseItemResolver<MusicAlbum> + { + public override ResolverPriority Priority + { + get { return ResolverPriority.Third; } // we need to be ahead of the generic folder resolver but behind the movie one + } + + protected override MusicAlbum Resolve(ItemResolveArgs args) + { + if (!args.IsDirectory) return null; + + //Avoid mis-identifying top folders + if (args.Parent == null) return null; + if (args.Parent.IsRoot) return null; + + return EntityResolutionHelper.IsMusicAlbum(args) ? new MusicAlbum() : null; + } + + } +} diff --git a/MediaBrowser.Controller/Resolvers/Audio/MusicArtistResolver.cs b/MediaBrowser.Controller/Resolvers/Audio/MusicArtistResolver.cs new file mode 100644 index 0000000000..8060e8d334 --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/Audio/MusicArtistResolver.cs @@ -0,0 +1,29 @@ +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using System.ComponentModel.Composition; +using System.Linq; + +namespace MediaBrowser.Controller.Resolvers.Audio +{ + [Export(typeof(IBaseItemResolver))] + public class MusicArtistResolver : BaseItemResolver<MusicArtist> + { + public override ResolverPriority Priority + { + get { return ResolverPriority.Third; } // we need to be ahead of the generic folder resolver but behind the movie one + } + + protected override MusicArtist Resolve(ItemResolveArgs args) + { + if (!args.IsDirectory) return null; + + //Avoid mis-identifying top folders + if (args.Parent == null) return null; + if (args.Parent.IsRoot) return null; + + // If we contain an album assume we are an artist folder + return args.FileSystemChildren.Any(EntityResolutionHelper.IsMusicAlbum) ? new MusicArtist() : null; + } + + } +} diff --git a/MediaBrowser.Controller/Resolvers/AudioResolver.cs b/MediaBrowser.Controller/Resolvers/AudioResolver.cs deleted file mode 100644 index 8f10e45e50..0000000000 --- a/MediaBrowser.Controller/Resolvers/AudioResolver.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers
-{
- [Export(typeof(IBaseItemResolver))]
- public class AudioResolver : BaseItemResolver<Audio>
- {
- public override ResolverPriority Priority
- {
- get { return ResolverPriority.Last; }
- }
-
- protected override Audio Resolve(ItemResolveEventArgs args)
- {
- // Return audio if the path is a file and has a matching extension
-
- if (!args.IsDirectory)
- {
- if (IsAudioFile(args.Path))
- {
- return new Audio();
- }
- }
-
- return null;
- }
-
- private static bool IsAudioFile(string path)
- {
- string extension = Path.GetExtension(path).ToLower();
-
- switch (extension)
- {
- case ".mp3":
- case ".wma":
- case ".aac":
- case ".acc":
- case ".flac":
- case ".m4a":
- case ".m4b":
- case ".wav":
- case ".ape":
- return true;
-
- default:
- return false;
- }
-
- }
- }
-}
diff --git a/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs b/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs index 7c9677e4e4..8e43a791fc 100644 --- a/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs @@ -1,126 +1,165 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Common.Extensions;
-using System;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers
-{
- public abstract class BaseItemResolver<T> : IBaseItemResolver
- where T : BaseItem, new()
- {
- protected virtual T Resolve(ItemResolveEventArgs args)
- {
- return null;
- }
-
- public virtual ResolverPriority Priority
- {
- get
- {
- return ResolverPriority.First;
- }
- }
-
- /// <summary>
- /// Sets initial values on the newly resolved item
- /// </summary>
- protected virtual void SetInitialItemValues(T item, ItemResolveEventArgs args)
- {
- // If the subclass didn't specify this
- if (string.IsNullOrEmpty(item.Path))
- {
- item.Path = args.Path;
- }
-
- // If the subclass didn't specify this
- if (args.Parent != null)
- {
- item.Parent = args.Parent;
- }
-
- item.Id = (item.GetType().FullName + item.Path).GetMD5();
- }
-
- public BaseItem ResolvePath(ItemResolveEventArgs args)
- {
- T item = Resolve(args);
-
- if (item != null)
- {
- // Set initial values on the newly resolved item
- SetInitialItemValues(item, args);
-
- // Make sure the item has a name
- EnsureName(item);
-
- // Make sure DateCreated and DateModified have values
- EnsureDates(item, args);
- }
-
- return item;
- }
-
- private void EnsureName(T item)
- {
- // If the subclass didn't supply a name, add it here
- if (string.IsNullOrEmpty(item.Name))
- {
- item.Name = Path.GetFileNameWithoutExtension(item.Path);
- }
-
- }
-
- /// <summary>
- /// Ensures DateCreated and DateModified have values
- /// </summary>
- private void EnsureDates(T item, ItemResolveEventArgs args)
- {
- if (!Path.IsPathRooted(item.Path))
- {
- return;
- }
-
- // See if a different path came out of the resolver than what went in
- if (!args.Path.Equals(item.Path, StringComparison.OrdinalIgnoreCase))
- {
- WIN32_FIND_DATA? childData = args.GetFileSystemEntry(item.Path);
-
- if (childData != null)
- {
- item.DateCreated = childData.Value.CreationTimeUtc;
- item.DateModified = childData.Value.LastWriteTimeUtc;
- }
- else
- {
- WIN32_FIND_DATA fileData = FileData.GetFileData(item.Path);
- item.DateCreated = fileData.CreationTimeUtc;
- item.DateModified = fileData.LastWriteTimeUtc;
- }
- }
- else
- {
- item.DateCreated = args.FileInfo.CreationTimeUtc;
- item.DateModified = args.FileInfo.LastWriteTimeUtc;
- }
- }
- }
-
- /// <summary>
- /// Weed this to keep a list of resolvers, since Resolvers are built with generics
- /// </summary>
- public interface IBaseItemResolver
- {
- BaseItem ResolvePath(ItemResolveEventArgs args);
- ResolverPriority Priority { get; }
- }
-
- public enum ResolverPriority
- {
- First = 1,
- Second = 2,
- Third = 3,
- Last = 4
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using System.IO; +using System.Text.RegularExpressions; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Class BaseItemResolver + /// </summary> + /// <typeparam name="T"></typeparam> + public abstract class BaseItemResolver<T> : IBaseItemResolver + where T : BaseItem, new() + { + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>`0.</returns> + protected virtual T Resolve(ItemResolveArgs args) + { + return null; + } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public virtual ResolverPriority Priority + { + get + { + return ResolverPriority.First; + } + } + + /// <summary> + /// Sets initial values on the newly resolved item + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected virtual void SetInitialItemValues(T item, ItemResolveArgs args) + { + // If the subclass didn't specify this + if (string.IsNullOrEmpty(item.Path)) + { + item.Path = args.Path; + } + + // If the subclass didn't specify this + if (args.Parent != null) + { + item.Parent = args.Parent; + } + + item.Id = item.Path.GetMBId(item.GetType()); + item.DisplayMediaType = item.GetType().Name; + } + + /// <summary> + /// Resolves the path. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>BaseItem.</returns> + public BaseItem ResolvePath(ItemResolveArgs args) + { + T item = Resolve(args); + + if (item != null) + { + // Set the args on the item + item.ResolveArgs = args; + + // Set initial values on the newly resolved item + SetInitialItemValues(item, args); + + // Make sure the item has a name + EnsureName(item); + + // Make sure DateCreated and DateModified have values + EntityResolutionHelper.EnsureDates(item, args); + } + + return item; + } + + /// <summary> + /// Ensures the name. + /// </summary> + /// <param name="item">The item.</param> + private void EnsureName(T item) + { + // If the subclass didn't supply a name, add it here + if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(item.Path)) + { + //we use our resolve args name here to get the name of the containg folder, not actual video file + item.Name = GetMBName(item.ResolveArgs.FileInfo.cFileName, item.ResolveArgs.FileInfo.IsDirectory); + } + } + + /// <summary> + /// The MB name regex + /// </summary> + private static readonly Regex MBNameRegex = new Regex("(\\[.*\\])", RegexOptions.Compiled); + + /// <summary> + /// Strip out attribute items and return just the name we will use for items + /// </summary> + /// <param name="path">Assumed to be a file or directory path</param> + /// <param name="isDirectory">if set to <c>true</c> [is directory].</param> + /// <returns>The cleaned name</returns> + private static string GetMBName(string path, bool isDirectory) + { + //first just get the file or directory name + var fn = isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); + + //now - strip out anything inside brackets + fn = MBNameRegex.Replace(fn, string.Empty); + + return fn; + } + } + + /// <summary> + /// Weed this to keep a list of resolvers, since Resolvers are built with generics + /// </summary> + public interface IBaseItemResolver + { + /// <summary> + /// Resolves the path. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>BaseItem.</returns> + BaseItem ResolvePath(ItemResolveArgs args); + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + ResolverPriority Priority { get; } + } + + /// <summary> + /// Enum ResolverPriority + /// </summary> + public enum ResolverPriority + { + /// <summary> + /// The first + /// </summary> + First = 1, + /// <summary> + /// The second + /// </summary> + Second = 2, + /// <summary> + /// The third + /// </summary> + Third = 3, + /// <summary> + /// The last + /// </summary> + Last = 4 + } +} diff --git a/MediaBrowser.Controller/Resolvers/BaseResolutionIgnoreRule.cs b/MediaBrowser.Controller/Resolvers/BaseResolutionIgnoreRule.cs new file mode 100644 index 0000000000..45effc4da1 --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/BaseResolutionIgnoreRule.cs @@ -0,0 +1,12 @@ +using MediaBrowser.Controller.Library; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Provides a base "rule" that anyone can use to have paths ignored by the resolver + /// </summary> + public abstract class BaseResolutionIgnoreRule + { + public abstract bool ShouldIgnore(ItemResolveArgs args); + } +} diff --git a/MediaBrowser.Controller/Resolvers/CoreResolutionIgnoreRule.cs b/MediaBrowser.Controller/Resolvers/CoreResolutionIgnoreRule.cs new file mode 100644 index 0000000000..2d69f8deff --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/CoreResolutionIgnoreRule.cs @@ -0,0 +1,52 @@ +using MediaBrowser.Controller.Library; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Provides the core resolver ignore rules + /// </summary> + [Export(typeof(BaseResolutionIgnoreRule))] + public class CoreResolutionIgnoreRule : BaseResolutionIgnoreRule + { + /// <summary> + /// Any folder named in this list will be ignored - can be added to at runtime for extensibility + /// </summary> + private static readonly List<string> IgnoreFolders = new List<string> + { + "trailers", + "metadata", + "certificate", + "backup", + "ps3_update", + "ps3_vprm", + "adv_obj", + "extrafanart" + }; + + public override bool ShouldIgnore(ItemResolveArgs args) + { + // Ignore hidden files and folders + if (args.IsHidden) + { + return true; + } + + if (args.IsDirectory) + { + var filename = args.FileInfo.cFileName; + + // Ignore any folders in our list + if (IgnoreFolders.Contains(filename, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/EntityResolutionHelper.cs b/MediaBrowser.Controller/Resolvers/EntityResolutionHelper.cs index b821f88018..75e1305268 100644 --- a/MediaBrowser.Controller/Resolvers/EntityResolutionHelper.cs +++ b/MediaBrowser.Controller/Resolvers/EntityResolutionHelper.cs @@ -1,70 +1,211 @@ -using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.IO;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Entities.TV;
-
-namespace MediaBrowser.Controller.Resolvers
-{
- public static class EntityResolutionHelper
- {
- /// <summary>
- /// Any folder named in this list will be ignored - can be added to at runtime for extensibility
- /// </summary>
- public static List<string> IgnoreFolders = new List<string>()
- {
- "trailers",
- "metadata",
- "bdmv",
- "certificate",
- "backup",
- "video_ts",
- "audio_ts",
- "ps3_update",
- "ps3_vprm",
- "adv_obj",
- "hvdvd_ts"
- };
- /// <summary>
- /// Determines whether a path should be resolved or ignored entirely - called before we even look at the contents
- /// </summary>
- /// <param name="path"></param>
- /// <returns>false if the path should be ignored</returns>
- public static bool ShouldResolvePath(WIN32_FIND_DATA path)
- {
- bool resolve = true;
- // Ignore hidden files and folders
- if (path.IsHidden || path.IsSystemFile)
- {
- resolve = false;
- }
-
- // Ignore any folders in our list
- else if (path.IsDirectory && IgnoreFolders.Contains(Path.GetFileName(path.Path), StringComparer.OrdinalIgnoreCase))
- {
- resolve = false;
- }
-
- return resolve;
- }
-
- /// <summary>
- /// Determines whether a path should be ignored based on its contents - called after the contents have been read
- /// </summary>
- public static bool ShouldResolvePathContents(ItemResolveEventArgs args)
- {
- bool resolve = true;
- if (args.ContainsFile(".ignore"))
- {
- // Ignore any folders containing a file called .ignore
- resolve = false;
- }
-
- return resolve;
- }
- }
-}
+using MediaBrowser.Common.IO; +using MediaBrowser.Common.Win32; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Class EntityResolutionHelper + /// </summary> + public static class EntityResolutionHelper + { + /// <summary> + /// Any extension in this list is considered a metadata file - can be added to at runtime for extensibility + /// </summary> + public static List<string> MetaExtensions = new List<string> + { + ".xml", + ".jpg", + ".png", + ".json", + ".data" + }; + /// <summary> + /// Any extension in this list is considered a video file - can be added to at runtime for extensibility + /// </summary> + public static List<string> VideoFileExtensions = new List<string> + { + ".mkv", + ".m2t", + ".m2ts", + ".img", + ".iso", + ".ts", + ".rmvb", + ".mov", + ".avi", + ".mpg", + ".mpeg", + ".wmv", + ".mp4", + ".divx", + ".dvr-ms", + ".wtv", + ".ogm", + ".ogv", + ".asf", + ".m4v", + ".flv", + ".f4v", + ".3gp", + ".webm" + }; + + /// <summary> + /// Determines whether [is video file] [the specified path]. + /// </summary> + /// <param name="path">The path.</param> + /// <returns><c>true</c> if [is video file] [the specified path]; otherwise, <c>false</c>.</returns> + public static bool IsVideoFile(string path) + { + var extension = Path.GetExtension(path) ?? string.Empty; + return VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + } + + /// <summary> + /// The audio file extensions + /// </summary> + public static readonly string[] AudioFileExtensions = new[] { + ".mp3", + ".flac", + ".wma", + ".aac", + ".acc", + ".m4a", + ".m4b", + ".wav", + ".ape", + ".ogg", + ".oga" + }; + + /// <summary> + /// Determines whether [is audio file] [the specified args]. + /// </summary> + /// <param name="args">The args.</param> + /// <returns><c>true</c> if [is audio file] [the specified args]; otherwise, <c>false</c>.</returns> + public static bool IsAudioFile(ItemResolveArgs args) + { + return AudioFileExtensions.Contains(Path.GetExtension(args.Path), StringComparer.OrdinalIgnoreCase); + } + + /// <summary> + /// Determines whether [is audio file] [the specified file]. + /// </summary> + /// <param name="file">The file.</param> + /// <returns><c>true</c> if [is audio file] [the specified file]; otherwise, <c>false</c>.</returns> + public static bool IsAudioFile(WIN32_FIND_DATA file) + { + return AudioFileExtensions.Contains(Path.GetExtension(file.Path), StringComparer.OrdinalIgnoreCase); + } + + /// <summary> + /// Determine if the supplied file data points to a music album + /// </summary> + /// <param name="data">The data.</param> + /// <returns><c>true</c> if [is music album] [the specified data]; otherwise, <c>false</c>.</returns> + public static bool IsMusicAlbum(WIN32_FIND_DATA data) + { + return ContainsMusic(FileSystem.GetFiles(data.Path)); + } + + /// <summary> + /// Determine if the supplied reslove args should be considered a music album + /// </summary> + /// <param name="args">The args.</param> + /// <returns><c>true</c> if [is music album] [the specified args]; otherwise, <c>false</c>.</returns> + public static bool IsMusicAlbum(ItemResolveArgs args) + { + // Args points to an album if parent is an Artist folder or it directly contains music + if (args.IsDirectory) + { + //if (args.Parent is MusicArtist) return true; //saves us from testing children twice + if (ContainsMusic(args.FileSystemChildren)) return true; + } + + + return false; + } + + /// <summary> + /// Determine if the supplied list contains what we should consider music + /// </summary> + /// <param name="list">The list.</param> + /// <returns><c>true</c> if the specified list contains music; otherwise, <c>false</c>.</returns> + public static bool ContainsMusic(IEnumerable<WIN32_FIND_DATA> list) + { + // If list contains at least 2 audio files or at least one and no video files consider it to contain music + var foundAudio = 0; + var foundVideo = 0; + foreach (var file in list) + { + if (IsAudioFile(file)) foundAudio++; + if (foundAudio >= 2) + { + return true; + } + if (IsVideoFile(file.Path)) foundVideo++; + } + + // or a single audio file and no video files + if (foundAudio > 0 && foundVideo == 0) return true; + return false; + } + + /// <summary> + /// Determines whether a path should be ignored based on its contents - called after the contents have been read + /// </summary> + /// <param name="args">The args.</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + public static bool ShouldResolvePathContents(ItemResolveArgs args) + { + // Ignore any folders containing a file called .ignore + return !args.ContainsFileSystemEntryByName(".ignore"); + } + + /// <summary> + /// Ensures DateCreated and DateModified have values + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + public static void EnsureDates(BaseItem item, ItemResolveArgs args) + { + if (!Path.IsPathRooted(item.Path)) + { + return; + } + + // See if a different path came out of the resolver than what went in + if (!args.Path.Equals(item.Path, StringComparison.OrdinalIgnoreCase)) + { + var childData = args.IsDirectory ? args.GetFileSystemEntryByPath(item.Path) : null; + + if (childData.HasValue) + { + item.DateCreated = childData.Value.CreationTimeUtc; + item.DateModified = childData.Value.LastWriteTimeUtc; + } + else + { + var fileData = FileSystem.GetFileData(item.Path); + + if (fileData.HasValue) + { + item.DateCreated = fileData.Value.CreationTimeUtc; + item.DateModified = fileData.Value.LastWriteTimeUtc; + } + } + } + else + { + item.DateCreated = args.FileInfo.CreationTimeUtc; + item.DateModified = args.FileInfo.LastWriteTimeUtc; + } + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/FolderResolver.cs b/MediaBrowser.Controller/Resolvers/FolderResolver.cs index 028c85f862..e37c18692d 100644 --- a/MediaBrowser.Controller/Resolvers/FolderResolver.cs +++ b/MediaBrowser.Controller/Resolvers/FolderResolver.cs @@ -1,36 +1,71 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-
-namespace MediaBrowser.Controller.Resolvers
-{
- [Export(typeof(IBaseItemResolver))]
- public class FolderResolver : BaseFolderResolver<Folder>
- {
- public override ResolverPriority Priority
- {
- get { return ResolverPriority.Last; }
- }
-
- protected override Folder Resolve(ItemResolveEventArgs args)
- {
- if (args.IsDirectory)
- {
- return new Folder();
- }
-
- return null;
- }
- }
-
- public abstract class BaseFolderResolver<TItemType> : BaseItemResolver<TItemType>
- where TItemType : Folder, new()
- {
- protected override void SetInitialItemValues(TItemType item, ItemResolveEventArgs args)
- {
- base.SetInitialItemValues(item, args);
-
- item.IsRoot = args.Parent == null;
- }
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using System.ComponentModel.Composition; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Class FolderResolver + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class FolderResolver : BaseFolderResolver<Folder> + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override ResolverPriority Priority + { + get { return ResolverPriority.Last; } + } + + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Folder.</returns> + protected override Folder Resolve(ItemResolveArgs args) + { + if (args.IsDirectory) + { + if (args.IsPhysicalRoot) + { + return new AggregateFolder(); + } + if (args.IsRoot) + { + return new UserRootFolder(); //if we got here and still a root - must be user root + } + if (args.IsVf) + { + return new CollectionFolder(); + } + + return new Folder(); + } + + return null; + } + } + + /// <summary> + /// Class BaseFolderResolver + /// </summary> + /// <typeparam name="TItemType">The type of the T item type.</typeparam> + public abstract class BaseFolderResolver<TItemType> : BaseItemResolver<TItemType> + where TItemType : Folder, new() + { + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + item.IsRoot = args.Parent == null; + item.IsPhysicalRoot = args.IsPhysicalRoot; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/LocalTrailerResolver.cs b/MediaBrowser.Controller/Resolvers/LocalTrailerResolver.cs new file mode 100644 index 0000000000..c26b0ce37e --- /dev/null +++ b/MediaBrowser.Controller/Resolvers/LocalTrailerResolver.cs @@ -0,0 +1,40 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using System; +using System.ComponentModel.Composition; +using System.IO; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Class LocalTrailerResolver + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class LocalTrailerResolver : BaseVideoResolver<Trailer> + { + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Trailer.</returns> + protected override Trailer Resolve(ItemResolveArgs args) + { + // Trailers are not Children, therefore this can never happen + if (args.Parent != null) + { + return null; + } + + // If the file is within a trailers folder, see if the VideoResolver returns something + if (!args.IsDirectory) + { + if (string.Equals(Path.GetFileName(Path.GetDirectoryName(args.Path)), BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase)) + { + return base.Resolve(args); + } + } + + return null; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/Movies/BoxSetResolver.cs b/MediaBrowser.Controller/Resolvers/Movies/BoxSetResolver.cs index 069068067f..ccca0cfabb 100644 --- a/MediaBrowser.Controller/Resolvers/Movies/BoxSetResolver.cs +++ b/MediaBrowser.Controller/Resolvers/Movies/BoxSetResolver.cs @@ -1,28 +1,43 @@ -using MediaBrowser.Controller.Entities.Movies;
-using MediaBrowser.Controller.Library;
-using System;
-using System.ComponentModel.Composition;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers.Movies
-{
- [Export(typeof(IBaseItemResolver))]
- public class BoxSetResolver : BaseFolderResolver<BoxSet>
- {
- protected override BoxSet Resolve(ItemResolveEventArgs args)
- {
- // It's a boxset if all of the following conditions are met:
- // Is a Directory
- // Contains [boxset] in the path
- if (args.IsDirectory)
- {
- if (Path.GetFileName(args.Path).IndexOf("[boxset]", StringComparison.OrdinalIgnoreCase) != -1)
- {
- return new BoxSet();
- }
- }
-
- return null;
- }
- }
-}
+using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using System; +using System.ComponentModel.Composition; +using System.IO; + +namespace MediaBrowser.Controller.Resolvers.Movies +{ + /// <summary> + /// Class BoxSetResolver + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class BoxSetResolver : BaseFolderResolver<BoxSet> + { + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>BoxSet.</returns> + protected override BoxSet Resolve(ItemResolveArgs args) + { + // It's a boxset if all of the following conditions are met: + // Is a Directory + // Contains [boxset] in the path + if (args.IsDirectory) + { + var filename = Path.GetFileName(args.Path); + + if (string.IsNullOrEmpty(filename)) + { + return null; + } + + if (filename.IndexOf("[boxset]", StringComparison.OrdinalIgnoreCase) != -1) + { + return new BoxSet(); + } + } + + return null; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/Movies/MovieResolver.cs b/MediaBrowser.Controller/Resolvers/Movies/MovieResolver.cs index 825850b20c..14f6357478 100644 --- a/MediaBrowser.Controller/Resolvers/Movies/MovieResolver.cs +++ b/MediaBrowser.Controller/Resolvers/Movies/MovieResolver.cs @@ -1,116 +1,210 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Movies;
-using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using System.ComponentModel.Composition;
-using System.Collections.Generic;
-
-namespace MediaBrowser.Controller.Resolvers.Movies
-{
- [Export(typeof(IBaseItemResolver))]
- public class MovieResolver : BaseVideoResolver<Movie>
- {
- protected override Movie Resolve(ItemResolveEventArgs args)
- {
- // Must be a directory and under a 'Movies' VF
- if (args.IsDirectory)
- {
- // If the parent is not a boxset, the only other allowed parent type is Folder
- if (!(args.Parent is BoxSet))
- {
- if (args.Parent != null && args.Parent.GetType() != typeof(Folder))
- {
- return null;
- }
- }
-
- // Optimization to avoid running all these tests against VF's
- if (args.Parent != null && args.Parent.IsRoot)
- {
- return null;
- }
-
- // Return a movie if the video resolver finds something in the folder
- return GetMovie(args);
- }
-
- return null;
- }
-
- protected override void SetInitialItemValues(Movie item, ItemResolveEventArgs args)
- {
- base.SetInitialItemValues(item, args);
-
- SetProviderIdFromPath(item);
- }
-
- private void SetProviderIdFromPath(Movie item)
- {
- const string srch = "[tmdbid=";
- int index = item.Path.IndexOf(srch, System.StringComparison.OrdinalIgnoreCase);
-
- if (index != -1)
- {
- string id = item.Path.Substring(index + srch.Length);
-
- id = id.Substring(0, id.IndexOf(']'));
-
- item.SetProviderId(MetadataProviders.Tmdb, id);
- }
- }
-
- private Movie GetMovie(ItemResolveEventArgs args)
- {
- //first see if the discovery process has already determined we are a DVD or BD
- if (args.IsDVDFolder)
- {
- return new Movie()
- {
- Path = args.Path,
- VideoType = VideoType.Dvd
- };
- }
- else if (args.IsBDFolder)
- {
- return new Movie()
- {
- Path = args.Path,
- VideoType = VideoType.BluRay
- };
- }
- else if (args.IsHDDVDFolder)
- {
- return new Movie()
- {
- Path = args.Path,
- VideoType = VideoType.HdDvd
- };
- }
-
- // Loop through each child file/folder and see if we find a video
- foreach (var child in args.FileSystemChildren)
- {
- var childArgs = new ItemResolveEventArgs
- {
- FileInfo = child,
- FileSystemChildren = new WIN32_FIND_DATA[] { },
- Path = child.Path
- };
-
- var item = base.Resolve(childArgs);
-
- if (item != null)
- {
- return new Movie
- {
- Path = item.Path,
- VideoType = item.VideoType
- };
- }
- }
-
- return null;
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers.Movies; +using MediaBrowser.Model.Entities; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; + +namespace MediaBrowser.Controller.Resolvers.Movies +{ + /// <summary> + /// Class MovieResolver + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class MovieResolver : BaseVideoResolver<Movie> + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override ResolverPriority Priority + { + get + { + // Give plugins a chance to catch iso's first + // Also since we have to loop through child files looking for videos, + // see if we can avoid some of that by letting other resolvers claim folders first + return ResolverPriority.Second; + } + } + + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Movie.</returns> + protected override Movie Resolve(ItemResolveArgs args) + { + // Must be a directory and under a 'Movies' VF + if (args.IsDirectory) + { + // Avoid expensive tests against VF's and all their children by not allowing this + if (args.Parent == null || args.Parent.IsRoot) + { + return null; + } + + // If the parent is not a boxset, the only other allowed parent type is Folder + if (!(args.Parent is BoxSet)) + { + if (args.Parent.GetType() != typeof(Folder)) + { + return null; + } + } + + // Optimization to avoid running all these tests against Top folders + if (args.Parent != null && args.Parent.IsRoot) + { + return null; + } + + // The movie must be a video file + return FindMovie(args); + } + + return null; + } + + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected override void SetInitialItemValues(Movie item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + SetProviderIdFromPath(item); + } + + /// <summary> + /// Sets the provider id from path. + /// </summary> + /// <param name="item">The item.</param> + private void SetProviderIdFromPath(Movie item) + { + //we need to only look at the name of this actual item (not parents) + var justName = item.Path.Substring(item.Path.LastIndexOf(Path.DirectorySeparatorChar)); + + var id = justName.GetAttributeValue("tmdbid"); + + if (!string.IsNullOrEmpty(id)) + { + item.SetProviderId(MetadataProviders.Tmdb, id); + } + } + + /// <summary> + /// Finds a movie based on a child file system entries + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Movie.</returns> + private Movie FindMovie(ItemResolveArgs args) + { + // Since the looping is expensive, this is an optimization to help us avoid it + if (args.ContainsMetaFileByName("series.xml") || args.Path.IndexOf("[tvdbid", StringComparison.OrdinalIgnoreCase) != -1) + { + return null; + } + + // Optimization to avoid having to resolve every file + bool? isKnownMovie = null; + + var movies = new List<Movie>(); + + // Loop through each child file/folder and see if we find a video + foreach (var child in args.FileSystemChildren) + { + if (child.IsDirectory) + { + if (IsDvdDirectory(child.cFileName)) + { + return new Movie + { + Path = args.Path, + VideoType = VideoType.Dvd + }; + } + if (IsBluRayDirectory(child.cFileName)) + { + return new Movie + { + Path = args.Path, + VideoType = VideoType.BluRay + }; + } + if (IsHdDvdDirectory(child.cFileName)) + { + return new Movie + { + Path = args.Path, + VideoType = VideoType.HdDvd + }; + } + + continue; + } + + var childArgs = new ItemResolveArgs + { + FileInfo = child, + Path = child.Path + }; + + var item = base.Resolve(childArgs); + + if (item != null) + { + // If we already know it's a movie, we can stop looping + if (!isKnownMovie.HasValue) + { + isKnownMovie = args.ContainsMetaFileByName("movie.xml") || args.ContainsMetaFileByName(MovieDbProvider.LOCAL_META_FILE_NAME) || args.Path.IndexOf("[tmdbid", StringComparison.OrdinalIgnoreCase) != -1; + } + + if (isKnownMovie.Value) + { + return item; + } + + movies.Add(item); + } + } + + // If there are multiple video files, return null, and let the VideoResolver catch them later as plain videos + return movies.Count == 1 ? movies[0] : null; + } + + /// <summary> + /// Determines whether [is DVD directory] [the specified directory name]. + /// </summary> + /// <param name="directoryName">Name of the directory.</param> + /// <returns><c>true</c> if [is DVD directory] [the specified directory name]; otherwise, <c>false</c>.</returns> + private bool IsDvdDirectory(string directoryName) + { + return directoryName.Equals("video_ts", StringComparison.OrdinalIgnoreCase); + } + /// <summary> + /// Determines whether [is hd DVD directory] [the specified directory name]. + /// </summary> + /// <param name="directoryName">Name of the directory.</param> + /// <returns><c>true</c> if [is hd DVD directory] [the specified directory name]; otherwise, <c>false</c>.</returns> + private bool IsHdDvdDirectory(string directoryName) + { + return directoryName.Equals("hvdvd_ts", StringComparison.OrdinalIgnoreCase); + } + /// <summary> + /// Determines whether [is blu ray directory] [the specified directory name]. + /// </summary> + /// <param name="directoryName">Name of the directory.</param> + /// <returns><c>true</c> if [is blu ray directory] [the specified directory name]; otherwise, <c>false</c>.</returns> + private bool IsBluRayDirectory(string directoryName) + { + return directoryName.Equals("bdmv", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/TV/EpisodeResolver.cs b/MediaBrowser.Controller/Resolvers/TV/EpisodeResolver.cs index 0961edd1ac..f2f3ce122d 100644 --- a/MediaBrowser.Controller/Resolvers/TV/EpisodeResolver.cs +++ b/MediaBrowser.Controller/Resolvers/TV/EpisodeResolver.cs @@ -1,21 +1,65 @@ -using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-
-namespace MediaBrowser.Controller.Resolvers.TV
-{
- [Export(typeof(IBaseItemResolver))]
- public class EpisodeResolver : BaseVideoResolver<Episode>
- {
- protected override Episode Resolve(ItemResolveEventArgs args)
- {
- // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something
- if (args.Parent is Season || args.Parent is Series)
- {
- return base.Resolve(args);
- }
-
- return null;
- }
- }
-}
+using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using System.ComponentModel.Composition; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Resolvers.TV +{ + [Export(typeof(IBaseItemResolver))] + public class EpisodeResolver : BaseVideoResolver<Episode> + { + protected override Episode Resolve(ItemResolveArgs args) + { + // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something + if (args.Parent is Season || args.Parent is Series) + { + if (args.IsDirectory) + { + if (args.ContainsFileSystemEntryByName("video_ts")) + { + return new Episode + { + Path = args.Path, + VideoType = VideoType.Dvd + }; + } + if (args.ContainsFileSystemEntryByName("bdmv")) + { + return new Episode + { + Path = args.Path, + VideoType = VideoType.BluRay + }; + } + } + + return base.Resolve(args); + } + + return null; + } + + protected override void SetInitialItemValues(Episode item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + //fill in our season and series ids + var season = args.Parent as Season; + if (season != null) + { + item.SeasonItemId = season.Id; + var series = season.Parent as Series; + if (series != null) + { + item.SeriesItemId = series.Id; + } + } + else + { + var series = args.Parent as Series; + item.SeriesItemId = series != null ? series.Id : Guid.Empty; + } + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/TV/SeasonResolver.cs b/MediaBrowser.Controller/Resolvers/TV/SeasonResolver.cs index 0ad0782e07..6569c85bc8 100644 --- a/MediaBrowser.Controller/Resolvers/TV/SeasonResolver.cs +++ b/MediaBrowser.Controller/Resolvers/TV/SeasonResolver.cs @@ -1,25 +1,34 @@ -using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using System.ComponentModel.Composition;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers.TV
-{
- [Export(typeof(IBaseItemResolver))]
- public class SeasonResolver : BaseFolderResolver<Season>
- {
- protected override Season Resolve(ItemResolveEventArgs args)
- {
- if (args.Parent is Series && args.IsDirectory)
- {
- var season = new Season { };
-
- season.IndexNumber = TVUtils.GetSeasonNumberFromPath(args.Path);
-
- return season;
- }
-
- return null;
- }
- }
-}
+using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using System; +using System.ComponentModel.Composition; + +namespace MediaBrowser.Controller.Resolvers.TV +{ + [Export(typeof(IBaseItemResolver))] + public class SeasonResolver : BaseFolderResolver<Season> + { + protected override Season Resolve(ItemResolveArgs args) + { + if (args.Parent is Series && args.IsDirectory) + { + return new Season + { + IndexNumber = TVUtils.GetSeasonNumberFromPath(args.Path) + }; + } + + return null; + } + + protected override void SetInitialItemValues(Season item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + var series = args.Parent as Series; + item.SeriesItemId = series != null ? series.Id : Guid.Empty; + + Season.AddMetadataFiles(args); + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/TV/SeriesResolver.cs b/MediaBrowser.Controller/Resolvers/TV/SeriesResolver.cs index b8ff2c37be..7c0bc3df14 100644 --- a/MediaBrowser.Controller/Resolvers/TV/SeriesResolver.cs +++ b/MediaBrowser.Controller/Resolvers/TV/SeriesResolver.cs @@ -1,64 +1,100 @@ -using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using System;
-using System.ComponentModel.Composition;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers.TV
-{
- [Export(typeof(IBaseItemResolver))]
- public class SeriesResolver : BaseFolderResolver<Series>
- {
- protected override Series Resolve(ItemResolveEventArgs args)
- {
- if (args.IsDirectory)
- {
- // Optimization to avoid running all these tests against VF's
- if (args.Parent != null && args.Parent.IsRoot)
- {
- return null;
- }
-
- // Optimization to avoid running these tests against Seasons
- if (args.Parent is Series)
- {
- return null;
- }
-
- // It's a Series if any of the following conditions are met:
- // series.xml exists
- // [tvdbid= is present in the path
- // TVUtils.IsSeriesFolder returns true
- if (args.ContainsFile("series.xml") || Path.GetFileName(args.Path).IndexOf("[tvdbid=", StringComparison.OrdinalIgnoreCase) != -1 || TVUtils.IsSeriesFolder(args.Path, args.FileSystemChildren))
- {
- return new Series();
- }
- }
-
- return null;
- }
-
- protected override void SetInitialItemValues(Series item, ItemResolveEventArgs args)
- {
- base.SetInitialItemValues(item, args);
-
- SetProviderIdFromPath(item);
- }
-
- private void SetProviderIdFromPath(Series item)
- {
- const string srch = "[tvdbid=";
- int index = item.Path.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
-
- if (index != -1)
- {
- string id = item.Path.Substring(index + srch.Length);
-
- id = id.Substring(0, id.IndexOf(']'));
-
- item.SetProviderId(MetadataProviders.Tvdb, id);
- }
- }
- }
-}
+using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; + +namespace MediaBrowser.Controller.Resolvers.TV +{ + /// <summary> + /// Class SeriesResolver + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class SeriesResolver : BaseFolderResolver<Series> + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override ResolverPriority Priority + { + get + { + return ResolverPriority.Second; + } + } + + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Series.</returns> + protected override Series Resolve(ItemResolveArgs args) + { + if (args.IsDirectory) + { + // Avoid expensive tests against VF's and all their children by not allowing this + if (args.Parent == null || args.Parent.IsRoot) + { + return null; + } + + // Optimization to avoid running these tests against Seasons + if (args.Parent is Series) + { + return null; + } + + // It's a Series if any of the following conditions are met: + // series.xml exists + // [tvdbid= is present in the path + // TVUtils.IsSeriesFolder returns true + var filename = Path.GetFileName(args.Path); + + if (string.IsNullOrEmpty(filename)) + { + return null; + } + + if (args.ContainsMetaFileByName("series.xml") || filename.IndexOf("[tvdbid=", StringComparison.OrdinalIgnoreCase) != -1 || TVUtils.IsSeriesFolder(args.Path, args.FileSystemChildren)) + { + return new Series(); + } + } + + return null; + } + + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected override void SetInitialItemValues(Series item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + Season.AddMetadataFiles(args); + + SetProviderIdFromPath(item); + } + + /// <summary> + /// Sets the provider id from path. + /// </summary> + /// <param name="item">The item.</param> + private void SetProviderIdFromPath(Series item) + { + var justName = item.Path.Substring(item.Path.LastIndexOf(Path.DirectorySeparatorChar)); + + var id = justName.GetAttributeValue("tvdbid"); + + if (!string.IsNullOrEmpty(id)) + { + item.SetProviderId(MetadataProviders.Tvdb, id); + } + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/TV/TVUtils.cs b/MediaBrowser.Controller/Resolvers/TV/TVUtils.cs index ec3305e167..3e7421a397 100644 --- a/MediaBrowser.Controller/Resolvers/TV/TVUtils.cs +++ b/MediaBrowser.Controller/Resolvers/TV/TVUtils.cs @@ -1,164 +1,251 @@ -using MediaBrowser.Controller.IO;
-using System;
-using System.Text.RegularExpressions;
-
-namespace MediaBrowser.Controller.Resolvers.TV
-{
- public static class TVUtils
- {
- /// <summary>
- /// A season folder must contain one of these somewhere in the name
- /// </summary>
- private static readonly string[] SeasonFolderNames = new string[] {
- "season",
- "sæson",
- "temporada",
- "saison",
- "staffel"
- };
-
- /// <summary>
- /// Used to detect paths that represent episodes, need to make sure they don't also
- /// match movie titles like "2001 A Space..."
- /// Currently we limit the numbers here to 2 digits to try and avoid this
- /// </summary>
- /// <remarks>
- /// The order here is important, if the order is changed some of the later
- /// ones might incorrectly match things that higher ones would have caught.
- /// The most restrictive expressions should appear first
- /// </remarks>
- private static readonly Regex[] episodeExpressions = new Regex[] {
- new Regex(@".*\\[s|S]?(?<seasonnumber>\d{1,2})[x|X](?<epnumber>\d{1,3})[^\\]*$", RegexOptions.Compiled), // 01x02 blah.avi S01x01 balh.avi
- new Regex(@".*\\[s|S](?<seasonnumber>\d{1,2})x?[e|E](?<epnumber>\d{1,3})[^\\]*$", RegexOptions.Compiled), // S01E02 blah.avi, S01xE01 blah.avi
- new Regex(@".*\\(?<seriesname>[^\\]*)[s|S]?(?<seasonnumber>\d{1,2})[x|X](?<epnumber>\d{1,3})[^\\]*$", RegexOptions.Compiled), // 01x02 blah.avi S01x01 balh.avi
- new Regex(@".*\\(?<seriesname>[^\\]*)[s|S](?<seasonnumber>\d{1,2})[x|X|\.]?[e|E](?<epnumber>\d{1,3})[^\\]*$", RegexOptions.Compiled) // S01E02 blah.avi, S01xE01 blah.avi
- };
- /// <summary>
- /// To avoid the following matching movies they are only valid when contained in a folder which has been matched as a being season
- /// </summary>
- private static readonly Regex[] episodeExpressionsInASeasonFolder = new Regex[] {
- new Regex(@".*\\(?<epnumber>\d{1,2})\s?-\s?[^\\]*$", RegexOptions.Compiled), // 01 - blah.avi, 01-blah.avi
- new Regex(@".*\\(?<epnumber>\d{1,2})[^\d\\]*[^\\]*$", RegexOptions.Compiled), // 01.avi, 01.blah.avi "01 - 22 blah.avi"
- new Regex(@".*\\(?<seasonnumber>\d)(?<epnumber>\d{1,2})[^\d\\]+[^\\]*$", RegexOptions.Compiled), // 01.avi, 01.blah.avi
- new Regex(@".*\\\D*\d+(?<epnumber>\d{2})", RegexOptions.Compiled) // hell0 - 101 - hello.avi
-
- };
-
- public static int? GetSeasonNumberFromPath(string path)
- {
- // Look for one of the season folder names
- foreach (string name in SeasonFolderNames)
- {
- int index = path.IndexOf(name, StringComparison.OrdinalIgnoreCase);
-
- if (index != -1)
- {
- return GetSeasonNumberFromPathSubstring(path.Substring(index + name.Length));
- }
- }
-
- return null;
- }
-
- /// <summary>
- /// Extracts the season number from the second half of the Season folder name (everything after "Season", or "Staffel")
- /// </summary>
- private static int? GetSeasonNumberFromPathSubstring(string path)
- {
- int numericStart = -1;
- int length = 0;
-
- // Find out where the numbers start, and then keep going until they end
- for (int i = 0; i < path.Length; i++)
- {
- if (char.IsNumber(path, i))
- {
- if (numericStart == -1)
- {
- numericStart = i;
- }
- length++;
- }
- else if (numericStart != -1)
- {
- break;
- }
- }
-
- if (numericStart == -1)
- {
- return null;
- }
-
- return int.Parse(path.Substring(numericStart, length));
- }
-
- public static bool IsSeasonFolder(string path)
- {
- return GetSeasonNumberFromPath(path) != null;
- }
-
- public static bool IsSeriesFolder(string path, WIN32_FIND_DATA[] fileSystemChildren)
- {
- // A folder with more than 3 non-season folders in will not becounted as a series
- int nonSeriesFolders = 0;
-
- for (int i = 0; i < fileSystemChildren.Length; i++)
- {
- var child = fileSystemChildren[i];
-
- if (child.IsHidden || child.IsSystemFile)
- {
- continue;
- }
-
- if (child.IsDirectory)
- {
- if (IsSeasonFolder(child.Path))
- {
- return true;
- }
-
- nonSeriesFolders++;
-
- if (nonSeriesFolders >= 3)
- {
- return false;
- }
- }
- else
- {
- if (FileSystemHelper.IsVideoFile(child.Path) && !string.IsNullOrEmpty(EpisodeNumberFromFile(child.Path, false)))
- {
- return true;
- }
- }
- }
-
- return false;
- }
-
- public static string EpisodeNumberFromFile(string fullPath, bool isInSeason)
- {
- string fl = fullPath.ToLower();
- foreach (Regex r in episodeExpressions)
- {
- Match m = r.Match(fl);
- if (m.Success)
- return m.Groups["epnumber"].Value;
- }
- if (isInSeason)
- {
- foreach (Regex r in episodeExpressionsInASeasonFolder)
- {
- Match m = r.Match(fl);
- if (m.Success)
- return m.Groups["epnumber"].Value;
- }
-
- }
-
- return null;
- }
- }
-}
+using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Win32; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Linq; + +namespace MediaBrowser.Controller.Resolvers.TV +{ + public static class TVUtils + { + public static readonly string TVDBApiKey = "B89CE93890E9419B"; + public static readonly string BannerUrl = "http://www.thetvdb.com/banners/"; + + /// <summary> + /// A season folder must contain one of these somewhere in the name + /// </summary> + private static readonly string[] SeasonFolderNames = new[] + { + "season", + "sæson", + "temporada", + "saison", + "staffel" + }; + + /// <summary> + /// Used to detect paths that represent episodes, need to make sure they don't also + /// match movie titles like "2001 A Space..." + /// Currently we limit the numbers here to 2 digits to try and avoid this + /// </summary> + /// <remarks> + /// The order here is important, if the order is changed some of the later + /// ones might incorrectly match things that higher ones would have caught. + /// The most restrictive expressions should appear first + /// </remarks> + private static readonly Regex[] EpisodeExpressions = new[] + { + new Regex( + @".*\\[s|S]?(?<seasonnumber>\d{1,2})[x|X](?<epnumber>\d{1,3})[^\\]*$", + RegexOptions.Compiled), + // 01x02 blah.avi S01x01 balh.avi + new Regex( + @".*\\[s|S](?<seasonnumber>\d{1,2})x?[e|E](?<epnumber>\d{1,3})[^\\]*$", + RegexOptions.Compiled), + // S01E02 blah.avi, S01xE01 blah.avi + new Regex( + @".*\\(?<seriesname>[^\\]*)[s|S]?(?<seasonnumber>\d{1,2})[x|X](?<epnumber>\d{1,3})[^\\]*$", + RegexOptions.Compiled), + // 01x02 blah.avi S01x01 balh.avi + new Regex( + @".*\\(?<seriesname>[^\\]*)[s|S](?<seasonnumber>\d{1,2})[x|X|\.]?[e|E](?<epnumber>\d{1,3})[^\\]*$", + RegexOptions.Compiled) + // S01E02 blah.avi, S01xE01 blah.avi + }; + + /// <summary> + /// To avoid the following matching movies they are only valid when contained in a folder which has been matched as a being season + /// </summary> + private static readonly Regex[] EpisodeExpressionsInASeasonFolder = new[] + { + new Regex( + @".*\\(?<epnumber>\d{1,2})\s?-\s?[^\\]*$", + RegexOptions.Compiled), + // 01 - blah.avi, 01-blah.avi + new Regex( + @".*\\(?<epnumber>\d{1,2})[^\d\\]*[^\\]*$", + RegexOptions.Compiled), + // 01.avi, 01.blah.avi "01 - 22 blah.avi" + new Regex( + @".*\\(?<seasonnumber>\d)(?<epnumber>\d{1,2})[^\d\\]+[^\\]*$", + RegexOptions.Compiled), + // 01.avi, 01.blah.avi + new Regex( + @".*\\\D*\d+(?<epnumber>\d{2})", + RegexOptions.Compiled) + // hell0 - 101 - hello.avi + + }; + + public static int? GetSeasonNumberFromPath(string path) + { + // Look for one of the season folder names + foreach (var name in SeasonFolderNames) + { + int index = path.IndexOf(name, StringComparison.OrdinalIgnoreCase); + + if (index != -1) + { + return GetSeasonNumberFromPathSubstring(path.Substring(index + name.Length)); + } + } + + return null; + } + + /// <summary> + /// Extracts the season number from the second half of the Season folder name (everything after "Season", or "Staffel") + /// </summary> + private static int? GetSeasonNumberFromPathSubstring(string path) + { + int numericStart = -1; + int length = 0; + + // Find out where the numbers start, and then keep going until they end + for (int i = 0; i < path.Length; i++) + { + if (char.IsNumber(path, i)) + { + if (numericStart == -1) + { + numericStart = i; + } + length++; + } + else if (numericStart != -1) + { + break; + } + } + + if (numericStart == -1) + { + return null; + } + + return int.Parse(path.Substring(numericStart, length)); + } + + public static bool IsSeasonFolder(string path) + { + return GetSeasonNumberFromPath(path) != null; + } + + public static bool IsSeriesFolder(string path, IEnumerable<WIN32_FIND_DATA> fileSystemChildren) + { + // A folder with more than 3 non-season folders in will not becounted as a series + var nonSeriesFolders = 0; + + foreach (var child in fileSystemChildren) + { + if (child.IsHidden || child.IsSystemFile) + { + continue; + } + + if (child.IsDirectory) + { + if (IsSeasonFolder(child.Path)) + { + return true; + } + + nonSeriesFolders++; + + if (nonSeriesFolders >= 3) + { + return false; + } + } + else + { + if (EntityResolutionHelper.IsVideoFile(child.Path) && + !string.IsNullOrEmpty(EpisodeNumberFromFile(child.Path, false))) + { + return true; + } + } + } + + return false; + } + + public static string EpisodeNumberFromFile(string fullPath, bool isInSeason) + { + string fl = fullPath.ToLower(); + foreach (var r in EpisodeExpressions) + { + Match m = r.Match(fl); + if (m.Success) + return m.Groups["epnumber"].Value; + } + if (isInSeason) + { + var match = EpisodeExpressionsInASeasonFolder.Select(r => r.Match(fl)) + .FirstOrDefault(m => m.Success); + + if (match != null) + { + return match.Value; + } + } + + return null; + } + + public static string SeasonNumberFromEpisodeFile(string fullPath) + { + string fl = fullPath.ToLower(); + foreach (var r in EpisodeExpressions) + { + Match m = r.Match(fl); + if (m.Success) + { + Group g = m.Groups["seasonnumber"]; + if (g != null) + return g.Value; + return null; + } + } + return null; + } + + public static List<DayOfWeek> GetAirDays(string day) + { + if (!string.IsNullOrWhiteSpace(day)) + { + if (day.Equals("Daily", StringComparison.OrdinalIgnoreCase)) + { + return new List<DayOfWeek> + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + }; + } + + DayOfWeek value; + + if (Enum.TryParse(day, true, out value)) + { + return new List<DayOfWeek> + { + value + }; + } + + Logger.LogWarning("Invalid value passed into GetAirDays: {0}", day); + + return new List<DayOfWeek> + { + }; + } + return null; + } + } +} diff --git a/MediaBrowser.Controller/Resolvers/VideoResolver.cs b/MediaBrowser.Controller/Resolvers/VideoResolver.cs index bc3be5e434..bfb364349b 100644 --- a/MediaBrowser.Controller/Resolvers/VideoResolver.cs +++ b/MediaBrowser.Controller/Resolvers/VideoResolver.cs @@ -1,100 +1,73 @@ -using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Controller.IO;
-using System.ComponentModel.Composition;
-using System.IO;
-
-namespace MediaBrowser.Controller.Resolvers
-{
- /// <summary>
- /// Resolves a Path into a Video
- /// </summary>
- [Export(typeof(IBaseItemResolver))]
- public class VideoResolver : BaseVideoResolver<Video>
- {
- public override ResolverPriority Priority
- {
- get { return ResolverPriority.Last; }
- }
- }
-
- /// <summary>
- /// Resolves a Path into a Video or Video subclass
- /// </summary>
- public abstract class BaseVideoResolver<T> : BaseItemResolver<T>
- where T : Video, new()
- {
- protected override T Resolve(ItemResolveEventArgs args)
- {
- // If the path is a file check for a matching extensions
- if (!args.IsDirectory)
- {
- if (FileSystemHelper.IsVideoFile(args.Path))
- {
- VideoType type = Path.GetExtension(args.Path).EndsWith("iso", System.StringComparison.OrdinalIgnoreCase) ? VideoType.Iso : VideoType.VideoFile;
-
- return new T
- {
- VideoType = type,
- Path = args.Path
- };
- }
- }
-
- else
- {
- // If the path is a folder, check if it's bluray or dvd
- T item = ResolveFromFolderName(args.Path);
-
- if (item != null)
- {
- return item;
- }
-
- // Also check the subfolders for bluray or dvd
- for (int i = 0; i < args.FileSystemChildren.Length; i++)
- {
- var folder = args.FileSystemChildren[i];
-
- if (!folder.IsDirectory)
- {
- continue;
- }
-
- item = ResolveFromFolderName(folder.Path);
-
- if (item != null)
- {
- return item;
- }
- }
- }
-
- return null;
- }
-
- private T ResolveFromFolderName(string folder)
- {
- if (folder.IndexOf("video_ts", System.StringComparison.OrdinalIgnoreCase) != -1)
- {
- return new T
- {
- VideoType = VideoType.Dvd,
- Path = Path.GetDirectoryName(folder)
- };
- }
- if (folder.IndexOf("bdmv", System.StringComparison.OrdinalIgnoreCase) != -1)
- {
- return new T
- {
- VideoType = VideoType.BluRay,
- Path = Path.GetDirectoryName(folder)
- };
- }
-
- return null;
- }
-
- }
-}
+using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using System; +using System.ComponentModel.Composition; +using System.IO; + +namespace MediaBrowser.Controller.Resolvers +{ + /// <summary> + /// Resolves a Path into a Video + /// </summary> + [Export(typeof(IBaseItemResolver))] + public class VideoResolver : BaseVideoResolver<Video> + { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public override ResolverPriority Priority + { + get { return ResolverPriority.Last; } + } + } + + /// <summary> + /// Resolves a Path into a Video or Video subclass + /// </summary> + /// <typeparam name="T"></typeparam> + public abstract class BaseVideoResolver<T> : BaseItemResolver<T> + where T : Video, new() + { + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>`0.</returns> + protected override T Resolve(ItemResolveArgs args) + { + // If the path is a file check for a matching extensions + if (!args.IsDirectory) + { + if (EntityResolutionHelper.IsVideoFile(args.Path)) + { + var extension = Path.GetExtension(args.Path); + + var type = string.Equals(extension, ".iso", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".img", StringComparison.OrdinalIgnoreCase) ? + VideoType.Iso : VideoType.VideoFile; + + return new T + { + VideoType = type, + Path = args.Path + }; + } + } + + return null; + } + + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected override void SetInitialItemValues(T item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + item.VideoFormat = item.Path.IndexOf("[3d]", StringComparison.OrdinalIgnoreCase) != -1 ? VideoFormat.Digital3D : item.Path.IndexOf("[sbs3d]", StringComparison.OrdinalIgnoreCase) != -1 ? VideoFormat.Sbs3D : VideoFormat.Standard; + } + } +} diff --git a/MediaBrowser.Controller/ScheduledTasks/ChapterImagesTask.cs b/MediaBrowser.Controller/ScheduledTasks/ChapterImagesTask.cs new file mode 100644 index 0000000000..21f1bce5ac --- /dev/null +++ b/MediaBrowser.Controller/ScheduledTasks/ChapterImagesTask.cs @@ -0,0 +1,100 @@ +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ScheduledTasks +{ + [Export(typeof(IScheduledTask))] + class ChapterImagesTask : BaseScheduledTask<Kernel> + { + /// <summary> + /// Creates the triggers that define when the task will run + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + protected override IEnumerable<BaseTaskTrigger> GetDefaultTriggers() + { + return new BaseTaskTrigger[] + { + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(4) } + }; + } + + /// <summary> + /// Returns the task to be executed + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + protected override Task ExecuteInternal(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + var videos = Kernel.RootFolder.RecursiveChildren.OfType<Video>().Where(v => v.Chapters != null).ToList(); + + var numComplete = 0; + + var tasks = videos.Select(v => Task.Run(async () => + { + try + { + await Kernel.FFMpegManager.PopulateChapterImages(v, cancellationToken, true, true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.ErrorException("Error creating chapter images for {0}", ex, v.Name); + } + finally + { + lock (progress) + { + numComplete++; + double percent = numComplete; + percent /= videos.Count; + + progress.Report(new TaskProgress { PercentComplete = 100 * percent }); + } + } + })); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public override string Name + { + get { return "Create video chapter thumbnails"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public override string Description + { + get { return "Creates thumbnails for videos that have chapters."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public override string Category + { + get + { + return "Library"; + } + } + } +} diff --git a/MediaBrowser.Controller/ScheduledTasks/ImageCleanupTask.cs b/MediaBrowser.Controller/ScheduledTasks/ImageCleanupTask.cs new file mode 100644 index 0000000000..c16f714dbc --- /dev/null +++ b/MediaBrowser.Controller/ScheduledTasks/ImageCleanupTask.cs @@ -0,0 +1,202 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ScheduledTasks +{ + /// <summary> + /// Class ImageCleanupTask + /// </summary> + [Export(typeof(IScheduledTask))] + public class ImageCleanupTask : BaseScheduledTask<Kernel> + { + /// <summary> + /// Creates the triggers that define when the task will run + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + protected override IEnumerable<BaseTaskTrigger> GetDefaultTriggers() + { + return new BaseTaskTrigger[] + { + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(2) } + }; + } + + /// <summary> + /// Returns the task to be executed + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + protected override async Task ExecuteInternal(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + await EnsureChapterImages(cancellationToken).ConfigureAwait(false); + + // First gather all image files + var files = GetFiles(Kernel.FFMpegManager.AudioImagesDataPath) + .Concat(GetFiles(Kernel.FFMpegManager.VideoImagesDataPath)) + .Concat(GetFiles(Kernel.ProviderManager.ImagesDataPath)) + .ToList(); + + // Now gather all items + var items = Kernel.RootFolder.RecursiveChildren.ToList(); + items.Add(Kernel.RootFolder); + + // Determine all possible image paths + var pathsInUse = items.SelectMany(GetPathsInUse) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(p => p, StringComparer.OrdinalIgnoreCase); + + var numComplete = 0; + + var tasks = files.Select(file => Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!pathsInUse.ContainsKey(file)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + File.Delete(file); + } + catch (IOException ex) + { + Logger.ErrorException("Error deleting {0}", ex, file); + } + } + + // Update progress + lock (progress) + { + numComplete++; + double percent = numComplete; + percent /= files.Count; + + progress.Report(new TaskProgress { PercentComplete = 100 * percent }); + } + })); + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + /// <summary> + /// Ensures the chapter images. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private Task EnsureChapterImages(CancellationToken cancellationToken) + { + var videos = Kernel.RootFolder.RecursiveChildren.OfType<Video>().Where(v => v.Chapters != null).ToList(); + + var tasks = videos.Select(v => Task.Run(async () => + { + await Kernel.FFMpegManager.PopulateChapterImages(v, cancellationToken, false, true); + })); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Gets the paths in use. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>IEnumerable{System.String}.</returns> + private IEnumerable<string> GetPathsInUse(BaseItem item) + { + IEnumerable<string> images = new List<string> { }; + + if (item.Images != null) + { + images = images.Concat(item.Images.Values); + } + + if (item.BackdropImagePaths != null) + { + images = images.Concat(item.BackdropImagePaths); + } + + if (item.ScreenshotImagePaths != null) + { + images = images.Concat(item.ScreenshotImagePaths); + } + + var video = item as Video; + + if (video != null && video.Chapters != null) + { + images = images.Concat(video.Chapters.Where(i => !string.IsNullOrEmpty(i.ImagePath)).Select(i => i.ImagePath)); + } + + if (item.LocalTrailers != null) + { + foreach (var subItem in item.LocalTrailers) + { + images = images.Concat(GetPathsInUse(subItem)); + } + } + + var movie = item as Movie; + + if (movie != null && movie.SpecialFeatures != null) + { + foreach (var subItem in movie.SpecialFeatures) + { + images = images.Concat(GetPathsInUse(subItem)); + } + } + + return images; + } + + /// <summary> + /// Gets the files. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>IEnumerable{System.String}.</returns> + private IEnumerable<string> GetFiles(string path) + { + return Directory.EnumerateFiles(path, "*.jpg", SearchOption.AllDirectories).Concat(Directory.EnumerateFiles(path, "*.png", SearchOption.AllDirectories)); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public override string Name + { + get { return "Images cleanup"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public override string Description + { + get { return "Deletes downloaded and extracted images that are no longer being used."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public override string Category + { + get + { + return "Maintenance"; + } + } + } +} diff --git a/MediaBrowser.Controller/ScheduledTasks/PeopleValidationTask.cs b/MediaBrowser.Controller/ScheduledTasks/PeopleValidationTask.cs new file mode 100644 index 0000000000..a5f87f212b --- /dev/null +++ b/MediaBrowser.Controller/ScheduledTasks/PeopleValidationTask.cs @@ -0,0 +1,72 @@ +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ScheduledTasks +{ + /// <summary> + /// Class PeopleValidationTask + /// </summary> + [Export(typeof(IScheduledTask))] + public class PeopleValidationTask : BaseScheduledTask<Kernel> + { + /// <summary> + /// Creates the triggers that define when the task will run + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + protected override IEnumerable<BaseTaskTrigger> GetDefaultTriggers() + { + return new BaseTaskTrigger[] + { + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(2) }, + + new IntervalTrigger{ Interval = TimeSpan.FromHours(12)} + }; + } + + /// <summary> + /// Returns the task to be executed + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + protected override Task ExecuteInternal(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + return Kernel.LibraryManager.ValidatePeople(cancellationToken, progress); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public override string Name + { + get { return "Refresh people"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public override string Description + { + get { return "Updates metadata for actors, artists and directors in your media library."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public override string Category + { + get + { + return "Library"; + } + } + } +} diff --git a/MediaBrowser.Controller/ScheduledTasks/PluginUpdateTask.cs b/MediaBrowser.Controller/ScheduledTasks/PluginUpdateTask.cs new file mode 100644 index 0000000000..3a700d94d4 --- /dev/null +++ b/MediaBrowser.Controller/ScheduledTasks/PluginUpdateTask.cs @@ -0,0 +1,113 @@ +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ScheduledTasks +{ + /// <summary> + /// Plugin Update Task + /// </summary> + [Export(typeof(IScheduledTask))] + public class PluginUpdateTask : BaseScheduledTask<Kernel> + { + /// <summary> + /// Creates the triggers that define when the task will run + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + protected override IEnumerable<BaseTaskTrigger> GetDefaultTriggers() + { + return new BaseTaskTrigger[] { + + // 1:30am + new DailyTrigger { TimeOfDay = TimeSpan.FromHours(1.5) }, + + new IntervalTrigger { Interval = TimeSpan.FromHours(2)} + }; + } + + /// <summary> + /// Update installed plugins + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + protected override async Task ExecuteInternal(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + progress.Report(new TaskProgress { Description = "Checking for plugin updates", PercentComplete = 0 }); + + var packagesToInstall = (await Kernel.InstallationManager.GetAvailablePluginUpdates(true, cancellationToken).ConfigureAwait(false)).ToList(); + + progress.Report(new TaskProgress { PercentComplete = 10 }); + + var numComplete = 0; + + // Create tasks for each one + var tasks = packagesToInstall.Select(i => Task.Run(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await Kernel.InstallationManager.InstallPackage(i, new Progress<double> { }, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // InstallPackage has it's own inner cancellation token, so only throw this if it's ours + if (cancellationToken.IsCancellationRequested) + { + throw; + } + } + catch (HttpException ex) + { + Logger.ErrorException("Error downloading {0}", ex, i.name); + } + catch (IOException ex) + { + Logger.ErrorException("Error updating {0}", ex, i.name); + } + + // Update progress + lock (progress) + { + numComplete++; + double percent = numComplete; + percent /= packagesToInstall.Count; + + progress.Report(new TaskProgress { PercentComplete = (90 * percent) + 10 }); + } + })); + + cancellationToken.ThrowIfCancellationRequested(); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + progress.Report(new TaskProgress { PercentComplete = 100 }); + } + + /// <summary> + /// Gets the name of the task + /// </summary> + /// <value>The name.</value> + public override string Name + { + get { return "Check for plugin updates"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public override string Description + { + get { return "Downloads and installs updates for plugins that are configured to update automatically."; } + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Controller/ScheduledTasks/RefreshMediaLibraryTask.cs b/MediaBrowser.Controller/ScheduledTasks/RefreshMediaLibraryTask.cs new file mode 100644 index 0000000000..b01019fce3 --- /dev/null +++ b/MediaBrowser.Controller/ScheduledTasks/RefreshMediaLibraryTask.cs @@ -0,0 +1,78 @@ +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Model.Tasks; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ScheduledTasks +{ + /// <summary> + /// Class RefreshMediaLibraryTask + /// </summary> + [Export(typeof(IScheduledTask))] + public class RefreshMediaLibraryTask : BaseScheduledTask<Kernel> + { + /// <summary> + /// Gets the default triggers. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + protected override IEnumerable<BaseTaskTrigger> GetDefaultTriggers() + { + return new BaseTaskTrigger[] { + + new StartupTrigger(Kernel), + + new SystemEventTrigger{ SystemEvent = SystemEvent.WakeFromSleep}, + + new IntervalTrigger{ Interval = TimeSpan.FromHours(2)} + }; + } + + /// <summary> + /// Executes the internal. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + protected override Task ExecuteInternal(CancellationToken cancellationToken, IProgress<TaskProgress> progress) + { + cancellationToken.ThrowIfCancellationRequested(); + + progress.Report(new TaskProgress { PercentComplete = 0 }); + + return Kernel.LibraryManager.ValidateMediaLibrary(progress, cancellationToken); + } + + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public override string Name + { + get { return "Scan media library"; } + } + + /// <summary> + /// Gets the description. + /// </summary> + /// <value>The description.</value> + public override string Description + { + get { return "Scans your media library and refreshes metatata based on configuration."; } + } + + /// <summary> + /// Gets the category. + /// </summary> + /// <value>The category.</value> + public override string Category + { + get + { + return "Library"; + } + } + } +} diff --git a/MediaBrowser.Controller/ServerApplicationPaths.cs b/MediaBrowser.Controller/ServerApplicationPaths.cs index f657ee259d..a376afed8a 100644 --- a/MediaBrowser.Controller/ServerApplicationPaths.cs +++ b/MediaBrowser.Controller/ServerApplicationPaths.cs @@ -1,278 +1,334 @@ -using System.IO;
-using MediaBrowser.Common.Kernel;
-
-namespace MediaBrowser.Controller
-{
- /// <summary>
- /// Extends BaseApplicationPaths to add paths that are only applicable on the server
- /// </summary>
- public class ServerApplicationPaths : BaseApplicationPaths
- {
- private string _rootFolderPath;
- /// <summary>
- /// Gets the path to the root media directory
- /// </summary>
- public string RootFolderPath
- {
- get
- {
- if (_rootFolderPath == null)
- {
- _rootFolderPath = Path.Combine(ProgramDataPath, "root");
- if (!Directory.Exists(_rootFolderPath))
- {
- Directory.CreateDirectory(_rootFolderPath);
- }
- }
- return _rootFolderPath;
- }
- }
-
- private string _ibnPath;
- /// <summary>
- /// Gets the path to the Images By Name directory
- /// </summary>
- public string ImagesByNamePath
- {
- get
- {
- if (_ibnPath == null)
- {
- _ibnPath = Path.Combine(ProgramDataPath, "ImagesByName");
- if (!Directory.Exists(_ibnPath))
- {
- Directory.CreateDirectory(_ibnPath);
- }
- }
-
- return _ibnPath;
- }
- }
-
- private string _PeoplePath;
- /// <summary>
- /// Gets the path to the People directory
- /// </summary>
- public string PeoplePath
- {
- get
- {
- if (_PeoplePath == null)
- {
- _PeoplePath = Path.Combine(ImagesByNamePath, "People");
- if (!Directory.Exists(_PeoplePath))
- {
- Directory.CreateDirectory(_PeoplePath);
- }
- }
-
- return _PeoplePath;
- }
- }
-
- private string _GenrePath;
- /// <summary>
- /// Gets the path to the Genre directory
- /// </summary>
- public string GenrePath
- {
- get
- {
- if (_GenrePath == null)
- {
- _GenrePath = Path.Combine(ImagesByNamePath, "Genre");
- if (!Directory.Exists(_GenrePath))
- {
- Directory.CreateDirectory(_GenrePath);
- }
- }
-
- return _GenrePath;
- }
- }
-
- private string _StudioPath;
- /// <summary>
- /// Gets the path to the Studio directory
- /// </summary>
- public string StudioPath
- {
- get
- {
- if (_StudioPath == null)
- {
- _StudioPath = Path.Combine(ImagesByNamePath, "Studio");
- if (!Directory.Exists(_StudioPath))
- {
- Directory.CreateDirectory(_StudioPath);
- }
- }
-
- return _StudioPath;
- }
- }
-
- private string _yearPath;
- /// <summary>
- /// Gets the path to the Year directory
- /// </summary>
- public string YearPath
- {
- get
- {
- if (_yearPath == null)
- {
- _yearPath = Path.Combine(ImagesByNamePath, "Year");
- if (!Directory.Exists(_yearPath))
- {
- Directory.CreateDirectory(_yearPath);
- }
- }
-
- return _yearPath;
- }
- }
-
- private string _userConfigurationDirectoryPath;
- /// <summary>
- /// Gets the path to the user configuration directory
- /// </summary>
- public string UserConfigurationDirectoryPath
- {
- get
- {
- if (_userConfigurationDirectoryPath == null)
- {
- _userConfigurationDirectoryPath = Path.Combine(ConfigurationDirectoryPath, "user");
- if (!Directory.Exists(_userConfigurationDirectoryPath))
- {
- Directory.CreateDirectory(_userConfigurationDirectoryPath);
- }
- }
- return _userConfigurationDirectoryPath;
- }
- }
-
- private string _CacheDirectory;
- /// <summary>
- /// Gets the folder path to the cache directory
- /// </summary>
- public string CacheDirectory
- {
- get
- {
- if (_CacheDirectory == null)
- {
- _CacheDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.ProgramDataPath, "cache");
-
- if (!Directory.Exists(_CacheDirectory))
- {
- Directory.CreateDirectory(_CacheDirectory);
- }
- }
-
- return _CacheDirectory;
- }
- }
-
- private string _FFProbeAudioCacheDirectory;
- /// <summary>
- /// Gets the folder path to the ffprobe audio cache directory
- /// </summary>
- public string FFProbeAudioCacheDirectory
- {
- get
- {
- if (_FFProbeAudioCacheDirectory == null)
- {
- _FFProbeAudioCacheDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.CacheDirectory, "ffprobe-audio");
-
- if (!Directory.Exists(_FFProbeAudioCacheDirectory))
- {
- Directory.CreateDirectory(_FFProbeAudioCacheDirectory);
- }
- }
-
- return _FFProbeAudioCacheDirectory;
- }
- }
-
- private string _FFProbeVideoCacheDirectory;
- /// <summary>
- /// Gets the folder path to the ffprobe video cache directory
- /// </summary>
- public string FFProbeVideoCacheDirectory
- {
- get
- {
- if (_FFProbeVideoCacheDirectory == null)
- {
- _FFProbeVideoCacheDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.CacheDirectory, "ffprobe-video");
-
- if (!Directory.Exists(_FFProbeVideoCacheDirectory))
- {
- Directory.CreateDirectory(_FFProbeVideoCacheDirectory);
- }
- }
-
- return _FFProbeVideoCacheDirectory;
- }
- }
-
- private string _FFMpegDirectory;
- /// <summary>
- /// Gets the folder path to ffmpeg
- /// </summary>
- public string FFMpegDirectory
- {
- get
- {
- if (_FFMpegDirectory == null)
- {
- _FFMpegDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.ProgramDataPath, "FFMpeg");
-
- if (!Directory.Exists(_FFMpegDirectory))
- {
- Directory.CreateDirectory(_FFMpegDirectory);
- }
- }
-
- return _FFMpegDirectory;
- }
- }
-
- private string _FFMpegPath;
- /// <summary>
- /// Gets the path to ffmpeg.exe
- /// </summary>
- public string FFMpegPath
- {
- get
- {
- if (_FFMpegPath == null)
- {
- _FFMpegPath = Path.Combine(FFMpegDirectory, "ffmpeg.exe");
- }
-
- return _FFMpegPath;
- }
- }
-
- private string _FFProbePath;
- /// <summary>
- /// Gets the path to ffprobe.exe
- /// </summary>
- public string FFProbePath
- {
- get
- {
- if (_FFProbePath == null)
- {
- _FFProbePath = Path.Combine(FFMpegDirectory, "ffprobe.exe");
- }
-
- return _FFProbePath;
- }
- }
- }
-}
+using MediaBrowser.Common.Kernel; +using System.IO; + +namespace MediaBrowser.Controller +{ + /// <summary> + /// Extends BaseApplicationPaths to add paths that are only applicable on the server + /// </summary> + public class ServerApplicationPaths : BaseApplicationPaths + { + /// <summary> + /// The _root folder path + /// </summary> + private string _rootFolderPath; + /// <summary> + /// Gets the path to the base root media directory + /// </summary> + /// <value>The root folder path.</value> + public string RootFolderPath + { + get + { + if (_rootFolderPath == null) + { + _rootFolderPath = Path.Combine(ProgramDataPath, "Root"); + if (!Directory.Exists(_rootFolderPath)) + { + Directory.CreateDirectory(_rootFolderPath); + } + } + return _rootFolderPath; + } + } + + /// <summary> + /// The _default user views path + /// </summary> + private string _defaultUserViewsPath; + /// <summary> + /// Gets the path to the default user view directory. Used if no specific user view is defined. + /// </summary> + /// <value>The default user views path.</value> + public string DefaultUserViewsPath + { + get + { + if (_defaultUserViewsPath == null) + { + _defaultUserViewsPath = Path.Combine(RootFolderPath, "Default"); + if (!Directory.Exists(_defaultUserViewsPath)) + { + Directory.CreateDirectory(_defaultUserViewsPath); + } + } + return _defaultUserViewsPath; + } + } + + /// <summary> + /// The _localization path + /// </summary> + private string _localizationPath; + /// <summary> + /// Gets the path to localization data. + /// </summary> + /// <value>The localization path.</value> + public string LocalizationPath + { + get + { + if (_localizationPath == null) + { + _localizationPath = Path.Combine(ProgramDataPath, "Localization"); + if (!Directory.Exists(_localizationPath)) + { + Directory.CreateDirectory(_localizationPath); + } + } + return _localizationPath; + } + } + + /// <summary> + /// The _ibn path + /// </summary> + private string _ibnPath; + /// <summary> + /// Gets the path to the Images By Name directory + /// </summary> + /// <value>The images by name path.</value> + public string ImagesByNamePath + { + get + { + if (_ibnPath == null) + { + _ibnPath = Path.Combine(ProgramDataPath, "ImagesByName"); + if (!Directory.Exists(_ibnPath)) + { + Directory.CreateDirectory(_ibnPath); + } + } + + return _ibnPath; + } + } + + /// <summary> + /// The _people path + /// </summary> + private string _peoplePath; + /// <summary> + /// Gets the path to the People directory + /// </summary> + /// <value>The people path.</value> + public string PeoplePath + { + get + { + if (_peoplePath == null) + { + _peoplePath = Path.Combine(ImagesByNamePath, "People"); + if (!Directory.Exists(_peoplePath)) + { + Directory.CreateDirectory(_peoplePath); + } + } + + return _peoplePath; + } + } + + /// <summary> + /// The _genre path + /// </summary> + private string _genrePath; + /// <summary> + /// Gets the path to the Genre directory + /// </summary> + /// <value>The genre path.</value> + public string GenrePath + { + get + { + if (_genrePath == null) + { + _genrePath = Path.Combine(ImagesByNamePath, "Genre"); + if (!Directory.Exists(_genrePath)) + { + Directory.CreateDirectory(_genrePath); + } + } + + return _genrePath; + } + } + + /// <summary> + /// The _studio path + /// </summary> + private string _studioPath; + /// <summary> + /// Gets the path to the Studio directory + /// </summary> + /// <value>The studio path.</value> + public string StudioPath + { + get + { + if (_studioPath == null) + { + _studioPath = Path.Combine(ImagesByNamePath, "Studio"); + if (!Directory.Exists(_studioPath)) + { + Directory.CreateDirectory(_studioPath); + } + } + + return _studioPath; + } + } + + /// <summary> + /// The _year path + /// </summary> + private string _yearPath; + /// <summary> + /// Gets the path to the Year directory + /// </summary> + /// <value>The year path.</value> + public string YearPath + { + get + { + if (_yearPath == null) + { + _yearPath = Path.Combine(ImagesByNamePath, "Year"); + if (!Directory.Exists(_yearPath)) + { + Directory.CreateDirectory(_yearPath); + } + } + + return _yearPath; + } + } + + /// <summary> + /// The _general path + /// </summary> + private string _generalPath; + /// <summary> + /// Gets the path to the General IBN directory + /// </summary> + /// <value>The general path.</value> + public string GeneralPath + { + get + { + if (_generalPath == null) + { + _generalPath = Path.Combine(ImagesByNamePath, "General"); + if (!Directory.Exists(_generalPath)) + { + Directory.CreateDirectory(_generalPath); + } + } + + return _generalPath; + } + } + + /// <summary> + /// The _ratings path + /// </summary> + private string _ratingsPath; + /// <summary> + /// Gets the path to the Ratings IBN directory + /// </summary> + /// <value>The ratings path.</value> + public string RatingsPath + { + get + { + if (_ratingsPath == null) + { + _ratingsPath = Path.Combine(ImagesByNamePath, "Ratings"); + if (!Directory.Exists(_ratingsPath)) + { + Directory.CreateDirectory(_ratingsPath); + } + } + + return _ratingsPath; + } + } + + /// <summary> + /// The _user configuration directory path + /// </summary> + private string _userConfigurationDirectoryPath; + /// <summary> + /// Gets the path to the user configuration directory + /// </summary> + /// <value>The user configuration directory path.</value> + public string UserConfigurationDirectoryPath + { + get + { + if (_userConfigurationDirectoryPath == null) + { + _userConfigurationDirectoryPath = Path.Combine(ConfigurationDirectoryPath, "users"); + if (!Directory.Exists(_userConfigurationDirectoryPath)) + { + Directory.CreateDirectory(_userConfigurationDirectoryPath); + } + } + return _userConfigurationDirectoryPath; + } + } + + /// <summary> + /// The _f F MPEG stream cache path + /// </summary> + private string _fFMpegStreamCachePath; + /// <summary> + /// Gets the FF MPEG stream cache path. + /// </summary> + /// <value>The FF MPEG stream cache path.</value> + public string FFMpegStreamCachePath + { + get + { + if (_fFMpegStreamCachePath == null) + { + _fFMpegStreamCachePath = Path.Combine(CachePath, "ffmpeg-streams"); + + if (!Directory.Exists(_fFMpegStreamCachePath)) + { + Directory.CreateDirectory(_fFMpegStreamCachePath); + } + } + + return _fFMpegStreamCachePath; + } + } + + /// <summary> + /// The _media tools path + /// </summary> + private string _mediaToolsPath; + /// <summary> + /// Gets the folder path to tools + /// </summary> + /// <value>The media tools path.</value> + public string MediaToolsPath + { + get + { + if (_mediaToolsPath == null) + { + _mediaToolsPath = Path.Combine(ProgramDataPath, "MediaTools"); + + if (!Directory.Exists(_mediaToolsPath)) + { + Directory.CreateDirectory(_mediaToolsPath); + } + } + + return _mediaToolsPath; + } + } + } +} diff --git a/MediaBrowser.Controller/Sorting/BaseItemComparer.cs b/MediaBrowser.Controller/Sorting/BaseItemComparer.cs new file mode 100644 index 0000000000..6e49f396f7 --- /dev/null +++ b/MediaBrowser.Controller/Sorting/BaseItemComparer.cs @@ -0,0 +1,231 @@ +using MediaBrowser.Common.Logging; +using MediaBrowser.Controller.Entities; +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Controller.Sorting { + /// <summary> + /// Class BaseItemComparer + /// </summary> + public class BaseItemComparer : IComparer<BaseItem> { + /// <summary> + /// The _order + /// </summary> + private readonly SortOrder _order; + /// <summary> + /// The _property name + /// </summary> + private readonly string _propertyName; + /// <summary> + /// The _compare culture + /// </summary> + private readonly StringComparison _compareCulture = StringComparison.CurrentCultureIgnoreCase; + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemComparer" /> class. + /// </summary> + /// <param name="order">The order.</param> + public BaseItemComparer(SortOrder order) { + _order = order; + } + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemComparer" /> class. + /// </summary> + /// <param name="order">The order.</param> + /// <param name="compare">The compare.</param> + public BaseItemComparer(SortOrder order, StringComparison compare) { + _order = order; + _compareCulture = compare; + } + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemComparer" /> class. + /// </summary> + /// <param name="property">The property.</param> + public BaseItemComparer(string property) { + _order = SortOrder.Custom; + _propertyName = property; + } + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemComparer" /> class. + /// </summary> + /// <param name="property">The property.</param> + /// <param name="compare">The compare.</param> + public BaseItemComparer(string property, StringComparison compare) { + _order = SortOrder.Custom; + _propertyName = property; + _compareCulture = compare; + } + + #region IComparer<BaseItem> Members + + /// <summary> + /// Compares the specified x. + /// </summary> + /// <param name="x">The x.</param> + /// <param name="y">The y.</param> + /// <returns>System.Int32.</returns> + public int Compare(BaseItem x, BaseItem y) { + int compare = 0; + + switch (_order) { + + case SortOrder.Date: + compare = -x.DateCreated.CompareTo(y.DateCreated); + break; + + case SortOrder.Year: + + var xProductionYear = x.ProductionYear ?? 0; + var yProductionYear = y.ProductionYear ?? 0; + + + compare = yProductionYear.CompareTo(xProductionYear); + break; + + case SortOrder.Rating: + + var xRating = x.CommunityRating ?? 0; + var yRating = y.CommunityRating ?? 0; + + compare = yRating.CompareTo(xRating); + break; + + case SortOrder.Runtime: + var xRuntime = x.RunTimeTicks ?? 0; + var yRuntime = y.RunTimeTicks ?? 0; + + compare = xRuntime.CompareTo(yRuntime); + break; + + case SortOrder.Custom: + + Logger.LogDebugInfo("Sorting on custom field " + _propertyName); + var yProp = y.GetType().GetProperty(_propertyName); + var xProp = x.GetType().GetProperty(_propertyName); + if (yProp == null || xProp == null) break; + var yVal = yProp.GetValue(y, null); + var xVal = xProp.GetValue(x,null); + if (yVal == null && xVal == null) break; + if (yVal == null) return 1; + if (xVal == null) return -1; + compare = String.Compare(xVal.ToString(), yVal.ToString(),_compareCulture); + break; + + default: + compare = 0; + break; + } + + if (compare == 0) { + + var name1 = x.SortName ?? x.Name ?? ""; + var name2 = y.SortName ?? y.Name ?? ""; + + //if (Config.Instance.EnableAlphanumericSorting) + compare = AlphaNumericCompare(name1, name2,_compareCulture); + //else + // compare = String.Compare(name1,name2,_compareCulture); + } + + return compare; + } + + + #endregion + + /// <summary> + /// Alphas the numeric compare. + /// </summary> + /// <param name="s1">The s1.</param> + /// <param name="s2">The s2.</param> + /// <param name="compareCulture">The compare culture.</param> + /// <returns>System.Int32.</returns> + public static int AlphaNumericCompare(string s1, string s2, StringComparison compareCulture) { + // http://dotnetperls.com/Content/Alphanumeric-Sorting.aspx + + int len1 = s1.Length; + int len2 = s2.Length; + int marker1 = 0; + int marker2 = 0; + + // Walk through two the strings with two markers. + while (marker1 < len1 && marker2 < len2) { + char ch1 = s1[marker1]; + char ch2 = s2[marker2]; + + // Some buffers we can build up characters in for each chunk. + var space1 = new char[len1]; + var loc1 = 0; + var space2 = new char[len2]; + var loc2 = 0; + + // Walk through all following characters that are digits or + // characters in BOTH strings starting at the appropriate marker. + // Collect char arrays. + do { + space1[loc1++] = ch1; + marker1++; + + if (marker1 < len1) { + ch1 = s1[marker1]; + } else { + break; + } + } while (char.IsDigit(ch1) == char.IsDigit(space1[0])); + + do { + space2[loc2++] = ch2; + marker2++; + + if (marker2 < len2) { + ch2 = s2[marker2]; + } else { + break; + } + } while (char.IsDigit(ch2) == char.IsDigit(space2[0])); + + // If we have collected numbers, compare them numerically. + // Otherwise, if we have strings, compare them alphabetically. + var str1 = new string(space1); + var str2 = new string(space2); + + var result = 0; + + //biggest int - 2147483647 + if (char.IsDigit(space1[0]) && char.IsDigit(space2[0]) /*&& str1.Length < 10 && str2.Length < 10*/) //this assumed the entire string was a number... + { + int thisNumericChunk; + var isValid = false; + + if (int.TryParse(str1.Substring(0, str1.Length > 9 ? 10 : str1.Length), out thisNumericChunk)) + { + int thatNumericChunk; + + if (int.TryParse(str2.Substring(0, str2.Length > 9 ? 10 : str2.Length), out thatNumericChunk)) + { + isValid = true; + result = thisNumericChunk.CompareTo(thatNumericChunk); + } + } + + if (!isValid) + { + Logger.LogError("Error comparing numeric strings: " + str1 + "/" + str2); + result = String.Compare(str1, str2, compareCulture); + } + + } else { + result = String.Compare(str1,str2,compareCulture); + } + + if (result != 0) { + return result; + } + } + return len1 - len2; + } + } +} diff --git a/MediaBrowser.Controller/Sorting/SortOrder.cs b/MediaBrowser.Controller/Sorting/SortOrder.cs new file mode 100644 index 0000000000..3152ac67e4 --- /dev/null +++ b/MediaBrowser.Controller/Sorting/SortOrder.cs @@ -0,0 +1,33 @@ + +namespace MediaBrowser.Controller.Sorting { + /// <summary> + /// Enum SortOrder + /// </summary> + public enum SortOrder { + + /// <summary> + /// Sort by name + /// </summary> + Name, + /// <summary> + /// Sort by date added to the library + /// </summary> + Date, + /// <summary> + /// Sort by community rating + /// </summary> + Rating, + /// <summary> + /// Sort by runtime + /// </summary> + Runtime, + /// <summary> + /// Sort by year + /// </summary> + Year, + /// <summary> + /// Custom sort order added by plugins + /// </summary> + Custom + } +} diff --git a/MediaBrowser.Controller/Updates/InstallationManager.cs b/MediaBrowser.Controller/Updates/InstallationManager.cs new file mode 100644 index 0000000000..63afa2ce88 --- /dev/null +++ b/MediaBrowser.Controller/Updates/InstallationManager.cs @@ -0,0 +1,486 @@ +using System.Security.Cryptography; +using Ionic.Zip; +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Kernel; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Progress; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Updates; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Updates +{ + /// <summary> + /// Manages all install, uninstall and update operations (both plugins and system) + /// </summary> + public class InstallationManager : BaseManager<Kernel> + { + /// <summary> + /// The current installations + /// </summary> + public readonly List<Tuple<InstallationInfo, CancellationTokenSource>> CurrentInstallations = + new List<Tuple<InstallationInfo, CancellationTokenSource>>(); + + /// <summary> + /// The completed installations + /// </summary> + public readonly ConcurrentBag<InstallationInfo> CompletedInstallations = new ConcurrentBag<InstallationInfo>(); + + #region PluginUninstalled Event + /// <summary> + /// Occurs when [plugin uninstalled]. + /// </summary> + public event EventHandler<GenericEventArgs<IPlugin>> PluginUninstalled; + + /// <summary> + /// Called when [plugin uninstalled]. + /// </summary> + /// <param name="plugin">The plugin.</param> + private void OnPluginUninstalled(IPlugin plugin) + { + EventHelper.QueueEventIfNotNull(PluginUninstalled, this, new GenericEventArgs<IPlugin> { Argument = plugin }); + + // Notify connected ui's + Kernel.TcpManager.SendWebSocketMessage("PluginUninstalled", plugin.GetPluginInfo()); + } + #endregion + + #region PluginUpdated Event + /// <summary> + /// Occurs when [plugin updated]. + /// </summary> + public event EventHandler<GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>>> PluginUpdated; + /// <summary> + /// Called when [plugin updated]. + /// </summary> + /// <param name="plugin">The plugin.</param> + /// <param name="newVersion">The new version.</param> + public void OnPluginUpdated(IPlugin plugin, PackageVersionInfo newVersion) + { + Logger.Info("Plugin updated: {0} {1} {2}", newVersion.name, newVersion.version, newVersion.classification); + + EventHelper.QueueEventIfNotNull(PluginUpdated, this, new GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> { Argument = new Tuple<IPlugin, PackageVersionInfo>(plugin, newVersion) }); + + Kernel.NotifyPendingRestart(); + } + #endregion + + #region PluginInstalled Event + /// <summary> + /// Occurs when [plugin updated]. + /// </summary> + public event EventHandler<GenericEventArgs<PackageVersionInfo>> PluginInstalled; + /// <summary> + /// Called when [plugin installed]. + /// </summary> + /// <param name="package">The package.</param> + public void OnPluginInstalled(PackageVersionInfo package) + { + Logger.Info("New plugin installed: {0} {1} {2}", package.name, package.version, package.classification); + + EventHelper.QueueEventIfNotNull(PluginInstalled, this, new GenericEventArgs<PackageVersionInfo> { Argument = package }); + + Kernel.NotifyPendingRestart(); + } + #endregion + + /// <summary> + /// Initializes a new instance of the <see cref="InstallationManager" /> class. + /// </summary> + /// <param name="kernel">The kernel.</param> + public InstallationManager(Kernel kernel) + : base(kernel) + { + + } + + /// <summary> + /// Gets all available packages. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="packageType">Type of the package.</param> + /// <param name="applicationVersion">The application version.</param> + /// <returns>Task{List{PackageInfo}}.</returns> + public async Task<IEnumerable<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken, + PackageType? packageType = null, + Version applicationVersion = null) + { + var data = new Dictionary<string, string> { { "key", Kernel.PluginSecurityManager.SupporterKey }, { "mac", NetUtils.GetMacAddress() } }; + + using (var json = await Kernel.HttpManager.Post(Controller.Kernel.MBAdminUrl + "service/package/retrieveall", data, Kernel.ResourcePools.Mb, cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var packages = JsonSerializer.DeserializeFromStream<List<PackageInfo>>(json).ToList(); + + foreach (var package in packages) + { + package.versions = package.versions.Where(v => !string.IsNullOrWhiteSpace(v.sourceUrl)) + .OrderByDescending(v => v.version).ToList(); + } + + if (packageType.HasValue) + { + packages = packages.Where(p => p.type == packageType.Value).ToList(); + } + + // If an app version was supplied, filter the versions for each package to only include supported versions + if (applicationVersion != null) + { + foreach (var package in packages) + { + package.versions = package.versions.Where(v => IsPackageVersionUpToDate(v, applicationVersion)).ToList(); + } + } + + // Remove packages with no versions + packages = packages.Where(p => p.versions.Any()).ToList(); + + return packages; + } + } + + /// <summary> + /// Determines whether [is package version up to date] [the specified package version info]. + /// </summary> + /// <param name="packageVersionInfo">The package version info.</param> + /// <param name="applicationVersion">The application version.</param> + /// <returns><c>true</c> if [is package version up to date] [the specified package version info]; otherwise, <c>false</c>.</returns> + private bool IsPackageVersionUpToDate(PackageVersionInfo packageVersionInfo, Version applicationVersion) + { + if (string.IsNullOrEmpty(packageVersionInfo.requiredVersionStr)) + { + return true; + } + + Version requiredVersion; + + return Version.TryParse(packageVersionInfo.requiredVersionStr, out requiredVersion) && applicationVersion >= requiredVersion; + } + + /// <summary> + /// Gets the package. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="classification">The classification.</param> + /// <param name="version">The version.</param> + /// <returns>Task{PackageVersionInfo}.</returns> + public async Task<PackageVersionInfo> GetPackage(string name, PackageVersionClass classification, Version version) + { + var packages = await GetAvailablePackages(CancellationToken.None).ConfigureAwait(false); + + var package = packages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (package == null) + { + return null; + } + + return package.versions.FirstOrDefault(v => v.version.Equals(version) && v.classification == classification); + } + + /// <summary> + /// Gets the latest compatible version. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="classification">The classification.</param> + /// <returns>Task{PackageVersionInfo}.</returns> + public async Task<PackageVersionInfo> GetLatestCompatibleVersion(string name, PackageVersionClass classification = PackageVersionClass.Release) + { + var packages = await GetAvailablePackages(CancellationToken.None).ConfigureAwait(false); + + return GetLatestCompatibleVersion(packages, name, classification); + } + + /// <summary> + /// Gets the latest compatible version. + /// </summary> + /// <param name="availablePackages">The available packages.</param> + /// <param name="name">The name.</param> + /// <param name="classification">The classification.</param> + /// <returns>PackageVersionInfo.</returns> + public PackageVersionInfo GetLatestCompatibleVersion(IEnumerable<PackageInfo> availablePackages, string name, PackageVersionClass classification = PackageVersionClass.Release) + { + var package = availablePackages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (package == null) + { + return null; + } + + return package.versions + .OrderByDescending(v => v.version) + .FirstOrDefault(v => v.classification <= classification && IsPackageVersionUpToDate(v, Kernel.ApplicationVersion)); + } + + /// <summary> + /// Gets the available plugin updates. + /// </summary> + /// <param name="withAutoUpdateEnabled">if set to <c>true</c> [with auto update enabled].</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{IEnumerable{PackageVersionInfo}}.</returns> + public async Task<IEnumerable<PackageVersionInfo>> GetAvailablePluginUpdates(bool withAutoUpdateEnabled, CancellationToken cancellationToken) + { + var catalog = await Kernel.InstallationManager.GetAvailablePackages(cancellationToken).ConfigureAwait(false); + + var plugins = Kernel.Plugins; + + if (withAutoUpdateEnabled) + { + plugins = plugins.Where(p => p.Configuration.EnableAutoUpdate); + } + + // Figure out what needs to be installed + return plugins.Select(p => + { + var latestPluginInfo = Kernel.InstallationManager.GetLatestCompatibleVersion(catalog, p.Name, p.Configuration.UpdateClass); + + return latestPluginInfo != null && latestPluginInfo.version > p.Version ? latestPluginInfo : null; + + }).Where(p => !CompletedInstallations.Any(i => i.Name.Equals(p.name, StringComparison.OrdinalIgnoreCase))) + .Where(p => p != null && !string.IsNullOrWhiteSpace(p.sourceUrl)); + } + + /// <summary> + /// Installs the package. + /// </summary> + /// <param name="package">The package.</param> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">package</exception> + public async Task InstallPackage(PackageVersionInfo package, IProgress<double> progress, CancellationToken cancellationToken) + { + if (package == null) + { + throw new ArgumentNullException("package"); + } + + if (progress == null) + { + throw new ArgumentNullException("progress"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + var installationInfo = new InstallationInfo + { + Id = Guid.NewGuid(), + Name = package.name, + UpdateClass = package.classification, + Version = package.versionStr + }; + + var innerCancellationTokenSource = new CancellationTokenSource(); + + var tuple = new Tuple<InstallationInfo, CancellationTokenSource>(installationInfo, innerCancellationTokenSource); + + // Add it to the in-progress list + lock (CurrentInstallations) + { + CurrentInstallations.Add(tuple); + } + + var innerProgress = new ActionableProgress<double> { }; + + // Whenever the progress updates, update the outer progress object and InstallationInfo + innerProgress.RegisterAction(percent => + { + progress.Report(percent); + + installationInfo.PercentComplete = percent; + }); + + var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; + + Kernel.TcpManager.SendWebSocketMessage("PackageInstalling", installationInfo); + + try + { + await InstallPackageInternal(package, innerProgress, linkedToken).ConfigureAwait(false); + + lock (CurrentInstallations) + { + CurrentInstallations.Remove(tuple); + } + + CompletedInstallations.Add(installationInfo); + + Kernel.TcpManager.SendWebSocketMessage("PackageInstallationCompleted", installationInfo); + } + catch (OperationCanceledException) + { + lock (CurrentInstallations) + { + CurrentInstallations.Remove(tuple); + } + + Logger.Info("Package installation cancelled: {0} {1}", package.name, package.versionStr); + + Kernel.TcpManager.SendWebSocketMessage("PackageInstallationCancelled", installationInfo); + + throw; + } + catch + { + lock (CurrentInstallations) + { + CurrentInstallations.Remove(tuple); + } + + Kernel.TcpManager.SendWebSocketMessage("PackageInstallationFailed", installationInfo); + + throw; + } + finally + { + // Dispose the progress object and remove the installation from the in-progress list + + innerProgress.Dispose(); + tuple.Item2.Dispose(); + } + } + + /// <summary> + /// Installs the package internal. + /// </summary> + /// <param name="package">The package.</param> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task InstallPackageInternal(PackageVersionInfo package, IProgress<double> progress, CancellationToken cancellationToken) + { + // Target based on if it is an archive or single assembly + // zip archives are assumed to contain directory structures relative to our ProgramDataPath + var isArchive = string.Equals(Path.GetExtension(package.sourceUrl), ".zip", StringComparison.OrdinalIgnoreCase); + var target = isArchive ? Kernel.ApplicationPaths.ProgramDataPath : Path.Combine(Kernel.ApplicationPaths.PluginsPath, package.targetFilename); + + // Download to temporary file so that, if interrupted, it won't destroy the existing installation + var tempFile = await Kernel.HttpManager.FetchToTempFile(package.sourceUrl, Kernel.ResourcePools.Mb, cancellationToken, progress).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + // Validate with a checksum + if (package.checksum != Guid.Empty) // support for legacy uploads for now + { + using (var crypto = new MD5CryptoServiceProvider()) + using (var stream = new BufferedStream(File.OpenRead(tempFile), 100000)) + { + var check = Guid.Parse(BitConverter.ToString(crypto.ComputeHash(stream)).Replace("-", String.Empty)); + if (check != package.checksum) + { + throw new ApplicationException(string.Format("Download validation failed for {0}. Probably corrupted during transfer.", package.name)); + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Success - move it to the real target based on type + if (isArchive) + { + try + { + // Extract to target in full - overwriting + using (var zipFile = ZipFile.Read(tempFile)) + { + zipFile.ExtractAll(target, ExtractExistingFileAction.OverwriteSilently); + } + } + catch (IOException e) + { + Logger.ErrorException("Error attempting to extract archive from {0} to {1}", e, tempFile, target); + throw; + } + + } + else + { + try + { + File.Copy(tempFile, target, true); + File.Delete(tempFile); + } + catch (IOException e) + { + Logger.ErrorException("Error attempting to move file from {0} to {1}", e, tempFile, target); + throw; + } + } + + // Set last update time if we were installed before + var plugin = Kernel.Plugins.FirstOrDefault(p => p.Name.Equals(package.name, StringComparison.OrdinalIgnoreCase)); + + if (plugin != null) + { + // Synchronize the UpdateClass value + if (plugin.Configuration.UpdateClass != package.classification) + { + plugin.Configuration.UpdateClass = package.classification; + plugin.SaveConfiguration(); + } + + OnPluginUpdated(plugin, package); + } + else + { + OnPluginInstalled(package); + } + } + + /// <summary> + /// Uninstalls a plugin + /// </summary> + /// <param name="plugin">The plugin.</param> + /// <exception cref="System.ArgumentException"></exception> + public void UninstallPlugin(IPlugin plugin) + { + if (plugin.IsCorePlugin) + { + throw new ArgumentException(string.Format("{0} cannot be uninstalled because it is a core plugin.", plugin.Name)); + } + + plugin.OnUninstalling(); + + // Remove it the quick way for now + Kernel.RemovePlugin(plugin); + + File.Delete(plugin.AssemblyFilePath); + + OnPluginUninstalled(plugin); + + Kernel.NotifyPendingRestart(); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected override void Dispose(bool dispose) + { + if (dispose) + { + lock (CurrentInstallations) + { + foreach (var tuple in CurrentInstallations) + { + tuple.Item2.Dispose(); + } + + CurrentInstallations.Clear(); + } + } + base.Dispose(dispose); + } + } +} diff --git a/MediaBrowser.Controller/Weather/BaseWeatherProvider.cs b/MediaBrowser.Controller/Weather/BaseWeatherProvider.cs index c3d436e667..4ae7a3991c 100644 --- a/MediaBrowser.Controller/Weather/BaseWeatherProvider.cs +++ b/MediaBrowser.Controller/Weather/BaseWeatherProvider.cs @@ -1,34 +1,37 @@ -using MediaBrowser.Common.Logging;
-using MediaBrowser.Model.Weather;
-using System;
-using System.Net;
-using System.Net.Cache;
-using System.Net.Http;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Weather
-{
- public abstract class BaseWeatherProvider : IDisposable
- {
- protected HttpClient HttpClient { get; private set; }
-
- protected BaseWeatherProvider()
- {
- var handler = new WebRequestHandler { };
-
- handler.AutomaticDecompression = DecompressionMethods.Deflate;
- handler.CachePolicy = new RequestCachePolicy(RequestCacheLevel.Revalidate);
-
- HttpClient = new HttpClient(handler);
- }
-
- public virtual void Dispose()
- {
- Logger.LogInfo("Disposing " + GetType().Name);
-
- HttpClient.Dispose();
- }
-
- public abstract Task<WeatherInfo> GetWeatherInfoAsync(string zipCode);
- }
-}
+using MediaBrowser.Model.Weather; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Weather +{ + /// <summary> + /// Class BaseWeatherProvider + /// </summary> + public abstract class BaseWeatherProvider : IDisposable + { + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + } + + /// <summary> + /// Gets the weather info async. + /// </summary> + /// <param name="location">The location.</param> + /// <returns>Task{WeatherInfo}.</returns> + public abstract Task<WeatherInfo> GetWeatherInfoAsync(string location, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/Weather/WeatherProvider.cs b/MediaBrowser.Controller/Weather/WeatherProvider.cs index 0fc7288790..1560aa92c6 100644 --- a/MediaBrowser.Controller/Weather/WeatherProvider.cs +++ b/MediaBrowser.Controller/Weather/WeatherProvider.cs @@ -1,189 +1,311 @@ -using MediaBrowser.Common.Logging;
-using MediaBrowser.Common.Serialization;
-using MediaBrowser.Model.Weather;
-using System;
-using System.ComponentModel.Composition;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Controller.Weather
-{
- /// <summary>
- /// Based on http://www.worldweatheronline.com/free-weather-feed.aspx
- /// The classes in this file are a reproduction of the json output, which will then be converted to our weather model classes
- /// </summary>
- [Export(typeof(BaseWeatherProvider))]
- public class WeatherProvider : BaseWeatherProvider
- {
- public override async Task<WeatherInfo> GetWeatherInfoAsync(string zipCode)
- {
- if (string.IsNullOrWhiteSpace(zipCode))
- {
- return null;
- }
-
- const int numDays = 5;
- const string apiKey = "24902f60f1231941120109";
-
- string url = "http://free.worldweatheronline.com/feed/weather.ashx?q=" + zipCode + "&format=json&num_of_days=" + numDays + "&key=" + apiKey;
-
- Logger.LogInfo("Accessing weather from " + url);
-
- using (Stream stream = await HttpClient.GetStreamAsync(url).ConfigureAwait(false))
- {
- WeatherData data = JsonSerializer.DeserializeFromStream<WeatherResult>(stream).data;
-
- return GetWeatherInfo(data);
- }
- }
-
- /// <summary>
- /// Converst the json output to our WeatherInfo model class
- /// </summary>
- private WeatherInfo GetWeatherInfo(WeatherData data)
- {
- var info = new WeatherInfo();
-
- if (data.current_condition != null)
- {
- if (data.current_condition.Any())
- {
- info.CurrentWeather = data.current_condition.First().ToWeatherStatus();
- }
- }
-
- if (data.weather != null)
- {
- info.Forecasts = data.weather.Select(w => w.ToWeatherForecast()).ToArray();
- }
-
- return info;
- }
- }
-
- class WeatherResult
- {
- public WeatherData data { get; set; }
- }
-
- public class WeatherData
- {
- public WeatherCondition[] current_condition { get; set; }
- public DailyWeatherInfo[] weather { get; set; }
- }
-
- public class WeatherCondition
- {
- public string temp_C { get; set; }
- public string temp_F { get; set; }
- public string humidity { get; set; }
- public string weatherCode { get; set; }
-
- public WeatherStatus ToWeatherStatus()
- {
- return new WeatherStatus
- {
- TemperatureCelsius = int.Parse(temp_C),
- TemperatureFahrenheit = int.Parse(temp_F),
- Humidity = int.Parse(humidity),
- Condition = DailyWeatherInfo.GetCondition(weatherCode)
- };
- }
- }
-
- public class DailyWeatherInfo
- {
- public string date { get; set; }
- public string precipMM { get; set; }
- public string tempMaxC { get; set; }
- public string tempMaxF { get; set; }
- public string tempMinC { get; set; }
- public string tempMinF { get; set; }
- public string weatherCode { get; set; }
- public string winddir16Point { get; set; }
- public string winddirDegree { get; set; }
- public string winddirection { get; set; }
- public string windspeedKmph { get; set; }
- public string windspeedMiles { get; set; }
-
- public WeatherForecast ToWeatherForecast()
- {
- return new WeatherForecast
- {
- Date = DateTime.Parse(date),
- HighTemperatureCelsius = int.Parse(tempMaxC),
- HighTemperatureFahrenheit = int.Parse(tempMaxF),
- LowTemperatureCelsius = int.Parse(tempMinC),
- LowTemperatureFahrenheit = int.Parse(tempMinF),
- Condition = GetCondition(weatherCode)
- };
- }
-
- public static WeatherConditions GetCondition(string weatherCode)
- {
- switch (weatherCode)
- {
- case "362":
- case "365":
- case "320":
- case "317":
- case "182":
- return WeatherConditions.Sleet;
- case "338":
- case "335":
- case "332":
- case "329":
- case "326":
- case "323":
- case "377":
- case "374":
- case "371":
- case "368":
- case "395":
- case "392":
- case "350":
- case "227":
- case "179":
- return WeatherConditions.Snow;
- case "314":
- case "311":
- case "308":
- case "305":
- case "302":
- case "299":
- case "296":
- case "293":
- case "284":
- case "281":
- case "266":
- case "263":
- case "359":
- case "356":
- case "353":
- case "185":
- case "176":
- return WeatherConditions.Rain;
- case "260":
- case "248":
- return WeatherConditions.Fog;
- case "389":
- case "386":
- case "200":
- return WeatherConditions.Thunderstorm;
- case "230":
- return WeatherConditions.Blizzard;
- case "143":
- return WeatherConditions.Mist;
- case "122":
- return WeatherConditions.Overcast;
- case "119":
- return WeatherConditions.Cloudy;
- case "115":
- return WeatherConditions.PartlyCloudy;
- default:
- return WeatherConditions.Sunny;
- }
- }
- }
-}
+using MediaBrowser.Common.Logging; +using MediaBrowser.Common.Serialization; +using MediaBrowser.Model.Weather; +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Weather +{ + /// <summary> + /// Based on http://www.worldweatheronline.com/free-weather-feed.aspx + /// The classes in this file are a reproduction of the json output, which will then be converted to our weather model classes + /// </summary> + [Export(typeof(BaseWeatherProvider))] + public class WeatherProvider : BaseWeatherProvider + { + /// <summary> + /// The _weather semaphore + /// </summary> + private readonly SemaphoreSlim _weatherSemaphore = new SemaphoreSlim(10, 10); + + /// <summary> + /// Gets the weather info async. + /// </summary> + /// <param name="location">The location.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task{WeatherInfo}.</returns> + /// <exception cref="System.ArgumentNullException">location</exception> + public override async Task<WeatherInfo> GetWeatherInfoAsync(string location, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(location)) + { + throw new ArgumentNullException("location"); + } + + if (cancellationToken == null) + { + throw new ArgumentNullException("cancellationToken"); + } + + const int numDays = 5; + const string apiKey = "24902f60f1231941120109"; + + var url = "http://free.worldweatheronline.com/feed/weather.ashx?q=" + location + "&format=json&num_of_days=" + numDays + "&key=" + apiKey; + + Logger.LogInfo("Accessing weather from " + url); + + using (var stream = await Kernel.Instance.HttpManager.Get(url, _weatherSemaphore, cancellationToken).ConfigureAwait(false)) + { + var data = JsonSerializer.DeserializeFromStream<WeatherResult>(stream).data; + + return GetWeatherInfo(data); + } + } + + /// <summary> + /// Converst the json output to our WeatherInfo model class + /// </summary> + /// <param name="data">The data.</param> + /// <returns>WeatherInfo.</returns> + private WeatherInfo GetWeatherInfo(WeatherData data) + { + var info = new WeatherInfo(); + + if (data.current_condition != null) + { + var condition = data.current_condition.FirstOrDefault(); + + if (condition != null) + { + info.CurrentWeather = condition.ToWeatherStatus(); + } + } + + if (data.weather != null) + { + info.Forecasts = data.weather.Select(w => w.ToWeatherForecast()).ToArray(); + } + + return info; + } + } + + /// <summary> + /// Class WeatherResult + /// </summary> + class WeatherResult + { + /// <summary> + /// Gets or sets the data. + /// </summary> + /// <value>The data.</value> + public WeatherData data { get; set; } + } + + /// <summary> + /// Class WeatherData + /// </summary> + public class WeatherData + { + /// <summary> + /// Gets or sets the current_condition. + /// </summary> + /// <value>The current_condition.</value> + public WeatherCondition[] current_condition { get; set; } + /// <summary> + /// Gets or sets the weather. + /// </summary> + /// <value>The weather.</value> + public DailyWeatherInfo[] weather { get; set; } + } + + /// <summary> + /// Class WeatherCondition + /// </summary> + public class WeatherCondition + { + /// <summary> + /// Gets or sets the temp_ C. + /// </summary> + /// <value>The temp_ C.</value> + public string temp_C { get; set; } + /// <summary> + /// Gets or sets the temp_ F. + /// </summary> + /// <value>The temp_ F.</value> + public string temp_F { get; set; } + /// <summary> + /// Gets or sets the humidity. + /// </summary> + /// <value>The humidity.</value> + public string humidity { get; set; } + /// <summary> + /// Gets or sets the weather code. + /// </summary> + /// <value>The weather code.</value> + public string weatherCode { get; set; } + + /// <summary> + /// To the weather status. + /// </summary> + /// <returns>WeatherStatus.</returns> + public WeatherStatus ToWeatherStatus() + { + return new WeatherStatus + { + TemperatureCelsius = int.Parse(temp_C), + TemperatureFahrenheit = int.Parse(temp_F), + Humidity = int.Parse(humidity), + Condition = DailyWeatherInfo.GetCondition(weatherCode) + }; + } + } + + /// <summary> + /// Class DailyWeatherInfo + /// </summary> + public class DailyWeatherInfo + { + /// <summary> + /// Gets or sets the date. + /// </summary> + /// <value>The date.</value> + public string date { get; set; } + /// <summary> + /// Gets or sets the precip MM. + /// </summary> + /// <value>The precip MM.</value> + public string precipMM { get; set; } + /// <summary> + /// Gets or sets the temp max C. + /// </summary> + /// <value>The temp max C.</value> + public string tempMaxC { get; set; } + /// <summary> + /// Gets or sets the temp max F. + /// </summary> + /// <value>The temp max F.</value> + public string tempMaxF { get; set; } + /// <summary> + /// Gets or sets the temp min C. + /// </summary> + /// <value>The temp min C.</value> + public string tempMinC { get; set; } + /// <summary> + /// Gets or sets the temp min F. + /// </summary> + /// <value>The temp min F.</value> + public string tempMinF { get; set; } + /// <summary> + /// Gets or sets the weather code. + /// </summary> + /// <value>The weather code.</value> + public string weatherCode { get; set; } + /// <summary> + /// Gets or sets the winddir16 point. + /// </summary> + /// <value>The winddir16 point.</value> + public string winddir16Point { get; set; } + /// <summary> + /// Gets or sets the winddir degree. + /// </summary> + /// <value>The winddir degree.</value> + public string winddirDegree { get; set; } + /// <summary> + /// Gets or sets the winddirection. + /// </summary> + /// <value>The winddirection.</value> + public string winddirection { get; set; } + /// <summary> + /// Gets or sets the windspeed KMPH. + /// </summary> + /// <value>The windspeed KMPH.</value> + public string windspeedKmph { get; set; } + /// <summary> + /// Gets or sets the windspeed miles. + /// </summary> + /// <value>The windspeed miles.</value> + public string windspeedMiles { get; set; } + + /// <summary> + /// To the weather forecast. + /// </summary> + /// <returns>WeatherForecast.</returns> + public WeatherForecast ToWeatherForecast() + { + return new WeatherForecast + { + Date = DateTime.Parse(date), + HighTemperatureCelsius = int.Parse(tempMaxC), + HighTemperatureFahrenheit = int.Parse(tempMaxF), + LowTemperatureCelsius = int.Parse(tempMinC), + LowTemperatureFahrenheit = int.Parse(tempMinF), + Condition = GetCondition(weatherCode) + }; + } + + /// <summary> + /// Gets the condition. + /// </summary> + /// <param name="weatherCode">The weather code.</param> + /// <returns>WeatherConditions.</returns> + public static WeatherConditions GetCondition(string weatherCode) + { + switch (weatherCode) + { + case "362": + case "365": + case "320": + case "317": + case "182": + return WeatherConditions.Sleet; + case "338": + case "335": + case "332": + case "329": + case "326": + case "323": + case "377": + case "374": + case "371": + case "368": + case "395": + case "392": + case "350": + case "227": + case "179": + return WeatherConditions.Snow; + case "314": + case "311": + case "308": + case "305": + case "302": + case "299": + case "296": + case "293": + case "284": + case "281": + case "266": + case "263": + case "359": + case "356": + case "353": + case "185": + case "176": + return WeatherConditions.Rain; + case "260": + case "248": + return WeatherConditions.Fog; + case "389": + case "386": + case "200": + return WeatherConditions.Thunderstorm; + case "230": + return WeatherConditions.Blizzard; + case "143": + return WeatherConditions.Mist; + case "122": + return WeatherConditions.Overcast; + case "119": + return WeatherConditions.Cloudy; + case "115": + return WeatherConditions.PartlyCloudy; + default: + return WeatherConditions.Sunny; + } + } + } +} diff --git a/MediaBrowser.Controller/Xml/XmlExtensions.cs b/MediaBrowser.Controller/Xml/XmlExtensions.cs deleted file mode 100644 index d2e8e19832..0000000000 --- a/MediaBrowser.Controller/Xml/XmlExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Globalization;
-using System.Xml;
-
-namespace MediaBrowser.Controller.Xml
-{
- public static class XmlExtensions
- {
- private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
-
- /// <summary>
- /// Reads a float from the current element of an XmlReader
- /// </summary>
- public static float ReadFloatSafe(this XmlReader reader)
- {
- string valueString = reader.ReadElementContentAsString();
-
- float value = 0;
-
- if (!string.IsNullOrWhiteSpace(valueString))
- {
- // float.TryParse is local aware, so it can be probamatic, force us culture
- float.TryParse(valueString, NumberStyles.AllowDecimalPoint, _usCulture, out value);
- }
-
- return value;
- }
-
- /// <summary>
- /// Reads an int from the current element of an XmlReader
- /// </summary>
- public static int ReadIntSafe(this XmlReader reader)
- {
- string valueString = reader.ReadElementContentAsString();
-
- int value = 0;
-
- if (!string.IsNullOrWhiteSpace(valueString))
- {
-
- int.TryParse(valueString, out value);
- }
-
- return value;
- }
- }
-}
diff --git a/MediaBrowser.Controller/packages.config b/MediaBrowser.Controller/packages.config index 42f16a2676..8a94d35ff5 100644 --- a/MediaBrowser.Controller/packages.config +++ b/MediaBrowser.Controller/packages.config @@ -1,6 +1,10 @@ -<?xml version="1.0" encoding="utf-8"?>
-<packages>
- <package id="Rx-Core" version="2.0.20823" targetFramework="net45" />
- <package id="Rx-Interfaces" version="2.0.20823" targetFramework="net45" />
- <package id="Rx-Linq" version="2.0.20823" targetFramework="net45" />
+<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="DotNetZip" version="1.9.1.8" targetFramework="net45" /> + <package id="morelinq" version="1.0.15631-beta" targetFramework="net45" /> + <package id="protobuf-net" version="2.0.0.621" targetFramework="net45" /> + <package id="Rx-Core" version="2.0.21114" targetFramework="net45" /> + <package id="Rx-Interfaces" version="2.0.21114" targetFramework="net45" /> + <package id="Rx-Linq" version="2.0.21114" targetFramework="net45" /> + <package id="System.Data.SQLite" version="1.0.84.0" targetFramework="net45" /> </packages>
\ No newline at end of file |
