Added initial implementation of the metadata provider network, along with the first few providers
This commit is contained in:
parent
803ce0968e
commit
d794eecec4
|
@ -12,6 +12,7 @@ using MediaBrowser.Common.Net;
|
|||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Common.Serialization;
|
||||
using MediaBrowser.Model.Progress;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Common.Kernel
|
||||
{
|
||||
|
@ -51,18 +52,21 @@ namespace MediaBrowser.Common.Kernel
|
|||
ApplicationPaths = new TApplicationPathsType();
|
||||
}
|
||||
|
||||
public virtual void Init(IProgress<TaskProgress> progress)
|
||||
public virtual Task Init(IProgress<TaskProgress> progress)
|
||||
{
|
||||
ReloadLogger();
|
||||
return Task.Run(() =>
|
||||
{
|
||||
ReloadLogger();
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Loading configuration", PercentComplete = 0 });
|
||||
ReloadConfiguration();
|
||||
progress.Report(new TaskProgress() { Description = "Loading configuration", PercentComplete = 0 });
|
||||
ReloadConfiguration();
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Starting Http server", PercentComplete = 5 });
|
||||
ReloadHttpServer();
|
||||
progress.Report(new TaskProgress() { Description = "Starting Http server", PercentComplete = 5 });
|
||||
ReloadHttpServer();
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Loading Plugins", PercentComplete = 10 });
|
||||
ReloadComposableParts();
|
||||
progress.Report(new TaskProgress() { Description = "Loading Plugins", PercentComplete = 10 });
|
||||
ReloadComposableParts();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -151,6 +151,50 @@ namespace MediaBrowser.Controller.Configuration
|
|||
}
|
||||
}
|
||||
|
||||
private string _CacheDirectory = null;
|
||||
/// <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 = null;
|
||||
/// <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 _FFMpegDirectory = null;
|
||||
/// <summary>
|
||||
/// Gets the folder path to ffmpeg
|
||||
|
@ -221,7 +265,7 @@ namespace MediaBrowser.Controller.Configuration
|
|||
|
||||
_FFProbePath = Path.Combine(FFMpegDirectory, filename);
|
||||
|
||||
// Always re-extract the first time to handle new versions
|
||||
/*// Always re-extract the first time to handle new versions
|
||||
if (File.Exists(_FFProbePath))
|
||||
{
|
||||
File.Delete(_FFProbePath);
|
||||
|
@ -234,7 +278,7 @@ namespace MediaBrowser.Controller.Configuration
|
|||
{
|
||||
stream.CopyTo(fileStream);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
return _FFProbePath;
|
||||
|
|
|
@ -1,13 +1,41 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Logging;
|
||||
using MediaBrowser.Common.Serialization;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.FFMpeg
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs FFProbe against a media file and returns metadata.
|
||||
/// </summary>
|
||||
public static class FFProbe
|
||||
{
|
||||
public static FFProbeResult Run(string path)
|
||||
public async static Task<FFProbeResult> Run(Audio item, string outputCachePath)
|
||||
{
|
||||
// Use try catch to avoid having to use File.Exists
|
||||
try
|
||||
{
|
||||
using (FileStream stream = File.OpenRead(outputCachePath))
|
||||
{
|
||||
return JsonSerializer.DeserializeFromStream<FFProbeResult>(stream);
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
|
||||
await Run(item.Path, outputCachePath);
|
||||
|
||||
using (FileStream stream = File.OpenRead(outputCachePath))
|
||||
{
|
||||
return JsonSerializer.DeserializeFromStream<FFProbeResult>(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private async static Task Run(string input, string output)
|
||||
{
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo();
|
||||
|
||||
|
@ -21,13 +49,15 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
|
||||
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", path);
|
||||
startInfo.Arguments = string.Format("\"{0}\" -v quiet -print_format json -show_streams -show_format", input);
|
||||
|
||||
Logger.LogInfo(startInfo.FileName + " " + startInfo.Arguments);
|
||||
//Logger.LogInfo(startInfo.FileName + " " + startInfo.Arguments);
|
||||
|
||||
Process process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
|
||||
FileStream stream = new FileStream(output, FileMode.Create);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
|
@ -36,18 +66,23 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
// If we ever decide to disable the ffmpeg log then you must uncomment the below line.
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
FFProbeResult result = JsonSerializer.DeserializeFromStream<FFProbeResult>(process.StandardOutput.BaseStream);
|
||||
await process.StandardOutput.BaseStream.CopyToAsync(stream);
|
||||
|
||||
process.WaitForExit();
|
||||
|
||||
Logger.LogInfo("FFMpeg exited with code " + process.ExitCode);
|
||||
stream.Dispose();
|
||||
|
||||
return result;
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Logger.LogInfo("FFProbe exited with code {0} for {1}", process.ExitCode, input);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogException(ex);
|
||||
|
||||
stream.Dispose();
|
||||
|
||||
// Hate having to do this
|
||||
try
|
||||
{
|
||||
|
@ -56,8 +91,7 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
File.Delete(output);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
@ -13,6 +13,11 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
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>
|
||||
public class MediaStream
|
||||
{
|
||||
public int index { get; set; }
|
||||
|
@ -20,28 +25,28 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
public string codec_name { get; set; }
|
||||
public string codec_long_name { get; set; }
|
||||
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; }
|
||||
//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; }
|
||||
public string sample_rate { get; set; }
|
||||
public int channels { get; set; }
|
||||
public int bits_per_sample { get; set; }
|
||||
public string r_frame_rate { get; set; }
|
||||
public string avg_frame_rate { get; set; }
|
||||
public string time_base { get; set; }
|
||||
public string start_time { get; set; }
|
||||
//public int bits_per_sample { get; set; }
|
||||
//public string r_frame_rate { get; set; }
|
||||
//public string avg_frame_rate { get; set; }
|
||||
//public string time_base { get; set; }
|
||||
//public string start_time { get; set; }
|
||||
public string duration { get; set; }
|
||||
public string bit_rate { get; set; }
|
||||
|
||||
public int width { get; set; }
|
||||
public int height { get; set; }
|
||||
public int has_b_frames { get; set; }
|
||||
public string sample_aspect_ratio { get; set; }
|
||||
public string display_aspect_ratio { get; set; }
|
||||
public string pix_fmt { get; set; }
|
||||
public int level { get; set; }
|
||||
public MediaTags tags { get; set; }
|
||||
//public int has_b_frames { get; set; }
|
||||
//public string sample_aspect_ratio { get; set; }
|
||||
//public string display_aspect_ratio { get; set; }
|
||||
//public string pix_fmt { get; set; }
|
||||
//public int level { get; set; }
|
||||
public Dictionary<string,string> tags { get; set; }
|
||||
}
|
||||
|
||||
public class MediaFormat
|
||||
|
@ -54,23 +59,6 @@ namespace MediaBrowser.Controller.FFMpeg
|
|||
public string duration { get; set; }
|
||||
public string size { get; set; }
|
||||
public string bit_rate { get; set; }
|
||||
public MediaTags tags { get; set; }
|
||||
}
|
||||
|
||||
public class MediaTags
|
||||
{
|
||||
public string title { get; set; }
|
||||
public string comment { get; set; }
|
||||
public string artist { get; set; }
|
||||
public string album { get; set; }
|
||||
public string album_artist { get; set; }
|
||||
public string composer { get; set; }
|
||||
public string copyright { get; set; }
|
||||
public string publisher { get; set; }
|
||||
public string track { get; set; }
|
||||
public string disc { get; set; }
|
||||
public string genre { get; set; }
|
||||
public string date { get; set; }
|
||||
public string language { get; set; }
|
||||
public Dictionary<string, string> tags { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.IO
|
|||
|
||||
private void ProcessPathChanges(IEnumerable<string> paths)
|
||||
{
|
||||
List<BaseItem> itemsToRefresh = new List<BaseItem>();
|
||||
/*List<BaseItem> itemsToRefresh = new List<BaseItem>();
|
||||
|
||||
foreach (BaseItem item in paths.Select(p => GetAffectedBaseItem(p)))
|
||||
{
|
||||
|
@ -113,7 +113,7 @@ namespace MediaBrowser.Controller.IO
|
|||
{
|
||||
Kernel.Instance.ReloadItem(itemsToRefresh[i]);
|
||||
});
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
private BaseItem GetAffectedBaseItem(string path)
|
||||
|
|
|
@ -5,11 +5,13 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Kernel;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Progress;
|
||||
|
@ -35,6 +37,12 @@ namespace MediaBrowser.Controller
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of currently registered metadata prvoiders
|
||||
/// </summary>
|
||||
[ImportMany(typeof(BaseMetadataProvider))]
|
||||
public IEnumerable<BaseMetadataProvider> MetadataProviders { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of currently registered entity resolvers
|
||||
/// </summary>
|
||||
|
@ -56,35 +64,63 @@ namespace MediaBrowser.Controller
|
|||
ItemController.BeginResolvePath += ItemController_BeginResolvePath;
|
||||
}
|
||||
|
||||
public override void Init(IProgress<TaskProgress> progress)
|
||||
public async override Task Init(IProgress<TaskProgress> progress)
|
||||
{
|
||||
base.Init(progress);
|
||||
await base.Init(progress);
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Loading Users", PercentComplete = 15 });
|
||||
ReloadUsers();
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Loading Media Library", PercentComplete = 20 });
|
||||
ReloadRoot();
|
||||
await ReloadRoot();
|
||||
|
||||
progress.Report(new TaskProgress() { Description = "Loading Complete", PercentComplete = 100 });
|
||||
}
|
||||
|
||||
protected override void OnComposablePartsLoaded()
|
||||
{
|
||||
List<IBaseItemResolver> resolvers = EntityResolvers.ToList();
|
||||
|
||||
// Add the internal resolvers
|
||||
resolvers.Add(new VideoResolver());
|
||||
resolvers.Add(new AudioResolver());
|
||||
resolvers.Add(new VirtualFolderResolver());
|
||||
resolvers.Add(new FolderResolver());
|
||||
|
||||
EntityResolvers = resolvers;
|
||||
AddCoreResolvers();
|
||||
AddCoreProviders();
|
||||
|
||||
// The base class will start up all the plugins
|
||||
base.OnComposablePartsLoaded();
|
||||
}
|
||||
|
||||
private void AddCoreResolvers()
|
||||
{
|
||||
List<IBaseItemResolver> list = EntityResolvers.ToList();
|
||||
|
||||
// Add the core resolvers
|
||||
list.AddRange(new IBaseItemResolver[]{
|
||||
new AudioResolver(),
|
||||
new VideoResolver(),
|
||||
new VirtualFolderResolver(),
|
||||
new FolderResolver()
|
||||
});
|
||||
|
||||
EntityResolvers = list;
|
||||
}
|
||||
|
||||
private void AddCoreProviders()
|
||||
{
|
||||
List<BaseMetadataProvider> list = MetadataProviders.ToList();
|
||||
|
||||
// Add the core resolvers
|
||||
list.InsertRange(0, new BaseMetadataProvider[]{
|
||||
new ImageFromMediaLocationProvider(),
|
||||
new LocalTrailerProvider(),
|
||||
new AudioInfoProvider(),
|
||||
new FolderProviderFromXml()
|
||||
});
|
||||
|
||||
MetadataProviders = list;
|
||||
|
||||
Parallel.ForEach(MetadataProviders, provider =>
|
||||
{
|
||||
provider.Init();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires when a path is about to be resolved, but before child folders and files
|
||||
/// have been collected from the file system.
|
||||
|
@ -129,7 +165,7 @@ namespace MediaBrowser.Controller
|
|||
/// <summary>
|
||||
/// Reloads the root media folder
|
||||
/// </summary>
|
||||
public void ReloadRoot()
|
||||
public async Task ReloadRoot()
|
||||
{
|
||||
if (!Directory.Exists(MediaRootFolderPath))
|
||||
{
|
||||
|
@ -138,7 +174,7 @@ namespace MediaBrowser.Controller
|
|||
|
||||
DirectoryWatchers.Stop();
|
||||
|
||||
RootFolder = ItemController.GetItem(MediaRootFolderPath) as Folder;
|
||||
RootFolder = await ItemController.GetItem(null, MediaRootFolderPath) as Folder;
|
||||
|
||||
DirectoryWatchers.Start();
|
||||
}
|
||||
|
@ -152,23 +188,23 @@ namespace MediaBrowser.Controller
|
|||
}
|
||||
}
|
||||
|
||||
public void ReloadItem(BaseItem item)
|
||||
public async Task ReloadItem(BaseItem item)
|
||||
{
|
||||
Folder folder = item as Folder;
|
||||
|
||||
if (folder != null && folder.IsRoot)
|
||||
{
|
||||
ReloadRoot();
|
||||
await ReloadRoot();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!Directory.Exists(item.Path) && !File.Exists(item.Path))
|
||||
{
|
||||
ReloadItem(item.Parent);
|
||||
await ReloadItem(item.Parent);
|
||||
return;
|
||||
}
|
||||
|
||||
BaseItem newItem = ItemController.GetItem(item.Parent, item.Path);
|
||||
BaseItem newItem = await ItemController.GetItem(item.Parent, item.Path);
|
||||
|
||||
List<BaseItem> children = item.Parent.Children.ToList();
|
||||
|
||||
|
|
|
@ -3,8 +3,6 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Events;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
|
@ -58,67 +56,12 @@ namespace MediaBrowser.Controller.Library
|
|||
}
|
||||
#endregion
|
||||
|
||||
#region BaseItem Events
|
||||
/// <summary>
|
||||
/// Called when an item is being created.
|
||||
/// This should be used to fill item values, such as metadata
|
||||
/// </summary>
|
||||
public event EventHandler<GenericItemEventArgs<BaseItem>> BaseItemCreating;
|
||||
|
||||
/// <summary>
|
||||
/// Called when an item has been created.
|
||||
/// This should be used to process or modify item values.
|
||||
/// </summary>
|
||||
public event EventHandler<GenericItemEventArgs<BaseItem>> BaseItemCreated;
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Called when an item has been created
|
||||
/// </summary>
|
||||
private void OnBaseItemCreated(BaseItem item, Folder parent)
|
||||
{
|
||||
GenericItemEventArgs<BaseItem> args = new GenericItemEventArgs<BaseItem> { Item = item };
|
||||
|
||||
if (BaseItemCreating != null)
|
||||
{
|
||||
BaseItemCreating(this, args);
|
||||
}
|
||||
|
||||
if (BaseItemCreated != null)
|
||||
{
|
||||
BaseItemCreated(this, args);
|
||||
}
|
||||
}
|
||||
|
||||
private void FireCreateEventsRecursive(Folder folder, Folder parent)
|
||||
{
|
||||
OnBaseItemCreated(folder, parent);
|
||||
|
||||
int count = folder.Children.Length;
|
||||
|
||||
Parallel.For(0, count, i =>
|
||||
{
|
||||
BaseItem item = folder.Children[i];
|
||||
|
||||
Folder childFolder = item as Folder;
|
||||
|
||||
if (childFolder != null)
|
||||
{
|
||||
FireCreateEventsRecursive(childFolder, folder);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnBaseItemCreated(item, folder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private BaseItem ResolveItem(ItemResolveEventArgs args)
|
||||
private async Task<BaseItem> ResolveItem(ItemResolveEventArgs args)
|
||||
{
|
||||
// If that didn't pan out, try the slow ones
|
||||
foreach (IBaseItemResolver resolver in Kernel.Instance.EntityResolvers)
|
||||
{
|
||||
var item = resolver.ResolvePath(args);
|
||||
var item = await resolver.ResolvePath(args);
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
|
@ -132,39 +75,15 @@ namespace MediaBrowser.Controller.Library
|
|||
/// <summary>
|
||||
/// Resolves a path into a BaseItem
|
||||
/// </summary>
|
||||
public BaseItem GetItem(string path)
|
||||
public async Task<BaseItem> GetItem(Folder parent, string path)
|
||||
{
|
||||
return GetItem(null, path);
|
||||
return await GetItemInternal(parent, path, File.GetAttributes(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path into a BaseItem
|
||||
/// </summary>
|
||||
public BaseItem GetItem(Folder parent, string path)
|
||||
{
|
||||
BaseItem item = GetItemInternal(parent, path, File.GetAttributes(path));
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
var folder = item as Folder;
|
||||
|
||||
if (folder != null)
|
||||
{
|
||||
FireCreateEventsRecursive(folder, parent);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnBaseItemCreated(item, parent);
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path into a BaseItem
|
||||
/// </summary>
|
||||
private BaseItem GetItemInternal(Folder parent, string path, FileAttributes attributes)
|
||||
private async Task<BaseItem> GetItemInternal(Folder parent, string path, FileAttributes attributes)
|
||||
{
|
||||
if (!OnPreBeginResolvePath(parent, path, attributes))
|
||||
{
|
||||
|
@ -201,14 +120,14 @@ namespace MediaBrowser.Controller.Library
|
|||
return null;
|
||||
}
|
||||
|
||||
BaseItem item = ResolveItem(args);
|
||||
BaseItem item = await ResolveItem(args);
|
||||
|
||||
var folder = item as Folder;
|
||||
|
||||
if (folder != null)
|
||||
{
|
||||
// If it's a folder look for child entities
|
||||
AttachChildren(folder, fileSystemChildren);
|
||||
await AttachChildren(folder, fileSystemChildren);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
@ -217,30 +136,25 @@ namespace MediaBrowser.Controller.Library
|
|||
/// <summary>
|
||||
/// Finds child BaseItems for a given Folder
|
||||
/// </summary>
|
||||
private void AttachChildren(Folder folder, IEnumerable<KeyValuePair<string, FileAttributes>> fileSystemChildren)
|
||||
private async Task AttachChildren(Folder folder, IEnumerable<KeyValuePair<string, FileAttributes>> fileSystemChildren)
|
||||
{
|
||||
List<BaseItem> baseItemChildren = new List<BaseItem>();
|
||||
KeyValuePair<string, FileAttributes>[] fileSystemChildrenArray = fileSystemChildren.ToArray();
|
||||
|
||||
int count = fileSystemChildren.Count();
|
||||
int count = fileSystemChildrenArray.Length;
|
||||
|
||||
// Resolve the child folder paths into entities
|
||||
Parallel.For(0, count, i =>
|
||||
Task<BaseItem>[] tasks = new Task<BaseItem>[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
KeyValuePair<string, FileAttributes> child = fileSystemChildren.ElementAt(i);
|
||||
var child = fileSystemChildrenArray[i];
|
||||
|
||||
BaseItem item = GetItemInternal(folder, child.Key, child.Value);
|
||||
tasks[i] = GetItemInternal(folder, child.Key, child.Value);
|
||||
}
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
lock (baseItemChildren)
|
||||
{
|
||||
baseItemChildren.Add(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
BaseItem[] baseItemChildren = await Task<BaseItem>.WhenAll(tasks);
|
||||
|
||||
// Sort them
|
||||
folder.Children = baseItemChildren.OrderBy(f =>
|
||||
folder.Children = baseItemChildren.Where(i => i != null).OrderBy(f =>
|
||||
{
|
||||
return string.IsNullOrEmpty(f.SortName) ? f.Name : f.SortName;
|
||||
|
||||
|
@ -363,7 +277,7 @@ namespace MediaBrowser.Controller.Library
|
|||
/// Creates an IBN item based on a given path
|
||||
/// </summary>
|
||||
private T CreateImagesByNameItem<T>(string path, string name)
|
||||
where T : BaseEntity, new ()
|
||||
where T : BaseEntity, new()
|
||||
{
|
||||
T item = new T();
|
||||
|
||||
|
|
|
@ -59,6 +59,11 @@
|
|||
<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="Resolvers\AudioResolver.cs" />
|
||||
<Compile Include="Resolvers\BaseItemResolver.cs" />
|
||||
<Compile Include="Resolvers\FolderResolver.cs" />
|
||||
|
|
86
MediaBrowser.Controller/Providers/AudioInfoProvider.cs
Normal file
86
MediaBrowser.Controller/Providers/AudioInfoProvider.cs
Normal file
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.FFMpeg;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
[Export(typeof(BaseMetadataProvider))]
|
||||
public class AudioInfoProvider : BaseMetadataProvider
|
||||
{
|
||||
public override bool Supports(BaseItem item)
|
||||
{
|
||||
return item is Audio;
|
||||
}
|
||||
|
||||
public async override Task Fetch(BaseItem item, ItemResolveEventArgs args)
|
||||
{
|
||||
Audio audio = item as Audio;
|
||||
|
||||
string outputDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, item.Id.ToString().Substring(0, 1));
|
||||
|
||||
string outputPath = Path.Combine(outputDirectory, item.Id + "-" + item.DateModified.Ticks + ".js");
|
||||
|
||||
FFProbeResult data = await FFProbe.Run(audio, outputPath);
|
||||
|
||||
MediaStream stream = data.streams.FirstOrDefault(s => s.codec_type.Equals("audio", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
audio.Channels = stream.channels;
|
||||
|
||||
string bitrate = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(stream.sample_rate))
|
||||
{
|
||||
audio.SampleRate = int.Parse(stream.sample_rate);
|
||||
|
||||
bitrate = stream.bit_rate;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(bitrate))
|
||||
{
|
||||
bitrate = data.format.bit_rate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(bitrate))
|
||||
{
|
||||
audio.BitRate = int.Parse(bitrate);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOutputCachePath(BaseItem item)
|
||||
{
|
||||
string outputDirectory = Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, item.Id.ToString().Substring(0, 1));
|
||||
|
||||
return Path.Combine(outputDirectory, item.Id + "-" + item.DateModified.Ticks + ".js");
|
||||
}
|
||||
|
||||
public override void Init()
|
||||
{
|
||||
base.Init();
|
||||
|
||||
for (int i = 0; i <= 9; i++)
|
||||
{
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, i.ToString()));
|
||||
}
|
||||
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "a"));
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "b"));
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "c"));
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "d"));
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "e"));
|
||||
EnsureDirectory(Path.Combine(Kernel.Instance.ApplicationPaths.FFProbeAudioCacheDirectory, "f"));
|
||||
}
|
||||
|
||||
private void EnsureDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
23
MediaBrowser.Controller/Providers/BaseMetadataProvider.cs
Normal file
23
MediaBrowser.Controller/Providers/BaseMetadataProvider.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
public abstract class BaseMetadataProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// If the provider needs any startup routines, add them here
|
||||
/// </summary>
|
||||
public virtual void Init()
|
||||
{
|
||||
}
|
||||
|
||||
public virtual bool Supports(BaseItem item)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract Task Fetch(BaseItem item, ItemResolveEventArgs args);
|
||||
}
|
||||
}
|
30
MediaBrowser.Controller/Providers/FolderProviderFromXml.cs
Normal file
30
MediaBrowser.Controller/Providers/FolderProviderFromXml.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
using System.ComponentModel.Composition;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Xml;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
[Export(typeof(BaseMetadataProvider))]
|
||||
public class FolderProviderFromXml : BaseMetadataProvider
|
||||
{
|
||||
public override bool Supports(BaseItem item)
|
||||
{
|
||||
return item is Folder;
|
||||
}
|
||||
|
||||
public override Task Fetch(BaseItem item, ItemResolveEventArgs args)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var metadataFile = args.GetFileByName("folder.xml");
|
||||
|
||||
if (metadataFile.HasValue)
|
||||
{
|
||||
new FolderXmlParser().Fetch(item as Folder, metadataFile.Value.Key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
[Export(typeof(BaseMetadataProvider))]
|
||||
public class ImageFromMediaLocationProvider : BaseMetadataProvider
|
||||
{
|
||||
public override Task Fetch(BaseItem item, ItemResolveEventArgs args)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
if (args.IsFolder)
|
||||
{
|
||||
PopulateImages(item, args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills in image paths based on files win the folder
|
||||
/// </summary>
|
||||
private void PopulateImages(BaseItem item, ItemResolveEventArgs args)
|
||||
{
|
||||
List<string> backdropFiles = new List<string>();
|
||||
|
||||
foreach (KeyValuePair<string, FileAttributes> file in args.FileSystemChildren)
|
||||
{
|
||||
if (file.Value.HasFlag(FileAttributes.Directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = file.Key;
|
||||
|
||||
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("art", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ArtImagePath = filePath;
|
||||
}
|
||||
if (name.Equals("thumb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ThumbnailImagePath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (backdropFiles.Any())
|
||||
{
|
||||
item.BackdropImagePaths = backdropFiles;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
39
MediaBrowser.Controller/Providers/LocalTrailerProvider.cs
Normal file
39
MediaBrowser.Controller/Providers/LocalTrailerProvider.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
[Export(typeof(BaseMetadataProvider))]
|
||||
public class LocalTrailerProvider : BaseMetadataProvider
|
||||
{
|
||||
public async override Task Fetch(BaseItem item, ItemResolveEventArgs args)
|
||||
{
|
||||
var trailerPath = args.GetFolderByName("trailers");
|
||||
|
||||
if (trailerPath.HasValue)
|
||||
{
|
||||
string[] allFiles = Directory.GetFileSystemEntries(trailerPath.Value.Key, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
List<Video> localTrailers = new List<Video>();
|
||||
|
||||
foreach (string file in allFiles)
|
||||
{
|
||||
BaseItem child = await Kernel.Instance.ItemController.GetItem(null, file);
|
||||
|
||||
Video video = child as Video;
|
||||
|
||||
if (video != null)
|
||||
{
|
||||
localTrailers.Add(video);
|
||||
}
|
||||
}
|
||||
|
||||
item.LocalTrailers = localTrailers;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Resolvers
|
||||
|
@ -33,19 +33,15 @@ namespace MediaBrowser.Controller.Resolvers
|
|||
}
|
||||
|
||||
item.Id = Kernel.GetMD5(item.Path);
|
||||
|
||||
PopulateImages(item, args);
|
||||
PopulateLocalTrailers(item, args);
|
||||
}
|
||||
|
||||
public BaseItem ResolvePath(ItemResolveEventArgs args)
|
||||
public async Task<BaseItem> ResolvePath(ItemResolveEventArgs args)
|
||||
{
|
||||
T item = Resolve(args);
|
||||
|
||||
if (item != null)
|
||||
{
|
||||
// Set initial values on the newly resolved item
|
||||
|
||||
SetItemValues(item, args);
|
||||
|
||||
// Make sure the item has a name
|
||||
|
@ -53,11 +49,24 @@ namespace MediaBrowser.Controller.Resolvers
|
|||
|
||||
// Make sure DateCreated and DateModified have values
|
||||
EnsureDates(item);
|
||||
|
||||
await FetchMetadataFromProviders(item, args);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task FetchMetadataFromProviders(T item, ItemResolveEventArgs args)
|
||||
{
|
||||
foreach (BaseMetadataProvider provider in Kernel.Instance.MetadataProviders)
|
||||
{
|
||||
if (provider.Supports(item))
|
||||
{
|
||||
await provider.Fetch(item, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureName(T item)
|
||||
{
|
||||
// If the subclass didn't supply a name, add it here
|
||||
|
@ -84,76 +93,6 @@ namespace MediaBrowser.Controller.Resolvers
|
|||
item.DateModified = Path.IsPathRooted(item.Path) ? File.GetLastWriteTime(item.Path) : DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills in image paths based on files win the folder
|
||||
/// </summary>
|
||||
protected virtual void PopulateImages(T item, ItemResolveEventArgs args)
|
||||
{
|
||||
List<string> backdropFiles = new List<string>();
|
||||
|
||||
foreach (KeyValuePair<string,FileAttributes> file in args.FileSystemChildren)
|
||||
{
|
||||
if (file.Value.HasFlag(FileAttributes.Directory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = file.Key;
|
||||
|
||||
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("art", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ArtImagePath = filePath;
|
||||
}
|
||||
if (name.Equals("thumb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ThumbnailImagePath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (backdropFiles.Any())
|
||||
{
|
||||
item.BackdropImagePaths = backdropFiles;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void PopulateLocalTrailers(T item, ItemResolveEventArgs args)
|
||||
{
|
||||
var trailerPath = args.GetFolderByName("trailers");
|
||||
|
||||
if (trailerPath.HasValue)
|
||||
{
|
||||
string[] allFiles = Directory.GetFileSystemEntries(trailerPath.Value.Key, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
item.LocalTrailers = allFiles.Select(f => Kernel.Instance.ItemController.GetItem(f)).OfType<Video>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -161,6 +100,6 @@ namespace MediaBrowser.Controller.Resolvers
|
|||
/// </summary>
|
||||
public interface IBaseItemResolver
|
||||
{
|
||||
BaseItem ResolvePath(ItemResolveEventArgs args);
|
||||
Task<BaseItem> ResolvePath(ItemResolveEventArgs args);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
using System.ComponentModel.Composition;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Xml;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Resolvers
|
||||
|
@ -27,19 +26,6 @@ namespace MediaBrowser.Controller.Resolvers
|
|||
base.SetItemValues(item, args);
|
||||
|
||||
item.IsRoot = args.Parent == null;
|
||||
|
||||
// Read data from folder.xml, if it exists
|
||||
PopulateFolderMetadata(item, args);
|
||||
}
|
||||
|
||||
private void PopulateFolderMetadata(TItemType folder, ItemResolveEventArgs args)
|
||||
{
|
||||
var metadataFile = args.GetFileByName("folder.xml");
|
||||
|
||||
if (metadataFile.HasValue)
|
||||
{
|
||||
new FolderXmlParser().Fetch(folder, metadataFile.Value.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,7 +85,7 @@ namespace MediaBrowser.Movies.Resolvers
|
|||
{
|
||||
string[] allFiles = Directory.GetFileSystemEntries(trailerPath.Value.Key, "*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
item.SpecialFeatures = allFiles.Select(f => Kernel.Instance.ItemController.GetItem(f)).OfType<Video>();
|
||||
item.SpecialFeatures = allFiles.Select(f => Kernel.Instance.ItemController.GetItem(null, f)).OfType<Video>();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:tb="http://www.hardcodet.net/taskbar"
|
||||
Title="MainWindow" Height="350" Width="525" AllowsTransparency="True" Background="Transparent" WindowStyle="None" ShowInTaskbar="False" Loaded="MainWindow_Loaded">
|
||||
Title="MainWindow" Height="350" Width="525" AllowsTransparency="True" Background="Transparent" WindowStyle="None" ShowInTaskbar="False">
|
||||
<Grid>
|
||||
<tb:TaskbarIcon Name="MbTaskbarIcon" IconSource="/Icons/Icon.ico" ToolTipText="MediaBrowser Server" Visibility="Hidden">
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ using System.Windows;
|
|||
using MediaBrowser.Common.Logging;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Progress;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.UI;
|
||||
|
||||
namespace MediaBrowser.ServerApplication
|
||||
{
|
||||
|
@ -18,10 +20,10 @@ namespace MediaBrowser.ServerApplication
|
|||
LoadKernel();
|
||||
}
|
||||
|
||||
private void LoadKernel()
|
||||
private async void LoadKernel()
|
||||
{
|
||||
Progress<TaskProgress> progress = new Progress<TaskProgress>();
|
||||
Common.UI.Splash splash = new Common.UI.Splash(progress);
|
||||
Splash splash = new Splash(progress);
|
||||
|
||||
splash.Show();
|
||||
|
||||
|
@ -29,11 +31,14 @@ namespace MediaBrowser.ServerApplication
|
|||
{
|
||||
DateTime now = DateTime.Now;
|
||||
|
||||
new Kernel().Init(progress);
|
||||
await new Kernel().Init(progress);
|
||||
|
||||
double seconds = (DateTime.Now - now).TotalSeconds;
|
||||
|
||||
Logger.LogInfo("Kernel.Init completed in {0} seconds.", seconds);
|
||||
|
||||
// Don't show the system tray icon until the kernel finishes.
|
||||
this.MbTaskbarIcon.Visibility = System.Windows.Visibility.Visible;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -46,16 +51,6 @@ namespace MediaBrowser.ServerApplication
|
|||
}
|
||||
}
|
||||
|
||||
#region Main Window Events
|
||||
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Don't show the system tray icon until the app has loaded.
|
||||
this.MbTaskbarIcon.Visibility = System.Windows.Visibility.Visible;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Context Menu events
|
||||
|
||||
private void cmOpenDashboard_click(object sender, RoutedEventArgs e)
|
||||
|
|
Loading…
Reference in New Issue
Block a user