jellyfin-server/MediaBrowser.Dlna/DlnaManager.cs

573 lines
20 KiB
C#
Raw Normal View History

2014-04-10 15:06:54 +00:00
using MediaBrowser.Common.Configuration;
2014-03-26 15:06:48 +00:00
using MediaBrowser.Common.Extensions;
2014-03-15 04:14:07 +00:00
using MediaBrowser.Common.IO;
2015-02-01 21:32:01 +00:00
using MediaBrowser.Controller;
2014-03-15 04:14:07 +00:00
using MediaBrowser.Controller.Dlna;
2014-11-30 19:01:33 +00:00
using MediaBrowser.Controller.Drawing;
2014-10-21 00:54:01 +00:00
using MediaBrowser.Controller.Plugins;
2014-03-23 16:42:02 +00:00
using MediaBrowser.Dlna.Profiles;
2014-04-10 15:06:54 +00:00
using MediaBrowser.Dlna.Server;
2014-03-26 15:06:48 +00:00
using MediaBrowser.Model.Dlna;
2014-11-29 19:51:30 +00:00
using MediaBrowser.Model.Drawing;
2014-03-26 15:06:48 +00:00
using MediaBrowser.Model.Logging;
2014-03-15 04:14:07 +00:00
using MediaBrowser.Model.Serialization;
2014-03-25 05:25:03 +00:00
using System;
2014-03-13 19:08:02 +00:00
using System.Collections.Generic;
2014-03-26 15:06:48 +00:00
using System.IO;
2014-03-17 14:48:16 +00:00
using System.Linq;
2014-04-10 15:06:54 +00:00
using System.Text;
2014-03-13 19:08:02 +00:00
using System.Text.RegularExpressions;
2015-10-04 04:23:11 +00:00
using CommonIO;
2014-03-13 19:08:02 +00:00
namespace MediaBrowser.Dlna
{
public class DlnaManager : IDlnaManager
{
2014-03-26 15:06:48 +00:00
private readonly IApplicationPaths _appPaths;
2014-03-15 04:14:07 +00:00
private readonly IXmlSerializer _xmlSerializer;
private readonly IFileSystem _fileSystem;
2014-03-26 15:06:48 +00:00
private readonly ILogger _logger;
2014-03-29 02:38:22 +00:00
private readonly IJsonSerializer _jsonSerializer;
2015-02-01 21:32:01 +00:00
private readonly IServerApplicationHost _appHost;
2014-04-21 16:02:30 +00:00
2014-04-25 17:30:41 +00:00
public DlnaManager(IXmlSerializer xmlSerializer,
IFileSystem fileSystem,
IApplicationPaths appPaths,
ILogger logger,
2015-02-01 21:32:01 +00:00
IJsonSerializer jsonSerializer, IServerApplicationHost appHost)
2014-03-15 04:14:07 +00:00
{
_xmlSerializer = xmlSerializer;
_fileSystem = fileSystem;
2014-03-26 15:06:48 +00:00
_appPaths = appPaths;
_logger = logger;
2014-03-29 02:38:22 +00:00
_jsonSerializer = jsonSerializer;
2015-02-01 21:32:01 +00:00
_appHost = appHost;
2014-03-15 04:14:07 +00:00
}
public IEnumerable<DeviceProfile> GetProfiles()
2014-03-26 15:06:48 +00:00
{
ExtractProfilesIfNeeded();
2014-03-26 20:14:47 +00:00
var list = GetProfiles(UserProfilesPath, DeviceProfileType.User)
2014-03-26 15:06:48 +00:00
.OrderBy(i => i.Name)
.ToList();
2014-03-26 20:14:47 +00:00
list.AddRange(GetProfiles(SystemProfilesPath, DeviceProfileType.System)
2014-03-26 15:06:48 +00:00
.OrderBy(i => i.Name));
return list;
}
private bool _extracted;
private readonly object _syncLock = new object();
private void ExtractProfilesIfNeeded()
{
if (!_extracted)
{
lock (_syncLock)
{
if (!_extracted)
{
try
{
ExtractSystemProfiles();
}
catch (Exception ex)
{
_logger.ErrorException("Error extracting DLNA profiles.", ex);
}
_extracted = true;
}
}
}
2014-03-13 19:08:02 +00:00
}
2014-03-15 04:14:07 +00:00
public DeviceProfile GetDefaultProfile()
2014-03-13 19:08:02 +00:00
{
2014-03-26 16:10:46 +00:00
ExtractProfilesIfNeeded();
2014-03-23 16:42:02 +00:00
return new DefaultProfile();
2014-03-13 19:08:02 +00:00
}
2014-03-17 14:48:16 +00:00
public DeviceProfile GetProfile(DeviceIdentification deviceInfo)
{
2014-03-26 20:14:47 +00:00
if (deviceInfo == null)
{
throw new ArgumentNullException("deviceInfo");
}
var profile = GetProfiles()
.FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification));
2014-03-26 15:06:48 +00:00
2014-03-26 15:17:36 +00:00
if (profile != null)
{
_logger.Debug("Found matching device profile: {0}", profile.Name);
}
else
{
_logger.Debug("No matching device profile found. The default will need to be used.");
2014-04-06 17:53:23 +00:00
LogUnmatchedProfile(deviceInfo);
2014-03-26 15:17:36 +00:00
}
2014-03-26 15:06:48 +00:00
return profile;
2014-03-17 14:48:16 +00:00
}
2014-04-06 17:53:23 +00:00
private void LogUnmatchedProfile(DeviceIdentification profile)
{
var builder = new StringBuilder();
builder.AppendLine(string.Format("DeviceDescription:{0}", profile.DeviceDescription ?? string.Empty));
builder.AppendLine(string.Format("FriendlyName:{0}", profile.FriendlyName ?? string.Empty));
builder.AppendLine(string.Format("Manufacturer:{0}", profile.Manufacturer ?? string.Empty));
builder.AppendLine(string.Format("ManufacturerUrl:{0}", profile.ManufacturerUrl ?? string.Empty));
builder.AppendLine(string.Format("ModelDescription:{0}", profile.ModelDescription ?? string.Empty));
builder.AppendLine(string.Format("ModelName:{0}", profile.ModelName ?? string.Empty));
builder.AppendLine(string.Format("ModelNumber:{0}", profile.ModelNumber ?? string.Empty));
builder.AppendLine(string.Format("ModelUrl:{0}", profile.ModelUrl ?? string.Empty));
builder.AppendLine(string.Format("SerialNumber:{0}", profile.SerialNumber ?? string.Empty));
_logger.LogMultiline("No matching device profile found. The default will need to be used.", LogSeverity.Info, builder);
}
2014-03-17 14:48:16 +00:00
private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
2014-03-13 19:08:02 +00:00
{
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.DeviceDescription))
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.DeviceDescription == null || !IsRegexMatch(deviceInfo.DeviceDescription, profileInfo.DeviceDescription))
2014-03-18 01:45:41 +00:00
return false;
}
if (!string.IsNullOrWhiteSpace(profileInfo.FriendlyName))
2014-03-13 19:08:02 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.FriendlyName == null || !IsRegexMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName))
2014-03-17 14:48:16 +00:00
return false;
}
2014-03-13 19:08:02 +00:00
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.Manufacturer))
2014-03-17 14:48:16 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.Manufacturer == null || !IsRegexMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer))
2014-03-17 14:48:16 +00:00
return false;
}
2014-03-13 19:08:02 +00:00
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.ManufacturerUrl))
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.ManufacturerUrl == null || !IsRegexMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl))
2014-03-18 01:45:41 +00:00
return false;
}
if (!string.IsNullOrWhiteSpace(profileInfo.ModelDescription))
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.ModelDescription == null || !IsRegexMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription))
2014-03-18 01:45:41 +00:00
return false;
}
if (!string.IsNullOrWhiteSpace(profileInfo.ModelName))
2014-03-17 14:48:16 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.ModelName == null || !IsRegexMatch(deviceInfo.ModelName, profileInfo.ModelName))
2014-03-17 14:48:16 +00:00
return false;
}
2014-03-13 19:08:02 +00:00
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.ModelNumber))
2014-03-17 14:48:16 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.ModelNumber == null || !IsRegexMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber))
2014-03-17 14:48:16 +00:00
return false;
}
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.ModelUrl))
2014-03-17 14:48:16 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.ModelUrl == null || !IsRegexMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl))
2014-03-17 14:48:16 +00:00
return false;
}
2014-03-13 19:08:02 +00:00
2014-03-18 01:45:41 +00:00
if (!string.IsNullOrWhiteSpace(profileInfo.SerialNumber))
2014-03-17 14:48:16 +00:00
{
2015-01-22 16:41:34 +00:00
if (deviceInfo.SerialNumber == null || !IsRegexMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber))
2014-03-17 14:48:16 +00:00
return false;
2014-03-13 19:08:02 +00:00
}
2014-03-17 14:48:16 +00:00
return true;
2014-03-13 19:08:02 +00:00
}
2014-03-25 05:25:03 +00:00
2015-01-22 16:41:34 +00:00
private bool IsRegexMatch(string input, string pattern)
{
try
{
return Regex.IsMatch(input, pattern);
}
catch (ArgumentException ex)
{
_logger.ErrorException("Error evaluating regex pattern {0}", ex, pattern);
return false;
}
}
2014-03-25 05:25:03 +00:00
public DeviceProfile GetProfile(IDictionary<string, string> headers)
{
2014-03-26 20:14:47 +00:00
if (headers == null)
{
throw new ArgumentNullException("headers");
}
2016-02-14 02:27:13 +00:00
//_logger.Debug("GetProfile. Headers: " + _jsonSerializer.SerializeToString(headers));
// Convert to case insensitive
headers = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase);
2014-04-03 22:50:04 +00:00
var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification));
if (profile != null)
{
_logger.Debug("Found matching device profile: {0}", profile.Name);
}
2014-04-25 17:30:41 +00:00
else
{
string userAgent = null;
headers.TryGetValue("User-Agent", out userAgent);
2016-02-14 02:27:13 +00:00
var msg = "No matching device profile via headers found. The default will be used. ";
2014-04-25 17:30:41 +00:00
if (!string.IsNullOrEmpty(userAgent))
{
msg += "User-agent: " + userAgent + ". ";
}
_logger.Debug(msg);
}
2014-04-03 22:50:04 +00:00
return profile;
2014-03-25 05:25:03 +00:00
}
private bool IsMatch(IDictionary<string, string> headers, DeviceIdentification profileInfo)
{
return profileInfo.Headers.Any(i => IsMatch(headers, i));
}
private bool IsMatch(IDictionary<string, string> headers, HttpHeaderInfo header)
{
string value;
if (headers.TryGetValue(header.Name, out value))
{
switch (header.Match)
{
case HeaderMatchType.Equals:
return string.Equals(value, header.Value, StringComparison.OrdinalIgnoreCase);
case HeaderMatchType.Substring:
2016-02-14 02:27:13 +00:00
var isMatch = value.IndexOf(header.Value, StringComparison.OrdinalIgnoreCase) != -1;
//_logger.Debug("IsMatch-Substring value: {0} testValue: {1} isMatch: {2}", value, header.Value, isMatch);
return isMatch;
2014-03-25 05:25:03 +00:00
case HeaderMatchType.Regex:
2015-11-23 16:01:42 +00:00
// Reports of IgnoreCase not working on linux so try it a couple different ways.
return Regex.IsMatch(value, header.Value, RegexOptions.IgnoreCase) || Regex.IsMatch(value.ToUpper(), header.Value.ToUpper(), RegexOptions.IgnoreCase);
2014-03-25 05:25:03 +00:00
default:
throw new ArgumentException("Unrecognized HeaderMatchType");
}
}
return false;
}
2014-03-26 15:06:48 +00:00
private string UserProfilesPath
{
get
{
return Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "user");
}
}
private string SystemProfilesPath
{
get
{
return Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "system");
}
}
2014-03-26 20:14:47 +00:00
private IEnumerable<DeviceProfile> GetProfiles(string path, DeviceProfileType type)
2014-03-26 15:06:48 +00:00
{
try
{
2015-11-23 16:01:42 +00:00
return _fileSystem.GetFiles(path)
2014-03-26 15:06:48 +00:00
.Where(i => string.Equals(i.Extension, ".xml", StringComparison.OrdinalIgnoreCase))
2014-03-26 20:14:47 +00:00
.Select(i => ParseProfileXmlFile(i.FullName, type))
2014-03-26 15:06:48 +00:00
.Where(i => i != null)
.ToList();
}
catch (DirectoryNotFoundException)
{
return new List<DeviceProfile>();
}
}
2014-03-26 20:14:47 +00:00
private DeviceProfile ParseProfileXmlFile(string path, DeviceProfileType type)
2014-03-26 15:06:48 +00:00
{
try
{
var profile = (DeviceProfile)_xmlSerializer.DeserializeFromFile(typeof(DeviceProfile), path);
profile.Id = path.ToLower().GetMD5().ToString("N");
2014-03-26 20:14:47 +00:00
profile.ProfileType = type;
2014-03-26 15:06:48 +00:00
return profile;
}
catch (Exception ex)
{
_logger.ErrorException("Error parsing profile xml: {0}", ex, path);
return null;
}
}
public DeviceProfile GetProfile(string id)
{
2014-03-26 20:14:47 +00:00
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentNullException("id");
}
2015-11-23 16:01:42 +00:00
var info = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase));
2014-03-26 15:06:48 +00:00
2014-03-26 20:14:47 +00:00
return ParseProfileXmlFile(info.Path, info.Info.Type);
2014-03-26 15:06:48 +00:00
}
private IEnumerable<InternalProfileInfo> GetProfileInfosInternal()
{
ExtractProfilesIfNeeded();
return GetProfileInfos(UserProfilesPath, DeviceProfileType.User)
.Concat(GetProfileInfos(SystemProfilesPath, DeviceProfileType.System))
.OrderBy(i => i.Info.Type == DeviceProfileType.User ? 0 : 1)
.ThenBy(i => i.Info.Name);
}
public IEnumerable<DeviceProfileInfo> GetProfileInfos()
{
return GetProfileInfosInternal().Select(i => i.Info);
}
private IEnumerable<InternalProfileInfo> GetProfileInfos(string path, DeviceProfileType type)
{
try
{
2015-11-23 16:01:42 +00:00
return _fileSystem.GetFiles(path)
2014-03-26 15:06:48 +00:00
.Where(i => string.Equals(i.Extension, ".xml", StringComparison.OrdinalIgnoreCase))
.Select(i => new InternalProfileInfo
{
Path = i.FullName,
Info = new DeviceProfileInfo
{
Id = i.FullName.ToLower().GetMD5().ToString("N"),
2014-07-26 17:30:15 +00:00
Name = _fileSystem.GetFileNameWithoutExtension(i),
2014-03-26 15:06:48 +00:00
Type = type
}
})
.ToList();
}
catch (DirectoryNotFoundException)
{
return new List<InternalProfileInfo>();
}
}
private void ExtractSystemProfiles()
{
var assembly = GetType().Assembly;
var namespaceName = GetType().Namespace + ".Profiles.Xml.";
var systemProfilesPath = SystemProfilesPath;
foreach (var name in assembly.GetManifestResourceNames()
.Where(i => i.StartsWith(namespaceName))
.ToList())
{
var filename = Path.GetFileName(name).Substring(namespaceName.Length);
var path = Path.Combine(systemProfilesPath, filename);
using (var stream = assembly.GetManifestResourceStream(name))
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists || fileInfo.Length != stream.Length)
{
2015-11-23 16:01:42 +00:00
_fileSystem.CreateDirectory(systemProfilesPath);
2014-03-26 15:06:48 +00:00
using (var fileStream = _fileSystem.GetFileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
stream.CopyTo(fileStream);
}
}
}
}
// Not necessary, but just to make it easy to find
2015-11-23 16:01:42 +00:00
_fileSystem.CreateDirectory(UserProfilesPath);
2014-03-26 15:06:48 +00:00
}
2014-03-26 19:21:29 +00:00
public void DeleteProfile(string id)
{
2015-11-23 16:01:42 +00:00
var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
2014-03-26 19:21:29 +00:00
if (info.Info.Type == DeviceProfileType.System)
{
throw new ArgumentException("System profiles cannot be deleted.");
}
_fileSystem.DeleteFile(info.Path);
2014-03-26 19:21:29 +00:00
}
2014-03-26 20:14:47 +00:00
public void CreateProfile(DeviceProfile profile)
{
2014-03-29 02:38:22 +00:00
profile = ReserializeProfile(profile);
if (string.IsNullOrWhiteSpace(profile.Name))
{
throw new ArgumentException("Profile is missing Name");
}
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
var path = Path.Combine(UserProfilesPath, newFilename);
_xmlSerializer.SerializeToFile(profile, path);
2014-03-26 20:14:47 +00:00
}
public void UpdateProfile(DeviceProfile profile)
{
2014-03-29 02:38:22 +00:00
profile = ReserializeProfile(profile);
if (string.IsNullOrWhiteSpace(profile.Id))
{
throw new ArgumentException("Profile is missing Id");
}
if (string.IsNullOrWhiteSpace(profile.Name))
{
throw new ArgumentException("Profile is missing Name");
}
var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profile.Id, StringComparison.OrdinalIgnoreCase));
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
var path = Path.Combine(UserProfilesPath, newFilename);
2014-04-20 05:21:08 +00:00
if (!string.Equals(path, current.Path, StringComparison.Ordinal) &&
current.Info.Type != DeviceProfileType.System)
2014-03-29 02:38:22 +00:00
{
_fileSystem.DeleteFile(current.Path);
2014-03-29 02:38:22 +00:00
}
2015-11-23 16:01:42 +00:00
2014-03-29 02:38:22 +00:00
_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 serlialize properly to xml (different root element tag name)
/// </summary>
/// <param name="profile"></param>
/// <returns></returns>
private DeviceProfile ReserializeProfile(DeviceProfile profile)
{
if (profile.GetType() == typeof(DeviceProfile))
{
return profile;
}
var json = _jsonSerializer.SerializeToString(profile);
return _jsonSerializer.DeserializeFromString<DeviceProfile>(json);
2014-03-26 20:14:47 +00:00
}
2014-03-26 15:06:48 +00:00
class InternalProfileInfo
{
internal DeviceProfileInfo Info { get; set; }
internal string Path { get; set; }
}
2014-04-10 15:06:54 +00:00
2015-01-30 21:14:08 +00:00
public string GetServerDescriptionXml(IDictionary<string, string> headers, string serverUuId, string serverAddress)
2014-04-10 15:06:54 +00:00
{
var profile = GetProfile(headers) ??
GetDefaultProfile();
2015-05-08 16:28:06 +00:00
return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverUuId.GetMD5().ToString("N")).GetXml();
2014-04-10 15:06:54 +00:00
}
2014-11-30 19:01:33 +00:00
public ImageStream GetIcon(string filename)
2014-04-16 05:08:12 +00:00
{
var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? ImageFormat.Png
: ImageFormat.Jpg;
2014-11-30 19:01:33 +00:00
return new ImageStream
2014-04-16 05:08:12 +00:00
{
Format = format,
Stream = GetType().Assembly.GetManifestResourceStream("MediaBrowser.Dlna.Images." + filename.ToLower())
};
2014-04-10 15:06:54 +00:00
}
2014-03-13 19:08:02 +00:00
}
2014-10-21 00:54:01 +00:00
class DlnaProfileEntryPoint : IServerEntryPoint
{
private readonly IApplicationPaths _appPaths;
private readonly IXmlSerializer _xmlSerializer;
private readonly IFileSystem _fileSystem;
public DlnaProfileEntryPoint(IApplicationPaths appPaths, IXmlSerializer xmlSerializer, IFileSystem fileSystem)
{
_appPaths = appPaths;
_xmlSerializer = xmlSerializer;
_fileSystem = fileSystem;
}
public void Run()
{
2015-02-04 19:13:00 +00:00
//DumpProfiles();
2014-10-21 00:54:01 +00:00
}
private void DumpProfiles()
{
var list = new List<DeviceProfile>
{
new SamsungSmartTvProfile(),
new Xbox360Profile(),
new XboxOneProfile(),
new SonyPs3Profile(),
2015-06-16 17:37:49 +00:00
new SonyPs4Profile(),
2014-10-21 00:54:01 +00:00
new SonyBravia2010Profile(),
new SonyBravia2011Profile(),
new SonyBravia2012Profile(),
new SonyBravia2013Profile(),
2015-08-27 01:41:35 +00:00
new SonyBravia2014Profile(),
2014-10-21 00:54:01 +00:00
new SonyBlurayPlayer2013Profile(),
new SonyBlurayPlayerProfile(),
new PanasonicVieraProfile(),
new WdtvLiveProfile(),
new DenonAvrProfile(),
new LinksysDMA2100Profile(),
new LgTvProfile(),
new Foobar2000Profile(),
new MediaMonkeyProfile(),
2014-12-13 21:26:04 +00:00
//new Windows81Profile(),
2014-10-21 00:54:01 +00:00
//new WindowsMediaCenterProfile(),
2014-12-13 21:26:04 +00:00
//new WindowsPhoneProfile(),
2014-10-21 00:54:01 +00:00
new DirectTvProfile(),
new DishHopperJoeyProfile(),
2014-10-22 04:42:26 +00:00
new DefaultProfile(),
2015-05-04 22:07:46 +00:00
new PopcornHourProfile(),
new VlcProfile(),
2015-08-27 01:31:54 +00:00
new BubbleUpnpProfile(),
2015-08-27 01:41:35 +00:00
new KodiProfile(),
2014-10-21 00:54:01 +00:00
};
foreach (var item in list)
{
var path = Path.Combine(_appPaths.ProgramDataPath, _fileSystem.GetValidFilename(item.Name) + ".xml");
_xmlSerializer.SerializeToFile(item, path);
}
}
public void Dispose()
{
}
}
2014-03-14 14:27:32 +00:00
}