mirror of
https://github.com/jellyfin/jellyfin.git
synced 2024-11-18 03:19:06 -07:00
492 lines
18 KiB
C#
492 lines
18 KiB
C#
#pragma warning disable CS1591
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using Emby.Dlna.Profiles;
|
|
using Emby.Dlna.Server;
|
|
using Jellyfin.Extensions.Json;
|
|
using MediaBrowser.Common.Configuration;
|
|
using MediaBrowser.Common.Extensions;
|
|
using MediaBrowser.Controller;
|
|
using MediaBrowser.Controller.Dlna;
|
|
using MediaBrowser.Controller.Drawing;
|
|
using MediaBrowser.Model.Dlna;
|
|
using MediaBrowser.Model.Drawing;
|
|
using MediaBrowser.Model.IO;
|
|
using MediaBrowser.Model.Serialization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Primitives;
|
|
|
|
namespace Emby.Dlna
|
|
{
|
|
public class DlnaManager : IDlnaManager
|
|
{
|
|
private readonly IApplicationPaths _appPaths;
|
|
private readonly IXmlSerializer _xmlSerializer;
|
|
private readonly IFileSystem _fileSystem;
|
|
private readonly ILogger<DlnaManager> _logger;
|
|
private readonly IServerApplicationHost _appHost;
|
|
private static readonly Assembly _assembly = typeof(DlnaManager).Assembly;
|
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
|
|
|
private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal);
|
|
|
|
public DlnaManager(
|
|
IXmlSerializer xmlSerializer,
|
|
IFileSystem fileSystem,
|
|
IApplicationPaths appPaths,
|
|
ILoggerFactory loggerFactory,
|
|
IServerApplicationHost appHost)
|
|
{
|
|
_xmlSerializer = xmlSerializer;
|
|
_fileSystem = fileSystem;
|
|
_appPaths = appPaths;
|
|
_logger = loggerFactory.CreateLogger<DlnaManager>();
|
|
_appHost = appHost;
|
|
}
|
|
|
|
private string UserProfilesPath => Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "user");
|
|
|
|
private string SystemProfilesPath => Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "system");
|
|
|
|
public async Task InitProfilesAsync()
|
|
{
|
|
try
|
|
{
|
|
await ExtractSystemProfilesAsync().ConfigureAwait(false);
|
|
Directory.CreateDirectory(UserProfilesPath);
|
|
LoadProfiles();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error extracting DLNA profiles.");
|
|
}
|
|
}
|
|
|
|
private void LoadProfiles()
|
|
{
|
|
var list = GetProfiles(UserProfilesPath, DeviceProfileType.User)
|
|
.OrderBy(i => i.Name)
|
|
.ToList();
|
|
|
|
list.AddRange(GetProfiles(SystemProfilesPath, DeviceProfileType.System)
|
|
.OrderBy(i => i.Name));
|
|
}
|
|
|
|
public IEnumerable<DeviceProfile> GetProfiles()
|
|
{
|
|
lock (_profiles)
|
|
{
|
|
return _profiles.Values
|
|
.OrderBy(i => i.Item1.Info.Type == DeviceProfileType.User ? 0 : 1)
|
|
.ThenBy(i => i.Item1.Info.Name)
|
|
.Select(i => i.Item2)
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DeviceProfile GetDefaultProfile()
|
|
{
|
|
return new DefaultProfile();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(deviceInfo);
|
|
|
|
var profile = GetProfiles()
|
|
.FirstOrDefault(i => i.Identification is not null && IsMatch(deviceInfo, i.Identification));
|
|
|
|
if (profile is null)
|
|
{
|
|
_logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to match a device with a profile.
|
|
/// Rules:
|
|
/// - If the profile field has no value, the field matches regardless of its contents.
|
|
/// - the profile field can be an exact match, or a reg exp.
|
|
/// </summary>
|
|
/// <param name="deviceInfo">The <see cref="DeviceIdentification"/> of the device.</param>
|
|
/// <param name="profileInfo">The <see cref="DeviceIdentification"/> of the profile.</param>
|
|
/// <returns><b>True</b> if they match.</returns>
|
|
public bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
|
|
{
|
|
return IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)
|
|
&& IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber);
|
|
}
|
|
|
|
private bool IsRegexOrSubstringMatch(string input, string pattern)
|
|
{
|
|
if (string.IsNullOrEmpty(pattern))
|
|
{
|
|
// In profile identification: An empty pattern matches anything.
|
|
return true;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(input))
|
|
{
|
|
// The profile contains a value, and the device doesn't.
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
return input.Equals(pattern, StringComparison.OrdinalIgnoreCase)
|
|
|| Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
_logger.LogError(ex, "Error evaluating regex pattern {Pattern}", pattern);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DeviceProfile? GetProfile(IHeaderDictionary headers)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(headers);
|
|
|
|
var profile = GetProfiles().FirstOrDefault(i => i.Identification is not null && IsMatch(headers, i.Identification));
|
|
if (profile is null)
|
|
{
|
|
_logger.LogDebug("No matching device profile found. {@Headers}", headers);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("Found matching device profile: {0}", profile.Name);
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
private bool IsMatch(IHeaderDictionary headers, DeviceIdentification profileInfo)
|
|
{
|
|
return profileInfo.Headers.Any(i => IsMatch(headers, i));
|
|
}
|
|
|
|
private bool IsMatch(IHeaderDictionary headers, HttpHeaderInfo header)
|
|
{
|
|
// Handle invalid user setup
|
|
if (string.IsNullOrEmpty(header.Name))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (headers.TryGetValue(header.Name, out StringValues value))
|
|
{
|
|
if (StringValues.IsNullOrEmpty(value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (header.Match)
|
|
{
|
|
case HeaderMatchType.Equals:
|
|
return string.Equals(value, header.Value, StringComparison.OrdinalIgnoreCase);
|
|
case HeaderMatchType.Substring:
|
|
var isMatch = value.ToString().IndexOf(header.Value, StringComparison.OrdinalIgnoreCase) != -1;
|
|
// _logger.LogDebug("IsMatch-Substring value: {0} testValue: {1} isMatch: {2}", value, header.Value, isMatch);
|
|
return isMatch;
|
|
case HeaderMatchType.Regex:
|
|
// Can't be null, we checked above the switch statement
|
|
return Regex.IsMatch(value!, header.Value, RegexOptions.IgnoreCase);
|
|
default:
|
|
throw new ArgumentException("Unrecognized HeaderMatchType");
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private IEnumerable<DeviceProfile> GetProfiles(string path, DeviceProfileType type)
|
|
{
|
|
try
|
|
{
|
|
return _fileSystem.GetFilePaths(path)
|
|
.Where(i => string.Equals(Path.GetExtension(i), ".xml", StringComparison.OrdinalIgnoreCase))
|
|
.Select(i => ParseProfileFile(i, type))
|
|
.Where(i => i is not null)
|
|
.ToList()!; // We just filtered out all the nulls
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return Array.Empty<DeviceProfile>();
|
|
}
|
|
}
|
|
|
|
private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type)
|
|
{
|
|
lock (_profiles)
|
|
{
|
|
if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
|
|
{
|
|
return profileTuple.Item2;
|
|
}
|
|
|
|
try
|
|
{
|
|
var tempProfile = (DeviceProfile)_xmlSerializer.DeserializeFromFile(typeof(DeviceProfile), path);
|
|
var profile = ReserializeProfile(tempProfile);
|
|
|
|
profile.Id = path.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
|
|
|
_profiles[path] = new Tuple<InternalProfileInfo, DeviceProfile>(GetInternalProfileInfo(_fileSystem.GetFileInfo(path), type), profile);
|
|
|
|
return profile;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error parsing profile file: {Path}", path);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DeviceProfile? GetProfile(string id)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(id);
|
|
|
|
var info = GetProfileInfosInternal().FirstOrDefault(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (info is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return ParseProfileFile(info.Path, info.Info.Type);
|
|
}
|
|
|
|
private IEnumerable<InternalProfileInfo> GetProfileInfosInternal()
|
|
{
|
|
lock (_profiles)
|
|
{
|
|
return _profiles.Values
|
|
.Select(i => i.Item1)
|
|
.OrderBy(i => i.Info.Type == DeviceProfileType.User ? 0 : 1)
|
|
.ThenBy(i => i.Info.Name);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerable<DeviceProfileInfo> GetProfileInfos()
|
|
{
|
|
return GetProfileInfosInternal().Select(i => i.Info);
|
|
}
|
|
|
|
private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
|
|
{
|
|
return new InternalProfileInfo(
|
|
new DeviceProfileInfo
|
|
{
|
|
Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
|
|
Name = _fileSystem.GetFileNameWithoutExtension(file),
|
|
Type = type
|
|
},
|
|
file.FullName);
|
|
}
|
|
|
|
private async Task ExtractSystemProfilesAsync()
|
|
{
|
|
var namespaceName = GetType().Namespace + ".Profiles.Xml.";
|
|
|
|
var systemProfilesPath = SystemProfilesPath;
|
|
|
|
foreach (var name in _assembly.GetManifestResourceNames())
|
|
{
|
|
if (!name.StartsWith(namespaceName, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = Path.Join(
|
|
systemProfilesPath,
|
|
Path.GetFileName(name.AsSpan())[namespaceName.Length..]);
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// The stream should exist as we just got its name from GetManifestResourceNames
|
|
using (var stream = _assembly.GetManifestResourceStream(name)!)
|
|
{
|
|
Directory.CreateDirectory(systemProfilesPath);
|
|
|
|
var fileOptions = AsyncFile.WriteOptions;
|
|
fileOptions.Mode = FileMode.CreateNew;
|
|
fileOptions.PreallocationSize = stream.Length;
|
|
var fileStream = new FileStream(path, fileOptions);
|
|
await using (fileStream.ConfigureAwait(false))
|
|
{
|
|
await stream.CopyToAsync(fileStream).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void DeleteProfile(string id)
|
|
{
|
|
var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (info.Info.Type == DeviceProfileType.System)
|
|
{
|
|
throw new ArgumentException("System profiles cannot be deleted.");
|
|
}
|
|
|
|
_fileSystem.DeleteFile(info.Path);
|
|
|
|
lock (_profiles)
|
|
{
|
|
_profiles.Remove(info.Path);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void CreateProfile(DeviceProfile profile)
|
|
{
|
|
profile = ReserializeProfile(profile);
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(profile.Name);
|
|
|
|
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
|
|
var path = Path.Combine(UserProfilesPath, newFilename);
|
|
|
|
SaveProfile(profile, path, DeviceProfileType.User);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void UpdateProfile(string profileId, DeviceProfile profile)
|
|
{
|
|
profile = ReserializeProfile(profile);
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(profile.Id);
|
|
|
|
ArgumentException.ThrowIfNullOrEmpty(profile.Name);
|
|
|
|
var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profileId, StringComparison.OrdinalIgnoreCase));
|
|
if (current.Info.Type == DeviceProfileType.System)
|
|
{
|
|
throw new ArgumentException("System profiles can't be edited");
|
|
}
|
|
|
|
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
|
|
var path = Path.Join(UserProfilesPath, newFilename);
|
|
|
|
if (!string.Equals(path, current.Path, StringComparison.Ordinal))
|
|
{
|
|
lock (_profiles)
|
|
{
|
|
_profiles.Remove(current.Path);
|
|
}
|
|
}
|
|
|
|
SaveProfile(profile, path, DeviceProfileType.User);
|
|
}
|
|
|
|
private void SaveProfile(DeviceProfile profile, string path, DeviceProfileType type)
|
|
{
|
|
lock (_profiles)
|
|
{
|
|
_profiles[path] = new Tuple<InternalProfileInfo, DeviceProfile>(GetInternalProfileInfo(_fileSystem.GetFileInfo(path), type), profile);
|
|
}
|
|
|
|
SerializeToXml(profile, path);
|
|
}
|
|
|
|
internal void SerializeToXml(DeviceProfile profile, string path)
|
|
{
|
|
_xmlSerializer.SerializeToFile(profile, path);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recreates the object using serialization, to ensure it's not a subclass.
|
|
/// If it's a subclass it may not serialize properly to xml (different root element tag name).
|
|
/// </summary>
|
|
/// <param name="profile">The device profile.</param>
|
|
/// <returns>The re-serialized device profile.</returns>
|
|
private DeviceProfile ReserializeProfile(DeviceProfile profile)
|
|
{
|
|
if (profile.GetType() == typeof(DeviceProfile))
|
|
{
|
|
return profile;
|
|
}
|
|
|
|
var json = JsonSerializer.Serialize(profile, _jsonOptions);
|
|
|
|
// Output can't be null if the input isn't null
|
|
return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions)!;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress)
|
|
{
|
|
var profile = GetProfile(headers) ?? GetDefaultProfile();
|
|
|
|
var serverId = _appHost.SystemId;
|
|
|
|
return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ImageStream? GetIcon(string filename)
|
|
{
|
|
var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
|
? ImageFormat.Png
|
|
: ImageFormat.Jpg;
|
|
|
|
var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant();
|
|
var stream = _assembly.GetManifestResourceStream(resource);
|
|
if (stream is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ImageStream(stream)
|
|
{
|
|
Format = format
|
|
};
|
|
}
|
|
|
|
private class InternalProfileInfo
|
|
{
|
|
internal InternalProfileInfo(DeviceProfileInfo info, string path)
|
|
{
|
|
Info = info;
|
|
Path = path;
|
|
}
|
|
|
|
internal DeviceProfileInfo Info { get; }
|
|
|
|
internal string Path { get; }
|
|
}
|
|
}
|
|
}
|