diff --git a/Inspector/Inspector.csproj b/Inspector/Inspector.csproj index 5930dc6..1d2f12c 100644 --- a/Inspector/Inspector.csproj +++ b/Inspector/Inspector.csproj @@ -3,6 +3,7 @@ Exe netcoreapp2.2 + AnyCPU;x64 diff --git a/Roadie.Api.Library/Configuration/Dlna.cs b/Roadie.Api.Library/Configuration/Dlna.cs new file mode 100644 index 0000000..b711807 --- /dev/null +++ b/Roadie.Api.Library/Configuration/Dlna.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Roadie.Library.Configuration +{ + [Serializable] + public class Dlna : IDlna + { + public bool IsEnabled { get; set; } + + public int? Port { get; set; } + + public string FriendlyName { get; set; } + + public string Description { get; set; } + + public IEnumerable AllowedIps { get; set; } = Enumerable.Empty(); + public IEnumerable AllowedUserAgents { get; set; } = Enumerable.Empty(); + + public Dlna() + { + IsEnabled = true; + FriendlyName = "Roadie Music Server"; + } + } +} diff --git a/Roadie.Api.Library/Configuration/IDlna.cs b/Roadie.Api.Library/Configuration/IDlna.cs new file mode 100644 index 0000000..7d30e8a --- /dev/null +++ b/Roadie.Api.Library/Configuration/IDlna.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Roadie.Library.Configuration +{ + public interface IDlna + { + bool IsEnabled { get; set; } + string Description { get; set; } + string FriendlyName { get; set; } + int? Port { get; set; } + IEnumerable AllowedIps { get; set; } + IEnumerable AllowedUserAgents { get; set; } + } +} \ No newline at end of file diff --git a/Roadie.Api.Library/Configuration/IRoadieSettings.cs b/Roadie.Api.Library/Configuration/IRoadieSettings.cs index b01d890..0f56bf9 100644 --- a/Roadie.Api.Library/Configuration/IRoadieSettings.cs +++ b/Roadie.Api.Library/Configuration/IRoadieSettings.cs @@ -6,30 +6,33 @@ namespace Roadie.Library.Configuration { Dictionary> ArtistNameReplace { get; set; } string BehindProxyHost { get; set; } + string CollectionImageFolder { get; } string ConnectionString { get; set; } string ContentPath { get; set; } Converting Converting { get; set; } + short DefaultRowsPerPage { get; set; } string DefaultTimeZone { get; set; } + Dlna Dlna { get; set; } IEnumerable DontDoMetaDataProvidersSearchArtists { get; set; } IEnumerable FileExtensionsToDelete { get; set; } FilePlugins FilePlugins { get; set; } + string GenreImageFolder { get; } + string ImageFolder { get; set; } string InboundFolder { get; set; } Inspector Inspector { get; set; } Integrations Integrations { get; set; } + bool IsRegistrationClosed { get; set; } + string LabelImageFolder { get; } ImageSize LargeImageSize { get; set; } string LibraryFolder { get; set; } - string ImageFolder { get; set; } - string LabelImageFolder { get; } - string CollectionImageFolder { get; } - string GenreImageFolder { get; } - string PlaylistImageFolder { get; } - string UserImageFolder { get; } string ListenAddress { get; set; } ImageSize MaximumImageSize { get; set; } ImageSize MediumImageSize { get; set; } + string PlaylistImageFolder { get; } Processing Processing { get; set; } bool RecordNoResultSearches { get; set; } RedisCache Redis { get; set; } + string SearchEngineReposFolder { get; set; } string SecretKey { get; set; } string SiteName { get; set; } ImageSize SmallImageSize { get; set; } @@ -39,15 +42,12 @@ namespace Roadie.Library.Configuration int SmtpPort { get; set; } string SmtpUsername { get; set; } bool SmtpUseSSl { get; set; } + short? SubsonicRatingBoost { get; set; } ImageSize ThumbnailImageSize { get; set; } Dictionary TrackPathReplace { get; set; } + bool UseRegistrationTokens { get; set; } + string UserImageFolder { get; } bool UseSSLBehindProxy { get; set; } string WebsocketAddress { get; set; } - short? SubsonicRatingBoost { get; set; } - - bool IsRegistrationClosed { get; set; } - bool UseRegistrationTokens { get; set; } - string SearchEngineReposFolder { get; set; } - short DefaultRowsPerPage { get; set; } } } \ No newline at end of file diff --git a/Roadie.Api.Library/Configuration/RoadieSettings.cs b/Roadie.Api.Library/Configuration/RoadieSettings.cs index 70cc500..f0a7fe0 100644 --- a/Roadie.Api.Library/Configuration/RoadieSettings.cs +++ b/Roadie.Api.Library/Configuration/RoadieSettings.cs @@ -15,6 +15,14 @@ namespace Roadie.Library.Configuration public string BehindProxyHost { get; set; } + public string CollectionImageFolder + { + get + { + return Path.Combine(ImageFolder ?? LibraryFolder, "__roadie_images", "collections"); + } + } + /// /// Set to the Roadie Database for DbDataReader operations /// @@ -27,42 +35,17 @@ namespace Roadie.Library.Configuration public Converting Converting { get; set; } + public short DefaultRowsPerPage { get; set; } public string DefaultTimeZone { get; set; } + public Dlna Dlna { get; set; } + public IEnumerable DontDoMetaDataProvidersSearchArtists { get; set; } public IEnumerable FileExtensionsToDelete { get; set; } public FilePlugins FilePlugins { get; set; } - public string InboundFolder { get; set; } - - public Inspector Inspector { get; set; } - - public Integrations Integrations { get; set; } - - public ImageSize LargeImageSize { get; set; } - - public string LibraryFolder { get; set; } - - public string ImageFolder { get; set; } - - public string LabelImageFolder - { - get - { - return Path.Combine(ImageFolder ?? LibraryFolder, "__roadie_images", "labels"); - } - } - - public string CollectionImageFolder - { - get - { - return Path.Combine(ImageFolder ?? LibraryFolder, "__roadie_images", "collections"); - } - } - public string GenreImageFolder { get @@ -71,6 +54,35 @@ namespace Roadie.Library.Configuration } } + public string ImageFolder { get; set; } + public string InboundFolder { get; set; } + + public Inspector Inspector { get; set; } + + public Integrations Integrations { get; set; } + + /// + /// If true then don't allow new registrations + /// + public bool IsRegistrationClosed { get; set; } + + public string LabelImageFolder + { + get + { + return Path.Combine(ImageFolder ?? LibraryFolder, "__roadie_images", "labels"); + } + } + + public ImageSize LargeImageSize { get; set; } + + public string LibraryFolder { get; set; } + public string ListenAddress { get; set; } + + public ImageSize MaximumImageSize { get; set; } + + public ImageSize MediumImageSize { get; set; } + public string PlaylistImageFolder { get @@ -79,26 +91,17 @@ namespace Roadie.Library.Configuration } } - public string UserImageFolder - { - get - { - return Path.Combine(LibraryFolder, "__roadie_images", "users"); - } - } - - public string ListenAddress { get; set; } - - public ImageSize MaximumImageSize { get; set; } - - public ImageSize MediumImageSize { get; set; } - public Processing Processing { get; set; } public bool RecordNoResultSearches { get; set; } public RedisCache Redis { get; set; } + /// + /// Place to hold cache repositories used by SearchEngine and MetaData engines + /// + public string SearchEngineReposFolder { get; set; } + public string SecretKey { get; set; } public string SiteName { get; set; } @@ -117,31 +120,28 @@ namespace Roadie.Library.Configuration public bool SmtpUseSSl { get; set; } + public short? SubsonicRatingBoost { get; set; } + public ImageSize ThumbnailImageSize { get; set; } public Dictionary TrackPathReplace { get; set; } - public bool UseSSLBehindProxy { get; set; } - - public string WebsocketAddress { get; set; } - - public short? SubsonicRatingBoost { get; set; } - /// /// When true require a "invite" token to exist for a user to register. /// public bool UseRegistrationTokens { get; set; } - /// - /// If true then don't allow new registrations - /// - public bool IsRegistrationClosed { get; set; } - /// - /// Place to hold cache repositories used by SearchEngine and MetaData engines - /// - public string SearchEngineReposFolder { get; set; } + public string UserImageFolder + { + get + { + return Path.Combine(LibraryFolder, "__roadie_images", "users"); + } + } - public short DefaultRowsPerPage { get; set; } + public bool UseSSLBehindProxy { get; set; } + + public string WebsocketAddress { get; set; } public RoadieSettings() { @@ -152,7 +152,7 @@ namespace Roadie.Library.Configuration }; DefaultTimeZone = "US / Central"; DontDoMetaDataProvidersSearchArtists = new List { "Various Artists", "Sound Tracks" }; - FileExtensionsToDelete = new List{ ".accurip", ".bmp", ".cue", ".dat", ".db", ".exe", ".htm", ".html", ".ini", ".log", ".jpg", ".jpeg", ".par", ".par2", ".pdf", ".png", ".md5", ".mht", ".mpg", ".m3u", ".nfo", ".nzb", ".pls", ".sfv", ".srr", ".txt", ".url" }; + FileExtensionsToDelete = new List { ".accurip", ".bmp", ".cue", ".dat", ".db", ".exe", ".htm", ".html", ".ini", ".log", ".jpg", ".jpeg", ".par", ".par2", ".pdf", ".png", ".md5", ".mht", ".mpg", ".m3u", ".nfo", ".nzb", ".pls", ".sfv", ".srr", ".txt", ".url" }; InboundFolder = "M:/inbound"; LargeImageSize = new ImageSize { Width = 500, Height = 500 }; LibraryFolder = "M:/library"; @@ -174,6 +174,8 @@ namespace Roadie.Library.Configuration Converting = new Converting(); Integrations = new Integrations(); Processing = new Processing(); + Dlna = new Dlna(); + } } } \ No newline at end of file diff --git a/Roadie.Api.Library/FilePlugins/Audio.cs b/Roadie.Api.Library/FilePlugins/Audio.cs index 24aa4e5..6403566 100644 --- a/Roadie.Api.Library/FilePlugins/Audio.cs +++ b/Roadie.Api.Library/FilePlugins/Audio.cs @@ -25,7 +25,7 @@ namespace Roadie.Library.FilePlugins public IAudioMetaDataHelper AudioMetaDataHelper { get; } - public override string[] HandlesTypes => new string[1] { "audio/mpeg" }; + public override string[] HandlesTypes => new string[1] { MimeTypeHelper.Mp3MimeType }; public Audio(IRoadieSettings configuration, IHttpEncoder httpEncoder, ICacheManager cacheManager, ILogger logger, IArtistLookupEngine artistLookupEngine, IReleaseLookupEngine releaseLookupEngine, diff --git a/Roadie.Api.Library/Imaging/DefaultNotFoundImages.cs b/Roadie.Api.Library/Imaging/DefaultNotFoundImages.cs index 6f0a4ae..e91997d 100644 --- a/Roadie.Api.Library/Imaging/DefaultNotFoundImages.cs +++ b/Roadie.Api.Library/Imaging/DefaultNotFoundImages.cs @@ -44,6 +44,12 @@ namespace Roadie.Library.Imaging Logger = logger; } + public DefaultNotFoundImages(ILogger logger, IRoadieSettings configuration) + { + Configuration = configuration; + Logger = logger; + } + private static Image MakeImageFromFile(string filename) { if (!File.Exists(filename)) return new Image(); diff --git a/Roadie.Api.Library/Inspect/Inspector.cs b/Roadie.Api.Library/Inspect/Inspector.cs index ad432bc..97cd68a 100644 --- a/Roadie.Api.Library/Inspect/Inspector.cs +++ b/Roadie.Api.Library/Inspect/Inspector.cs @@ -263,7 +263,7 @@ namespace Roadie.Library.Inspect Console.ForegroundColor = ConsoleColor.DarkYellow; Console.WriteLine( $"╟ ❗ INVALID: Missing: {ID3TagsHelper.DetermineMissingRequiredMetaData(originalMetaData)}"); - Console.WriteLine($"╟ [{JsonConvert.SerializeObject(tagLib, Formatting.Indented)}]"); + Console.WriteLine($"╟ [{JsonConvert.SerializeObject(tagLib, Newtonsoft.Json.Formatting.Indented)}]"); Console.ResetColor(); } diff --git a/Roadie.Api.Library/Models/TrackStreamInfo.cs b/Roadie.Api.Library/Models/TrackStreamInfo.cs index 4b63f9e..afc43b5 100644 --- a/Roadie.Api.Library/Models/TrackStreamInfo.cs +++ b/Roadie.Api.Library/Models/TrackStreamInfo.cs @@ -1,4 +1,6 @@ -namespace Roadie.Library.Models +using Roadie.Library.Utility; + +namespace Roadie.Library.Models { public sealed class TrackStreamInfo { @@ -10,7 +12,7 @@ public string ContentDuration { get; set; } public string ContentLength { get; set; } public string ContentRange { get; set; } - public string ContentType => "audio/mpeg"; + public string ContentType => MimeTypeHelper.Mp3MimeType; public long EndBytes { get; set; } public string Etag { get; set; } public string Expires { get; set; } diff --git a/Roadie.Api.Library/Roadie.Library.csproj b/Roadie.Api.Library/Roadie.Library.csproj index 8c6a7ab..263c0e4 100644 --- a/Roadie.Api.Library/Roadie.Library.csproj +++ b/Roadie.Api.Library/Roadie.Library.csproj @@ -11,7 +11,7 @@ - + @@ -23,8 +23,9 @@ + - + diff --git a/Roadie.Api.Library/Scrobble/RoadieScrobbler.cs b/Roadie.Api.Library/Scrobble/RoadieScrobbler.cs index a1b1ac6..1172540 100644 --- a/Roadie.Api.Library/Scrobble/RoadieScrobbler.cs +++ b/Roadie.Api.Library/Scrobble/RoadieScrobbler.cs @@ -14,12 +14,19 @@ namespace Roadie.Library.Scrobble { public class RoadieScrobbler : ScrobblerIntegrationBase, IRoadieScrobbler { + + public RoadieScrobbler(IRoadieSettings configuration, ILogger logger, data.IRoadieDbContext dbContext, ICacheManager cacheManager) + : base(configuration, logger, dbContext, cacheManager, null) + { + } + public RoadieScrobbler(IRoadieSettings configuration, ILogger logger, data.IRoadieDbContext dbContext, - ICacheManager cacheManager, IHttpContext httpContext) + ICacheManager cacheManager, IHttpContext httpContext) : base(configuration, logger, dbContext, cacheManager, httpContext) { } + /// /// For Roadie we only add a user play on the full scrobble event, otherwise we get double track play numbers. /// @@ -39,14 +46,17 @@ namespace Roadie.Library.Scrobble { try { - // If less than half of duration then do nothing - if (scrobble.ElapsedTimeOfTrackPlayed.TotalSeconds < scrobble.TrackDuration.TotalSeconds / 2) + // If a user and If less than half of duration then do nothing + if (roadieUser != null && + scrobble.ElapsedTimeOfTrackPlayed.TotalSeconds < scrobble.TrackDuration.TotalSeconds / 2) + { + Logger.LogTrace("Skipping Scrobble, Playback did not exceed minimum elapsed time"); return new OperationResult { Data = true, IsSuccess = true }; - + } var sw = Stopwatch.StartNew(); var track = DbContext.Tracks .Include(x => x.ReleaseMedia) @@ -55,35 +65,44 @@ namespace Roadie.Library.Scrobble .Include(x => x.TrackArtist) .FirstOrDefault(x => x.RoadieId == scrobble.TrackId); if (track == null) + { return new OperationResult($"Scrobble: Unable To Find Track [{scrobble.TrackId}]"); + } if (!track.IsValid) - return new OperationResult( - $"Scrobble: Invalid Track. Track Id [{scrobble.TrackId}], FilePath [{track.FilePath}], Filename [{track.FileName}]"); + { + return new OperationResult($"Scrobble: Invalid Track. Track Id [{scrobble.TrackId}], FilePath [{track.FilePath}], Filename [{track.FileName}]"); + } data.UserTrack userTrack = null; var now = DateTime.UtcNow; var success = false; try { - var user = DbContext.Users.FirstOrDefault(x => x.RoadieId == roadieUser.UserId); - userTrack = DbContext.UserTracks.FirstOrDefault(x => x.UserId == user.Id && x.TrackId == track.Id); - if (userTrack == null) + if (roadieUser != null) { - userTrack = new data.UserTrack(now) + var user = DbContext.Users.FirstOrDefault(x => x.RoadieId == roadieUser.UserId); + userTrack = DbContext.UserTracks.FirstOrDefault(x => x.UserId == user.Id && x.TrackId == track.Id); + if (userTrack == null) { - UserId = user.Id, - TrackId = track.Id - }; - DbContext.UserTracks.Add(userTrack); - } + userTrack = new data.UserTrack(now) + { + UserId = user.Id, + TrackId = track.Id + }; + DbContext.UserTracks.Add(userTrack); + } - userTrack.LastPlayed = now; - userTrack.PlayedCount = (userTrack.PlayedCount ?? 0) + 1; + userTrack.LastPlayed = now; + userTrack.PlayedCount = (userTrack.PlayedCount ?? 0) + 1; + + CacheManager.ClearRegion(user.CacheRegion); + } track.PlayedCount = (track.PlayedCount ?? 0) + 1; track.LastPlayed = now; - var release = DbContext.Releases.Include(x => x.Artist) - .FirstOrDefault(x => x.RoadieId == track.ReleaseMedia.Release.RoadieId); + var release = DbContext.Releases + .Include(x => x.Artist) + .FirstOrDefault(x => x.RoadieId == track.ReleaseMedia.Release.RoadieId); release.LastPlayed = now; release.PlayedCount = (release.PlayedCount ?? 0) + 1; @@ -112,18 +131,16 @@ namespace Roadie.Library.Scrobble CacheManager.ClearRegion(track.CacheRegion); CacheManager.ClearRegion(track.ReleaseMedia.Release.CacheRegion); CacheManager.ClearRegion(track.ReleaseMedia.Release.Artist.CacheRegion); - CacheManager.ClearRegion(user.CacheRegion); success = true; } catch (Exception ex) { - Logger.LogError(ex, - $"Error in Scrobble, Creating UserTrack: User `{roadieUser}` TrackId [{track.Id}"); + Logger.LogError(ex,$"Error in Scrobble, Creating UserTrack: User `{roadieUser}` TrackId [{track.Id}"); } sw.Stop(); - Logger.LogInformation($"RoadieScrobbler: RoadieUser `{roadieUser}` Scrobble `{scrobble}`"); + Logger.LogInformation($"RoadieScrobbler: RoadieUser `{ (roadieUser == null ? "None" : roadieUser.ToString()) }` Scrobble `{scrobble}`"); return new OperationResult { Data = success, diff --git a/Roadie.Api.Library/Scrobble/ScrobbleHandler.cs b/Roadie.Api.Library/Scrobble/ScrobbleHandler.cs index 9cc751f..610c432 100644 --- a/Roadie.Api.Library/Scrobble/ScrobbleHandler.cs +++ b/Roadie.Api.Library/Scrobble/ScrobbleHandler.cs @@ -50,6 +50,18 @@ namespace Roadie.Library.Scrobble Scrobblers = scrobblers; } + public ScrobbleHandler(IRoadieSettings configuration, ILogger logger, data.IRoadieDbContext dbContext, ICacheManager cacheManager, RoadieScrobbler roadieScrobbler) + { + Logger = logger; + Configuration = configuration; + DbContext = dbContext; + var scrobblers = new List + { + roadieScrobbler + }; + Scrobblers = scrobblers; + } + /// /// Send Now Playing Requests /// @@ -70,7 +82,10 @@ namespace Roadie.Library.Scrobble public async Task> Scrobble(User user, ScrobbleInfo scrobble) { var s = GetScrobbleInfoDetails(scrobble); - foreach (var scrobbler in Scrobblers) await Task.Run(async () => await scrobbler.Scrobble(user, s)); + foreach (var scrobbler in Scrobblers) + { + await Task.Run(async () => await scrobbler.Scrobble(user, s)); + } return new OperationResult { Data = true, diff --git a/Roadie.Api.Library/SearchEngines/MetaData/Audio/AudioMetaDataHelper.cs b/Roadie.Api.Library/SearchEngines/MetaData/Audio/AudioMetaDataHelper.cs index ee02cb4..b99c5ac 100644 --- a/Roadie.Api.Library/SearchEngines/MetaData/Audio/AudioMetaDataHelper.cs +++ b/Roadie.Api.Library/SearchEngines/MetaData/Audio/AudioMetaDataHelper.cs @@ -247,14 +247,15 @@ namespace Roadie.Library.MetaData.Audio try { var metaDataFromFile = ID3TagsHelper.MetaDataForFile(fileInfo.FullName); - if (metaDataFromFile.IsSuccess) return metaDataFromFile.Data; + if (metaDataFromFile.IsSuccess) + { + return metaDataFromFile.Data; + } } catch (Exception ex) { - Logger.LogError(ex, - string.Format("Error With ID3TagsHelper.MetaDataForFile From File [{0}]", fileInfo.FullName)); + Logger.LogError(ex, string.Format("Error With ID3TagsHelper.MetaDataForFile From File [{0}]", fileInfo.FullName)); } - return new AudioMetaData { Filename = fileInfo.FullName diff --git a/Roadie.Api.Library/SearchEngines/MetaData/ID3Tags/ID3TagsHelper.cs b/Roadie.Api.Library/SearchEngines/MetaData/ID3Tags/ID3TagsHelper.cs index d122634..9fafe43 100644 --- a/Roadie.Api.Library/SearchEngines/MetaData/ID3Tags/ID3TagsHelper.cs +++ b/Roadie.Api.Library/SearchEngines/MetaData/ID3Tags/ID3TagsHelper.cs @@ -22,6 +22,8 @@ namespace Roadie.Library.MetaData.ID3Tags { public class ID3TagsHelper : MetaDataProviderBase, IID3TagsHelper { + public const int MaximumDiscNumber = 500; // Damnit Karajan + public ID3TagsHelper(IRoadieSettings configuration, ICacheManager cacheManager, ILogger logger) : base(configuration, cacheManager, logger) { @@ -29,21 +31,39 @@ namespace Roadie.Library.MetaData.ID3Tags public static int DetermineDiscNumber(AudioMetaData metaData) { - var maxDiscNumber = 500; // Damnit Karajan - for (var i = maxDiscNumber; i > 0; i--) + for (var i = MaximumDiscNumber; i > 0; i--) + { if (Regex.IsMatch(metaData.Filename, @"(cd\s*(0*" + i + "))", RegexOptions.IgnoreCase)) + { return i; + } + } return 1; } public static string DetermineMissingRequiredMetaData(AudioMetaData metaData) { var result = new List(); - if (string.IsNullOrEmpty(metaData.Artist)) result.Add("Artist Name (TPE1)"); - if (string.IsNullOrEmpty(metaData.Release)) result.Add("Release Title (TALB)"); - if (string.IsNullOrEmpty(metaData.Title)) result.Add("Track Title (TIT2)"); - if ((metaData.Year ?? 0) < 1) result.Add("Release Year (TYER | TDRC | TORY | TDOR)"); - if ((metaData.TrackNumber ?? 0) < 1) result.Add("TrackNumber (TRCK)"); + if (string.IsNullOrEmpty(metaData.Artist)) + { + result.Add("Artist Name (TPE1)"); + } + if (string.IsNullOrEmpty(metaData.Release)) + { + result.Add("Release Title (TALB)"); + } + if (string.IsNullOrEmpty(metaData.Title)) + { + result.Add("Track Title (TIT2)"); + } + if ((metaData.Year ?? 0) < 1) + { + result.Add("Release Year (TYER | TDRC | TORY | TDOR)"); + } + if ((metaData.TrackNumber ?? 0) < 1) + { + result.Add("TrackNumber (TRCK)"); + } return string.Join(", ", result); } @@ -194,24 +214,42 @@ namespace Roadie.Library.MetaData.ID3Tags { var r = new OperationResult(); var result = MetaDataForFileFromIdSharp(fileName); - if (result.Messages != null && result.Messages.Any()) + if (result.Messages?.Any() == true) + { foreach (var m in result.Messages) + { r.AddMessage(m); - if (result.Errors != null && result.Errors.Any()) + } + } + if (result.Errors?.Any() == true) + { foreach (var e in result.Errors) + { r.AddError(e); + } + } if (!result.IsSuccess) { result = MetaDataForFileFromATL(fileName); - if (result.Messages != null && result.Messages.Any()) + if (result.Messages?.Any() == true) + { foreach (var m in result.Messages) + { r.AddMessage(m); - if (result.Errors != null && result.Errors.Any()) + } + } + if (result.Errors?.Any() == true) + { foreach (var e in result.Errors) + { r.AddError(e); + } + } + } + if (!result.IsSuccess) + { + r.AddMessage($"Missing Data `[{DetermineMissingRequiredMetaData(result.Data)}]`"); } - - if (!result.IsSuccess) r.AddMessage($"Missing Data `[{DetermineMissingRequiredMetaData(result.Data)}]`"); r.Data = result.Data; r.IsSuccess = result.IsSuccess; return r; diff --git a/Roadie.Api.Library/Utility/AsyncHelper.cs b/Roadie.Api.Library/Utility/AsyncHelper.cs new file mode 100644 index 0000000..c32d0c2 --- /dev/null +++ b/Roadie.Api.Library/Utility/AsyncHelper.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Roadie.Library.Utility +{ + public static class AsyncHelper + { + private static readonly TaskFactory taskFactory = new + TaskFactory(CancellationToken.None, + TaskCreationOptions.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + + /// + /// Executes an async Task method which has a void return value synchronously + /// USAGE: AsyncHelper.RunSync(() => AsyncMethod()); + /// + /// Task method to execute + public static void RunSync(Func task) + => taskFactory + .StartNew(task) + .Unwrap() + .GetAwaiter() + .GetResult(); + + /// + /// Executes an async Task method which has a T return type synchronously + /// USAGE: T result = AsyncHelper.RunSync(() => AsyncMethod()); + /// + /// Return Type + /// Task method to execute + /// + public static TResult RunSync(Func> task) + => taskFactory + .StartNew(task) + .Unwrap() + .GetAwaiter() + .GetResult(); + } +} diff --git a/Roadie.Api.Library/Utility/EtagHelper.cs b/Roadie.Api.Library/Utility/EtagHelper.cs index aebc3b0..71da949 100644 --- a/Roadie.Api.Library/Utility/EtagHelper.cs +++ b/Roadie.Api.Library/Utility/EtagHelper.cs @@ -6,9 +6,12 @@ namespace Roadie.Library.Utility { public static class EtagHelper { - public static bool CompareETag(IHttpEncoder encoder, EntityTagHeaderValue eTagLeft, - EntityTagHeaderValue eTagRight) + public static bool CompareETag(IHttpEncoder encoder, EntityTagHeaderValue eTagLeft, EntityTagHeaderValue eTagRight) { + if(encoder == null) + { + return false; + } if (eTagLeft == null && eTagRight == null) return true; if (eTagLeft == null && eTagRight != null) return false; if (eTagRight == null && eTagLeft != null) return false; @@ -17,6 +20,10 @@ namespace Roadie.Library.Utility public static bool CompareETag(IHttpEncoder encoder, EntityTagHeaderValue eTag, byte[] bytes) { + if (encoder == null) + { + return false; + } if (eTag == null && (bytes == null || !bytes.Any())) return true; if (eTag == null && bytes != null || bytes.Any()) return false; if (eTag != null && (bytes == null || !bytes.Any())) return false; diff --git a/Roadie.Api.Library/Utility/MimeTypeHelper.cs b/Roadie.Api.Library/Utility/MimeTypeHelper.cs new file mode 100644 index 0000000..fa4aa60 --- /dev/null +++ b/Roadie.Api.Library/Utility/MimeTypeHelper.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Roadie.Library.Utility +{ + public static class MimeTypeHelper + { + public static string Mp3Extension = ".mp3"; + + public static readonly Dictionary AudioMimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { Mp3Extension, "audio/mpeg" }, + { ".m4a", "audio/mp4" }, + { ".aac", "audio/mp4" }, + { ".webma", "audio/webm" }, + { ".wav", "audio/wav" }, + { ".wma", "audio/x-ms-wma" }, + { ".ogg", "audio/ogg" }, + { ".oga", "audio/ogg" }, + { ".opus", "audio/ogg" }, + { ".ac3", "audio/ac3" }, + { ".dsf", "audio/dsf" }, + { ".m4b", "audio/m4b" }, + { ".xsp", "audio/xsp" }, + { ".dsp", "audio/dsp" } + }; + + public static readonly Dictionary ImageMimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".tbn", "image/jpeg" }, + { ".png", "image/png" }, + { ".gif", "image/gif" }, + { ".tiff", "image/tiff" }, + { ".webp", "image/webp" }, + { ".ico", "image/vnd.microsoft.icon" }, + { ".svg", "image/svg+xml" }, + { ".svgz", "image/svg+xml" } + }; + + + public static string Mp3MimeType => AudioMimeTypes[Mp3Extension]; + + public static bool IsFileAudioType(string fileName) => IsFileAudioType(new FileInfo(fileName)); + + public static bool IsFileAudioType(FileInfo file) + { + if(file?.Exists != true) + { + return false; + } + var ext = file.Extension; + return AudioMimeTypes.TryGetValue(ext, out _); + } + + public static bool IsFileImageType(string fileName) => IsFileImageType(new FileInfo(fileName)); + + public static bool IsFileImageType(FileInfo file) + { + if (file?.Exists != true) + { + return false; + } + var ext = file.Extension; + return ImageMimeTypes.TryGetValue(ext, out _); + } + } +} diff --git a/Roadie.Api.Services/IPlayActivityService.cs b/Roadie.Api.Services/IPlayActivityService.cs index 970eb9c..54fbde6 100644 --- a/Roadie.Api.Services/IPlayActivityService.cs +++ b/Roadie.Api.Services/IPlayActivityService.cs @@ -10,8 +10,7 @@ namespace Roadie.Api.Services { public interface IPlayActivityService { - Task> List(PagedRequest request, User roadieUser = null, - DateTime? newerThan = null); + Task> List(PagedRequest request, User roadieUser = null, DateTime? newerThan = null); Task> NowPlaying(User roadieUser, ScrobbleInfo scrobble); diff --git a/Roadie.Api.Services/ImageService.cs b/Roadie.Api.Services/ImageService.cs index 71b223c..34e80ca 100644 --- a/Roadie.Api.Services/ImageService.cs +++ b/Roadie.Api.Services/ImageService.cs @@ -44,6 +44,13 @@ namespace Roadie.Api.Services ImageSearchManager = imageSearchManager; } + public ImageService(IRoadieSettings configuration, data.IRoadieDbContext dbContext, ICacheManager cacheManager, + ILogger logger, DefaultNotFoundImages defaultNotFoundImages) + : base(configuration, null, dbContext, cacheManager, logger, null) + { + DefaultNotFoundImages = defaultNotFoundImages; + } + public async Task> ArtistImage(Guid id, int? width, int? height, EntityTagHeaderValue etag = null) { return await GetImageFileOperation("ArtistImage", @@ -118,7 +125,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await GenreImageAction(id, etag); }, + async () => await GenreImageAction(id, etag), etag); } @@ -129,7 +136,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await LabelImageAction(id, etag); }, + async () => await LabelImageAction(id, etag), etag); } @@ -140,7 +147,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await PlaylistImageAction(id, etag); }, + async () => await PlaylistImageAction(id, etag), etag); } @@ -151,7 +158,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await ReleaseImageAction(id, etag); }, + async () => await ReleaseImageAction(id, etag), etag); } @@ -199,7 +206,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await TrackImageAction(id, width, height, etag); }, + async () => await TrackImageAction(id, width, height, etag), etag); } @@ -210,7 +217,7 @@ namespace Roadie.Api.Services id, width, height, - async () => { return await UserImageAction(id, etag); }, + async () => await UserImageAction(id, etag), etag); } @@ -365,9 +372,13 @@ namespace Roadie.Api.Services { var imageEtag = EtagHelper.GenerateETag(HttpEncoder, image.Bytes); if (EtagHelper.CompareETag(HttpEncoder, etag, imageEtag)) + { return new FileOperationResult(OperationMessages.NotModified); - if (!image?.Bytes?.Any() ?? false) + } + if (image?.Bytes?.Any() != true) + { return new FileOperationResult(string.Format("ImageById Not Set [{0}]", id)); + } return new FileOperationResult(image?.Bytes?.Any() ?? false ? OperationMessages.OkMessage : OperationMessages.NoImageDataFound) @@ -427,8 +438,14 @@ namespace Roadie.Api.Services { var sw = Stopwatch.StartNew(); var result = (await CacheManager.GetAsync($"urn:{type}_by_id_operation:{id}", action, regionUrn)).Adapt>(); - if (!result.IsSuccess) return new FileOperationResult(result.IsNotFoundResult, result.Messages); - if (result.ETag == etag) return new FileOperationResult(OperationMessages.NotModified); + if (!result.IsSuccess) + { + return new FileOperationResult(result.IsNotFoundResult, result.Messages); + } + if (result.ETag == etag && etag != null) + { + return new FileOperationResult(OperationMessages.NotModified); + } var force = width.HasValue || height.HasValue; var newWidth = width ?? Configuration.MaximumImageSize.Width; var newHeight = height ?? Configuration.MaximumImageSize.Height; @@ -624,7 +641,7 @@ namespace Roadie.Api.Services CreatedDate = release.CreatedDate, LastUpdated = release.LastUpdated }; - if (release.Thumbnail == null || !release.Thumbnail.Any()) + if (release.Thumbnail?.Any() != true) { image = DefaultNotFoundImages.Release; } diff --git a/Roadie.Api.Services/PlayActivityService.cs b/Roadie.Api.Services/PlayActivityService.cs index c7b1f53..8d76744 100644 --- a/Roadie.Api.Services/PlayActivityService.cs +++ b/Roadie.Api.Services/PlayActivityService.cs @@ -27,22 +27,23 @@ namespace Roadie.Api.Services protected IScrobbleHandler ScrobblerHandler { get; } - public PlayActivityService(IRoadieSettings configuration, - IHttpEncoder httpEncoder, - IHttpContext httpContext, - data.IRoadieDbContext dbContext, - ICacheManager cacheManager, - ILogger logger, - IScrobbleHandler scrobbleHandler, - IHubContext playActivityHub) + public PlayActivityService(IRoadieSettings configuration, IHttpEncoder httpEncoder,IHttpContext httpContext, + data.IRoadieDbContext dbContext, ICacheManager cacheManager,ILogger logger, + IScrobbleHandler scrobbleHandler, IHubContext playActivityHub) : base(configuration, httpEncoder, dbContext, cacheManager, logger, httpContext) { PlayActivityHub = playActivityHub; ScrobblerHandler = scrobbleHandler; } - public Task> List(PagedRequest request, - User roadieUser = null, DateTime? newerThan = null) + public PlayActivityService(IRoadieSettings configuration, data.IRoadieDbContext dbContext, ICacheManager cacheManager, + ILogger logger, ScrobbleHandler scrobbleHandler) + : base(configuration, null, dbContext, cacheManager, logger, null) + { + ScrobblerHandler = scrobbleHandler; + } + + public Task> List(PagedRequest request,User roadieUser = null, DateTime? newerThan = null) { try { @@ -63,9 +64,8 @@ namespace Roadie.Api.Services where !request.FilterRatedOnly || roadieUser == null && t.Rating > 0 || roadieUser != null && usertrack.Rating > 0 where request.FilterValue.Length == 0 || request.FilterValue.Length > 0 && ( - t.Title != null && t.Title.ToLower().Contains(request.Filter.ToLower()) || - t.AlternateNames != null && t.AlternateNames.ToLower().Contains(request.Filter.ToLower()) - ) + t.Title != null && t.Title.Contains(request.Filter, StringComparison.OrdinalIgnoreCase) || + t.AlternateNames != null && t.AlternateNames.Contains(request.Filter, StringComparison.OrdinalIgnoreCase)) select new PlayActivityList { Release = new DataToken @@ -147,7 +147,10 @@ namespace Roadie.Api.Services public async Task> Scrobble(User roadieUser, ScrobbleInfo scrobble) { var scrobbleResult = await ScrobblerHandler.Scrobble(roadieUser, scrobble); - if (!scrobbleResult.IsSuccess) return scrobbleResult; + if (!scrobbleResult.IsSuccess) + { + return scrobbleResult; + } await PublishPlayActivity(roadieUser, scrobble, false); return scrobbleResult; } @@ -155,7 +158,7 @@ namespace Roadie.Api.Services private async Task PublishPlayActivity(User roadieUser, ScrobbleInfo scrobble, bool isNowPlaying) { // Only broadcast if the user is not public and played duration is more than half of duration - if (!roadieUser.IsPrivate && + if (roadieUser?.IsPrivate != true && scrobble.ElapsedTimeOfTrackPlayed.TotalSeconds > scrobble.TrackDuration.TotalSeconds / 2) { var sw = Stopwatch.StartNew(); diff --git a/Roadie.Api.Services/Roadie.Api.Services.csproj b/Roadie.Api.Services/Roadie.Api.Services.csproj index cf1c9ac..f5753aa 100644 --- a/Roadie.Api.Services/Roadie.Api.Services.csproj +++ b/Roadie.Api.Services/Roadie.Api.Services.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.2 @@ -11,12 +11,13 @@ - + + diff --git a/Roadie.Api.Services/SubsonicService.cs b/Roadie.Api.Services/SubsonicService.cs index faede30..a0c8c2f 100644 --- a/Roadie.Api.Services/SubsonicService.cs +++ b/Roadie.Api.Services/SubsonicService.cs @@ -2239,7 +2239,7 @@ namespace Roadie.Api.Services averageRatingSpecified = true, bitRate = 320, bitRateSpecified = true, - contentType = "audio/mpeg", + contentType = MimeTypeHelper.Mp3MimeType, coverArt = subsonic.Request.TrackIdIdentifier + t.Id, created = t.CreatedDate.Value, createdSpecified = true, @@ -2265,7 +2265,7 @@ namespace Roadie.Api.Services userRatingSpecified = t.UserRating != null, year = t.Year ?? 0, yearSpecified = t.Year.HasValue, - transcodedContentType = "audio/mpeg", + transcodedContentType = MimeTypeHelper.Mp3MimeType, transcodedSuffix = "mp3", isVideo = false, isVideoSpecified = true, diff --git a/Roadie.Api.Services/TrackService.cs b/Roadie.Api.Services/TrackService.cs index d8f15ed..76c1aee 100644 --- a/Roadie.Api.Services/TrackService.cs +++ b/Roadie.Api.Services/TrackService.cs @@ -47,6 +47,11 @@ namespace Roadie.Api.Services AdminService = adminService; } + public TrackService(IRoadieSettings configuration, data.IRoadieDbContext dbContext, ICacheManager cacheManager, ILogger logger) + : base(configuration, null, dbContext, cacheManager, logger, null) + { + } + public static long DetermineByteEndFromHeaders(IHeaderDictionary headers, long fileLength) { var defaultFileLength = fileLength - 1; @@ -393,7 +398,7 @@ namespace Roadie.Api.Services where !request.FilterFavoriteOnly || favoriteTrackIds.Contains(t.Id) where request.FilterToPlaylistId == null || playlistTrackIds.Contains(t.Id) where !request.FilterTopPlayedOnly || topTrackids.Contains(t.Id) - where request.FilterToArtistId == null || (request.FilterToArtistId != null && ((t.TrackArtist != null && t.TrackArtist.RoadieId == request.FilterToArtistId) || r.Artist.RoadieId == request.FilterToArtistId)) + where request.FilterToArtistId == null || ((t.TrackArtist != null && t.TrackArtist.RoadieId == request.FilterToArtistId) || r.Artist.RoadieId == request.FilterToArtistId) where !request.IsHistoryRequest || t.PlayedCount > 0 where request.FilterToCollectionId == null || collectionTrackIds.Contains(t.Id) select new @@ -694,8 +699,7 @@ namespace Roadie.Api.Services }; } - public async Task> TrackStreamInfo(Guid trackId, long beginBytes, - long endBytes, User roadieUser) + public async Task> TrackStreamInfo(Guid trackId, long beginBytes, long endBytes, User roadieUser) { var track = DbContext.Tracks.FirstOrDefault(x => x.RoadieId == trackId); if (!(track?.IsValid ?? true)) @@ -705,7 +709,7 @@ namespace Roadie.Api.Services join rm in DbContext.ReleaseMedias on r.Id equals rm.ReleaseId where rm.Id == track.ReleaseMediaId select r).FirstOrDefault(); - if (!release.IsLocked ?? false) + if (!release.IsLocked ?? false && roadieUser != null) { await AdminService.ScanRelease(new ApplicationUser { @@ -744,7 +748,7 @@ namespace Roadie.Api.Services join rm in DbContext.ReleaseMedias on r.Id equals rm.ReleaseId where rm.Id == track.ReleaseMediaId select r).FirstOrDefault(); - if (!release.IsLocked ?? false) + if (!release.IsLocked ?? false && roadieUser != null) { await AdminService.ScanRelease(new ApplicationUser { @@ -781,9 +785,8 @@ namespace Roadie.Api.Services var contentDurationTimeSpan = TimeSpan.FromMilliseconds(track.Duration ?? 0); var info = new TrackStreamInfo { - FileName = HttpEncoder.UrlEncode(track.FileName).ToContentDispositionFriendly(), - ContentDisposition = - $"attachment; filename=\"{HttpEncoder.UrlEncode(track.FileName).ToContentDispositionFriendly()}\"", + FileName = HttpEncoder?.UrlEncode(track.FileName).ToContentDispositionFriendly(), + ContentDisposition = $"attachment; filename=\"{HttpEncoder?.UrlEncode(track.FileName).ToContentDispositionFriendly()}\"", ContentDuration = contentDurationTimeSpan.TotalSeconds.ToString() }; var contentLength = endBytes - beginBytes + 1; diff --git a/Roadie.Api/Controllers/SubsonicController.cs b/Roadie.Api/Controllers/SubsonicController.cs index 0417628..201bb52 100644 --- a/Roadie.Api/Controllers/SubsonicController.cs +++ b/Roadie.Api/Controllers/SubsonicController.cs @@ -687,7 +687,7 @@ namespace Roadie.Api.Controllers } Logger.LogTrace( - $"Subsonic Request: Method [{method}], Accept Header [{acceptHeader}], Path [{queryPath}], Query String [{queryString}], Posted Body [{postBody}], Response Error Code [{response?.ErrorCode}], Request [{JsonConvert.SerializeObject(request, Formatting.Indented)}] ResponseType [{responseType}]"); + $"Subsonic Request: Method [{method}], Accept Header [{acceptHeader}], Path [{queryPath}], Query String [{queryString}], Posted Body [{postBody}], Response Error Code [{response?.ErrorCode}], Request [{JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented)}] ResponseType [{responseType}]"); if (response?.ErrorCode.HasValue ?? false) return SendError(request, response); if (request.IsJSONRequest) { diff --git a/Roadie.Api/Roadie.Api.csproj b/Roadie.Api/Roadie.Api.csproj index 40f8deb..73ee52f 100644 --- a/Roadie.Api/Roadie.Api.csproj +++ b/Roadie.Api/Roadie.Api.csproj @@ -33,7 +33,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -53,6 +53,7 @@ + diff --git a/Roadie.Api/Startup.cs b/Roadie.Api/Startup.cs index c6ee6c3..b0b85ca 100644 --- a/Roadie.Api/Startup.cs +++ b/Roadie.Api/Startup.cs @@ -18,6 +18,7 @@ using Pomelo.EntityFrameworkCore.MySql.Infrastructure; using Roadie.Api.Hubs; using Roadie.Api.ModelBinding; using Roadie.Api.Services; +using Roadie.Dlna.Services; using Roadie.Library.Caching; using Roadie.Library.Configuration; using Roadie.Library.Data; @@ -228,6 +229,8 @@ namespace Roadie.Api services.AddScoped(); services.AddScoped(); + services.AddSingleton(); + var securityKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(_configuration["Tokens:PrivateKey"])); services.AddAuthentication(options => diff --git a/Roadie.Api/appsettings.Development.json b/Roadie.Api/appsettings.Development.json index aa5f1ad..30e8ba5 100644 --- a/Roadie.Api/appsettings.Development.json +++ b/Roadie.Api/appsettings.Development.json @@ -61,6 +61,9 @@ "RoadieSettings": { "InboundFolder": "C:\\roadie_dev_root\\inbound", "LibraryFolder": "C:\\\\roadie_dev_root\\\\library", + "Dlna": { + "Port": 61903 + }, "Processing": { "RemoveStringsRegex": "\\b[0-9]+\\s#\\s\\b", "ReplaceStrings": [ diff --git a/Roadie.Api/appsettings.json b/Roadie.Api/appsettings.json index 6a0cc48..814b1ac 100644 --- a/Roadie.Api/appsettings.json +++ b/Roadie.Api/appsettings.json @@ -21,7 +21,7 @@ "Name": "Console", "Args": { "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "restrictedToMinimumLevel": "Warning" + "restrictedToMinimumLevel": "Information" } }, { @@ -52,6 +52,9 @@ }, "CORSOrigins": "http://localhost:4200|http://localhost:8080|https://localhost:8080|http://localhost:80|https://localhost:80", "RoadieSettings": { + "Dlna": { + "Port": 61903 + }, "Converting": { "ConvertingEnabled": true, "DoDeleteAfter": true, diff --git a/Roadie.Dlna.Services/CoverArt.cs b/Roadie.Dlna.Services/CoverArt.cs new file mode 100644 index 0000000..aa9f5c0 --- /dev/null +++ b/Roadie.Dlna.Services/CoverArt.cs @@ -0,0 +1,47 @@ +using Roadie.Dlna.Server; +using Roadie.Dlna.Server.Metadata; +using System; +using System.IO; + +namespace Roadie.Dlna.Services +{ + public sealed class CoverArt : IMediaCoverResource, IMetaInfo + { + private byte[] bytes; + public IMediaCoverResource Cover => this; + + public string Id + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + public DateTime InfoDate { get; } + public long? InfoSize { get; } + public DlnaMediaTypes MediaType => DlnaMediaTypes.Image; + + public int? MetaHeight { get; } + public int? MetaWidth { get; } + public string Path => throw new NotImplementedException(); + public string PN => "JPEG_TN"; + + public IHeaders Properties => throw new NotImplementedException(); + public string Title => throw new NotImplementedException(); + public DlnaMime Type => DlnaMime.ImageJPEG; + + public CoverArt(byte[] data, int width, int height) + { + bytes = data; + MetaWidth = width; + MetaHeight = height; + } + + public int CompareTo(IMediaItem other) => throw new NotImplementedException(); + + public Stream CreateContentStream() => new MemoryStream(bytes); + + public bool Equals(IMediaItem other) => throw new NotImplementedException(); + + public string ToComparableTitle() => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna.Services/DlnaHostService.cs b/Roadie.Dlna.Services/DlnaHostService.cs new file mode 100644 index 0000000..0a61039 --- /dev/null +++ b/Roadie.Dlna.Services/DlnaHostService.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Pomelo.EntityFrameworkCore.MySql.Infrastructure; +using Roadie.Api.Services; +using Roadie.Dlna.Server; +using Roadie.Library.Caching; +using Roadie.Library.Configuration; +using Roadie.Library.Imaging; +using Roadie.Library.Scrobble; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using data = Roadie.Library.Data; + +namespace Roadie.Dlna.Services +{ + /// + /// Hosted Service for Dlna Service (not the actual Dlna Service) + /// + public class DlnaHostService : IHostedService, IDisposable + { + private HttpAuthorizer _authorizer = null; + + private ICacheManager CacheManager { get; } + private IRoadieSettings Configuration { get; } + private data.IRoadieDbContext DbContext { get; set; } + private ILogger Logger { get; } + private ILoggerFactory LoggerFactory { get; } + private IServiceScopeFactory ServiceScopeFactory { get; } + + public DlnaHostService(IServiceScopeFactory serviceScopeFactory, IRoadieSettings configuration, ICacheManager cacheManager, + ILoggerFactory loggerFactory) + { + ServiceScopeFactory = serviceScopeFactory; + Configuration = configuration; + CacheManager = cacheManager; + LoggerFactory = loggerFactory; + Logger = loggerFactory.CreateLogger("DlnaHostService"); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!Configuration.Dlna.IsEnabled) + { + Logger.LogInformation("DLNA service disabled."); + return; + } + var server = new HttpServer(LoggerFactory.CreateLogger("HttpServer"), Configuration.Dlna.Port ?? 0); + _authorizer = new HttpAuthorizer(server); + if (Configuration.Dlna.AllowedIps.Any()) + { + _authorizer.AddMethod(new IPAddressAuthorizer(Configuration.Dlna.AllowedIps)); + } + if (Configuration.Dlna.AllowedUserAgents.Any()) + { + _authorizer.AddMethod(new UserAgentAuthorizer(Configuration.Dlna.AllowedUserAgents)); + } + var types = new DlnaMediaTypes[] { DlnaMediaTypes.Image, DlnaMediaTypes.Audio }; + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseMySql(Configuration.ConnectionString, mySqlOptions => + { + mySqlOptions.ServerVersion(new Version(5, 5), ServerType.MariaDb); + mySqlOptions.EnableRetryOnFailure( + 10, + TimeSpan.FromSeconds(30), + null); + }); + + DbContext = new data.RoadieDbContext(optionsBuilder.Options); + + var defaultNotFoundImages = new DefaultNotFoundImages(LoggerFactory.CreateLogger("DefaultNotFoundImages"), Configuration); + var imageService = new ImageService(Configuration, DbContext, CacheManager, LoggerFactory.CreateLogger("ImageService"), defaultNotFoundImages); + var trackService = new TrackService(Configuration, DbContext, CacheManager, LoggerFactory.CreateLogger("TrackService")); + var roadieScrobbler = new RoadieScrobbler(Configuration, LoggerFactory.CreateLogger("RoadieScrobbler"), DbContext, CacheManager); + var scrobbleHandler = new ScrobbleHandler(Configuration, LoggerFactory.CreateLogger("ScrobbleHandler"), DbContext, CacheManager, roadieScrobbler); + var playActivityService = new PlayActivityService(Configuration, DbContext, CacheManager, LoggerFactory.CreateLogger("PlayActivityService"), scrobbleHandler); + + var rs = new DlnaService(Configuration, DbContext, CacheManager, LoggerFactory.CreateLogger("DlnaService"), imageService, trackService, playActivityService); + rs.Preload(); + + server.RegisterMediaServer(Configuration, LoggerFactory.CreateLogger("MediaMount"), rs); + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(5000, cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + #region IDisposable Support + + private bool disposedValue = false; // To detect redundant calls + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + if (_authorizer != null) + { + _authorizer.Dispose(); + } + if (DbContext != null) + { + DbContext.Dispose(); + } + } + disposedValue = true; + } + } + + // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. + // ~DlnaService() + // { + // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + // Dispose(false); + // } + + #endregion IDisposable Support + } +} \ No newline at end of file diff --git a/Roadie.Dlna.Services/DlnaService.cs b/Roadie.Dlna.Services/DlnaService.cs new file mode 100644 index 0000000..fed6d9b --- /dev/null +++ b/Roadie.Dlna.Services/DlnaService.cs @@ -0,0 +1,713 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Roadie.Api.Services; +using Roadie.Dlna.Server; +using Roadie.Library.Caching; +using Roadie.Library.Configuration; +using Roadie.Library.Extensions; +using Roadie.Library.Models; +using Roadie.Library.Models.Releases; +using Roadie.Library.Utility; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using data = Roadie.Library.Data; + +namespace Roadie.Dlna.Services +{ + public class DlnaService : IMediaServer + { + private Dictionary LastTimePlayedForToken = new Dictionary(); + private object lockObject = new object(); + public IHttpAuthorizationMethod Authorizer { get; set; } + public string FriendlyName { get; } + public Guid UUID { get; } = Guid.NewGuid(); + private ICacheManager CacheManager { get; } + private IRoadieSettings Configuration { get; } + private data.IRoadieDbContext DbContext { get; } + private IImageService ImageService { get; } + private ILogger Logger { get; } + private IPlayActivityService PlayActivityService { get; } + private int RandomTrackLimit { get; } + private ITrackService TrackService { get; } + + public DlnaService(IRoadieSettings configuration, data.IRoadieDbContext dbContext, ICacheManager cacheManager, + ILogger logger, IImageService imageService, ITrackService trackService, IPlayActivityService playActivityService) + { + Configuration = configuration; + DbContext = dbContext; + CacheManager = cacheManager; + Logger = logger; + FriendlyName = configuration.Dlna.FriendlyName; + ImageService = imageService; + TrackService = trackService; + PlayActivityService = playActivityService; + RandomTrackLimit = 50; + } + + public void Preload() + { + var sw = Stopwatch.StartNew(); + RootFolder(); + sw.Stop(); + Logger.LogInformation($"DLNA Service Preload Complete. Elapsed Time [{ sw.Elapsed }]"); + } + + public IMediaItem GetItem(string id, bool isFileRequest) + { + if (id.Equals(Identifiers.GENERAL_ROOT)) + { + return RootFolder(); + } + if (id.Equals("vf:artists")) + { + return Artists(); + } + if (id.Equals("vf:collections")) + { + return Collections(); + } + if (id.Equals("vf:playlists")) + { + return Playlists(); + } + if (id.Equals("vf:releases")) + { + return Releases(); + } + if (id.Equals("vf:randomizer")) + { + return Randomizer(); + } + if (id.Equals("vf:randomtracks")) + { + return RandomOrRatedTracks(false); + } + if (id.Equals("vf:randomratedtracks")) + { + return RandomOrRatedTracks(true); + } + if (id.StartsWith("vf:tracksforplaylist:")) + { + return TracksForPlaylist(id); + } + if (id.StartsWith("vf:artistsforfolder:")) + { + return ArtistsForFolder(id); + } + if (id.StartsWith("vf:releasesforcollection")) + { + return ReleasesForCollectionFolder(id); + } + if (id.StartsWith("vf:releasesforfolder:")) + { + return ReleasesForFolder(id); + } + if (id.StartsWith("vf:artist:")) + { + return ReleasesForArtist(id); + } + if (id.StartsWith("vf:release:")) + { + return TracksForRelease(id); + } + if (id.StartsWith("r:t:")) + { + return TrackDetail(id, isFileRequest); + } + Logger.LogWarning($"Unknown Item Key [{ id }]"); + + throw new NotImplementedException(); + } + + private byte[] ArtistArt(Guid artistId) + { + var imageResult = AsyncHelper.RunSync(() => ImageService.ArtistImage(artistId, 320, 320)); + return imageResult.Data?.Bytes; + } + + private Dictionary ArtistGroups() + { + lock (lockObject) + { + return CacheManager.Get("urn:DlnaService:Artists", () => + { + try + { + var sw = Stopwatch.StartNew(); + var result = (from a in DbContext.Artists + join r in DbContext.Releases on a.Id equals r.ArtistId + let sn = (a.SortName ?? a.Name ?? "?").ToUpper() + orderby sn + group a by sn[0] into ag + select new + { + FirstLetter = ag.Key.ToString(), + Artists = ag.ToArray() + }) + .ToDictionary(x => x.FirstLetter, x => x.Artists); + sw.Stop(); + Logger.LogDebug($"DLNA ArtistGroups fetch Elapsed Time [{ sw.Elapsed }]"); + return result; + } + catch (Exception ex) + { + Logger.LogError(ex); + } + return null; + }, "urn:DlnaServiceRegion"); + } + } + + /// + /// Returns groups of artists for level 2 + /// + /// + private IMediaFolder Artists() + { + try + { + var result = new VirtualFolder() + { + Name = "Artists", + Id = "vf:artists" + }; + foreach (var ag in ArtistGroups()) + { + var f = new VirtualFolder(result, ag.Key, $"vf:artistsforfolder:{ ag.Key }"); + foreach (var artistForGroup in ArtistsForGroup(ag.Key)) + { + var af = new VirtualFolder(f, artistForGroup.RoadieId.ToString(), $"vf:artist:{ artistForGroup.Id }"); + f.AddFolder(af); + } + result.AddFolder(f); + } + return result; + } + catch (Exception ex) + { + Logger.LogError(ex, "Artists Root"); + } + return null; + } + + /// + /// Returns artists for group letter (level 3) + /// + private IMediaItem ArtistsForFolder(string id) + { + var artistsForFolderKey = id.Replace("vf:artistsforfolder:", ""); + var result = new VirtualFolder() + { + Name = artistsForFolderKey, + Id = id + }; + + foreach (var artistForGroup in ArtistsForGroup(artistsForFolderKey)) + { + var af = new VirtualFolder(result, artistForGroup.SortName ?? artistForGroup.Name, $"vf:artist:{ artistForGroup.Id }"); + foreach (var artistRelease in ReleasesForArtist(artistForGroup.Id)) + { + var fr = new VirtualFolder(af, artistRelease.RoadieId.ToString(), $"vf:release:{ artistRelease.Id }"); + af.AddFolder(fr); + } + result.AddFolder(af); + } + return result; + } + + private IEnumerable ArtistsForGroup(string groupKey) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:ArtistsForGroup:{ groupKey }", () => + { + return (from a in DbContext.Artists + join r in DbContext.Releases on a.Id equals r.ArtistId + let sn = (a.SortName ?? a.Name).ToUpper() + where sn[0].ToString().ToUpper() == groupKey + select a).Distinct().ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IEnumerable CollectionGroups() + { + lock (lockObject) + { + return CacheManager.Get("urn:DlnaService:Collections", () => + { + return (from c in DbContext.Collections + let sn = (c.SortName ?? c.Name).ToUpper() + orderby sn + select c).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaFolder Collections() + { + var result = new VirtualFolder() + { + Name = "Collections", + Id = "vf:collections" + }; + foreach (var cg in CollectionGroups()) + { + var f = new VirtualFolder(result, cg.SortName ?? cg.Name, $"vf:releasesforcollection:{ cg.Id }"); + foreach (var releaseForCollection in ReleasesForCollection(cg.Id)) + { + var af = new VirtualFolder(f, releaseForCollection.RoadieId.ToString(), $"vf:release:{ releaseForCollection.Id }"); + f.AddFolder(af); + } + result.AddFolder(f); + } + return result; + } + + private IEnumerable CollectionsForGroup(string groupKey) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:CollectionsForGroup:{ groupKey }", () => + { + return (from c in DbContext.Collections + let sn = (c.SortName ?? c.Name).ToUpper() + where sn == groupKey + select c).Distinct().ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IEnumerable PlaylistGroups() + { lock (lockObject) + { + return CacheManager.Get("urn:DlnaService:Playlists", () => + { + return (from p in DbContext.Playlists + orderby p.Name + select p).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaFolder Playlists() + { + var result = new VirtualFolder() + { + Name = "Playlists", + Id = "vf:playlists" + }; + foreach (var pl in PlaylistGroups()) + { + var f = new VirtualFolder(result, pl.Name, $"vf:tracksforplaylist:{ pl.Id }"); + foreach (var track in TracksForPlaylist(pl.Id)) + { + var t = new VirtualFolder(result, pl.Name, $"t:tk:{track.Id}::{Guid.NewGuid()}"); + f.AddFolder(t); + } + result.AddFolder(f); + } + + return result; + } + + private IMediaFolder Randomizer() + { + var result = new VirtualFolder() + { + Name = "Randomizer", + Id = "vf:randomizer" + }; + var randomTracks = new VirtualFolder() + { + Name = "Random Tracks", + Id = "vf:randomtracks" + }; + for (var i = 0; i < RandomTrackLimit; i++) + { + randomTracks.AddFolder(new VirtualFolder()); + } + result.AddFolder(randomTracks); + var randomRatedTracks = new VirtualFolder() + { + Name = "Random Rated Tracks", + Id = "vf:randomratedtracks" + }; + for (var i = 0; i < RandomTrackLimit; i++) + { + randomRatedTracks.AddFolder(new VirtualFolder()); + } + result.AddFolder(randomRatedTracks); + return result; + } + + private IMediaFolder RandomOrRatedTracks(bool isRated) + { + var result = new VirtualFolder() + { + Name = isRated ? "Random Rated Tracks" : "Random Tracks", + Id = isRated ? "vf:randomratedtracks" : "vf:randomtracks" + }; + + foreach (var randomTrack in RandomTracks(RandomTrackLimit, (short)(isRated ? 1 : 0))) + { + var t = new Track($"r:t:tk:{randomTrack.ReleaseMedia.Release.Id}:{randomTrack.Id}:{ Guid.NewGuid() }", randomTrack.ReleaseMedia.Release.Artist.Name, randomTrack.ReleaseMedia.Release.Title, randomTrack.ReleaseMedia.MediaNumber, + randomTrack.Title, randomTrack.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), randomTrack.TrackArtist?.Name, randomTrack.TrackNumber, randomTrack.ReleaseMedia.Release.ReleaseYear, + TimeSpan.FromMilliseconds(SafeParser.ToNumber(randomTrack.Duration)), isRated ? $"Rating: { randomTrack.Rating }" : randomTrack.PartTitles, randomTrack.LastUpdated ?? randomTrack.CreatedDate, ReleaseCoverArt(randomTrack.ReleaseMedia.Release.RoadieId)); + result.AddResource(t); + } + return result; + } + + private IEnumerable RandomTracks(int randomLimit, short minimumRating) + { + var randomModels = (from t in DbContext.Tracks + join rm in DbContext.ReleaseMedias on t.ReleaseMediaId equals rm.Id + join r in DbContext.Releases on rm.ReleaseId equals r.Id + join a in DbContext.Artists on r.ArtistId equals a.Id + where t.Hash != null + where t.Rating >= minimumRating + select new TrackList + { + DatabaseId = t.Id, + Artist = new ArtistList + { + Artist = new DataToken { Value = a.RoadieId.ToString(), Text = a.Name } + }, + Release = new ReleaseList + { + Release = new DataToken { Value = r.RoadieId.ToString(), Text = r.Title } + } + }) + .OrderBy(x => x.Artist.RandomSortId) + .ThenBy(x => x.RandomSortId) + .ThenBy(x => x.RandomSortId) + .Take(randomLimit) + .Select(x => x.DatabaseId) + .ToArray(); + + return (from t in DbContext.Tracks + .Include(x => x.TrackArtist) + .Include(x => x.ReleaseMedia) + .Include(x => x.ReleaseMedia.Release) + .Include(x => x.ReleaseMedia.Release.Artist) + .Include(x => x.ReleaseMedia.Release.Genres) + .Include("ReleaseMedia.Release.Genres.Genre") + join rm in randomModels on t.Id equals rm + select t).ToArray(); + } + + private byte[] ReleaseCoverArt(Guid releaseId) + { + var imageResult = AsyncHelper.RunSync(() => ImageService.ReleaseImage(releaseId, 320, 320)); + return imageResult.Data?.Bytes; + } + + private Dictionary ReleaseGroups() + { + lock (lockObject) + { + return CacheManager.Get("urn:DlnaService:Releases", () => + { + return (from r in DbContext.Releases + orderby r.Title + group r by r.Title[0] into rg + select new { FirstLetter = rg.Key.ToString(), Releases = rg.ToArray() }) + .ToDictionary(x => x.FirstLetter, x => x.Releases); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaFolder Releases() + { + var result = new VirtualFolder() + { + Name = "Releases", + Id = "vf:releases" + }; + foreach (var ag in ReleaseGroups()) + { + var f = new VirtualFolder(result, ag.Key, $"vf:releasesforfolder:{ ag.Key }"); + foreach (var releaseForGroup in ReleasesForGroup(ag.Key)) + { + var af = new VirtualFolder(f, releaseForGroup.RoadieId.ToString(), $"vf:release:{ releaseForGroup.Id }"); + f.AddFolder(af); + } + result.AddFolder(f); + } + + return result; + } + + private IEnumerable ReleasesForArtist(int artistId) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:ReleasesForArtist:{ artistId }", () => + { + return (from r in DbContext.Releases + where r.ArtistId == artistId + orderby r.ReleaseYear, r.Title + select r).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + /// + /// Return releases for an artist (level 4) + /// + private IMediaItem ReleasesForArtist(string id) + { + var artistId = SafeParser.ToNumber(id.Replace("vf:artist:", "")); + var artist = DbContext.Artists.FirstOrDefault(x => x.Id == artistId); + var result = new VirtualFolder() + { + Name = artist.Name, + Id = id + }; + foreach (var artistRelease in ReleasesForArtist(artist.Id)) + { + var fr = new VirtualFolder(result, artistRelease.Title, $"vf:release:{ artistRelease.Id }"); + foreach (var releaseTrack in TracksForRelease(artistRelease.Id)) + { + var t = new Track(releaseTrack.RoadieId.ToString(), releaseTrack.ReleaseMedia.Release.Artist.Name, releaseTrack.ReleaseMedia.Release.Title, releaseTrack.ReleaseMedia.MediaNumber, + releaseTrack.Title, releaseTrack.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), releaseTrack.TrackArtist?.Name, releaseTrack.TrackNumber, releaseTrack.ReleaseMedia.Release.ReleaseYear, + TimeSpan.FromMilliseconds(SafeParser.ToNumber(releaseTrack.Duration)), releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, null); + fr.AddResource(t); + } + result.AddFolder(fr); + } + return result; + } + + private IEnumerable ReleasesForCollection(int collectionId) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:ReleasesForCollection:{ collectionId }", () => + { + return (from c in DbContext.Collections + join cr in DbContext.CollectionReleases on c.Id equals cr.CollectionId + join r in DbContext.Releases on cr.ReleaseId equals r.Id + where c.Id == collectionId + orderby cr.ListNumber, r.Title + select r).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaItem ReleasesForCollectionFolder(string id) + { + var collectionId = SafeParser.ToNumber(id.Replace("vf:releasesforcollection:", "")); + var collection = DbContext.Collections.FirstOrDefault(x => x.Id == collectionId); + var result = new VirtualFolder() + { + Name = collection.Name, + Id = id + }; + foreach (var collectionRelease in ReleasesForCollection(collection.Id)) + { + var fr = new VirtualFolder(result, collectionRelease.Title, $"vf:release:{ collectionRelease.Id }"); + foreach (var releaseTrack in TracksForRelease(collectionRelease.Id)) + { + var t = new Track(releaseTrack.RoadieId.ToString(), releaseTrack.ReleaseMedia.Release.Artist.Name, releaseTrack.ReleaseMedia.Release.Title, releaseTrack.ReleaseMedia.MediaNumber, + releaseTrack.Title, releaseTrack.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), releaseTrack.TrackArtist?.Name, releaseTrack.TrackNumber, releaseTrack.ReleaseMedia.Release.ReleaseYear, + TimeSpan.FromMilliseconds(SafeParser.ToNumber(releaseTrack.Duration)), releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, null); + fr.AddResource(t); + } + result.AddFolder(fr); + } + return result; + } + + /// + /// Returns releases for group letter (level 3) + /// + private IMediaItem ReleasesForFolder(string id) + { + var artistsForFolderKey = id.Replace("vf:releasesforfolder:", ""); + var result = new VirtualFolder() + { + Name = artistsForFolderKey, + Id = id + }; + + foreach (var releaseForGroup in ReleasesForGroup(artistsForFolderKey)) + { + var af = new VirtualFolder(result, releaseForGroup.Title, $"vf:release:{ releaseForGroup.Id }"); + foreach (var artistRelease in TracksForRelease(releaseForGroup.Id)) + { + var fr = new VirtualFolder(af, artistRelease.RoadieId.ToString(), $"vf:release:{ artistRelease.Id }"); + af.AddFolder(fr); + } + result.AddFolder(af); + } + return result; + } + + private IEnumerable ReleasesForGroup(string groupKey) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:ReleasesForGroup:{ groupKey }", () => + { + var sw = Stopwatch.StartNew(); + var result = (from r in DbContext.Releases + where r.Title[0].ToString() == groupKey + select r).Distinct().ToArray(); + sw.Stop(); + Logger.LogDebug($"DLNA ReleasesForGroup Elapsed Time [{ sw.Elapsed }]"); + return result; + }, "urn:DlnaServiceRegion"); + } + } + + /// + /// Returns items to display at top level (level 1) + /// + /// + private IMediaFolder RootFolder() + { + var result = new VirtualFolder(); + result.AddFolder(Artists()); + result.AddFolder(Collections()); + result.AddFolder(Playlists()); + result.AddFolder(Randomizer()); + result.AddFolder(Releases()); + return result; + } + + private bool ShouldMakeScrobble(string trackToken) + { + if (!LastTimePlayedForToken.ContainsKey(trackToken)) + { + LastTimePlayedForToken.Add(trackToken, DateTime.UtcNow); + } + return (DateTime.UtcNow - LastTimePlayedForToken[trackToken]).TotalMilliseconds < 1000; + } + + private async Task TrackBytesAndMarkPlayed(int releaseId, data.Track track, string trackToken) + { + var results = await TrackService.TrackStreamInfo(track.RoadieId, 0, SafeParser.ToNumber(track.FileSize), null).ConfigureAwait(false); + // Some DLNA clients call for the track file several times for each play + if (ShouldMakeScrobble(trackToken)) + { + await PlayActivityService.Scrobble(null, new Library.Scrobble.ScrobbleInfo + { + TrackId = track.RoadieId, + TimePlayed = DateTime.UtcNow + }).ConfigureAwait(false); + } + return results.Data.Bytes; + } + + private IMediaItem TrackDetail(string id, bool isFileRequest) + { + lock (lockObject) + { + var releaseId = SafeParser.ToNumber(id.Replace("r:t:tk:", "").Split(':')[0]); + var trackId = SafeParser.ToNumber(id.Replace("r:t:tk:", "").Split(':')[1]); + var trackToken = id.Replace("r:t:tk:", "").Split(':')[2]; + + var track = TracksForRelease(releaseId).First(x => x.Id == trackId); + + byte[] trackbytes = null; + if (isFileRequest) + { + trackbytes = AsyncHelper.RunSync(() => TrackBytesAndMarkPlayed(releaseId, track, trackToken)); + } + return new Track($"r:t:tk:{releaseId}:{trackId}:{ Guid.NewGuid() }", track.ReleaseMedia.Release.Artist.Name, track.ReleaseMedia.Release.Title, track.ReleaseMedia.MediaNumber, + track.Title, track.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), track.TrackArtist?.Name, + track.TrackNumber, track.ReleaseMedia.Release.ReleaseYear, TimeSpan.FromMilliseconds(SafeParser.ToNumber(track.Duration)), + track.PartTitles, track.LastUpdated ?? track.CreatedDate, ReleaseCoverArt(track.ReleaseMedia.Release.RoadieId), trackbytes); + } + } + + private IEnumerable TracksForPlaylist(int playlistId) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:TracksForPlaylist:{ playlistId }", () => + { + return (from pl in DbContext.Playlists + join plr in DbContext.PlaylistTracks on pl.Id equals plr.PlayListId + join t in DbContext.Tracks.Include(x => x.TrackArtist) + .Include(x => x.ReleaseMedia) + .Include(x => x.ReleaseMedia.Release) + .Include(x => x.ReleaseMedia.Release.Artist) + .Include(x => x.ReleaseMedia.Release.Genres) + .Include("ReleaseMedia.Release.Genres.Genre") on plr.TrackId equals t.Id + join rm in DbContext.ReleaseMedias on t.ReleaseMediaId equals rm.Id + where pl.Id == playlistId + orderby plr.ListNumber + select t).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaItem TracksForPlaylist(string id) + { + var playlistId = SafeParser.ToNumber(id.Replace("vf:tracksforplaylist:", "")); + var playlist = DbContext.Playlists.FirstOrDefault(x => x.Id == playlistId); + var result = new VirtualFolder() + { + Name = playlist.Name, + Id = id + }; + + foreach (var playlistTrack in TracksForPlaylist(playlist.Id)) + { + var t = new Track($"r:t:tk:{playlistTrack.ReleaseMedia.Release.Id}:{playlistTrack.Id}:{ Guid.NewGuid() }", playlistTrack.ReleaseMedia.Release.Artist.Name, playlistTrack.ReleaseMedia.Release.Title, playlistTrack.ReleaseMedia.MediaNumber, + playlistTrack.Title, playlistTrack.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), playlistTrack.TrackArtist?.Name, playlistTrack.TrackNumber, playlistTrack.ReleaseMedia.Release.ReleaseYear, + TimeSpan.FromMilliseconds(SafeParser.ToNumber(playlistTrack.Duration)), playlistTrack.PartTitles, playlistTrack.LastUpdated ?? playlistTrack.CreatedDate, ReleaseCoverArt(playlistTrack.ReleaseMedia.Release.RoadieId)); + result.AddResource(t); + } + return result; + } + + private IEnumerable TracksForRelease(int releaseId) + { + lock (lockObject) + { + return CacheManager.Get($"urn:DlnaService:TracksForRelease:{ releaseId }", () => + { + return (from t in DbContext.Tracks + .Include(x => x.TrackArtist) + .Include(x => x.ReleaseMedia) + .Include(x => x.ReleaseMedia.Release) + .Include(x => x.ReleaseMedia.Release.Artist) + .Include(x => x.ReleaseMedia.Release.Genres) + .Include("ReleaseMedia.Release.Genres.Genre") + join rm in DbContext.ReleaseMedias on t.ReleaseMediaId equals rm.Id + where rm.ReleaseId == releaseId + orderby rm.MediaNumber, t.TrackNumber + select t).ToArray(); + }, "urn:DlnaServiceRegion"); + } + } + + private IMediaItem TracksForRelease(string id) + { + var releaseId = SafeParser.ToNumber(id.Replace("vf:release:", "")); + var release = DbContext.Releases.FirstOrDefault(x => x.Id == releaseId); + var result = new VirtualFolder() + { + Name = release.Title, + Id = id + }; + + foreach (var releaseTrack in TracksForRelease(release.Id)) + { + var t = new Track($"r:t:tk:{release.Id}:{releaseTrack.Id}:{Guid.NewGuid()}", releaseTrack.ReleaseMedia.Release.Artist.Name, releaseTrack.ReleaseMedia.Release.Title, releaseTrack.ReleaseMedia.MediaNumber, + releaseTrack.Title, releaseTrack.ReleaseMedia.Release.Genres.Select(x => x.Genre.Name).ToCSV(), releaseTrack.TrackArtist?.Name, + releaseTrack.TrackNumber, releaseTrack.ReleaseMedia.Release.ReleaseYear, TimeSpan.FromMilliseconds(SafeParser.ToNumber(releaseTrack.Duration)), + releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, ReleaseCoverArt(release.RoadieId)); + result.AddResource(t); + } + return result; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna.Services/Roadie.Dlna.Services.csproj b/Roadie.Dlna.Services/Roadie.Dlna.Services.csproj new file mode 100644 index 0000000..7a868c6 --- /dev/null +++ b/Roadie.Dlna.Services/Roadie.Dlna.Services.csproj @@ -0,0 +1,14 @@ + + + + netcoreapp2.2 + AnyCPU;x64 + + + + + + + + + diff --git a/Roadie.Dlna.Services/Track.cs b/Roadie.Dlna.Services/Track.cs new file mode 100644 index 0000000..e0453c7 --- /dev/null +++ b/Roadie.Dlna.Services/Track.cs @@ -0,0 +1,131 @@ +using Roadie.Dlna.Server; +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace Roadie.Dlna.Services +{ + [Serializable] + public sealed class Track : IMediaAudioResource + { + private byte[] FileData = null; + + public IMediaCoverResource Cover { get; } + public string Id { get; set; } + public DateTime InfoDate { get; } + public long? InfoSize { get; } + public DlnaMediaTypes MediaType { get; } + + public string MetaAlbum { get; } + public string MetaArtist { get; } + public string MetaDescription { get; } + public TimeSpan? MetaDuration { get; } + public string MetaGenre { get; } + public string MetaPerformer { get; } + public int? MetaReleaseYear { get; } + public int? MetaTrack { get; } + public string Path { get; } + public string PN { get; } + + public IHeaders Properties + { + get + { + var rv = new RawHeaders { { "Title", Title }, { "MediaType", MediaType.ToString() }, { "Type", Type.ToString() } }; + if (InfoSize.HasValue) + { + rv.Add("SizeRaw", InfoSize.ToString()); + rv.Add("Size", InfoSize.Value.FormatFileSize()); + } + rv.Add("Date", InfoDate.ToString(CultureInfo.InvariantCulture)); + rv.Add("DateO", InfoDate.ToString("o")); + try + { + if (Cover != null) + { + rv.Add("HasCover", "true"); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to access CachedCover Ex [{ ex }]"); + } + if (MetaAlbum != null) + { + rv.Add("Album", MetaAlbum); + } + if (MetaArtist != null) + { + rv.Add("Artist", MetaArtist); + } + if (MetaDescription != null) + { + rv.Add("Description", MetaDescription); + } + if (MetaDuration != null) + { + rv.Add("Duration", MetaDuration.Value.ToString("g")); + } + if (MetaGenre != null) + { + rv.Add("Genre", MetaGenre); + } + if (MetaPerformer != null) + { + rv.Add("Performer", MetaPerformer); + } + if (MetaTrack != null) + { + rv.Add("Track", MetaTrack.Value.ToString()); + } + return rv; + } + } + + public string Title { get; } + public DlnaMime Type { get; } + + public Track(string id, string artistName, string releaseTitle, short mediaNumber, + string title, string genre, string trackArtistName, + int trackNumber, int? releaseYear, TimeSpan duration, + string description, DateTime infoDate, byte[] coverData, byte[] fileData = null) + { + Id = id; + Title = $"[{ trackNumber.ToString().PadLeft(3, '0') }] { title }"; + MetaArtist = artistName; + MetaAlbum = releaseTitle; + if (mediaNumber > 1) + { + MetaAlbum = $"{ mediaNumber.ToString().PadLeft(2, '0') } { releaseTitle}"; + } + MetaDescription = description; + MetaDuration = duration; + MetaGenre = genre; + MetaPerformer = trackArtistName; + MetaReleaseYear = releaseYear; + MetaTrack = trackNumber; + InfoDate = infoDate; + if (fileData != null) + { + FileData = fileData; + InfoSize = fileData.Length; + } + MediaType = DlnaMediaTypes.Audio; + Type = DlnaMime.AudioMP3; + if (coverData != null) + { + Cover = new CoverArt(coverData, 320, 320); + } + } + + public int CompareTo(IMediaItem other) => throw new NotImplementedException(); + + public Stream CreateContentStream() => new MemoryStream(FileData); + + public bool Equals(IMediaItem other) => throw new NotImplementedException(); + + public string ToComparableTitle() => throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Roadie.Dlna.csproj b/Roadie.Dlna/Roadie.Dlna.csproj new file mode 100644 index 0000000..3b7f055 --- /dev/null +++ b/Roadie.Dlna/Roadie.Dlna.csproj @@ -0,0 +1,48 @@ + + + + netcoreapp2.2 + AnyCPU;x64 + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Roadie.Dlna/Server/Comparers/BaseComparer.cs b/Roadie.Dlna/Server/Comparers/BaseComparer.cs new file mode 100644 index 0000000..e16a6d1 --- /dev/null +++ b/Roadie.Dlna/Server/Comparers/BaseComparer.cs @@ -0,0 +1,13 @@ +namespace Roadie.Dlna.Server.Comparers +{ + internal abstract class BaseComparer + { + public abstract string Description { get; } + + public abstract string Name { get; } + + public abstract int Compare(IMediaItem x, IMediaItem y); + + public override string ToString() => $"{Name} - {Description}"; + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Comparers/DateComparer.cs b/Roadie.Dlna/Server/Comparers/DateComparer.cs new file mode 100644 index 0000000..49ed5a7 --- /dev/null +++ b/Roadie.Dlna/Server/Comparers/DateComparer.cs @@ -0,0 +1,26 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server.Comparers +{ + internal class DateComparer : TitleComparer + { + public override string Description => "Sort by file date"; + + public override string Name => "date"; + + public override int Compare(IMediaItem x, IMediaItem y) + { + var xm = x as IMetaInfo; + var ym = y as IMetaInfo; + if (xm != null && ym != null) + { + var rv = xm.InfoDate.CompareTo(ym.InfoDate); + if (rv != 0) + { + return rv; + } + } + return base.Compare(x, y); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Comparers/FileSizeComparer.cs b/Roadie.Dlna/Server/Comparers/FileSizeComparer.cs new file mode 100644 index 0000000..8bda1e6 --- /dev/null +++ b/Roadie.Dlna/Server/Comparers/FileSizeComparer.cs @@ -0,0 +1,23 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server.Comparers +{ + internal class FileSizeComparer : TitleComparer + { + public override string Description => "Sort by file size"; + + public override string Name => "size"; + + public override int Compare(IMediaItem x, IMediaItem y) + { + var xm = x as IMetaInfo; + var ym = y as IMetaInfo; + if (xm == null || ym == null || !xm.InfoSize.HasValue || !ym.InfoSize.HasValue) + { + return base.Compare(x, y); + } + var rv = xm.InfoSize.Value.CompareTo(ym.InfoSize.Value); + return rv != 0 ? rv : base.Compare(x, y); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Comparers/IItemComparer.cs b/Roadie.Dlna/Server/Comparers/IItemComparer.cs new file mode 100644 index 0000000..68c57de --- /dev/null +++ b/Roadie.Dlna/Server/Comparers/IItemComparer.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Roadie.Dlna.Server.Comparers +{ + public interface IItemComparer : IComparer + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Comparers/TitleComparer.cs b/Roadie.Dlna/Server/Comparers/TitleComparer.cs new file mode 100644 index 0000000..134b74d --- /dev/null +++ b/Roadie.Dlna/Server/Comparers/TitleComparer.cs @@ -0,0 +1,31 @@ +using Roadie.Dlna.Utility; +using System; + +namespace Roadie.Dlna.Server.Comparers +{ + internal class TitleComparer : BaseComparer + { + private static readonly StringComparer comparer = new NaturalStringComparer(false); + + public override string Description => "Sort alphabetically"; + + public override string Name => "title"; + + public override int Compare(IMediaItem x, IMediaItem y) + { + if (x == null && y == null) + { + return 0; + } + if (x == null) + { + return 1; + } + if (y == null) + { + return -1; + } + return comparer.Compare(x.ToComparableTitle(), y.ToComparableTitle()); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/IconHandler.cs b/Roadie.Dlna/Server/Handlers/IconHandler.cs new file mode 100644 index 0000000..3ee4045 --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/IconHandler.cs @@ -0,0 +1,20 @@ +using System; + +namespace Roadie.Dlna.Server +{ + internal sealed class IconHandler : IPrefixHandler + { + public string Prefix => "/icon/"; + + public IResponse HandleRequest(IRequest req) + { + var resource = req.Path.Substring(Prefix.Length); + var isPNG = resource.EndsWith(".png", StringComparison.OrdinalIgnoreCase); + return new ResourceResponse( + HttpCode.Ok, + isPNG ? "image/png" : "image/jpeg", + resource + ); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/IndexHandler.cs b/Roadie.Dlna/Server/Handlers/IndexHandler.cs new file mode 100644 index 0000000..0692342 --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/IndexHandler.cs @@ -0,0 +1,43 @@ +using System.Linq; +using Roadie.Dlna.Utility; + +namespace Roadie.Dlna.Server +{ + internal sealed class IndexHandler : IPrefixHandler + { + private readonly HttpServer owner; + + public string Prefix => "/"; + + public IndexHandler(HttpServer owner) + { + this.owner = owner; + } + + public IResponse HandleRequest(IRequest req) + { + var article = HtmlTools.CreateHtmlArticle("Index"); + var document = article.OwnerDocument; + if (document == null) + { + throw new HttpStatusException(HttpCode.InternalError); + } + + var list = document.EL("ul"); + var mounts = owner.MediaMounts.OrderBy(m => m.Value, NaturalStringComparer.Comparer); + foreach (var m in mounts) + { + var li = document.EL("li"); + li.AppendChild(document.EL( + "a", + new AttributeCollection { { "href", m.Key } }, + m.Value)); + list.AppendChild(li); + } + + article.AppendChild(list); + + return new StringResponse(HttpCode.Ok, document.OuterXml); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/MediaMount.cs b/Roadie.Dlna/Server/Handlers/MediaMount.cs new file mode 100644 index 0000000..f91055f --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/MediaMount.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.Logging; +using Roadie.Dlna.Server.Metadata; +using Roadie.Dlna.Utility; +using Roadie.Library.Configuration; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Reflection; +using System.Text; +using System.Xml; + +namespace Roadie.Dlna.Server +{ + internal sealed partial class MediaMount : IMediaServer, IPrefixHandler + { + private static uint mount; + + private readonly Dictionary guidsForAddresses = new Dictionary(); + + private readonly IMediaServer server; + + private uint systemID = 1; + + public IHttpAuthorizationMethod Authorizer => server.Authorizer; + private IRoadieSettings Configuration { get; } + private ILogger Logger { get; } + public string DescriptorURI => $"{Prefix}description.xml"; + + public string FriendlyName => server.FriendlyName; + + public string Prefix { get; } + + public Guid UUID => server.UUID; + + public MediaMount(IRoadieSettings configuration, ILogger logger, IMediaServer aServer) + { + Configuration = configuration; + Logger = logger; + server = aServer; + Prefix = $"/mm-{++mount}/"; + var vms = server as IVolatileMediaServer; + if (vms != null) + { + vms.Changed += ChangedServer; + } + } + + public void AddDeviceGuid(Guid guid, IPAddress address) + { + guidsForAddresses.Add(address, guid); + } + + public IMediaItem GetItem(string id, bool isFileRequest) + { + return server.GetItem(id, isFileRequest); + } + + public IResponse HandleRequest(IRequest request) + { + if (Authorizer != null && + !IPAddress.IsLoopback(request.RemoteEndpoint.Address) && + !Authorizer.Authorize( + request.Headers, + request.RemoteEndpoint + )) + { + throw new HttpStatusException(HttpCode.Denied); + } + + var path = request.Path.Substring(Prefix.Length); + if (path == "description.xml") + { + return new StringResponse( + HttpCode.Ok, + "text/xml", + GenerateDescriptor(request.LocalEndPoint.Address) + ); + } + if (path == "contentDirectory.xml") + { + return new ResourceResponse( + HttpCode.Ok, + "text/xml", + "contentDirectory.xml" + ); + } + if (path == "connectionManager.xml") + { + return new ResourceResponse( + HttpCode.Ok, + "text/xml", + "connectionManager.xml" + ); + } + if (path == "MSMediaReceiverRegistrar.xml") + { + return new ResourceResponse( + HttpCode.Ok, + "text/xml", + "MSMediaReceiverRegistrar.xml" + ); + } + if (path == "control") + { + return ProcessSoapRequest(request); + } + if (path.StartsWith("file/", StringComparison.Ordinal)) + { + var id = path.Split('/')[1]; + Logger.LogTrace($"Serving file {id}"); + var item = GetItem(id, true) as IMediaResource; + return new ItemResponse(Prefix, request, item); + } + if (path.StartsWith("cover/", StringComparison.Ordinal)) + { + var id = path.Split('/')[1]; + Logger.LogTrace($"Serving cover {id}"); + var item = GetItem(id, false) as IMediaCover; + if (item == null) + { + throw new HttpStatusException(HttpCode.NotFound); + } + return new ItemResponse(Prefix, request, item.Cover, "Interactive"); + } + if (path.StartsWith("subtitle/", StringComparison.Ordinal)) + { + var id = path.Split('/')[1]; + Logger.LogTrace($"Serving subtitle {id}"); + var item = GetItem(id, false) as IMetaVideoItem; + if (item == null) + { + throw new HttpStatusException(HttpCode.NotFound); + } + return new ItemResponse(Prefix, request, item.Subtitle, "Background"); + } + + if (string.IsNullOrEmpty(path) || path == "index.html") + { + return new Redirect(request, Prefix + "index/0"); + } + if (path.StartsWith("index/", StringComparison.Ordinal)) + { + var id = path.Substring("index/".Length); + var item = GetItem(id, false); + return ProcessHtmlRequest(item); + } + if (request.Method == "SUBSCRIBE") + { + var res = new StringResponse(HttpCode.Ok, string.Empty); + res.Headers.Add("SID", $"uuid:{Guid.NewGuid()}"); + res.Headers.Add("TIMEOUT", request.Headers["timeout"]); + return res; + } + if (request.Method == "UNSUBSCRIBE") + { + return new StringResponse(HttpCode.Ok, string.Empty); + } + Logger.LogTrace($"Did not understand {request.Method} {path}"); + throw new HttpStatusException(HttpCode.NotFound); + } + + private void ChangedServer(object sender, EventArgs e) + { + soapCache.Clear(); + Logger.LogTrace($"Rescanned mount {UUID}"); + systemID++; + } + + private string GenerateDescriptor(IPAddress source) + { + var doc = new XmlDocument(); + doc.LoadXml(Encoding.UTF8.GetString(ResourceHelper.GetResourceData("description.xml") ?? new byte[0])); + Guid guid; + guidsForAddresses.TryGetValue(source, out guid); + doc.SelectSingleNode("//*[local-name() = 'UDN']").InnerText = $"uuid:{guid}"; + doc.SelectSingleNode("//*[local-name() = 'modelNumber']").InnerText = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + doc.SelectSingleNode("//*[local-name() = 'friendlyName']").InnerText = FriendlyName; + + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:ContentDirectory:1']/../*[local-name() = 'SCPDURL']").InnerText = + $"{Prefix}contentDirectory.xml"; + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:ContentDirectory:1']/../*[local-name() = 'controlURL']").InnerText = + $"{Prefix}control"; + doc.SelectSingleNode("//*[local-name() = 'eventSubURL']").InnerText = + $"{Prefix}events"; + + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:ConnectionManager:1']/../*[local-name() = 'SCPDURL']").InnerText = + $"{Prefix}connectionManager.xml"; + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:ConnectionManager:1']/../*[local-name() = 'controlURL']").InnerText + = + $"{Prefix}control"; + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:ConnectionManager:1']/../*[local-name() = 'eventSubURL']").InnerText + = + $"{Prefix}events"; + + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1']/../*[local-name() = 'SCPDURL']") + .InnerText = + $"{Prefix}MSMediaReceiverRegistrar.xml"; + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1']/../*[local-name() = 'controlURL']") + .InnerText = + $"{Prefix}control"; + doc.SelectSingleNode( + "//*[text() = 'urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1']/../*[local-name() = 'eventSubURL']") + .InnerText = + $"{Prefix}events"; + + return doc.OuterXml; + } + + public void Preload() + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/MediaMount_HTML.cs b/Roadie.Dlna/Server/Handlers/MediaMount_HTML.cs new file mode 100644 index 0000000..1a5ac5a --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/MediaMount_HTML.cs @@ -0,0 +1,133 @@ +using Roadie.Dlna.Utility; +using System.Collections.Generic; +using System.Xml; + +namespace Roadie.Dlna.Server +{ + internal partial class MediaMount + { + private readonly List htmlItemProperties = new List + { + "Type", + "Duration", + "Resolution", + "Director", + "Actors", + "Performer", + "Album", + "Genre", + "Date", + "Size" + }; + + private IResponse ProcessHtmlRequest(IMediaItem aItem) + { + var item = aItem as IMediaFolder; + if (item == null) + { + throw new HttpStatusException(HttpCode.NotFound); + } + + var article = HtmlTools.CreateHtmlArticle($"Folder: {item.Title}"); + var document = article.OwnerDocument; + if (document == null) + { + throw new HttpStatusException(HttpCode.InternalError); + } + + XmlNode e; + var folders = document.EL( + "ul", + new AttributeCollection { { "class", "folders" } } + ); + if (item.Parent != null) + { + folders.AppendChild(e = document.EL("li")); + e.AppendChild(document.EL( + "a", + new AttributeCollection + { + {"href", $"{Prefix}index/{item.Parent.Id}"}, + {"class", "parent"} + }, + "Parent" + )); + } + foreach (var i in item.ChildFolders) + { + folders.AppendChild(e = document.EL("li")); + e.AppendChild(document.EL( + "a", + new AttributeCollection + { + {"href", $"{Prefix}index/{i.Id}#{i.Path}"} + }, + $"{i.Title} ({i.ChildCount})")); + } + article.AppendChild(folders); + + XmlNode items; + article.AppendChild(items = document.EL("ul", new AttributeCollection { { "class", "items" } })); + foreach (var i in item.ChildItems) + { + items.AppendChild(e = document.EL("li")); + var link = document.EL( + "a", + new AttributeCollection + { + { + "href", $"{Prefix}file/{i.Id}/{i.Title}.{DlnaMaps.Dlna2Ext[i.Type][0]}" + } + } + ); + var details = document.EL("section"); + link.AppendChild(details); + e.AppendChild(link); + + details.AppendChild(document.EL( + "h3", new AttributeCollection { { "title", i.Title } }, i.Title)); + + var props = i.Properties; + if (props.ContainsKey("HasCover")) + { + details.AppendChild(document.EL( + "img", + new AttributeCollection + { + {"title", "Cover image"}, + {"alt", "Cover image"}, + { + "src", $"{Prefix}cover/{i.Id}/{i.Title}.{DlnaMaps.Dlna2Ext[i.Type][0]}" + } + })); + } + + var table = document.EL("table"); + foreach (var p in htmlItemProperties) + { + string v; + if (props.TryGetValue(p, out v)) + { + table.AppendChild(e = document.EL("tr")); + e.AppendChild(document.EL("th", p)); + e.AppendChild(document.EL("td", v)); + } + } + if (table.ChildNodes.Count != 0) + { + details.AppendChild(table); + } + + string description; + if (props.TryGetValue("Description", out description)) + { + link.AppendChild(document.EL( + "p", new AttributeCollection { { "class", "desc" } }, + description)); + } + } + + return new StringResponse(HttpCode.Ok, document.OuterXml); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/MediaMount_SOAP.cs b/Roadie.Dlna/Server/Handlers/MediaMount_SOAP.cs new file mode 100644 index 0000000..098fbb6 --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/MediaMount_SOAP.cs @@ -0,0 +1,629 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Roadie.Dlna.Server.Metadata; +using Roadie.Dlna.Utility; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Xml; + +namespace Roadie.Dlna.Server +{ + internal partial class MediaMount + { + private const string NS_DC = "http://purl.org/dc/elements/1.1/"; + + private const string NS_DIDL = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"; + + private const string NS_DLNA = "urn:schemas-dlna-org:metadata-1-0/"; + + private const string NS_SEC = "http://www.sec.co.kr/"; + + private const string NS_SOAPENV = "http://schemas.xmlsoap.org/soap/envelope/"; + + private const string NS_UPNP = "urn:schemas-upnp-org:metadata-1-0/upnp/"; + + private static readonly IDictionary soapCache = new LeastRecentlyUsedDictionary(200); + + private static readonly XmlNamespaceManager namespaceMgr = CreateNamespaceManager(); + + private static readonly string featureList = Encoding.UTF8.GetString(ResourceHelper.GetResourceData("x_featurelist.xml") ?? new byte[0]); + + private static void AddBookmarkInfo(IMediaResource resource, XmlElement item) + { + var bookmarkable = resource as IBookmarkable; + var bookmark = bookmarkable?.Bookmark; + if (bookmark != null) + { + var dcmInfo = item.OwnerDocument?.CreateElement( + "sec", "dcmInfo", NS_SEC); + if (dcmInfo != null) + { + dcmInfo.InnerText = $"BM={bookmark.Value}"; + item.AppendChild(dcmInfo); + } + } + } + + private void AddCover(IRequest request, IMediaItem resource, XmlNode item) + { + var result = item.OwnerDocument; + if (result == null) + { + return; + } + var cover = resource as IMediaCover; + if (cover == null) + { + return; + } + try + { + var c = cover.Cover; + var curl = + $"http://{request.LocalEndPoint.Address}:{request.LocalEndPoint.Port}{Prefix}cover/{resource.Id}/i.jpg"; + var icon = result.CreateElement("upnp", "albumArtURI", NS_UPNP); + var profile = result.CreateAttribute("dlna", "profileID", NS_DLNA); + profile.InnerText = "JPEG_TN"; + icon.SetAttributeNode(profile); + icon.InnerText = curl; + item.AppendChild(icon); + icon = result.CreateElement("upnp", "icon", NS_UPNP); + profile = result.CreateAttribute("dlna", "profileID", NS_DLNA); + profile.InnerText = "JPEG_TN"; + icon.SetAttributeNode(profile); + icon.InnerText = curl; + item.AppendChild(icon); + + var res = result.CreateElement(string.Empty, "res", NS_DIDL); + res.InnerText = curl; + + res.SetAttribute("protocolInfo", string.Format( + "http-get:*:{1}:DLNA.ORG_PN={0};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS={2}", + c.PN, DlnaMaps.Mime[c.Type], DlnaMaps.DefaultStreaming + )); + var width = c.MetaWidth; + var height = c.MetaHeight; + if (width.HasValue && height.HasValue) + { + res.SetAttribute("resolution", $"{width.Value}x{height.Value}"); + } + else + { + res.SetAttribute("resolution", "200x200"); + } + res.SetAttribute("protocolInfo", + $"http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_TN;DLNA.ORG_OP=01;DLNA.ORG_CI=1;DLNA.ORG_FLAGS={DlnaMaps.DefaultInteractive}"); + item.AppendChild(res); + } + catch (Exception) + { + // ignored + } + } + + private static void AddGeneralProperties(IHeaders props, XmlElement item) + { + string prop; + var ownerDocument = item.OwnerDocument; + if (ownerDocument == null) + { + throw new ArgumentNullException(nameof(item)); + } + if (props.TryGetValue("DateO", out prop)) + { + var e = ownerDocument.CreateElement("dc", "date", NS_DC); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Genre", out prop)) + { + var e = ownerDocument.CreateElement("upnp", "genre", NS_UPNP); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Description", out prop)) + { + var e = ownerDocument.CreateElement("dc", "description", NS_DC); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Artist", out prop)) + { + var e = ownerDocument.CreateElement("upnp", "artist", NS_UPNP); + e.SetAttribute("role", "AlbumArtist"); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Performer", out prop)) + { + var e = ownerDocument.CreateElement("upnp", "artist", NS_UPNP); + e.SetAttribute("role", "Performer"); + e.InnerText = prop; + item.AppendChild(e); + e = ownerDocument.CreateElement("dc", "creator", NS_DC); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Album", out prop)) + { + var e = ownerDocument.CreateElement("upnp", "album", NS_UPNP); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Track", out prop)) + { + var e = ownerDocument.CreateElement( + "upnp", "originalTrackNumber", NS_UPNP); + e.InnerText = prop; + item.AppendChild(e); + } + if (props.TryGetValue("Creator", out prop)) + { + var e = ownerDocument.CreateElement("dc", "creator", NS_DC); + e.InnerText = prop; + item.AppendChild(e); + } + + if (props.TryGetValue("Director", out prop)) + { + var e = ownerDocument.CreateElement("upnp", "director", NS_UPNP); + e.InnerText = prop; + item.AppendChild(e); + } + } + + private static void AddVideoProperties(IRequest request, IMediaResource resource, XmlNode item) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + var mvi = resource as IMetaVideoItem; + if (mvi == null) + { + return; + } + try + { + var ownerDocument = item.OwnerDocument; + var actors = mvi.MetaActors; + if (actors != null && ownerDocument != null) + { + foreach (var actor in actors) + { + var e = ownerDocument.CreateElement("upnp", "actor", NS_UPNP); + e.InnerText = actor; + item.AppendChild(e); + } + } + } + catch (Exception) + { + // ignored + } + } + + private static void Browse_AddFolder(XmlDocument result, IMediaFolder f) + { + var meta = f as IMetaInfo; + var container = result.CreateElement(string.Empty, "container", NS_DIDL); + container.SetAttribute("restricted", "0"); + container.SetAttribute("childCount", f.ChildCount.ToString()); + container.SetAttribute("id", f.Id); + var parent = f.Parent; + container.SetAttribute("parentID", parent == null ? Identifiers.GENERAL_ROOT : parent.Id); + + var title = result.CreateElement("dc", "title", NS_DC); + title.InnerText = f.Title; + container.AppendChild(title); + if (meta != null) + { + var date = result.CreateElement("dc", "date", NS_DC); + date.InnerText = meta.InfoDate.ToString("o"); + container.AppendChild(date); + } + + var objectClass = result.CreateElement("upnp", "class", NS_UPNP); + objectClass.InnerText = "object.container"; + container.AppendChild(objectClass); + result.DocumentElement?.AppendChild(container); + } + + private void Browse_AddItem(IRequest request, XmlDocument result, IMediaResource resource) + { + var props = resource.Properties; + + var item = result.CreateElement(string.Empty, "item", NS_DIDL); + item.SetAttribute("restricted", "1"); + item.SetAttribute("id", resource.Id); + item.SetAttribute("parentID", Identifiers.GENERAL_ROOT); + + item.AppendChild(CreateObjectClass(result, resource)); + + AddBookmarkInfo(resource, item); + + AddGeneralProperties(props, item); + + AddVideoProperties(request, resource, item); + + var title = result.CreateElement("dc", "title", NS_DC); + title.InnerText = resource.Title; + item.AppendChild(title); + + var res = result.CreateElement(string.Empty, "res", NS_DIDL); + res.InnerText = + $"http://{request.LocalEndPoint.Address}:{request.LocalEndPoint.Port}{Prefix}file/{resource.Id}/res"; + + string prop; + if (props.TryGetValue("SizeRaw", out prop)) + { + res.SetAttribute("size", prop); + } + if (props.TryGetValue("Resolution", out prop)) + { + res.SetAttribute("resolution", prop); + } + if (props.TryGetValue("Duration", out prop)) + { + res.SetAttribute("duration", prop); + } + + res.SetAttribute("protocolInfo", string.Format( + "http-get:*:{1}:DLNA.ORG_PN={0};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS={2}", + resource.PN, DlnaMaps.Mime[resource.Type], DlnaMaps.DefaultStreaming + )); + item.AppendChild(res); + + AddCover(request, resource, item); + result.DocumentElement?.AppendChild(item); + } + + private int BrowseFolder_AddItems(IRequest request, XmlDocument result, IMediaFolder root, int start, int requested) + { + var provided = 0; + foreach (var i in root.ChildFolders) + { + if (start > 0) + { + start--; + continue; + } + Browse_AddFolder(result, i); + if (++provided == requested) + { + break; + } + } + if (provided != requested) + { + foreach (var i in root.ChildItems) + { + if (start > 0) + { + start--; + continue; + } + Browse_AddItem(request, result, i); + if (++provided == requested) + { + break; + } + } + } + return provided; + } + + private static XmlNamespaceManager CreateNamespaceManager() + { + var rv = new XmlNamespaceManager(new NameTable()); + rv.AddNamespace("soap", NS_SOAPENV); + return rv; + } + + private static XmlElement CreateObjectClass(XmlDocument result, + IMediaResource resource) + { + var objectClass = result.CreateElement("upnp", "class", NS_UPNP); + switch (resource.MediaType) + { + case DlnaMediaTypes.Video: + objectClass.InnerText = "object.item.videoItem.movie"; + break; + + case DlnaMediaTypes.Image: + objectClass.InnerText = "object.item.imageItem.photo"; + break; + + case DlnaMediaTypes.Audio: + objectClass.InnerText = "object.item.audioItem.musicTrack"; + break; + + default: + throw new NotSupportedException(); + } + return objectClass; + } + + private IEnumerable> HandleBrowse(IRequest request, IHeaders sparams) + { + var key = Prefix + sparams.HeaderBlock; + AttributeCollection rv; + if (soapCache.TryGetValue(key, out rv)) + { + return rv; + } + + var id = sparams["ObjectID"]; + var flag = sparams["BrowseFlag"]; + + var requested = 20; + var provided = 0; + var start = 0; + try + { + if (int.TryParse(sparams["RequestedCount"], out requested) && + requested <= 0) + { + requested = 20; + } + if (int.TryParse(sparams["StartingIndex"], out start) && start <= 0) + { + start = 0; + } + } + catch (Exception ex) + { + Trace.WriteLine($"Not all params provided. Ex [{ ex }]"); + } + + var root = GetItem(id, false) as IMediaFolder; + if (root == null) + { + throw new ArgumentException("Invalid id"); + } + var result = new XmlDocument(); + + var didl = result.CreateElement(string.Empty, "DIDL-Lite", NS_DIDL); + didl.SetAttribute("xmlns:dc", NS_DC); + didl.SetAttribute("xmlns:dlna", NS_DLNA); + didl.SetAttribute("xmlns:upnp", NS_UPNP); + didl.SetAttribute("xmlns:sec", NS_SEC); + result.AppendChild(didl); + + if (flag == "BrowseMetadata") + { + Browse_AddFolder(result, root); + provided++; + } + else + { + provided = BrowseFolder_AddItems( + request, result, root, start, requested); + } + var resXML = result.OuterXml; + rv = new AttributeCollection + { + {"Result", resXML}, + {"NumberReturned", provided.ToString()}, + {"TotalMatches", root.ChildCount.ToString()}, + {"UpdateID", systemID.ToString()} + }; + soapCache[key] = rv; + return rv; + } + + private static IHeaders HandleGetCurrentConnectionIDs() + { + return new RawHeaders { { "ConnectionIDs", "0" } }; + } + + private static IHeaders HandleGetCurrentConnectionInfo() + { + return new RawHeaders + { + {"RcsID", "-1"}, + {"AVTransportID", "-1"}, + {"ProtocolInfo", string.Empty}, + {"PeerConnectionmanager", string.Empty}, + {"PeerConnectionID", "0"}, + {"Direction", "Output"}, + {"Status", "OK"} + }; + } + + private static IHeaders HandleGetProtocolInfo() + { + return new RawHeaders + { + {"Source", DlnaMaps.ProtocolInfo}, + {"Sink", string.Empty} + }; + } + + private static IHeaders HandleGetSearchCapabilities() + { + return new RawHeaders { { "SearchCaps", string.Empty } }; + } + + private static IHeaders HandleGetSortCapabilities() + { + return new RawHeaders { { "SortCaps", string.Empty } }; + } + + private IHeaders HandleGetSystemUpdateID() + { + return new RawHeaders { { "Id", systemID.ToString() } }; + } + + private static IHeaders HandleIsAuthorized() + { + return new RawHeaders { { "Result", "1" } }; + } + + private static IHeaders HandleIsValidated() + { + return new RawHeaders { { "Result", "1" } }; + } + + private static IHeaders HandleRegisterDevice() + { + return new RawHeaders { { "RegistrationRespMsg", string.Empty } }; + } + + private static IHeaders HandleXGetFeatureList() + { + return new RawHeaders { { "FeatureList", featureList } }; + } + + private IHeaders HandleXSetBookmark(IHeaders sparams) + { + var id = sparams["ObjectID"]; + var item = GetItem(id, false) as IBookmarkable; + if (item != null) + { + var newbookmark = long.Parse(sparams["PosSecond"]); + if (newbookmark > 30) + { + newbookmark -= 5; + } + if (newbookmark > 30 || !item.Bookmark.HasValue || + item.Bookmark.Value < 60) + { + item.Bookmark = newbookmark; + soapCache.Clear(); + } + } + return new RawHeaders(); + } + + private IResponse ProcessSoapRequest(IRequest request) + { + var soap = new XmlDocument(); + try //POST /mm-1/control HTTP/1.1 + { + soap.LoadXml(request.Body); + } + catch (Exception ex) + { + Logger.LogError(ex, $"ProcessSoapRequest. Error Loading Request From [{ request.RemoteEndpoint.Address }], Body [{ request.Body }]"); + } + var sparams = new RawHeaders(); + var body = soap.SelectSingleNode("//soap:Body", namespaceMgr); + if (body == null) + { + throw new HttpStatusException(HttpCode.InternalError); + } + var method = body.FirstChild; + foreach (var p in method.ChildNodes) + { + var e = p as XmlElement; + if (e == null) + { + continue; + } + sparams.Add(e.LocalName, e.InnerText.Trim()); + } + var env = new XmlDocument(); + env.AppendChild(env.CreateXmlDeclaration("1.0", "utf-8", "yes")); + var envelope = env.CreateElement("SOAP-ENV", "Envelope", NS_SOAPENV); + env.AppendChild(envelope); + envelope.SetAttribute( + "encodingStyle", NS_SOAPENV, + "http://schemas.xmlsoap.org/soap/encoding/"); + + var rbody = env.CreateElement("SOAP-ENV:Body", NS_SOAPENV); + env.DocumentElement?.AppendChild(rbody); + + var code = HttpCode.Ok; + try + { + IEnumerable> result; + switch (method.LocalName) + { + case "GetSearchCapabilities": + result = HandleGetSearchCapabilities(); + break; + + case "GetSortCapabilities": + result = HandleGetSortCapabilities(); + break; + + case "GetSystemUpdateID": + result = HandleGetSystemUpdateID(); + break; + + case "Browse": + result = HandleBrowse(request, sparams); + break; + + case "X_GetFeatureList": + result = HandleXGetFeatureList(); + break; + + case "X_SetBookmark": + result = HandleXSetBookmark(sparams); + break; + + case "GetCurrentConnectionIDs": + result = HandleGetCurrentConnectionIDs(); + break; + + case "GetCurrentConnectionInfo": + result = HandleGetCurrentConnectionInfo(); + break; + + case "GetProtocolInfo": + result = HandleGetProtocolInfo(); + break; + + case "IsAuthorized": + result = HandleIsAuthorized(); + break; + + case "IsValidated": + result = HandleIsValidated(); + break; + + case "RegisterDevice": + result = HandleRegisterDevice(); + break; + + default: + throw new HttpStatusException(HttpCode.NotFound); + } + var response = env.CreateElement($"u:{method.LocalName}Response", method.NamespaceURI); + rbody.AppendChild(response); + + foreach (var i in result) + { + var ri = env.CreateElement(i.Key); + ri.InnerText = i.Value; + response.AppendChild(ri); + } + } + catch (Exception ex) + { + code = HttpCode.InternalError; + var fault = env.CreateElement("SOAP-ENV", "Fault", NS_SOAPENV); + var faultCode = env.CreateElement("faultcode"); + faultCode.InnerText = "500"; + fault.AppendChild(faultCode); + var faultString = env.CreateElement("faultstring"); + faultString.InnerText = ex.ToString(); + fault.AppendChild(faultString); + var detail = env.CreateDocumentFragment(); + detail.InnerXml = + "401Invalid Action"; + fault.AppendChild(detail); + rbody.AppendChild(fault); + Trace.WriteLine($"Invalid call: Action: {method.LocalName}, Params: {sparams}, Problem {ex.Message}"); + } + + var rv = new StringResponse(code, "text/xml", env.OuterXml); + rv.Headers.Add("EXT", string.Empty); + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Handlers/StaticHandler.cs b/Roadie.Dlna/Server/Handlers/StaticHandler.cs new file mode 100644 index 0000000..72753e4 --- /dev/null +++ b/Roadie.Dlna/Server/Handlers/StaticHandler.cs @@ -0,0 +1,25 @@ +namespace Roadie.Dlna.Server +{ + internal sealed class StaticHandler : IPrefixHandler + { + private readonly IResponse response; + + public string Prefix { get; } + + public StaticHandler(IResponse aResponse) + : this("#", aResponse) + { + } + + public StaticHandler(string aPrefix, IResponse aResponse) + { + Prefix = aPrefix; + response = aResponse; + } + + public IResponse HandleRequest(IRequest req) + { + return response; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HTTPServer.cs b/Roadie.Dlna/Server/Http/HTTPServer.cs new file mode 100644 index 0000000..5bbc78c --- /dev/null +++ b/Roadie.Dlna/Server/Http/HTTPServer.cs @@ -0,0 +1,338 @@ +using Microsoft.Extensions.Logging; +using Roadie.Dlna.Server.Ssdp; +using Roadie.Dlna.Utility; +using Roadie.Library.Configuration; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Timers; + +namespace Roadie.Dlna.Server +{ + public sealed class HttpServer : IDisposable + { + public static readonly string Signature = GenerateServerSignature(); + + private readonly ConcurrentDictionary clients = new ConcurrentDictionary(); + + private readonly ConcurrentDictionary> devicesForServers = new ConcurrentDictionary>(); + + private readonly TcpListener listener; + + private readonly ConcurrentDictionary prefixes = new ConcurrentDictionary(); + + private readonly ConcurrentDictionary servers = new ConcurrentDictionary(); + + private readonly SsdpHandler ssdpServer; + + private readonly Timer timeouter = new Timer(10 * 1000); + + public ILogger Logger { get; } + + public Dictionary MediaMounts + { + get + { + var rv = new Dictionary(); + foreach (var m in servers) + { + rv[m.Value.Prefix] = m.Value.FriendlyName; + } + return rv; + } + } + + public int RealPort { get; } + + public HttpServer(ILogger logger, int port) + { + Logger = logger; + + prefixes.TryAdd( + "/favicon.ico", + new StaticHandler( + new ResourceResponse(HttpCode.Ok, "image/icon", "favicon.ico")) + ); + prefixes.TryAdd( + "/static/browse.css", + new StaticHandler( + new ResourceResponse(HttpCode.Ok, "text/css", "browse.css")) + ); + RegisterHandler(new IconHandler()); + + listener = new TcpListener(new IPEndPoint(IPAddress.Any, port)); + listener.Server.Ttl = 32; + listener.Server.UseOnlyOverlappedIO = true; + listener.Start(); + + RealPort = ((IPEndPoint)listener.LocalEndpoint).Port; + + Logger.LogInformation($"Running DLNA HTTP Server: {Signature} on port {RealPort}"); + ssdpServer = new SsdpHandler(logger); + + timeouter.Elapsed += TimeouterCallback; + timeouter.Enabled = true; + + Accept(); + } + + public event EventHandler OnAuthorizeClient; + + public void Dispose() + { + Logger.LogTrace("Disposing HTTP"); + timeouter.Enabled = false; + foreach (var s in servers.Values.ToList()) + { + UnregisterMediaServer(s); + } + ssdpServer.Dispose(); + timeouter.Dispose(); + listener.Stop(); + foreach (var c in clients.ToList()) + { + c.Key.Dispose(); + } + clients.Clear(); + } + + public void RegisterMediaServer(IRoadieSettings configuration, ILogger logger, IMediaServer server) + { + if (server == null) + { + throw new ArgumentNullException(nameof(server)); + } + var guid = server.UUID; + if (servers.ContainsKey(guid)) + { + throw new ArgumentException("Attempting to register more than once"); + } + + var end = (IPEndPoint)listener.LocalEndpoint; + var mount = new MediaMount(configuration, logger, server); + servers[guid] = mount; + RegisterHandler(mount); + + foreach (var address in IP.ExternalIPAddresses) + { + Logger.LogTrace($"Registering device for {address}"); + var deviceGuid = Guid.NewGuid(); + var list = devicesForServers.GetOrAdd(guid, new List()); + lock (list) + { + list.Add(deviceGuid); + } + mount.AddDeviceGuid(deviceGuid, address); + var uri = new Uri($"http://{address}:{end.Port}{mount.DescriptorURI}"); + lock (list) + { + ssdpServer.RegisterNotification(deviceGuid, uri, address); + } + Logger.LogTrace($"New mount at: {uri}"); + } + } + + public void UnregisterMediaServer(IMediaServer server) + { + if (server == null) + { + throw new ArgumentNullException(nameof(server)); + } + MediaMount mount; + if (!servers.TryGetValue(server.UUID, out mount)) + { + return; + } + + List list; + if (devicesForServers.TryGetValue(server.UUID, out list)) + { + lock (list) + { + foreach (var deviceGuid in list) + { + ssdpServer.UnregisterNotification(deviceGuid); + } + } + devicesForServers.TryRemove(server.UUID, out list); + } + + UnregisterHandler(mount); + + MediaMount ignored; + if (servers.TryRemove(server.UUID, out ignored)) + { + Logger.LogTrace($"Unregistered Media Server {server.UUID}"); + } + } + + internal bool AuthorizeClient(HttpClient client) + { + if (OnAuthorizeClient == null) + { + return true; + } + if (IPAddress.IsLoopback(client.RemoteEndpoint.Address)) + { + return true; + } + var e = new HttpAuthorizationEventArgs(client.Headers, client.RemoteEndpoint); + OnAuthorizeClient(this, e); + return !e.Cancel; + } + + internal IPrefixHandler FindHandler(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + throw new ArgumentNullException(nameof(prefix)); + } + + if (prefix == "/") + { + return new IndexHandler(this); + } + + return (from s in prefixes.Keys + where prefix.StartsWith(s, StringComparison.Ordinal) + select prefixes[s]).FirstOrDefault(); + } + + internal void RegisterHandler(IPrefixHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + var prefix = handler.Prefix; + if (!prefix.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("Invalid prefix; must start with /"); + } + if (!prefix.EndsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("Invalid prefix; must end with /"); + } + if (FindHandler(prefix) != null) + { + throw new ArgumentException("Invalid prefix; already taken"); + } + if (!prefixes.TryAdd(prefix, handler)) + { + throw new ArgumentException("Invalid preifx; already taken"); + } + Logger.LogTrace($"Registered Handler for {prefix}"); + } + + internal void RemoveClient(HttpClient client) + { + DateTime ignored; + clients.TryRemove(client, out ignored); + } + + internal void UnregisterHandler(IPrefixHandler handler) + { + IPrefixHandler ignored; + if (prefixes.TryRemove(handler.Prefix, out ignored)) + { + Logger.LogTrace($"Unregistered Handler for {handler.Prefix}"); + } + } + + private static string GenerateServerSignature() + { + var os = Environment.OSVersion; + var pstring = os.Platform.ToString(); + switch (os.Platform) + { + case PlatformID.Win32NT: + case PlatformID.Win32S: + case PlatformID.Win32Windows: + pstring = "WIN"; + break; + + default: + try + { + pstring = Formatting.GetSystemName(); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to get uname Ex [{ ex }]"); + } + break; + } + var version = Assembly.GetExecutingAssembly().GetName().Version; + var bitness = IntPtr.Size * 8; + return + $"{pstring}{bitness}/{os.Version.Major}.{os.Version.Minor} UPnP/1.0 DLNADOC/1.5 roadie/{version.Major}.{version.Minor}"; + } + + private void Accept() + { + try + { + if (!listener.Server.IsBound) + { + return; + } + listener.BeginAcceptTcpClient(AcceptCallback, null); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to accept [{ ex }]"); + } + } + + private void AcceptCallback(IAsyncResult result) + { + try + { + var tcpclient = listener.EndAcceptTcpClient(result); + var client = new HttpClient(this, tcpclient); + try + { + clients.AddOrUpdate(client, DateTime.Now, (k, v) => DateTime.Now); + Logger.LogTrace($"Accepted client {client}"); + client.Start(); + } + catch (Exception) + { + client.Dispose(); + throw; + } + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to accept a client Ex [{ ex }]"); + } + finally + { + Accept(); + } + } + + private void TimeouterCallback(object sender, ElapsedEventArgs e) + { + foreach (var c in clients.ToList()) + { + if (c.Key.IsATimeout) + { + Logger.LogTrace($"Collected timeout client {c}"); + c.Key.Close(); + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HttpAuthorizationEventArgs.cs b/Roadie.Dlna/Server/Http/HttpAuthorizationEventArgs.cs new file mode 100644 index 0000000..a12c87e --- /dev/null +++ b/Roadie.Dlna/Server/Http/HttpAuthorizationEventArgs.cs @@ -0,0 +1,21 @@ +using System; +using System.Net; + +namespace Roadie.Dlna.Server +{ + public sealed class HttpAuthorizationEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public IHeaders Headers { get; private set; } + + public IPEndPoint RemoteEndpoint { get; private set; } + + internal HttpAuthorizationEventArgs(IHeaders headers, + IPEndPoint remoteEndpoint) + { + Headers = headers; + RemoteEndpoint = remoteEndpoint; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HttpAuthorizer.cs b/Roadie.Dlna/Server/Http/HttpAuthorizer.cs new file mode 100644 index 0000000..fceeb27 --- /dev/null +++ b/Roadie.Dlna/Server/Http/HttpAuthorizer.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; + +namespace Roadie.Dlna.Server +{ + public sealed class HttpAuthorizer : IHttpAuthorizationMethod, IDisposable + { + private readonly List methods = + new List(); + + private readonly HttpServer server; + + public HttpAuthorizer() + { + } + + public HttpAuthorizer(HttpServer server) + { + if (server == null) + { + throw new ArgumentNullException(nameof(server)); + } + this.server = server; + server.OnAuthorizeClient += OnAuthorize; + } + + public void AddMethod(IHttpAuthorizationMethod method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + methods.Add(method); + } + + public bool Authorize(IHeaders headers, IPEndPoint endPoint) + { + if (methods.Count == 0) + { + return true; + } + try + { + return methods.Any(m => m.Authorize(headers, endPoint)); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to authorize [{ ex }]"); + return false; + } + } + + public void Dispose() + { + if (server != null) + { + server.OnAuthorizeClient -= OnAuthorize; + } + } + + private void OnAuthorize(object sender, HttpAuthorizationEventArgs e) + { + e.Cancel = !Authorize( + e.Headers, + e.RemoteEndpoint + ); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HttpClient.cs b/Roadie.Dlna/Server/Http/HttpClient.cs new file mode 100644 index 0000000..2483b18 --- /dev/null +++ b/Roadie.Dlna/Server/Http/HttpClient.cs @@ -0,0 +1,514 @@ +using Microsoft.Extensions.Logging; +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server +{ + internal sealed class HttpClient : IRequest, IDisposable + { + private const uint BEGIN_TIMEOUT = 30; + + private const int BUFFER_SIZE = 1 << 16; + + private const string CRLF = "\r\n"; + + private static readonly Regex bytes = + new Regex(@"^bytes=(\d+)(?:-(\d+)?)?$", RegexOptions.Compiled); + + private static readonly IHandler error403 = + new StaticHandler(new StringResponse( + HttpCode.Denied, + "Access denied!

Access denied!

You're not allowed to access the requested resource.

" + ) + ); + + private static readonly IHandler error404 = + new StaticHandler(new StringResponse( + HttpCode.NotFound, + "Not found!

Not found!

The requested resource was not found!

" + ) + ); + + private static readonly IHandler error416 = + new StaticHandler(new StringResponse( + HttpCode.RangeNotSatisfiable, + "Requested Range not satisfiable!

Requested Range not satisfiable!

Nice try, but do not try again :p

" + ) + ); + + private static readonly IHandler error500 = + new StaticHandler(new StringResponse( + HttpCode.InternalError, + "Internal Server Error

Internal Server Error

Something is very rotten in the State of Denmark!

" + ) + ); + + private readonly byte[] buffer = new byte[2048]; + + private readonly TcpClient client; + + private readonly HttpServer owner; + + private readonly uint readTimeout = + (uint)TimeSpan.FromMinutes(1).TotalSeconds; + + private readonly NetworkStream stream; + + private readonly uint writeTimeout = + (uint)TimeSpan.FromMinutes(180).TotalSeconds; + + private uint bodyBytes; + + private bool hasHeaders; + + private DateTime lastActivity; + + private MemoryStream readStream; + + private uint requestCount; + + private IResponse response; + + private HttpStates state; + + public string Body { get; private set; } + + public IHeaders Headers { get; } = new Headers(); + + public bool IsATimeout + { + get + { + var diff = (DateTime.Now - lastActivity).TotalSeconds; + switch (state) + { + case HttpStates.Accepted: + case HttpStates.ReadBegin: + case HttpStates.WriteBegin: + return diff > BEGIN_TIMEOUT; + + case HttpStates.Reading: + return diff > readTimeout; + + case HttpStates.Writing: + return diff > writeTimeout; + + case HttpStates.Closed: + return true; + + default: + throw new InvalidOperationException("Invalid state"); + } + } + } + + public IPEndPoint LocalEndPoint { get; } + + public string Method { get; private set; } + + public string Path { get; private set; } + + public IPEndPoint RemoteEndpoint { get; } + + private HttpStates State + { + set + { + lastActivity = DateTime.Now; + state = value; + } + } + + public HttpClient(HttpServer aOwner, TcpClient aClient) + { + State = HttpStates.Accepted; + lastActivity = DateTime.Now; + + owner = aOwner; + client = aClient; + stream = client.GetStream(); + client.Client.UseOnlyOverlappedIO = true; + + RemoteEndpoint = client.Client.RemoteEndPoint as IPEndPoint; + LocalEndPoint = client.Client.LocalEndPoint as IPEndPoint; + } + + internal enum HttpStates + { + Accepted, + Closed, + ReadBegin, + Reading, + WriteBegin, + Writing + } + + public void Dispose() + { + Close(); + readStream?.Dispose(); + } + + public void Start() + { + ReadNext(); + } + + public override string ToString() + { + return RemoteEndpoint.ToString(); + } + + internal void Close() + { + State = HttpStates.Closed; + + owner.Logger.LogTrace($"{this} - Closing connection after { requestCount} requests"); + try + { + client.Close(); + } + catch (Exception) + { + // ignored + } + owner.RemoveClient(this); + if (stream != null) + { + try + { + stream.Dispose(); + } + catch (ObjectDisposedException) + { + } + } + } + + private long GetContentLengthFromStream(Stream responseBody) + { + long contentLength = -1; + try + { + string clf; + if (!response.Headers.TryGetValue("Content-Length", out clf) || + !long.TryParse(clf, out contentLength)) + { + contentLength = responseBody.Length - responseBody.Position; + if (contentLength < 0) + { + throw new InvalidDataException(); + } + response.Headers["Content-Length"] = contentLength.ToString(); + } + } + catch (Exception) + { + // ignored + } + return contentLength; + } + + private Stream ProcessRanges(IResponse rangedResponse, ref HttpCode status) + { + var responseBody = rangedResponse.Body; + var contentLength = GetContentLengthFromStream(responseBody); + try + { + string ar; + if (status != HttpCode.Ok && contentLength > 0 || + !Headers.TryGetValue("Range", out ar)) + { + return responseBody; + } + var m = bytes.Match(ar); + if (!m.Success) + { + throw new InvalidDataException("Not parsed!"); + } + var totalLength = contentLength; + long start; + long end; + if (!long.TryParse(m.Groups[1].Value, out start) || start < 0) + { + throw new InvalidDataException("Not parsed"); + } + if (m.Groups.Count != 3 || + !long.TryParse(m.Groups[2].Value, out end) || + end <= start || end >= totalLength) + { + end = totalLength - 1; + } + if (start >= end) + { + responseBody.Close(); + rangedResponse = error416.HandleRequest(this); + return rangedResponse.Body; + } + + if (start > 0) + { + responseBody.Seek(start, SeekOrigin.Current); + } + contentLength = end - start + 1; + rangedResponse.Headers["Content-Length"] = contentLength.ToString(); + rangedResponse.Headers.Add( + "Content-Range", + $"bytes {start}-{end}/{totalLength}" + ); + status = HttpCode.Partial; + } + catch (Exception ex) + { + owner.Logger.LogTrace($"{this} - Failed to process range request! Ex [{ ex }]"); + } + return responseBody; + } + + private void Read() + { + try + { + stream.BeginRead(buffer, 0, buffer.Length, ReadCallback, 0); + } + catch (IOException ex) + { + owner.Logger.LogTrace($"{this} - Failed to BeginRead [{ ex }]"); + Close(); + } + } + + private void ReadCallback(IAsyncResult result) + { + if (state == HttpStates.Closed) + { + return; + } + + State = HttpStates.Reading; + + try + { + var read = stream.EndRead(result); + if (read < 0) + { + throw new HttpException("Client did not send anything"); + } + owner.Logger.LogTrace($"{this} - Read {read} bytes"); + readStream.Write(buffer, 0, read); + lastActivity = DateTime.Now; + } + catch (Exception) + { + if (!IsATimeout) + { + owner.Logger.LogTrace($"{this} - Failed to read data"); + Close(); + } + return; + } + + try + { + if (!hasHeaders) + { + readStream.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(readStream); + for (var line = reader.ReadLine(); + line != null; + line = reader.ReadLine()) + { + line = line.Trim(); + if (string.IsNullOrEmpty(line)) + { + hasHeaders = true; + readStream = StreamManager.GetStream(); + if (Headers.ContainsKey("content-length") && + uint.TryParse(Headers["content-length"], out bodyBytes)) + { + if (bodyBytes > 1 << 20) + { + throw new IOException("Body too long"); + } + var ascii = Encoding.ASCII.GetBytes(reader.ReadToEnd()); + readStream.Write(ascii, 0, ascii.Length); + owner.Logger.LogTrace($"Must read body bytes {bodyBytes}"); + } + break; + } + if (Method == null) + { + var parts = line.Split(new[] { ' ' }, 3); + Method = parts[0].Trim().ToUpperInvariant(); + Path = parts[1].Trim(); + owner.Logger.LogTrace($"{this} - {Method} request for {Path}"); + } + else + { + var parts = line.Split(new[] { ':' }, 2); + Headers[parts[0]] = Uri.UnescapeDataString(parts[1]).Trim(); + } + } + } + if (bodyBytes != 0 && bodyBytes > readStream.Length) + { + owner.Logger.LogTrace($"{this} - Bytes to go { (bodyBytes - readStream.Length) }"); + Read(); + return; + } + using (readStream) + { + Body = Encoding.UTF8.GetString(readStream.ToArray()); + } + SetupResponse(); + } + catch (Exception ex) + { + owner.Logger.LogTrace($"{this} - Failed to process request Ex [{ ex }]"); + response = error500.HandleRequest(this); + SendResponse(); + } + } + + private void ReadNext() + { + Method = null; + Headers.Clear(); + hasHeaders = false; + Body = null; + bodyBytes = 0; + readStream = StreamManager.GetStream(); + + ++requestCount; + State = HttpStates.ReadBegin; + + Read(); + } + + private void SendResponse() + { + var statusCode = response.Status; + var responseBody = ProcessRanges(response, ref statusCode); + var responseStream = new ConcatenatedStream(); + try + { + var headerBlock = new StringBuilder(); + headerBlock.AppendFormat( + "HTTP/1.1 {0} {1}\r\n", + (uint)statusCode, + HttpPhrases.Phrases[statusCode] + ); + headerBlock.Append(response.Headers.HeaderBlock); + headerBlock.Append(CRLF); + + var headerStream = new MemoryStream( + Encoding.ASCII.GetBytes(headerBlock.ToString())); + responseStream.AddStream(headerStream); + if (Method != "HEAD" && responseBody != null) + { + responseStream.AddStream(responseBody); + responseBody = null; + } + owner.Logger.LogTrace($"{this} - {(uint)statusCode} response for {Path}"); + state = HttpStates.Writing; + var sp = new StreamPump(responseStream, stream, BUFFER_SIZE); + sp.Pump((pump, result) => + { + pump.Input.Close(); + pump.Input.Dispose(); + if (result == StreamPumpResult.Delivered) + { + owner.Logger.LogTrace($"{this} - Done writing response"); + + string conn; + if (Headers.TryGetValue("connection", out conn) && + conn.ToUpperInvariant() == "KEEP-ALIVE") + { + ReadNext(); + return; + } + } + else + { + owner.Logger.LogTrace($"{this} - Client aborted connection"); + } + Close(); + }); + } + catch (Exception) + { + responseStream.Dispose(); + throw; + } + finally + { + responseBody?.Dispose(); + } + } + + private void SetupResponse() + { + State = HttpStates.WriteBegin; + try + { + if (!owner.AuthorizeClient(this)) + { + throw new HttpStatusException(HttpCode.Denied); + } + if (string.IsNullOrEmpty(Path)) + { + throw new HttpStatusException(HttpCode.NotFound); + } + var handler = owner.FindHandler(Path); + if (handler == null) + { + throw new HttpStatusException(HttpCode.NotFound); + } + response = handler.HandleRequest(this); + if (response == null) + { + throw new ArgumentException("Handler did not return a response"); + } + } + catch (HttpStatusException ex) + { + owner.Logger.LogTrace($"{this} - Got a {ex.Code}: {Path}"); + + switch (ex.Code) + { + case HttpCode.NotFound: + response = error404.HandleRequest(this); + break; + + case HttpCode.Denied: + response = error403.HandleRequest(this); + break; + + case HttpCode.InternalError: + response = error500.HandleRequest(this); + break; + + default: + response = new StaticHandler(new StringResponse( + ex.Code, + "text/plain", + ex.Message + )).HandleRequest(this); + break; + } + } + catch (Exception ex) + { + owner.Logger.LogTrace($"{this} - Failed to process response Ex [{ ex }]"); + response = error500.HandleRequest(this); + } + SendResponse(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HttpCode.cs b/Roadie.Dlna/Server/Http/HttpCode.cs new file mode 100644 index 0000000..8713f98 --- /dev/null +++ b/Roadie.Dlna/Server/Http/HttpCode.cs @@ -0,0 +1,16 @@ +namespace Roadie.Dlna.Server +{ + public enum HttpCode + { + None = 0, + Ok = 200, + Partial = 206, + MovedPermanently = 301, + NotModified = 304, + TemporaryRedirect = 307, + Denied = 403, + NotFound = 404, + RangeNotSatisfiable = 416, + InternalError = 500 + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/HttpPhrases.cs b/Roadie.Dlna/Server/Http/HttpPhrases.cs new file mode 100644 index 0000000..e45d88a --- /dev/null +++ b/Roadie.Dlna/Server/Http/HttpPhrases.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Roadie.Dlna.Server +{ + internal static class HttpPhrases + { + public static readonly IDictionary Phrases = + new Dictionary + { + {HttpCode.Ok, "OK"}, + {HttpCode.Partial, "Partial Content"}, + {HttpCode.MovedPermanently, "Moved Permanently"}, + {HttpCode.NotModified, "Not Modified"}, + {HttpCode.TemporaryRedirect, "Temprary Redirect"}, + {HttpCode.Denied, "Forbidden"}, + {HttpCode.NotFound, "Not Found"}, + {HttpCode.RangeNotSatisfiable, "Requested Range not satisfiable"}, + {HttpCode.InternalError, "Internal Server Error"} + }; + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/IHttpAuthorizationMethod.cs b/Roadie.Dlna/Server/Http/IHttpAuthorizationMethod.cs new file mode 100644 index 0000000..22df797 --- /dev/null +++ b/Roadie.Dlna/Server/Http/IHttpAuthorizationMethod.cs @@ -0,0 +1,15 @@ +using System.Net; + +namespace Roadie.Dlna.Server +{ + public interface IHttpAuthorizationMethod + { + /// + /// Checks if a request is authorized. + /// + /// Client supplied HttpHeaders. + /// Client EndPoint + /// true if authorized + bool Authorize(IHeaders headers, IPEndPoint endPoint); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/IPAddressAuthorizer.cs b/Roadie.Dlna/Server/Http/IPAddressAuthorizer.cs new file mode 100644 index 0000000..48ac34c --- /dev/null +++ b/Roadie.Dlna/Server/Http/IPAddressAuthorizer.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; + +namespace Roadie.Dlna.Server +{ + public sealed class IPAddressAuthorizer : IHttpAuthorizationMethod + { + private readonly Dictionary ips = + new Dictionary(); + + public IPAddressAuthorizer(IEnumerable addresses) + { + if (addresses == null) + { + throw new ArgumentNullException(nameof(addresses)); + } + foreach (var ip in addresses) + { + ips.Add(ip, null); + } + } + + public IPAddressAuthorizer(IEnumerable addresses) + : this(from a in addresses select IPAddress.Parse(a)) + { + } + + public bool Authorize(IHeaders headers, IPEndPoint endPoint) + { + var addr = endPoint?.Address; + if (addr == null) + { + return false; + } + var rv = ips.ContainsKey(addr); + Trace.WriteLine(!rv ? $"Rejecting {addr}. Not in IP whitelist" : $"Accepted {addr} via IP whitelist"); + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/ResponseHeaders.cs b/Roadie.Dlna/Server/Http/ResponseHeaders.cs new file mode 100644 index 0000000..adf77d0 --- /dev/null +++ b/Roadie.Dlna/Server/Http/ResponseHeaders.cs @@ -0,0 +1,23 @@ +using System; + +namespace Roadie.Dlna.Server +{ + public sealed class ResponseHeaders : RawHeaders + { + public ResponseHeaders() + : this(true) + { + } + + public ResponseHeaders(bool noCache) + { + this["Server"] = HttpServer.Signature; + this["Date"] = DateTime.Now.ToString("R"); + this["Connection"] = "keep-alive"; + if (noCache) + { + this["Cache-Control"] = "no-cache"; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Http/UserAgentAuthorizer.cs b/Roadie.Dlna/Server/Http/UserAgentAuthorizer.cs new file mode 100644 index 0000000..9979a5a --- /dev/null +++ b/Roadie.Dlna/Server/Http/UserAgentAuthorizer.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; + +namespace Roadie.Dlna.Server +{ + public sealed class UserAgentAuthorizer : IHttpAuthorizationMethod + { + private readonly Dictionary userAgents = new Dictionary(); + + public UserAgentAuthorizer(IEnumerable userAgents) + { + if (userAgents == null) + { + throw new ArgumentNullException(nameof(userAgents)); + } + foreach (var u in userAgents) + { + if (string.IsNullOrEmpty(u)) + { + throw new FormatException("Invalid User-Agent supplied"); + } + this.userAgents.Add(u, null); + } + } + + public bool Authorize(IHeaders headers, IPEndPoint endPoint) + { + if (headers == null) + { + throw new ArgumentNullException(nameof(headers)); + } + string ua; + if (!headers.TryGetValue("User-Agent", out ua)) + { + return false; + } + if (string.IsNullOrEmpty(ua)) + { + return false; + } + var rv = userAgents.ContainsKey(ua); + Trace.WriteLine(!rv ? $"Rejecting {ua}. Not in User-Agent whitelist" : $"Accepted {ua} via User-Agent whitelist"); + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IBookmarkable.cs b/Roadie.Dlna/Server/Interfaces/IBookmarkable.cs new file mode 100644 index 0000000..8bf77d0 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IBookmarkable.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server +{ + public interface IBookmarkable + { + long? Bookmark { get; set; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IHandler.cs b/Roadie.Dlna/Server/Interfaces/IHandler.cs new file mode 100644 index 0000000..17df6dd --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IHandler.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server +{ + internal interface IHandler + { + IResponse HandleRequest(IRequest request); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IHeaders.cs b/Roadie.Dlna/Server/Interfaces/IHeaders.cs new file mode 100644 index 0000000..2559556 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IHeaders.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.IO; + +namespace Roadie.Dlna.Server +{ + public interface IHeaders : IDictionary + { + string HeaderBlock { get; } + + Stream HeaderStream { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaAudioResource.cs b/Roadie.Dlna/Server/Interfaces/IMediaAudioResource.cs new file mode 100644 index 0000000..0c8eda1 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaAudioResource.cs @@ -0,0 +1,8 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server +{ + public interface IMediaAudioResource : IMediaResource, IMetaAudioItem, IMetaInfo + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaCover.cs b/Roadie.Dlna/Server/Interfaces/IMediaCover.cs new file mode 100644 index 0000000..72a80f1 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaCover.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server +{ + public interface IMediaCover + { + IMediaCoverResource Cover { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaCoverResource.cs b/Roadie.Dlna/Server/Interfaces/IMediaCoverResource.cs new file mode 100644 index 0000000..621c216 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaCoverResource.cs @@ -0,0 +1,8 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server +{ + public interface IMediaCoverResource : IMediaResource, IMetaResolution + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaFolder.cs b/Roadie.Dlna/Server/Interfaces/IMediaFolder.cs new file mode 100644 index 0000000..af51e19 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaFolder.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Roadie.Dlna.Server +{ + public interface IMediaFolder : IMediaItem + { + int ChildCount { get; } + + IEnumerable ChildFolders { get; } + IEnumerable ChildItems { get; } + int FullChildCount { get; } + IMediaFolder Parent { get; set; } + + void AddResource(IMediaResource res); + + void Cleanup(); + + bool RemoveResource(IMediaResource res); + + void Sort(IComparer sortComparer, bool descending); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaImageResource.cs b/Roadie.Dlna/Server/Interfaces/IMediaImageResource.cs new file mode 100644 index 0000000..efda5c6 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaImageResource.cs @@ -0,0 +1,8 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server +{ + public interface IMediaImageResource : IMediaResource, IMetaImageItem + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaItem.cs b/Roadie.Dlna/Server/Interfaces/IMediaItem.cs new file mode 100644 index 0000000..03fa729 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaItem.cs @@ -0,0 +1,15 @@ +using System; + +namespace Roadie.Dlna.Server +{ + public interface IMediaItem : IComparable, IEquatable, ITitleComparable + { + string Id { get; set; } + + string Path { get; } + + IHeaders Properties { get; } + + string Title { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaResource.cs b/Roadie.Dlna/Server/Interfaces/IMediaResource.cs new file mode 100644 index 0000000..76f0fe7 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaResource.cs @@ -0,0 +1,15 @@ +using System.IO; + +namespace Roadie.Dlna.Server +{ + public interface IMediaResource : IMediaItem, IMediaCover + { + DlnaMediaTypes MediaType { get; } + + string PN { get; } + + DlnaMime Type { get; } + + Stream CreateContentStream(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaServer.cs b/Roadie.Dlna/Server/Interfaces/IMediaServer.cs new file mode 100644 index 0000000..fceac17 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaServer.cs @@ -0,0 +1,17 @@ +using Roadie.Library.Configuration; +using System; + +namespace Roadie.Dlna.Server +{ + public interface IMediaServer + { + void Preload(); + + IHttpAuthorizationMethod Authorizer { get; } + string FriendlyName { get; } + + Guid UUID { get; } + + IMediaItem GetItem(string id, bool isFileRequest); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IMediaVideoResource.cs b/Roadie.Dlna/Server/Interfaces/IMediaVideoResource.cs new file mode 100644 index 0000000..cef7a81 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IMediaVideoResource.cs @@ -0,0 +1,8 @@ +using Roadie.Dlna.Server.Metadata; + +namespace Roadie.Dlna.Server +{ + public interface IMediaVideoResource : IMediaResource, IMetaVideoItem + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IPrefixHandler.cs b/Roadie.Dlna/Server/Interfaces/IPrefixHandler.cs new file mode 100644 index 0000000..6122250 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IPrefixHandler.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server +{ + internal interface IPrefixHandler : IHandler + { + string Prefix { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IRequest.cs b/Roadie.Dlna/Server/Interfaces/IRequest.cs new file mode 100644 index 0000000..eededd9 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IRequest.cs @@ -0,0 +1,19 @@ +using System.Net; + +namespace Roadie.Dlna.Server +{ + public interface IRequest + { + string Body { get; } + + IHeaders Headers { get; } + + IPEndPoint LocalEndPoint { get; } + + string Method { get; } + + string Path { get; } + + IPEndPoint RemoteEndpoint { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IResponse.cs b/Roadie.Dlna/Server/Interfaces/IResponse.cs new file mode 100644 index 0000000..3fd62a3 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IResponse.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace Roadie.Dlna.Server +{ + internal interface IResponse + { + Stream Body { get; } + + IHeaders Headers { get; } + + HttpCode Status { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/ITitleComparable.cs b/Roadie.Dlna/Server/Interfaces/ITitleComparable.cs new file mode 100644 index 0000000..b80faaa --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/ITitleComparable.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server +{ + public interface ITitleComparable + { + string ToComparableTitle(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/IVolatileMediaServer.cs b/Roadie.Dlna/Server/Interfaces/IVolatileMediaServer.cs new file mode 100644 index 0000000..79f0eb4 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/IVolatileMediaServer.cs @@ -0,0 +1,13 @@ +using System; + +namespace Roadie.Dlna.Server +{ + public interface IVolatileMediaServer + { + bool Rescanning { get; set; } + + event EventHandler Changed; + + void Rescan(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaAudioItem.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaAudioItem.cs new file mode 100644 index 0000000..92d4909 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaAudioItem.cs @@ -0,0 +1,17 @@ +using System; + +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaAudioItem : IMetaInfo, IMetaDescription, IMetaDuration, IMetaGenre + { + int? MetaReleaseYear { get; } + + string MetaAlbum { get; } + + string MetaArtist { get; } + + string MetaPerformer { get; } + + int? MetaTrack { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDescription.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDescription.cs new file mode 100644 index 0000000..2ca5587 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDescription.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaDescription + { + string MetaDescription { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDuration.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDuration.cs new file mode 100644 index 0000000..c33ef5c --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaDuration.cs @@ -0,0 +1,9 @@ +using System; + +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaDuration + { + TimeSpan? MetaDuration { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaGenre.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaGenre.cs new file mode 100644 index 0000000..2e22402 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaGenre.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaGenre + { + string MetaGenre { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaImageItem.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaImageItem.cs new file mode 100644 index 0000000..f62716c --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaImageItem.cs @@ -0,0 +1,8 @@ +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaImageItem + : IMetaInfo, IMetaResolution, IMetaDescription + { + string MetaCreator { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaInfo.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaInfo.cs new file mode 100644 index 0000000..99e1fa7 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaInfo.cs @@ -0,0 +1,11 @@ +using System; + +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaInfo + { + DateTime InfoDate { get; } + + long? InfoSize { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaResolution.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaResolution.cs new file mode 100644 index 0000000..3b3f12d --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaResolution.cs @@ -0,0 +1,9 @@ +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaResolution + { + int? MetaHeight { get; } + + int? MetaWidth { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Interfaces/Metadata/IMetaVideoItem.cs b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaVideoItem.cs new file mode 100644 index 0000000..4bdd0f3 --- /dev/null +++ b/Roadie.Dlna/Server/Interfaces/Metadata/IMetaVideoItem.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Roadie.Dlna.Server.Metadata +{ + public interface IMetaVideoItem + : IMetaInfo, IMetaDescription, IMetaGenre, IMetaDuration, IMetaResolution + { + IEnumerable MetaActors { get; } + + string MetaDirector { get; } + + Subtitle Subtitle { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/MSMediaReceiverRegistrar.xml b/Roadie.Dlna/Server/Resources/MSMediaReceiverRegistrar.xml new file mode 100644 index 0000000..4f5745c --- /dev/null +++ b/Roadie.Dlna/Server/Resources/MSMediaReceiverRegistrar.xml @@ -0,0 +1,88 @@ + + + + 1 + 0 + + + + IsAuthorized + + + DeviceID + in + A_ARG_TYPE_DeviceID + + + Result + out + A_ARG_TYPE_Result + + + + + RegisterDevice + + + RegistrationReqMsg + in + A_ARG_TYPE_RegistrationReqMsg + + + RegistrationRespMsg + out + A_ARG_TYPE_RegistrationRespMsg + + + + + IsValidated + + + DeviceID + in + A_ARG_TYPE_DeviceID + + + Result + out + A_ARG_TYPE_Result + + + + + + + A_ARG_TYPE_DeviceID + string + + + A_ARG_TYPE_Result + int + + + A_ARG_TYPE_RegistrationReqMsg + bin.base64 + + + A_ARG_TYPE_RegistrationRespMsg + bin.base64 + + + AuthorizationGrantedUpdateID + ui4 + + + AuthorizationDeniedUpdateID + ui4 + + + ValidationSucceededUpdateID + ui4 + + + ValidationRevokedUpdateID + ui4 + + + \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/browse.css b/Roadie.Dlna/Server/Resources/browse.css new file mode 100644 index 0000000..364807a --- /dev/null +++ b/Roadie.Dlna/Server/Resources/browse.css @@ -0,0 +1,164 @@ +* { + margin: 0; + padding: 0; + font-family: 'Segoe UI', Helvetica, sans-serif; +} + +html { + color: white; + background: #404040; + padding: 0; + margin: 0; +} + +article { + background: #404040; + padding: 5ex; + min-height: 30ex; +} + +footer, +article { + margin: auto; + padding-left: 40px; + padding-right: 40px; +} + + article:after { + content: '.'; + visibility: hidden; + display: block; + clear: both; + } + +footer { + border-top: 2px solid #244050; + background-color: #404040; + padding-top: 2em; + font-size: small; + margin-bottom: 1em; + color: white; + text-shadow: 2px 2px darkslategray; +} + + footer > img { + float: left; + margin-right: 2ex; + } + + footer > h3 { + margin-top: 0; + text-shadow: 2px 2px darkslategray; + } + + footer > a { + color: #acddfa; + } + +a { + color: white; + text-decoration: none; +} + + a:hover { + color: lightgray; + } + +h1, h2, h3 { + margin-bottom: 1ex; + text-shadow: 1px 1px darkgray; +} + +h2, h3 { + margin-top: 2em; +} + +p { + margin-top: 0.4em; + margin-bottom: 0.6em; +} + +ul { + clear: left; +} + + ul.folders { + border-radius: 6px; + background: #161e24; + } + + ul.folders > li { + display: inline-block; + padding: 1ex; + padding-right: 2em; + font-weight: bold; + } + + ul.items { + margin: 1ex; + block; + } + + ul.items > li { + display: block; + float: left; + margin: 1ex 2ex; + padding: 1em 2em; + border: 1px solid gray; + border-radius: 6px; + width: 400px; + height: 475px; + overflow-y: auto; + background: #161e24; + } + + ul.items > li h3 { + margin-top: 1ex; + overflow: hidden; + text-overflow: ellipsis ellipsis; + } + + ul.items > li table { + font-size: small; + width: 100%; + } + + ul.items > li th { + text-align: left; + font-weight: normal; + padding-right: 1em; + } + + ul.items > li td { + text-align: left; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis ellipsis; + } + +img, +li > a, +details { + display: block; + margin: auto; +} + +img { + margin-top: 1em; + margin-bottom: 2ex; +} + +li h3 { + display: block; + text-align: center; + font-weight: bold; + margin-bottom: 1ex; +} + +.desc { + font-style: italic; +} + +.clear { + clear: both; +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/connectionmanager.xml b/Roadie.Dlna/Server/Resources/connectionmanager.xml new file mode 100644 index 0000000..09ad275 --- /dev/null +++ b/Roadie.Dlna/Server/Resources/connectionmanager.xml @@ -0,0 +1,136 @@ + + + + 1 + 0 + + + + GetCurrentConnectionInfo + + + ConnectionID + in + A_ARG_TYPE_ConnectionID + + + RcsID + out + A_ARG_TYPE_RcsID + + + AVTransportID + out + A_ARG_TYPE_AVTransportID + + + ProtocolInfo + out + A_ARG_TYPE_ProtocolInfo + + + PeerConnectionManager + out + A_ARG_TYPE_ConnectionManager + + + PeerConnectionID + out + A_ARG_TYPE_ConnectionID + + + Direction + out + A_ARG_TYPE_Direction + + + Status + out + A_ARG_TYPE_ConnectionStatus + + + + + GetProtocolInfo + + + Source + out + SourceProtocolInfo + + + Sink + out + SinkProtocolInfo + + + + + GetCurrentConnectionIDs + + + ConnectionIDs + out + CurrentConnectionIDs + + + + + + + A_ARG_TYPE_ProtocolInfo + string + + + A_ARG_TYPE_ConnectionStatus + string + + OK + ContentFormatMismatch + InsufficientBandwidth + UnreliableChannel + Unknown + + + + A_ARG_TYPE_AVTransportID + i4 + 0 + + + A_ARG_TYPE_RcsID + i4 + 0 + + + A_ARG_TYPE_ConnectionID + i4 + 0 + + + A_ARG_TYPE_ConnectionManager + string + + + SourceProtocolInfo + string + + + SinkProtocolInfo + string + + + A_ARG_TYPE_Direction + string + + Input + Output + + + + CurrentConnectionIDs + string + 0 + + + \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/contentdirectory.xml b/Roadie.Dlna/Server/Resources/contentdirectory.xml new file mode 100644 index 0000000..88e657d --- /dev/null +++ b/Roadie.Dlna/Server/Resources/contentdirectory.xml @@ -0,0 +1,207 @@ + + + + 1 + 0 + + + + GetSystemUpdateID + + + Id + out + SystemUpdateID + + + + + GetSearchCapabilities + + + SearchCaps + out + SearchCapabilities + + + + + GetSortCapabilities + + + SortCaps + out + SortCapabilities + + + + + Browse + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + BrowseFlag + in + A_ARG_TYPE_BrowseFlag + + + Filter + in + A_ARG_TYPE_Filter + + + StartingIndex + in + A_ARG_TYPE_Index + + + RequestedCount + in + A_ARG_TYPE_Count + + + SortCriteria + in + A_ARG_TYPE_SortCriteria + + + Result + out + A_ARG_TYPE_Result + + + NumberReturned + out + A_ARG_TYPE_Count + + + TotalMatches + out + A_ARG_TYPE_Count + + + UpdateID + out + A_ARG_TYPE_UpdateID + + + + + X_GetFeatureList + + + FeatureList + out + A_ARG_TYPE_Featurelist + + + + + X_SetBookmark + + + CategoryType + in + A_ARG_TYPE_CategoryType + + + RID + in + A_ARG_TYPE_RID + + + ObjectID + in + A_ARG_TYPE_ObjectID + + + PosSecond + in + A_ARG_TYPE_PosSec + + + + + + + A_ARG_TYPE_SortCriteria + string + + + A_ARG_TYPE_UpdateID + ui4 + + + A_ARG_TYPE_SearchCriteria + string + + + A_ARG_TYPE_Filter + string + + + A_ARG_TYPE_Result + string + + + A_ARG_TYPE_Index + ui4 + + + A_ARG_TYPE_ObjectID + string + + + SortCapabilities + string + + + SearchCapabilities + string + + + A_ARG_TYPE_Count + ui4 + + + A_ARG_TYPE_BrowseFlag + string + + BrowseMetadata + BrowseDirectChildren + + + + SystemUpdateID + ui4 + + + A_ARG_TYPE_BrowseLetter + string + + + A_ARG_TYPE_CategoryType + ui4 + + + + A_ARG_TYPE_RID + ui4 + + + + A_ARG_TYPE_PosSec + ui4 + + + + A_ARG_TYPE_Featurelist + string + + + + \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/description.xml b/Roadie.Dlna/Server/Resources/description.xml new file mode 100644 index 0000000..3e7c71c --- /dev/null +++ b/Roadie.Dlna/Server/Resources/description.xml @@ -0,0 +1,77 @@ + + + + 1 + 0 + + + + DMS-1.50 + + M-DMS-1.50 + + urn:schemas-upnp-org:device:MediaServer:1 + Roadie + https://github.com/sphildreth/roadie + Roadie Music Server + + + https://github.com/sphildreth/roadie/ + + smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec + smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec + + + image/jpeg + 48 + 48 + 24 + /icon/small.jpg + + + image/png + 48 + 48 + 24 + /icon/small.png + + + image/png + 120 + 120 + 24 + /icon/large.png + + + image/jpeg + 120 + 120 + 24 + /icon/large.jpg + + + + + urn:schemas-upnp-org:service:ContentDirectory:1 + urn:upnp-org:serviceId:ContentDirectory + /contentDirectory.xml + /serviceControl + + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /connectionManager.xml + /serviceControl + + + + urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1 + urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar + /MSMediaReceiverRegistrar.xml + /serviceControl + + + + + \ No newline at end of file diff --git a/Roadie.Dlna/Server/Resources/favicon.ico b/Roadie.Dlna/Server/Resources/favicon.ico new file mode 100644 index 0000000..3599ec6 Binary files /dev/null and b/Roadie.Dlna/Server/Resources/favicon.ico differ diff --git a/Roadie.Dlna/Server/Resources/large.jpg b/Roadie.Dlna/Server/Resources/large.jpg new file mode 100644 index 0000000..2e60719 Binary files /dev/null and b/Roadie.Dlna/Server/Resources/large.jpg differ diff --git a/Roadie.Dlna/Server/Resources/large.png b/Roadie.Dlna/Server/Resources/large.png new file mode 100644 index 0000000..026646a Binary files /dev/null and b/Roadie.Dlna/Server/Resources/large.png differ diff --git a/Roadie.Dlna/Server/Resources/small.jpg b/Roadie.Dlna/Server/Resources/small.jpg new file mode 100644 index 0000000..5a74762 Binary files /dev/null and b/Roadie.Dlna/Server/Resources/small.jpg differ diff --git a/Roadie.Dlna/Server/Resources/small.png b/Roadie.Dlna/Server/Resources/small.png new file mode 100644 index 0000000..6a016b2 Binary files /dev/null and b/Roadie.Dlna/Server/Resources/small.png differ diff --git a/Roadie.Dlna/Server/Resources/x_featurelist.xml b/Roadie.Dlna/Server/Resources/x_featurelist.xml new file mode 100644 index 0000000..4789192 --- /dev/null +++ b/Roadie.Dlna/Server/Resources/x_featurelist.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Roadie.Dlna/Server/Responses/FileResponse.cs b/Roadie.Dlna/Server/Responses/FileResponse.cs new file mode 100644 index 0000000..ae43e5f --- /dev/null +++ b/Roadie.Dlna/Server/Responses/FileResponse.cs @@ -0,0 +1,29 @@ +using System.IO; + +namespace Roadie.Dlna.Server +{ + internal sealed class FileResponse : IResponse + { + private readonly FileInfo body; + + public Stream Body => body.OpenRead(); + + public IHeaders Headers { get; } = new ResponseHeaders(); + + public HttpCode Status { get; } + + public FileResponse(HttpCode aStatus, FileInfo aBody) + : this(aStatus, "text/html; charset=utf-8", aBody) + { + } + + public FileResponse(HttpCode aStatus, string aMime, FileInfo aBody) + { + Status = aStatus; + body = aBody; + + Headers["Content-Type"] = aMime; + Headers["Content-Length"] = body.Length.ToString(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Responses/ItemResponse.cs b/Roadie.Dlna/Server/Responses/ItemResponse.cs new file mode 100644 index 0000000..88d8891 --- /dev/null +++ b/Roadie.Dlna/Server/Responses/ItemResponse.cs @@ -0,0 +1,74 @@ +using Roadie.Dlna.Server.Metadata; +using System; +using System.IO; + +namespace Roadie.Dlna.Server +{ + internal sealed class ItemResponse : IResponse + { + private readonly Headers headers; + + private readonly IMediaResource item; + + public Stream Body => item.CreateContentStream(); + + public IHeaders Headers => headers; + + public HttpCode Status { get; } = HttpCode.Ok; + + public ItemResponse(string prefix, IRequest request, IMediaResource item, + string transferMode = "Streaming") + { + this.item = item; + headers = new ResponseHeaders(!(item is IMediaCoverResource)); + var meta = item as IMetaInfo; + if (meta != null) + { + headers.Add("Content-Length", meta.InfoSize.ToString()); + headers.Add("Last-Modified", meta.InfoDate.ToString("R")); + } + headers.Add("Accept-Ranges", "bytes"); + headers.Add("Content-Type", DlnaMaps.Mime[item.Type]); + if (request.Headers.ContainsKey("getcontentFeatures.dlna.org")) + { + try + { + headers.Add( + "contentFeatures.dlna.org", + item.MediaType == DlnaMediaTypes.Image + ? $"DLNA.ORG_PN={item.PN};DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS={DlnaMaps.DefaultInteractive}" + : $"DLNA.ORG_PN={item.PN};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS={DlnaMaps.DefaultStreaming}" + ); + } + catch (NotSupportedException) + { + } + catch (NotImplementedException) + { + } + } + if (request.Headers.ContainsKey("getCaptionInfo.sec")) + { + var mvi = item as IMetaVideoItem; + if (mvi != null && mvi.Subtitle.HasSubtitle) + { + var surl = + $"http://{request.LocalEndPoint.Address}:{request.LocalEndPoint.Port}{prefix}subtitle/{item.Id}/st.srt"; + headers.Add("CaptionInfo.sec", surl); + } + } + if (request.Headers.ContainsKey("getMediaInfo.sec")) + { + var md = item as IMetaDuration; + if (md?.MetaDuration != null) + { + headers.Add( + "MediaInfo.sec", + $"SEC_Duration={md.MetaDuration.Value.TotalMilliseconds};" + ); + } + } + headers.Add("transferMode.dlna.org", transferMode); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Responses/Redirect.cs b/Roadie.Dlna/Server/Responses/Redirect.cs new file mode 100644 index 0000000..c8f59b9 --- /dev/null +++ b/Roadie.Dlna/Server/Responses/Redirect.cs @@ -0,0 +1,38 @@ +using System; + +namespace Roadie.Dlna.Server +{ + internal sealed class Redirect : StringResponse + { + internal Redirect(string uri) + : this(HttpCode.TemporaryRedirect, uri) + { + } + + internal Redirect(Uri uri) + : this(HttpCode.TemporaryRedirect, uri) + { + } + + internal Redirect(IRequest request, string path) + : this(HttpCode.TemporaryRedirect, request, path) + { + } + + internal Redirect(HttpCode code, string uri) + : base(code, "text/plain", "Redirecting...") + { + Headers.Add("Location", uri); + } + + internal Redirect(HttpCode code, Uri uri) + : this(code, uri.AbsoluteUri) + { + } + + internal Redirect(HttpCode code, IRequest request, string path) + : this(code, $"http://{request.LocalEndPoint}{path}") + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Responses/ResourceResponse.cs b/Roadie.Dlna/Server/Responses/ResourceResponse.cs new file mode 100644 index 0000000..77c636e --- /dev/null +++ b/Roadie.Dlna/Server/Responses/ResourceResponse.cs @@ -0,0 +1,36 @@ +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.IO; + +namespace Roadie.Dlna.Server +{ + internal sealed class ResourceResponse : IResponse + { + private readonly byte[] resource; + + public Stream Body => new MemoryStream(resource); + + public IHeaders Headers { get; } = new ResponseHeaders(); + + public HttpCode Status { get; } + + public ResourceResponse(HttpCode aStatus, string type, string aResource) + { + Status = aStatus; + try + { + resource = ResourceHelper.GetResourceData(aResource); + + Headers["Content-Type"] = type; + var len = resource?.Length.ToString() ?? "0"; + Headers["Content-Length"] = len; + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to prepare resource { aResource }, Ex [{ ex }]"); + throw; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Responses/StringResponse.cs b/Roadie.Dlna/Server/Responses/StringResponse.cs new file mode 100644 index 0000000..f45f805 --- /dev/null +++ b/Roadie.Dlna/Server/Responses/StringResponse.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.Text; + +namespace Roadie.Dlna.Server +{ + internal class StringResponse : IResponse + { + private readonly string body; + + public Stream Body => new MemoryStream(Encoding.UTF8.GetBytes(body)); + + public IHeaders Headers { get; } = new ResponseHeaders(); + + public HttpCode Status { get; } + + public StringResponse(HttpCode aStatus, string aBody) + : this(aStatus, "text/html; charset=utf-8", aBody) + { + } + + public StringResponse(HttpCode aStatus, string aMime, string aBody) + { + Status = aStatus; + body = aBody; + + Headers["Content-Type"] = aMime; + Headers["Content-Length"] = Encoding.UTF8.GetByteCount(body).ToString(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Ssdp/Datagram.cs b/Roadie.Dlna/Server/Ssdp/Datagram.cs new file mode 100644 index 0000000..ec29387 --- /dev/null +++ b/Roadie.Dlna/Server/Ssdp/Datagram.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Roadie.Dlna.Server.Ssdp +{ + internal sealed class Datagram + { + public readonly IPEndPoint EndPoint; + + public readonly IPAddress LocalAddress; + + public readonly string Message; + + public readonly bool Sticky; + + public uint SendCount { get; private set; } + + public Datagram(IPEndPoint endPoint, IPAddress localAddress, + string message, bool sticky) + { + EndPoint = endPoint; + LocalAddress = localAddress; + Message = message; + Sticky = sticky; + SendCount = 0; + } + + public void Send() + { + var msg = Encoding.ASCII.GetBytes(Message); + try + { + var client = new UdpClient(); + client.Client.Bind(new IPEndPoint(LocalAddress, 0)); + client.Ttl = 10; + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 10); + client.BeginSend(msg, msg.Length, EndPoint, result => + { + try + { + client.EndSend(result); + } + catch (Exception ex) + { + Trace.WriteLine(ex); + } + finally + { + try + { + client.Close(); + } + catch (Exception) + { + // ignored + } + } + }, null); + } + catch (Exception ex) + { + Trace.WriteLine(ex); + } + ++SendCount; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Ssdp/SsdpHandler.cs b/Roadie.Dlna/Server/Ssdp/SsdpHandler.cs new file mode 100644 index 0000000..4e19273 --- /dev/null +++ b/Roadie.Dlna/Server/Ssdp/SsdpHandler.cs @@ -0,0 +1,343 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace Roadie.Dlna.Server.Ssdp +{ + internal sealed class SsdpHandler : IDisposable + { + internal static readonly IPEndPoint BroadEndp = + new IPEndPoint(IPAddress.Parse("255.255.255.255"), SSDP_PORT); + + private const int DATAGRAMS_PER_MESSAGE = 3; + + private const string SSDP_ADDR = "239.255.255.250"; + + private const int SSDP_PORT = 1900; + + private static readonly Random random = new Random(); + + private static readonly IPEndPoint ssdpEndp = new IPEndPoint(IPAddress.Parse(SSDP_ADDR), SSDP_PORT); + + private static readonly IPAddress ssdpIP = IPAddress.Parse(SSDP_ADDR); + + private readonly UdpClient client = new UdpClient(); + + private readonly AutoResetEvent datagramPosted = new AutoResetEvent(false); + + private readonly Dictionary> devices = new Dictionary>(); + + private readonly ConcurrentQueue messageQueue = new ConcurrentQueue(); + + private readonly Timer notificationTimer = new Timer(60000); + + private readonly Timer queueTimer = new Timer(1000); + + private bool running = true; + + private ILogger Logger { get; } + + private UpnpDevice[] Devices + { + get + { + UpnpDevice[] devs; + lock (devices) + { + devs = devices.Values.SelectMany(i => i).ToArray(); + } + return devs; + } + } + + public SsdpHandler(ILogger logger) + { + Logger = logger; + + notificationTimer.Elapsed += Tick; + notificationTimer.Enabled = true; + + queueTimer.Elapsed += ProcessQueue; + + client.Client.UseOnlyOverlappedIO = true; + client.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); + client.ExclusiveAddressUse = false; + client.Client.Bind(new IPEndPoint(IPAddress.Any, SSDP_PORT)); + client.JoinMulticastGroup(ssdpIP, 10); + Logger.LogTrace("SSDP service started"); + Receive(); + } + + public void Dispose() + { + Logger.LogTrace("Disposing SSDP"); + running = false; + while (messageQueue.Count != 0) + { + datagramPosted.WaitOne(); + } + + client.DropMulticastGroup(ssdpIP); + + notificationTimer.Enabled = false; + queueTimer.Enabled = false; + notificationTimer.Dispose(); + queueTimer.Dispose(); + datagramPosted.Dispose(); + } + + internal void NotifyAll() + { + Logger.LogTrace("NotifyAll"); + foreach (var d in Devices) + { + NotifyDevice(d, "alive", false); + } + } + + internal void NotifyDevice(UpnpDevice dev, string type, bool sticky) + { + Logger.LogTrace("NotifyDevice"); + var headers = new RawHeaders + { + {"HOST", "239.255.255.250:1900"}, + {"CACHE-CONTROL", "max-age = 600"}, + {"LOCATION", dev.Descriptor.ToString()}, + {"SERVER", HttpServer.Signature}, + {"NTS", "ssdp:" + type}, + {"NT", dev.Type}, + {"USN", dev.USN} + }; + + SendDatagram( + ssdpEndp, + dev.Address, + $"NOTIFY * HTTP/1.1\r\n{headers.HeaderBlock}\r\n", + sticky + ); + // Some buggy network equipment will swallow multicast packets, so lets + // cheat, increase the odds, by sending to broadcast. + SendDatagram( + BroadEndp, + dev.Address, + $"NOTIFY * HTTP/1.1\r\n{headers.HeaderBlock}\r\n", + sticky + ); + Logger.LogTrace($"{dev.USN} said {type}"); + } + + internal void RegisterNotification(Guid uuid, Uri descriptor, + IPAddress address) + { + List list; + lock (devices) + { + if (!devices.TryGetValue(uuid, out list)) + { + devices.Add(uuid, list = new List()); + } + } + list.AddRange(new[] + { + "upnp:rootdevice", "urn:schemas-upnp-org:device:MediaServer:1", + "urn:schemas-upnp-org:service:ContentDirectory:1", "urn:schemas-upnp-org:service:ConnectionManager:1", + "urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1", "uuid:" + uuid + }.Select(t => new UpnpDevice(uuid, t, descriptor, address))); + + NotifyAll(); + Logger.LogTrace($"Registered mount {uuid}, {address}"); + } + + internal void RespondToSearch(IPEndPoint endpoint, string req) + { + if (req == "ssdp:all") + { + req = null; + } + + Logger.LogTrace("RespondToSearch {endpoint} {req}"); + foreach (var d in Devices) + { + if (!string.IsNullOrEmpty(req) && req != d.Type) + { + continue; + } + SendSearchResponse(endpoint, d); + } + } + + internal void UnregisterNotification(Guid uuid) + { + List dl; + lock (devices) + { + if (!devices.TryGetValue(uuid, out dl)) + { + return; + } + devices.Remove(uuid); + } + foreach (var d in dl) + { + NotifyDevice(d, "byebye", true); + } + Logger.LogTrace("Unregistered mount {uuid}"); + } + + private void ProcessQueue(object sender, ElapsedEventArgs e) + { + while (messageQueue.Count != 0) + { + Datagram msg; + if (!messageQueue.TryPeek(out msg)) + { + continue; + } + if (msg != null && (running || msg.Sticky)) + { + msg.Send(); + if (msg.SendCount > DATAGRAMS_PER_MESSAGE) + { + messageQueue.TryDequeue(out msg); + } + break; + } + messageQueue.TryDequeue(out msg); + } + datagramPosted.Set(); + queueTimer.Enabled = messageQueue.Count != 0; + queueTimer.Interval = random.Next(25, running ? 75 : 50); + } + + private void Receive() + { + try + { + client.BeginReceive(ReceiveCallback, null); + } + catch (ObjectDisposedException) + { + } + } + + private void ReceiveCallback(IAsyncResult result) + { + try + { + var endpoint = new IPEndPoint(IPAddress.None, SSDP_PORT); + var received = client.EndReceive(result, ref endpoint); + if (received == null) + { + throw new IOException("Didn't receive anything"); + } + if (received.Length == 0) + { + throw new IOException("Didn't receive any bytes"); + } + + //Logger.LogTrace($"{endpoint} - SSDP Received a datagram"); + + using (var reader = new StreamReader(new MemoryStream(received), Encoding.ASCII)) + { + var proto = reader.ReadLine(); + if (proto == null) + { + throw new IOException("Couldn't read protocol line"); + } + proto = proto.Trim(); + if (string.IsNullOrEmpty(proto)) + { + throw new IOException("Invalid protocol line"); + } + var method = proto.Split(new[] { ' ' }, 2)[0]; + var headers = new Headers(); + for (var line = reader.ReadLine(); + line != null; + line = reader.ReadLine()) + { + line = line.Trim(); + if (string.IsNullOrEmpty(line)) + { + break; + } + var parts = line.Split(new[] { ':' }, 2); + headers[parts[0]] = parts[1].Trim(); + } + // Logger.LogTrace($"{endpoint} - Datagram method: {method}"); + if (method == "M-SEARCH") + { + RespondToSearch(endpoint, headers["st"]); + } + } + } + catch (IOException ex) + { + Logger.LogTrace($"Failed to read SSDP message Ex [{ ex }]"); + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to read SSDP message Ex [{ ex }]"); + } + Receive(); + } + + private void SendDatagram(IPEndPoint endpoint, IPAddress address, + string message, bool sticky) + { + if (!running) + { + return; + } + var dgram = new Datagram(endpoint, address, message, sticky); + if (messageQueue.Count == 0) + { + dgram.Send(); + } + messageQueue.Enqueue(dgram); + queueTimer.Enabled = true; + } + + private void SendSearchResponse(IPEndPoint endpoint, UpnpDevice dev) + { + var headers = new RawHeaders + { + {"CACHE-CONTROL", "max-age = 600"}, + {"DATE", DateTime.Now.ToString("R")}, + {"EXT", string.Empty}, + {"LOCATION", dev.Descriptor.ToString()}, + {"SERVER", HttpServer.Signature}, + {"ST", dev.Type}, + {"USN", dev.USN} + }; + + SendDatagram( + endpoint, + dev.Address, + $"HTTP/1.1 200 OK\r\n{headers.HeaderBlock}\r\n", + false + ); + Logger.LogTrace($"{dev.Address}, {endpoint} - Responded to a {dev.Type} request"); + } + + private void Tick(object sender, ElapsedEventArgs e) + { + Logger.LogTrace("Sending SSDP notifications!"); + notificationTimer.Interval = random.Next(60000, 120000); + NotifyAll(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/AudioResourceDecorator.cs b/Roadie.Dlna/Server/Types/AudioResourceDecorator.cs new file mode 100644 index 0000000..cfa3f7c --- /dev/null +++ b/Roadie.Dlna/Server/Types/AudioResourceDecorator.cs @@ -0,0 +1,26 @@ +using System; + +namespace Roadie.Dlna.Server +{ + internal class AudioResourceDecorator + : MediaResourceDecorator + { + public virtual string MetaAlbum => Resource.MetaAlbum; + + public virtual string MetaArtist => Resource.MetaArtist; + + public virtual string MetaDescription => Resource.MetaDescription; + + public virtual TimeSpan? MetaDuration => Resource.MetaDuration; + + public virtual string MetaGenre => Resource.MetaGenre; + + public virtual string MetaPerformer => Resource.MetaPerformer; + + public virtual int? MetaTrack => Resource.MetaTrack; + + public AudioResourceDecorator(IMediaAudioResource resource) : base(resource) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/DlnaFlags.cs b/Roadie.Dlna/Server/Types/DlnaFlags.cs new file mode 100644 index 0000000..7cd95ac --- /dev/null +++ b/Roadie.Dlna/Server/Types/DlnaFlags.cs @@ -0,0 +1,21 @@ +using System; + +namespace Roadie.Dlna.Server +{ + [Flags] + internal enum DlnaFlags : ulong + { + BackgroundTransferMode = 1 << 22, + ByteBasedSeek = 1 << 29, + ConnectionStall = 1 << 21, + DlnaV15 = 1 << 20, + InteractiveTransferMode = 1 << 23, + PlayContainer = 1 << 28, + RtspPause = 1 << 25, + S0Increase = 1 << 27, + SenderPaced = 1L << 31, + SnIncrease = 1 << 26, + StreamingTransferMode = 1 << 24, + TimeBasedSeek = 1 << 30 + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/DlnaMaps.cs b/Roadie.Dlna/Server/Types/DlnaMaps.cs new file mode 100644 index 0000000..243ff42 --- /dev/null +++ b/Roadie.Dlna/Server/Types/DlnaMaps.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Roadie.Dlna.Server +{ + public static class DlnaMaps + { + public static readonly Dictionary> AllPN = new Dictionary> + { + { + DlnaMime.AudioAAC, new List + { + "AAC" + } + }, + { + DlnaMime.AudioFLAC, new List + { + "FLAC" + } + }, + { + DlnaMime.AudioMP2, new List + { + "MP2_MPS" + } + }, + { + DlnaMime.AudioMP3, new List + { + "MP3" + } + }, + { + DlnaMime.AudioRAW, new List + { + "LPCM" + } + }, + { + DlnaMime.AudioVORBIS, new List + { + "OGG" + } + }, + { + DlnaMime.ImageGIF, new List + { + "GIF", + "GIF_LRG", + "GIF_MED", + "GIF_SM" + } + }, + { + DlnaMime.ImageJPEG, new List + { + "JPEG", + "JPEG_LRG", + "JPEG_MED", + "JPEG_SM", + "JPEG_TN" + } + }, + { + DlnaMime.ImagePNG, new List + { + "PNG", + "PNG_LRG", + "PNG_MED", + "PNG_SM", + "PNG_TN" + } + }, + { + DlnaMime.SubtitleSRT, new List + { + "SRT" + } + }, + { + DlnaMime.Video3GPP, new List + { + "MPEG4_P2_3GPP_SP_L0B_AMR", + "AVC_3GPP_BL_QCIF15_AAC", + "MPEG4_H263_3GPP_P0_L10_AMR", + "MPEG4_H263_MP4_P0_L10_AAC", + "MPEG4_P2_3GPP_SP_L0B_AAC" + } + }, + { + DlnaMime.VideoAVC, new List + { + "AVC_MP4_MP_SD_AAC_MULT5", + "AVC_MP4_HP_HD_AAC", + "AVC_MP4_HP_HD_DTS", + "AVC_MP4_LPCM", + "AVC_MP4_MP_SD_AC3", + "AVC_MP4_MP_SD_DTS", + "AVC_MP4_MP_SD_MPEG1_L3", + "AVC_TS_HD_50_LPCM_T", + "AVC_TS_HD_DTS_ISO", + "AVC_TS_HD_DTS_T", + "AVC_TS_HP_HD_MPEG1_L2_ISO", + "AVC_TS_HP_HD_MPEG1_L2_T", + "AVC_TS_HP_SD_MPEG1_L2_ISO", + "AVC_TS_HP_SD_MPEG1_L2_T", + "AVC_TS_MP_HD_AAC_MULT5", + "AVC_TS_MP_HD_AAC_MULT5_ISO", + "AVC_TS_MP_HD_AAC_MULT5_T", + "AVC_TS_MP_HD_AC3", + "AVC_TS_MP_HD_AC3_ISO", + "AVC_TS_MP_HD_AC3_T", + "AVC_TS_MP_HD_MPEG1_L3", + "AVC_TS_MP_HD_MPEG1_L3_ISO", + "AVC_TS_MP_HD_MPEG1_L3_T", + "AVC_TS_MP_SD_AAC_MULT5", + "AVC_TS_MP_SD_AAC_MULT5_ISO", + "AVC_TS_MP_SD_AAC_MULT5_T", + "AVC_TS_MP_SD_AC3", + "AVC_TS_MP_SD_AC3_ISO", + "AVC_TS_MP_SD_AC3_T", + "AVC_TS_MP_SD_MPEG1_L3", + "AVC_TS_MP_SD_MPEG1_L3_ISO", + "AVC_TS_MP_SD_MPEG1_L3_T" + } + }, + { + DlnaMime.VideoAVI, new List + { + "AVI" + } + }, + { + DlnaMime.VideoFLV, new List + { + "FLV" + } + }, + { + DlnaMime.VideoMKV, new List + { + "MATROSKA" + } + }, + { + DlnaMime.VideoMPEG, new List + { + "MPEG1", + "MPEG_PS_PAL", + "MPEG_PS_NTSC", + "MPEG_TS_SD_EU", + "MPEG_TS_SD_EU_T", + "MPEG_TS_SD_EU_ISO", + "MPEG_TS_SD_NA", + "MPEG_TS_SD_NA_T", + "MPEG_TS_SD_NA_ISO", + "MPEG_TS_SD_KO", + "MPEG_TS_SD_KO_T", + "MPEG_TS_SD_KO_ISO", + "MPEG_TS_JP_T" + } + }, + { + DlnaMime.VideoOGV, new List + { + "OGV" + } + }, + { + DlnaMime.VideoWMV, new List + { + "WMV_FULL", + "WMV_BASE", + "WMVHIGH_FULL", + "WMVHIGH_BASE", + "WMVHIGH_PRO", + "WMVMED_FULL", + "WMVMED_BASE", + "WMVMED_PRO", + "VC1_ASF_AP_L1_WMA", + "VC1_ASF_AP_L2_WMA", + "VC1_ASF_AP_L3_WMA" + } + } + }; + + public static readonly Dictionary> Dlna2Ext = + new Dictionary>(); + + public static readonly Dictionary Ext2Dlna = + new Dictionary(); + + public static readonly Dictionary Ext2Media = + new Dictionary(); + + public static readonly Dictionary MainPN = GenerateMainPN(); + + public static readonly Dictionary> Media2Ext = + new Dictionary>(); + + public static readonly Dictionary Mime = new Dictionary + { + {DlnaMime.AudioAAC, "audio/aac"}, + {DlnaMime.AudioFLAC, "audio/flac"}, + {DlnaMime.AudioMP2, "audio/mpeg"}, + {DlnaMime.AudioMP3, "audio/mpeg"}, + {DlnaMime.AudioRAW, "audio/L16;rate=44100;channels=2"}, + {DlnaMime.AudioVORBIS, "audio/ogg"}, + {DlnaMime.ImageGIF, "image/gif"}, + {DlnaMime.ImageJPEG, "image/jpeg"}, + {DlnaMime.ImagePNG, "image/png"}, + {DlnaMime.SubtitleSRT, "smi/caption"}, + {DlnaMime.Video3GPP, "video/3gpp"}, + {DlnaMime.VideoAVC, "video/mp4"}, + {DlnaMime.VideoAVI, "video/avi"}, + {DlnaMime.VideoFLV, "video/flv"}, + {DlnaMime.VideoMKV, "video/x-matroska"}, + {DlnaMime.VideoMPEG, "video/mpeg"}, + {DlnaMime.VideoOGV, "video/ogg"}, + {DlnaMime.VideoWMV, "video/x-ms-wmv"} + }; + + public static readonly string ProtocolInfo = GenerateProtocolInfo(); + + internal static readonly string DefaultInteractive = FlagsToString( + DlnaFlags.InteractiveTransferMode | + DlnaFlags.BackgroundTransferMode | + DlnaFlags.ConnectionStall | + DlnaFlags.ByteBasedSeek | + DlnaFlags.DlnaV15 + ); + + internal static readonly string DefaultStreaming = FlagsToString( + DlnaFlags.StreamingTransferMode | + DlnaFlags.BackgroundTransferMode | + DlnaFlags.ConnectionStall | + DlnaFlags.ByteBasedSeek | + DlnaFlags.DlnaV15 + ); + + private static readonly string[] ext3GPP = + {"3gp", "3gpp"}; + + private static readonly string[] extAAC = + {"aac", "mp4a", "m4a"}; + + private static readonly string[] extAVC = + {"avc", "mp4", "m4v", "mov"}; + + private static readonly string[] extAVI = + {"avi", "divx", "xvid"}; + + private static readonly string[] extFLAC = + {"flac"}; + + private static readonly string[] extFLV = + {"flv"}; + + private static readonly string[] extGIF = + {"gif"}; + + private static readonly string[] extJPEG = + {"jpg", "jpe", "jpeg", "jif", "jfif"}; + + private static readonly string[] extMKV = + {"mkv", "matroska", "mk3d", "webm"}; + + private static readonly string[] extMP2 = + {"mp2"}; + + private static readonly string[] extMP3 = + {"mp3", "mp3p", "mp3x", "mp3a", "mpa"}; + + private static readonly string[] extMPEG = + {"mpg", "mpe", "mpeg", "mpg2", "mpeg2", "ts", "vob", "m2v"}; + + private static readonly string[] extOGV = + {"ogm", "ogv"}; + + private static readonly string[] extPNG = + {"png"}; + + private static readonly string[] extRAWAUDIO = + {"wav"}; + + private static readonly string[] extVORBIS = + {"ogg", "oga"}; + + private static readonly string[] extWMV = + {"wmv", "asf", "wma", "wmf"}; + + static DlnaMaps() + { + var extToDLNA = new[] + { + new + {t = DlnaMime.AudioAAC, e = extAAC}, + new + {t = DlnaMime.AudioFLAC, e = extFLAC}, + new + {t = DlnaMime.AudioMP2, e = extMP2}, + new + {t = DlnaMime.AudioMP3, e = extMP3}, + new + {t = DlnaMime.AudioRAW, e = extRAWAUDIO}, + new + {t = DlnaMime.AudioVORBIS, e = extVORBIS}, + new + {t = DlnaMime.ImageGIF, e = extGIF}, + new + {t = DlnaMime.ImageJPEG, e = extJPEG}, + new + {t = DlnaMime.ImagePNG, e = extPNG}, + new + {t = DlnaMime.Video3GPP, e = ext3GPP}, + new + {t = DlnaMime.VideoAVC, e = extAVC}, + new + {t = DlnaMime.VideoAVI, e = extAVI}, + new + {t = DlnaMime.VideoFLV, e = extFLV}, + new + {t = DlnaMime.VideoMKV, e = extMKV}, + new + {t = DlnaMime.VideoMPEG, e = extMPEG}, + new + {t = DlnaMime.VideoOGV, e = extOGV}, + new + {t = DlnaMime.VideoWMV, e = extWMV} + }; + + foreach (var i in extToDLNA) + { + var t = i.t; + foreach (var e in i.e) + { + Ext2Dlna.Add(e.ToUpperInvariant(), t); + } + Dlna2Ext.Add(i.t, new List(i.e)); + } + + InitMedia( + new[] { ext3GPP, extAVI, extAVC, extFLV, extMKV, extMPEG, extOGV, extWMV }, + DlnaMediaTypes.Video); + InitMedia( + new[] { extJPEG, extPNG, extGIF }, + DlnaMediaTypes.Image); + InitMedia( + new[] { extAAC, extFLAC, extMP2, extMP3, extRAWAUDIO, extVORBIS }, + DlnaMediaTypes.Audio); + } + + public static Dictionary GenerateMainPN() + { + return AllPN.ToDictionary(p => p.Key, p => p.Value.FirstOrDefault()); + } + + internal static string FlagsToString(DlnaFlags flags) + { + return $"{(ulong)flags:X8}{0:D24}"; + } + + private static string GenerateProtocolInfo() + { + var pns = (from p in AllPN + let mime = Mime[p.Key] + from pn in p.Value + select + string.Format("http-get:*:{1}:DLNA.ORG_PN={0};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS={2}", pn, + mime, DefaultStreaming)).ToList(); + return string.Join(",", pns); + } + + private static void InitMedia(string[][] k, DlnaMediaTypes t) + { + foreach (var i in k) + { + var e = (from ext in i + select ext.ToUpperInvariant()).ToList(); + try + { + Media2Ext.Add(t, e); + } + catch (ArgumentException) + { + Media2Ext[t].AddRange(e); + } + foreach (var ext in e) + { + Ext2Media.Add(ext.ToUpperInvariant(), t); + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/DlnaMediaTypes.cs b/Roadie.Dlna/Server/Types/DlnaMediaTypes.cs new file mode 100644 index 0000000..14b48b8 --- /dev/null +++ b/Roadie.Dlna/Server/Types/DlnaMediaTypes.cs @@ -0,0 +1,13 @@ +using System; + +namespace Roadie.Dlna.Server +{ + [Flags] + public enum DlnaMediaTypes + { + Audio = 1 << 2, + Image = 1 << 1, + Video = 1 << 0, + All = ~(-1 << 3) + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/DlnaMime.cs b/Roadie.Dlna/Server/Types/DlnaMime.cs new file mode 100644 index 0000000..2f44fcb --- /dev/null +++ b/Roadie.Dlna/Server/Types/DlnaMime.cs @@ -0,0 +1,24 @@ +namespace Roadie.Dlna.Server +{ + public enum DlnaMime + { + AudioAAC, + AudioFLAC, + AudioMP2, + AudioMP3, + AudioRAW, + AudioVORBIS, + ImageGIF, + ImageJPEG, + ImagePNG, + SubtitleSRT, + Video3GPP, + VideoAVC, + VideoAVI, + VideoFLV, + VideoMKV, + VideoMPEG, + VideoOGV, + VideoWMV + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/Extensions.cs b/Roadie.Dlna/Server/Types/Extensions.cs new file mode 100644 index 0000000..329fdc4 --- /dev/null +++ b/Roadie.Dlna/Server/Types/Extensions.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Roadie.Dlna.Server +{ + public static class Extensions + { + public static IEnumerable GetExtensions(this DlnaMediaTypes types) + { + return (from i in DlnaMaps.Media2Ext + where types.HasFlag(i.Key) + select i.Value).SelectMany(i => i); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/Headers.cs b/Roadie.Dlna/Server/Types/Headers.cs new file mode 100644 index 0000000..3f63914 --- /dev/null +++ b/Roadie.Dlna/Server/Types/Headers.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server +{ + public class Headers : IHeaders + { + private static readonly Regex validator = new Regex( + @"^[a-z\d][a-z\d_.-]+$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private readonly bool asIs; + + private readonly Dictionary dict = + new Dictionary(); + + public int Count => dict.Count; + + public string HeaderBlock + { + get + { + var hb = new StringBuilder(); + foreach (var h in this) + { + hb.AppendFormat("{0}: {1}\r\n", h.Key, h.Value); + } + return hb.ToString(); + } + } + + public Stream HeaderStream => new MemoryStream(Encoding.ASCII.GetBytes(HeaderBlock)); + + public bool IsReadOnly => false; + + public ICollection Keys => dict.Keys; + + public ICollection Values => dict.Values; + + public string this[string key] + { + get { return dict[Normalize(key)]; } + set { dict[Normalize(key)] = value; } + } + + public Headers() + : this(false) + { + } + + protected Headers(bool asIs) + { + this.asIs = asIs; + } + + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + public void Add(string key, string value) + { + dict.Add(Normalize(key), value); + } + + public void Clear() + { + dict.Clear(); + } + + public bool Contains(KeyValuePair item) + { + var p = new KeyValuePair( + Normalize(item.Key), item.Value); + return dict.Contains(p); + } + + public bool ContainsKey(string key) + { + return dict.ContainsKey(Normalize(key)); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return dict.GetEnumerator(); + } + + public IEnumerator> GetEnumerator() + { + return dict.GetEnumerator(); + } + + public bool Remove(string key) + { + return dict.Remove(Normalize(key)); + } + + public bool Remove(KeyValuePair item) + { + return Remove(item.Key); + } + + public override string ToString() + { + return $"({string.Join(", ", from x in dict select $"{x.Key}={x.Value}")})"; + } + + public bool TryGetValue(string key, out string value) + { + return dict.TryGetValue(Normalize(key), out value); + } + + private string Normalize(string header) + { + if (!asIs) + { + header = header.ToUpperInvariant(); + } + header = header.Trim(); + if (!validator.IsMatch(header)) + { + throw new ArgumentException("Invalid header: " + header); + } + return header; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/HtmlTools.cs b/Roadie.Dlna/Server/Types/HtmlTools.cs new file mode 100644 index 0000000..2c0f9c0 --- /dev/null +++ b/Roadie.Dlna/Server/Types/HtmlTools.cs @@ -0,0 +1,65 @@ +using Roadie.Dlna.Utility; +using System.Reflection; +using System.Xml; + +namespace Roadie.Dlna.Server +{ + internal static class HtmlTools + { + public static XmlElement CreateHtmlArticle(string title) + { + title += " – Roadie Music Server"; + + var document = new XmlDocument(); + document.AppendChild(document.CreateDocumentType( + "html", null, null, null)); + + document.AppendChild(document.EL("html")); + + var head = document.EL("head"); + document.DocumentElement?.AppendChild(head); + head.AppendChild(document.EL("title", title)); + head.AppendChild(document.EL( + "link", + new AttributeCollection + { + {"rel", "stylesheet"}, + {"type", "text/css"}, + {"href", "/static/browse.css"} + })); + + var body = document.EL("body"); + document.DocumentElement?.AppendChild(body); + + var article = document.EL("article"); + body.AppendChild(article); + + var header = document.EL("header"); + header.AppendChild(document.EL("h1", title)); + article.AppendChild(header); + + var footer = document.EL("footer"); + footer.AppendChild(document.EL( + "img", + new AttributeCollection { { "src", "/icon/small.png" } } + )); + footer.AppendChild(document.EL("h3", + $"Roadie Music Server: roadie/{Assembly.GetExecutingAssembly().GetName().Version.Major}.{Assembly.GetExecutingAssembly().GetName().Version.Minor}")); + footer.AppendChild(document.EL( + "p", + new AttributeCollection { { "class", "desc" } }, + "A powerful API server." + )); + footer.AppendChild(document.EL( + "a", + new AttributeCollection + { + {"href", "https://github.com/sphildreth/roadie/"} + }, + "Fork me on GitHub") + ); + body.AppendChild(footer); + return article; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/HttpException.cs b/Roadie.Dlna/Server/Types/HttpException.cs new file mode 100644 index 0000000..2cfe726 --- /dev/null +++ b/Roadie.Dlna/Server/Types/HttpException.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.Serialization; + +namespace Roadie.Dlna.Server +{ + [Serializable] + public class HttpException : Exception + { + public HttpException() + { + } + + public HttpException(string message) + : base(message) + { + } + + public HttpException(string message, Exception innerException) + : base(message, innerException) + { + } + + protected HttpException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/HttpStatusException.cs b/Roadie.Dlna/Server/Types/HttpStatusException.cs new file mode 100644 index 0000000..69c2a50 --- /dev/null +++ b/Roadie.Dlna/Server/Types/HttpStatusException.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.Serialization; + +namespace Roadie.Dlna.Server +{ + [Serializable] + public sealed class HttpStatusException : HttpException + { + public HttpCode Code { get; private set; } + + public HttpStatusException() + { + } + + public HttpStatusException(HttpCode code) + : base(HttpPhrases.Phrases[code]) + { + Code = code; + } + + public HttpStatusException(string message) + : base(message) + { + Code = HttpCode.None; + } + + public HttpStatusException(HttpCode code, Exception innerException) + : base(HttpPhrases.Phrases[code], innerException) + { + Code = code; + } + + public HttpStatusException(string message, Exception innerException) + : base(message, innerException) + { + Code = HttpCode.None; + } + + private HttpStatusException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/Identifiers.cs b/Roadie.Dlna/Server/Types/Identifiers.cs new file mode 100644 index 0000000..3ef65dd --- /dev/null +++ b/Roadie.Dlna/Server/Types/Identifiers.cs @@ -0,0 +1,160 @@ +using Roadie.Dlna.Server.Comparers; +using Roadie.Dlna.Server.Views; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Roadie.Dlna.Server +{ + public sealed class Identifiers + { + public const string GENERAL_ROOT = "0"; + + public const string SAMSUNG_AUDIO = "A"; + + public const string SAMSUNG_IMAGES = "I"; + + public const string SAMSUNG_VIDEO = "V"; + + private static readonly Random idGen = new Random(); + + private readonly IItemComparer comparer; + private readonly List filters = new List(); + + private readonly Dictionary hardRefs = + new Dictionary(); + + private readonly Dictionary ids = + new Dictionary(); + + private readonly bool order; + + private readonly List views = new List(); + + private Dictionary paths = + new Dictionary(); + + public bool HasViews => views.Count != 0; + + public IEnumerable Resources => (from i in ids.Values + where i.Target is IMediaResource + select i).ToList(); + + public Identifiers(IItemComparer comparer, bool order) + { + this.comparer = comparer; + this.order = order; + } + + public void AddView(string name) + { + try + { + var view = ViewRepository.Lookup(name); + views.Add(view); + var filter = view as IFilteredView; + if (filter != null) + { + filters.Add(filter); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to add view Ex [{ ex }]"); + throw; + } + } + + public bool Allowed(IMediaResource item) + { + return filters.All(f => f.Allowed(item)); + } + + public void Cleanup() + { + GC.Collect(); + var pc = paths.Count; + var ic = ids.Count; + var npaths = new Dictionary(); + foreach (var p in paths) + { + if (ids[p.Value].Target == null) + { + ids.Remove(p.Value); + } + else + { + npaths.Add(p.Key, p.Value); + } + } + paths = npaths; + Trace.WriteLine($"Cleanup complete: ids (evicted) {ids.Count} ({ (ic - ids.Count) }), paths {paths.Count} ({ (pc - paths.Count)})"); + } + + public IMediaItem GetItemById(string id) + { + return ids[id].Target as IMediaItem; + } + + public IMediaItem GetItemByPath(string path) + { + string id; + if (!paths.TryGetValue(path, out id)) + { + return null; + } + return GetItemById(id); + } + + public IMediaFolder RegisterFolder(string id, IMediaFolder item) + { + var rv = item; + RegisterFolderTree(rv); + foreach (var v in views) + { + rv = v.Transform(rv); + RegisterFolderTree(rv); + } + rv.Cleanup(); + ids[id] = new WeakReference(rv); + hardRefs[id] = rv; + rv.Id = id; + rv.Sort(comparer, order); + return rv; + } + + private void RegisterFolderTree(IMediaFolder folder) + { + foreach (var f in folder.ChildFolders) + { + RegisterFolderTree(f); + } + foreach (var i in folder.ChildItems) + { + RegisterPath(i); + } + RegisterPath(folder); + } + + private void RegisterPath(IMediaItem item) + { + var path = item.Path; + string id; + if (!paths.ContainsKey(path)) + { + while (ids.ContainsKey(id = idGen.Next(1000, int.MaxValue).ToString("X8"))) + { + } + paths[path] = id; + } + else + { + id = paths[path]; + } + ids[id] = new WeakReference(item); + + item.Id = id; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/MediaResourceDecorator.cs b/Roadie.Dlna/Server/Types/MediaResourceDecorator.cs new file mode 100644 index 0000000..952227d --- /dev/null +++ b/Roadie.Dlna/Server/Types/MediaResourceDecorator.cs @@ -0,0 +1,61 @@ +using Roadie.Dlna.Server.Metadata; +using System; +using System.IO; + +namespace Roadie.Dlna.Server +{ + internal class MediaResourceDecorator : IMediaResource, IMetaInfo + where T : IMediaResource, IMetaInfo + { + protected T Resource; + + public virtual IMediaCoverResource Cover => Resource.Cover; + + public string Id + { + get { return Resource.Id; } + set { Resource.Id = value; } + } + + public DateTime InfoDate => Resource.InfoDate; + + public long? InfoSize => Resource.InfoSize; + + public virtual DlnaMediaTypes MediaType => Resource.MediaType; + + public string Path => Resource.Path; + + public virtual string PN => Resource.PN; + + public virtual IHeaders Properties => Resource.Properties; + + public virtual string Title => Resource.Title; + + public DlnaMime Type => Resource.Type; + + public MediaResourceDecorator(T resource) + { + Resource = resource; + } + + public virtual int CompareTo(IMediaItem other) + { + return Resource.CompareTo(other); + } + + public virtual Stream CreateContentStream() + { + return Resource.CreateContentStream(); + } + + public bool Equals(IMediaItem other) + { + return Resource.Equals(other); + } + + public string ToComparableTitle() + { + return Resource.ToComparableTitle(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/MediaTypes.cs b/Roadie.Dlna/Server/Types/MediaTypes.cs new file mode 100644 index 0000000..e69de29 diff --git a/Roadie.Dlna/Server/Types/RawHeaders.cs b/Roadie.Dlna/Server/Types/RawHeaders.cs new file mode 100644 index 0000000..e8932f4 --- /dev/null +++ b/Roadie.Dlna/Server/Types/RawHeaders.cs @@ -0,0 +1,10 @@ +namespace Roadie.Dlna.Server +{ + public class RawHeaders : Headers + { + public RawHeaders() + : base(true) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/SubTitle.cs b/Roadie.Dlna/Server/Types/SubTitle.cs new file mode 100644 index 0000000..2394275 --- /dev/null +++ b/Roadie.Dlna/Server/Types/SubTitle.cs @@ -0,0 +1,184 @@ +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +namespace Roadie.Dlna.Server +{ + [Serializable] + public sealed class Subtitle : IMediaResource + { + [NonSerialized] + private static readonly string[] exts = + { + ".srt", ".SRT", + ".ass", ".ASS", + ".ssa", ".SSA", + ".sub", ".SUB", + ".vtt", ".VTT" + }; + + [NonSerialized] private byte[] encodedText; + + private string text; + + public IMediaCoverResource Cover + { + get { throw new NotImplementedException(); } + } + + public bool HasSubtitle => !string.IsNullOrWhiteSpace(text); + + public string Id + { + get { return Path; } + set { throw new NotImplementedException(); } + } + + public DateTime InfoDate => DateTime.UtcNow; + + public long? InfoSize + { + get + { + try + { + using (var s = CreateContentStream()) + { + return s.Length; + } + } + catch (Exception) + { + return null; + } + } + } + + public DlnaMediaTypes MediaType + { + get { throw new NotImplementedException(); } + } + + public string Path => "ad-hoc-subtitle:"; + + public string PN => DlnaMaps.MainPN[Type]; + + public IHeaders Properties + { + get + { + var rv = new RawHeaders { { "Type", Type.ToString() } }; + if (InfoSize.HasValue) + { + rv.Add("SizeRaw", InfoSize.ToString()); + rv.Add("Size", InfoSize.Value.FormatFileSize()); + } + rv.Add("Date", InfoDate.ToString(CultureInfo.InvariantCulture)); + rv.Add("DateO", InfoDate.ToString("o")); + return rv; + } + } + + public string Title + { + get { throw new NotImplementedException(); } + } + + public DlnaMime Type => DlnaMime.SubtitleSRT; + + public Subtitle() + { + } + + public Subtitle(FileInfo file) + { + Load(file); + } + + public Subtitle(string text) + { + this.text = text; + } + + public int CompareTo(IMediaItem other) + { + throw new NotImplementedException(); + } + + public Stream CreateContentStream() + { + if (!HasSubtitle) + { + throw new NotSupportedException(); + } + if (encodedText == null) + { + encodedText = Encoding.UTF8.GetBytes(text); + } + return new MemoryStream(encodedText, false); + } + + public bool Equals(IMediaItem other) + { + throw new NotImplementedException(); + } + + public string ToComparableTitle() + { + throw new NotImplementedException(); + } + + private void Load(FileInfo file) + { + try + { + // Try external + foreach (var i in exts) + { + var sti = new FileInfo( + System.IO.Path.ChangeExtension(file.FullName, i)); + try + { + if (!sti.Exists) + { + sti = new FileInfo(file.FullName + i); + } + if (!sti.Exists) + { + continue; + } + text = FFmpeg.GetSubtitleSubrip(sti); + Trace.WriteLine($"Loaded subtitle from {sti.FullName}"); + } + catch (NotSupportedException) + { + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to get subtitle from {sti.FullName} Ex [{ ex }]"); + } + } + try + { + text = FFmpeg.GetSubtitleSubrip(file); + Trace.WriteLine($"Loaded subtitle from {file.FullName}"); + } + catch (NotSupportedException ex) + { + Trace.WriteLine($"Subtitle not supported {file.FullName} Ex [{ ex }]"); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to get subtitle from {file.FullName} Ex [{ ex }]"); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to load subtitle for {file.FullName} Ex [{ ex }]"); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/UpnpDevice.cs b/Roadie.Dlna/Server/Types/UpnpDevice.cs new file mode 100644 index 0000000..492ed28 --- /dev/null +++ b/Roadie.Dlna/Server/Types/UpnpDevice.cs @@ -0,0 +1,36 @@ +using System; +using System.Net; + +namespace Roadie.Dlna.Server +{ + internal sealed class UpnpDevice + { + public readonly IPAddress Address; + + public readonly Uri Descriptor; + + public readonly string Type; + + public readonly string USN; + + public readonly Guid UUID; + + public UpnpDevice(Guid uuid, string type, Uri descriptor, + IPAddress address) + { + UUID = uuid; + Type = type; + Descriptor = descriptor; + Address = address; + + if (Type.StartsWith("uuid:", StringComparison.Ordinal)) + { + USN = Type; + } + else + { + USN = $"uuid:{UUID}::{Type}"; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/VirtualClonedFolder.cs b/Roadie.Dlna/Server/Types/VirtualClonedFolder.cs new file mode 100644 index 0000000..c76a6e4 --- /dev/null +++ b/Roadie.Dlna/Server/Types/VirtualClonedFolder.cs @@ -0,0 +1,59 @@ +namespace Roadie.Dlna.Server +{ + public sealed class VirtualClonedFolder : VirtualFolder + { + private readonly IMediaFolder clone; + + private readonly DlnaMediaTypes types; + + public VirtualClonedFolder(IMediaFolder parent) + : this(parent, parent.Id, parent.Id, DlnaMediaTypes.All) + { + } + + public VirtualClonedFolder(IMediaFolder parent, string name) + : this(parent, name, name, DlnaMediaTypes.All) + { + } + + public VirtualClonedFolder(IMediaFolder parent, string name, + DlnaMediaTypes types) + : this(parent, name, name, types) + { + } + + private VirtualClonedFolder(IMediaFolder parent, string name, string id, + DlnaMediaTypes types) + : base(parent, name, id) + { + this.types = types; + Id = id; + clone = parent; + CloneFolder(this, parent); + Cleanup(); + } + + public override void Cleanup() + { + base.Cleanup(); + clone.Cleanup(); + } + + private void CloneFolder(VirtualFolder parent, IMediaFolder folder) + { + foreach (var f in folder.ChildFolders) + { + var vf = new VirtualFolder(parent, f.Title, f.Id); + parent.AdoptFolder(vf); + CloneFolder(vf, f); + } + foreach (var i in folder.ChildItems) + { + if ((types & i.MediaType) == i.MediaType) + { + parent.AddResource(i); + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Types/VirtualFolder.cs b/Roadie.Dlna/Server/Types/VirtualFolder.cs new file mode 100644 index 0000000..1b4a991 --- /dev/null +++ b/Roadie.Dlna/Server/Types/VirtualFolder.cs @@ -0,0 +1,214 @@ +using Roadie.Dlna.Utility; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Roadie.Dlna.Server +{ + public class VirtualFolder : IMediaFolder + { + protected List Folders = new List(); + + protected List Resources = new List(); + + private static readonly StringComparer comparer = + new NaturalStringComparer(true); + + private readonly List merged = new List(); + private string comparableTitle; + private string path; + + public IEnumerable AllItems + { + get + { + return Folders.SelectMany(f => ((VirtualFolder)f).AllItems).Concat(Resources); + } + } + + public int ChildCount => Folders.Count + Resources.Count; + + public IEnumerable ChildFolders => Folders; + + public IEnumerable ChildItems => Resources; + + public int FullChildCount => Resources.Count + (from f in Folders select f.FullChildCount).Sum(); + + public string Id { get; set; } + + public string Name { get; set; } + + public IMediaFolder Parent { get; set; } + + public virtual string Path + { + get + { + if (!string.IsNullOrEmpty(path)) + { + return path; + } + var p = string.IsNullOrEmpty(Id) ? Name : Id; + if (Parent != null) + { + var vp = Parent as VirtualFolder; + path = $"{(vp != null ? vp.Path : Parent.Id)}/v:{p}"; + } + else + { + path = p; + } + return path; + } + } + + public IHeaders Properties + { + get + { + var rv = new RawHeaders { { "Title", Title } }; + return rv; + } + } + + public virtual string Title => Name; + + public VirtualFolder() + { + } + + public VirtualFolder(IMediaFolder parent, string name) + : this(parent, name, name) + { + } + + public VirtualFolder(IMediaFolder parent, string name, string id) + { + Parent = parent; + Id = id; + Name = name; + } + + public void AddResource(IMediaResource res) + { + Resources.Add(res); + } + + public void AddFolder(IMediaFolder folder) + { + Folders.Add(folder); + } + + public void AddFolders(IEnumerable folders) + { + Folders.AddRange(folders); + } + + public void AdoptFolder(IMediaFolder folder) + { + if (folder == null) + { + throw new ArgumentNullException(nameof(folder)); + } + var vf = folder.Parent as VirtualFolder; + vf?.ReleaseFolder(folder); + folder.Parent = this; + if (!Folders.Contains(folder)) + { + Folders.Add(folder); + } + } + + public virtual void Cleanup() + { + foreach (var m in merged) + { + m.Cleanup(); + } + foreach (var f in Folders.ToList()) + { + f.Cleanup(); + } + if (ChildCount != 0) + { + return; + } + var vp = Parent as VirtualFolder; + vp?.ReleaseFolder(this); + } + + public int CompareTo(IMediaItem other) + { + if (other == null) + { + return 1; + } + return comparer.Compare(Title, other.Title); + } + + public bool Equals(IMediaItem other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + return Title.Equals(other.Title); + } + + public void Merge(IMediaFolder folder) + { + if (folder == null) + { + throw new ArgumentNullException(nameof(folder)); + } + merged.Add(folder); + foreach (var item in folder.ChildItems) + { + AddResource(item); + } + foreach (var cf in folder.ChildFolders) + { + var ownFolder = (from f in Folders + where f is VirtualFolder && f.Title == cf.Title + select f as VirtualFolder + ).FirstOrDefault(); + if (ownFolder == null) + { + ownFolder = new VirtualFolder(this, cf.Title, cf.Id); + AdoptFolder(ownFolder); + } + ownFolder.Merge(cf); + } + } + + public void ReleaseFolder(IMediaFolder folder) + { + Folders.Remove(folder); + } + + public bool RemoveResource(IMediaResource res) + { + return Resources.Remove(res); + } + + public void Sort(IComparer sortComparer, bool descending) + { + foreach (var f in Folders) + { + f.Sort(sortComparer, descending); + } + Folders.Sort(sortComparer); + Resources.Sort(sortComparer); + if (descending) + { + Folders.Reverse(); + Resources.Reverse(); + } + } + + public string ToComparableTitle() + { + return comparableTitle ?? (comparableTitle = Title.StemCompareBase()); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/BaseView.cs b/Roadie.Dlna/Server/Views/BaseView.cs new file mode 100644 index 0000000..38e75d2 --- /dev/null +++ b/Roadie.Dlna/Server/Views/BaseView.cs @@ -0,0 +1,47 @@ +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal abstract class BaseView : IView + { + public abstract string Description { get; } + + public abstract string Name { get; } + + public override string ToString() + { + return $"{Name} - {Description}"; + } + + public abstract IMediaFolder Transform(IMediaFolder oldRoot); + + protected static void MergeFolders(VirtualFolder aFrom, VirtualFolder aTo) + { + var merges = from f in aFrom.ChildFolders + join t in aTo.ChildFolders on f.Title.ToUpper() equals t.Title.ToUpper() + where f != t + select new + { + f = f as VirtualFolder, + t = t as VirtualFolder + }; + foreach (var m in merges.ToList()) + { + MergeFolders(m.f, m.t); + foreach (var c in m.f.ChildFolders.ToList()) + { + m.t.AdoptFolder(c); + } + foreach (var c in m.f.ChildItems.ToList()) + { + m.t.AddResource(c); + m.f.RemoveResource(c); + } + if (aFrom != aTo) + { + ((VirtualFolder)m.f.Parent).ReleaseFolder(m.f); + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/ByTitleView.cs b/Roadie.Dlna/Server/Views/ByTitleView.cs new file mode 100644 index 0000000..f62314d --- /dev/null +++ b/Roadie.Dlna/Server/Views/ByTitleView.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class ByTitleView : BaseView + { + public override string Description => "Reorganizes files into folders by title"; + + public override string Name => "bytitle"; + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + var root = new VirtualClonedFolder(oldRoot); + var titles = new SimpleKeyedVirtualFolder(root, "titles"); + SortFolder(root, titles); + foreach (var i in root.ChildFolders.ToList()) + { + root.ReleaseFolder(i); + } + foreach (var i in titles.ChildFolders.ToList()) + { + if (i.ChildCount > 100) + { + Trace.WriteLine($"Partioning folder {i.Title}"); + using (var prefixer = new Prefixer()) + { + PartitionChildren(i as VirtualFolder, prefixer); + } + } + root.AdoptFolder(i); + } + return root; + } + + private static string GetTitle(IMediaResource res) + { + var pre = res.ToComparableTitle(); + if (string.IsNullOrEmpty(pre)) + { + return "Unnamed"; + } + return pre; + } + + private static void SortFolder(VirtualFolder folder, SimpleKeyedVirtualFolder titles) + { + foreach (var f in folder.ChildFolders.ToList()) + { + SortFolder(f as VirtualFolder, titles); + } + + foreach (var c in folder.ChildItems.ToList()) + { + var pre = GetTitle(c); + pre = pre[0].ToString().ToUpperInvariant(); + titles.GetFolder(pre).AddResource(c); + folder.RemoveResource(c); + } + } + + private void PartitionChildren(VirtualFolder folder, Prefixer prefixer, int startfrom = 1) + { + for (var wordcount = startfrom; ;) + { + var curwc = wordcount; + var groups = from i in folder.ChildItems.ToList() + let prefix = prefixer.GetWordPrefix(GetTitle(i), curwc) + where !string.IsNullOrWhiteSpace(prefix) + group i by prefix.ToLowerInvariant() + into g + let gcount = g.LongCount() + where gcount > 3 + orderby g.LongCount() descending + select g; + var longest = groups.FirstOrDefault(); + if (longest == null) + { + if (wordcount++ > 5) + { + return; + } + continue; + } + var newfolder = new VirtualFolder(folder, longest.Key); + foreach (var item in longest) + { + folder.RemoveResource(item); + newfolder.AddResource(item); + } + if (newfolder.ChildCount > 100) + { + PartitionChildren(newfolder, prefixer, wordcount + 1); + } + if (newfolder.ChildFolders.LongCount() == 1) + { + foreach (var f in newfolder.ChildFolders.ToList()) + { + folder.AdoptFolder(f); + } + } + else + { + folder.AdoptFolder(newfolder); + } + } + } + + private sealed class Prefixer : IDisposable + { + private static readonly Regex numbers = new Regex(@"[\d+._()\[\]+-]+", RegexOptions.Compiled); + private static readonly Regex wordsplit = new Regex(@"(\b[^\s]+\b)", RegexOptions.Compiled); + private readonly Dictionary cache = new Dictionary(); + + public void Dispose() + { + cache.Clear(); + } + + public string GetWordPrefix(string str, int wordcount) + { + string[] m; + var key = str.ToUpperInvariant(); + if (!cache.TryGetValue(key, out m)) + { + m = (from w in wordsplit.Matches(str).Cast() + let v = numbers.Replace(w.Value, "").Trim() + where !string.IsNullOrWhiteSpace(v) + select v).ToArray(); + cache[key] = m; + } + if (m.Length < wordcount) + { + return null; + } + return string.Join(" ", m.Take(wordcount).ToArray()); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/CascadedView.cs b/Roadie.Dlna/Server/Views/CascadedView.cs new file mode 100644 index 0000000..fd64020 --- /dev/null +++ b/Roadie.Dlna/Server/Views/CascadedView.cs @@ -0,0 +1,65 @@ +using Roadie.Dlna.Utility; +using System.Diagnostics; +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal abstract class CascadedView : BaseView, IConfigurable + { + private bool cascade = true; + + public void SetParameters(ConfigParameters parameters) + { + cascade = !parameters.Has("no-cascade") && parameters.Get("cascade", cascade); + } + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + var root = new VirtualClonedFolder(oldRoot); + var sorted = new SimpleKeyedVirtualFolder(root, Name); + SortFolder(root, sorted); + Trace.WriteLine($"sort {sorted.ChildFolders.Count()} - {sorted.ChildItems.Count()}"); + Trace.WriteLine($"root {root.ChildFolders.Count()} - {root.ChildItems.Count()}"); + foreach (var f in sorted.ChildFolders.ToList()) + { + if (f.ChildCount < 2) + { + foreach (var file in f.ChildItems) + { + root.AddResource(file); + } + continue; + } + var fsmi = f as VirtualFolder; + root.AdoptFolder(fsmi); + } + foreach (var f in sorted.ChildItems.ToList()) + { + root.AddResource(f); + } + Trace.WriteLine($"merg {root.ChildFolders.Count()} - {root.ChildItems.Count()}"); + MergeFolders(root, root); + Trace.WriteLine($"done {root.ChildFolders.Count()} - {root.ChildItems.Count()}"); + + if (!cascade || root.ChildFolders.LongCount() <= 50) + { + return root; + } + var cascaded = new DoubleKeyedVirtualFolder(root, "Series"); + foreach (var i in root.ChildFolders.ToList()) + { + var folder = cascaded.GetFolder(i.Title.StemCompareBase().Substring(0, 1).ToUpper()); + folder.AdoptFolder(i); + } + foreach (var i in root.ChildItems.ToList()) + { + var folder = cascaded.GetFolder(i.Title.StemCompareBase().Substring(0, 1).ToUpper()); + folder.AddResource(i); + } + return cascaded; + } + + protected abstract void SortFolder(IMediaFolder folder, + SimpleKeyedVirtualFolder series); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/DimensionView.cs b/Roadie.Dlna/Server/Views/DimensionView.cs new file mode 100644 index 0000000..3d50863 --- /dev/null +++ b/Roadie.Dlna/Server/Views/DimensionView.cs @@ -0,0 +1,85 @@ +using Roadie.Dlna.Server.Metadata; +using Roadie.Dlna.Utility; +using System; + +namespace Roadie.Dlna.Server.Views +{ + internal class DimensionView : FilteringView, IConfigurable + { + private uint? max; + + private uint? maxHeight; + + private uint? maxWidth; + + private uint? min; + + private uint? minHeight; + + private uint? minWidth; + + public override string Description => "Show only items of a certain dimension"; + + public override string Name => "dimension"; + + public override bool Allowed(IMediaResource res) + { + var i = res as IMetaResolution; + if (i?.MetaWidth == null || !i.MetaHeight.HasValue) + { + return false; + } + var w = i.MetaWidth.Value; + var h = i.MetaHeight.Value; + if (min.HasValue && Math.Min(w, h) < min.Value) + { + return false; + } + if (max.HasValue && Math.Max(w, h) > max.Value) + { + return false; + } + if (minWidth.HasValue && w < minWidth.Value) + { + return false; + } + if (maxWidth.HasValue && w > maxWidth.Value) + { + return false; + } + if (minHeight.HasValue && h < minHeight.Value) + { + return false; + } + if (maxHeight.HasValue && h > maxHeight.Value) + { + return false; + } + return true; + } + + public void SetParameters(ConfigParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + min = parameters.MaybeGet("min"); + max = parameters.MaybeGet("max"); + minWidth = parameters.MaybeGet("minwidth"); + maxWidth = parameters.MaybeGet("maxwidth"); + minHeight = parameters.MaybeGet("minheight"); + maxHeight = parameters.MaybeGet("maxheight"); + } + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + if (!min.HasValue && !max.HasValue && !minWidth.HasValue && + !maxWidth.HasValue && !minHeight.HasValue && !maxHeight.HasValue) + { + return oldRoot; + } + return base.Transform(oldRoot); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/DoubleKeyedVirtualFolder.cs b/Roadie.Dlna/Server/Views/DoubleKeyedVirtualFolder.cs new file mode 100644 index 0000000..6f75bf4 --- /dev/null +++ b/Roadie.Dlna/Server/Views/DoubleKeyedVirtualFolder.cs @@ -0,0 +1,15 @@ +namespace Roadie.Dlna.Server.Views +{ + internal class DoubleKeyedVirtualFolder + : KeyedVirtualFolder + { + public DoubleKeyedVirtualFolder() + { + } + + public DoubleKeyedVirtualFolder(IMediaFolder aParent, string aName) + : base(aParent, aName) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/FilterView.cs b/Roadie.Dlna/Server/Views/FilterView.cs new file mode 100644 index 0000000..90d270b --- /dev/null +++ b/Roadie.Dlna/Server/Views/FilterView.cs @@ -0,0 +1,69 @@ +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server.Views +{ + internal class FilterView : FilteringView, IConfigurable + { + private static readonly string[] escapes = "\\.+|[]{}()$#^".ToArray().Select(c => new string(c, 1)).ToArray(); + private Regex filter; + + public override string Description => "Show only files matching a specific filter"; + + public override string Name => "filter"; + + public override bool Allowed(IMediaResource res) + { + if (res == null) + { + throw new ArgumentNullException(nameof(res)); + } + if (filter == null) + { + return true; + } + return filter.IsMatch(res.Title) || filter.IsMatch(res.Path); + } + + public void SetParameters(ConfigParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + var filters = from f in parameters.Keys + let e = Escape(f) + select e; + filter = new Regex( + string.Join("|", filters), + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + Trace.WriteLine($"Using filter { filter.ToString() }"); + } + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + if (filter == null) + { + return oldRoot; + } + return base.Transform(oldRoot); + } + + private static string Escape(string str) + { + str = escapes.Aggregate(str, (current, cs) => current.Replace(cs, "\\" + cs)); + if (str.Contains('*') || str.Contains("?")) + { + str = $"^{str}$"; + str = str.Replace("*", ".*"); + str = str.Replace("?", "."); + } + return str; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/FilteringView.cs b/Roadie.Dlna/Server/Views/FilteringView.cs new file mode 100644 index 0000000..b246f94 --- /dev/null +++ b/Roadie.Dlna/Server/Views/FilteringView.cs @@ -0,0 +1,32 @@ +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal abstract class FilteringView : BaseView, IFilteredView + { + public abstract bool Allowed(IMediaResource item); + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + oldRoot = new VirtualClonedFolder(oldRoot); + ProcessFolder(oldRoot); + return oldRoot; + } + + private void ProcessFolder(IMediaFolder root) + { + foreach (var f in root.ChildFolders) + { + ProcessFolder(f); + } + foreach (var f in root.ChildItems.ToList()) + { + if (Allowed(f)) + { + continue; + } + root.RemoveResource(f); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/FlattenView.cs b/Roadie.Dlna/Server/Views/FlattenView.cs new file mode 100644 index 0000000..a701617 --- /dev/null +++ b/Roadie.Dlna/Server/Views/FlattenView.cs @@ -0,0 +1,72 @@ +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class FlattenView : BaseView + { + public override string Description => "Removes empty intermediate folders and flattens folders with only few files"; + + public override string Name => "flatten"; + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + var r = new VirtualClonedFolder(oldRoot); + var cross = from f in r.ChildFolders + from t in r.ChildFolders + where f != t + orderby f.Title, t.Title + select new + { + f = f as VirtualFolder, + t = t as VirtualFolder + }; + foreach (var c in cross) + { + MergeFolders(c.f, c.t); + } + + TransformInternal(r, r); + MergeFolders(r, r); + return r; + } + + private static bool TransformInternal(VirtualFolder root, + VirtualFolder current) + { + foreach (var f in current.ChildFolders.ToList()) + { + var vf = f as VirtualFolder; + if (TransformInternal(root, vf)) + { + current.ReleaseFolder(vf); + } + } + + if (current == root || current.ChildItems.Count() > 3) + { + return false; + } + var newParent = (VirtualFolder)current.Parent; + foreach (var c in current.ChildItems.ToList()) + { + current.RemoveResource(c); + newParent.AddResource(c); + } + + if (current.ChildCount != 0) + { + MergeFolders(current, newParent); + foreach (var f in current.ChildFolders.ToList()) + { + newParent.AdoptFolder(f); + } + foreach (var f in current.ChildItems.ToList()) + { + current.RemoveResource(f); + newParent.AddResource(f); + } + } + return true; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/IFilteredView.cs b/Roadie.Dlna/Server/Views/IFilteredView.cs new file mode 100644 index 0000000..4885151 --- /dev/null +++ b/Roadie.Dlna/Server/Views/IFilteredView.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Server.Views +{ + public interface IFilteredView : IView + { + bool Allowed(IMediaResource item); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/IView.cs b/Roadie.Dlna/Server/Views/IView.cs new file mode 100644 index 0000000..da607fb --- /dev/null +++ b/Roadie.Dlna/Server/Views/IView.cs @@ -0,0 +1,8 @@ +using Roadie.Dlna.Utility; +namespace Roadie.Dlna.Server.Views +{ + public interface IView : IRepositoryItem + { + IMediaFolder Transform(IMediaFolder oldRoot); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/KeyedVirtualFolder.cs b/Roadie.Dlna/Server/Views/KeyedVirtualFolder.cs new file mode 100644 index 0000000..c9c4c5b --- /dev/null +++ b/Roadie.Dlna/Server/Views/KeyedVirtualFolder.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace Roadie.Dlna.Server.Views +{ + internal class KeyedVirtualFolder : VirtualFolder where T : VirtualFolder, new() + { + private readonly Dictionary keys = new Dictionary(StringComparer.CurrentCultureIgnoreCase); + + protected KeyedVirtualFolder() : this(null, null) + { + } + + protected KeyedVirtualFolder(IMediaFolder aParent, string aName) : base(aParent, aName) + { + } + + public T GetFolder(string key) + { + T rv; + if (!keys.TryGetValue(key, out rv)) + { + rv = new T + { + Name = key, + Parent = this + }; + Folders.Add(rv); + keys.Add(key, rv); + } + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/LargeView.cs b/Roadie.Dlna/Server/Views/LargeView.cs new file mode 100644 index 0000000..e559595 --- /dev/null +++ b/Roadie.Dlna/Server/Views/LargeView.cs @@ -0,0 +1,39 @@ +using Roadie.Dlna.Server.Metadata; +using Roadie.Dlna.Utility; +using System; + +namespace Roadie.Dlna.Server.Views +{ + internal class LargeView : FilteringView, IConfigurable + { + private long minSize = 300 * 1024 * 1024; + + public override string Description => "Show only large files"; + + public override string Name => "large"; + + public override bool Allowed(IMediaResource res) + { + var i = res as IMetaInfo; + if (i == null) + { + return false; + } + return i.InfoSize.HasValue && i.InfoSize.Value >= minSize; + } + + public void SetParameters(ConfigParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + long min; + if (parameters.TryGet("size", out min)) + { + minSize = min * 1024 * 1024; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/MusicView.cs b/Roadie.Dlna/Server/Views/MusicView.cs new file mode 100644 index 0000000..7a4238f --- /dev/null +++ b/Roadie.Dlna/Server/Views/MusicView.cs @@ -0,0 +1,114 @@ +using Roadie.Dlna.Utility; +using System.Globalization; +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class MusicView : BaseView + { + public override string Description => "Reorganizes files into a proper music collection"; + + public override string Name => "music"; + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + var root = new VirtualClonedFolder(oldRoot); + var artists = new TripleKeyedVirtualFolder(root, "Artists"); + var performers = new TripleKeyedVirtualFolder(root, "Performers"); + var albums = new DoubleKeyedVirtualFolder(root, "Albums"); + var genres = new SimpleKeyedVirtualFolder(root, "Genre"); + var folders = new VirtualFolder(root, "Folders"); + SortFolder(root, artists, performers, albums, genres); + foreach (var f in root.ChildFolders.ToList()) + { + folders.AdoptFolder(f); + } + root.AdoptFolder(artists); + root.AdoptFolder(performers); + root.AdoptFolder(albums); + root.AdoptFolder(genres); + root.AdoptFolder(folders); + return root; + } + + private static void LinkTriple(TripleKeyedVirtualFolder folder, IMediaAudioResource r, string key1,string key2) + { + if (string.IsNullOrWhiteSpace(key1)) + { + return; + } + if (string.IsNullOrWhiteSpace(key2)) + { + return; + } + var targetFolder = folder + .GetFolder(key1.StemCompareBase().First().ToString().ToUpper(CultureInfo.CurrentUICulture)) + .GetFolder(key1.StemNameBase()); + targetFolder + .GetFolder(key2.StemNameBase()) + .AddResource(r); + var allRes = new AlbumInTitleAudioResource(r); + targetFolder + .GetFolder("All Albums") + .AddResource(allRes); + } + + private static void SortFolder(VirtualFolder folder, TripleKeyedVirtualFolder artists, TripleKeyedVirtualFolder performers, + DoubleKeyedVirtualFolder albums, SimpleKeyedVirtualFolder genres) + { + foreach (var f in folder.ChildFolders.ToList()) + { + SortFolder(f as VirtualFolder, artists, performers, albums, genres); + } + foreach (var i in folder.ChildItems.ToList()) + { + var ai = i as IMediaAudioResource; + if (ai == null) + { + continue; + } + var album = ai.MetaAlbum ?? "Unspecified album"; + albums.GetFolder(album.StemCompareBase() + .First() + .ToString() + .ToUpper(CultureInfo.CurrentUICulture)) + .GetFolder(album.StemNameBase()) + .AddResource(i); + LinkTriple(artists, ai, ai.MetaArtist, album); + LinkTriple(performers, ai, ai.MetaPerformer, album); + var genre = ai.MetaGenre; + if (genre != null) + { + genres.GetFolder(genre.StemNameBase()).AddResource(i); + } + } + } + + private class AlbumInTitleAudioResource : AudioResourceDecorator + { + public override string Title + { + get + { + var album = MetaAlbum; + if (!string.IsNullOrWhiteSpace(album)) + { + return $"{album} — {base.Title}"; + } + return base.Title; + } + } + + public AlbumInTitleAudioResource(IMediaAudioResource resource) : base(resource) + { + } + } + + private class TripleKeyedVirtualFolder : KeyedVirtualFolder + { + public TripleKeyedVirtualFolder(IMediaFolder aParent, string aName) : base(aParent, aName) + { + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/NewView.cs b/Roadie.Dlna/Server/Views/NewView.cs new file mode 100644 index 0000000..16b68cb --- /dev/null +++ b/Roadie.Dlna/Server/Views/NewView.cs @@ -0,0 +1,42 @@ +using Roadie.Dlna.Server.Metadata; +using Roadie.Dlna.Utility; +using System; + +namespace Roadie.Dlna.Server.Views +{ + internal class NewView : FilteringView, IConfigurable + { + private DateTime minDate = DateTime.Now.AddDays(-7.0); + + public override string Description => "Show only new files"; + + public override string Name => "new"; + + public override bool Allowed(IMediaResource res) + { + var i = res as IMetaInfo; + if (i == null) + { + return false; + } + return i.InfoDate >= minDate; + } + + public void SetParameters(ConfigParameters parameters) + { + if (parameters == null) + { + throw new ArgumentNullException(nameof(parameters)); + } + + foreach (var v in parameters.GetValuesForKey("date")) + { + DateTime min; + if (DateTime.TryParse(v, out min)) + { + minDate = min; + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/PlainView.cs b/Roadie.Dlna/Server/Views/PlainView.cs new file mode 100644 index 0000000..af4209e --- /dev/null +++ b/Roadie.Dlna/Server/Views/PlainView.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class PlainView : BaseView + { + public override string Description => "Mushes all files together into the root folder"; + + public override string Name => "plain"; + + public override IMediaFolder Transform(IMediaFolder oldRoot) + { + if (oldRoot == null) + { + throw new ArgumentNullException(nameof(oldRoot)); + } + var rv = new VirtualFolder(null, oldRoot.Title, oldRoot.Id); + EatAll(rv, oldRoot); + return rv; + } + + private static void EatAll(IMediaFolder root, IMediaFolder folder) + { + foreach (var f in folder.ChildFolders.ToList()) + { + EatAll(root, f); + } + foreach (var c in folder.ChildItems.ToList()) + { + root.AddResource(c); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/SeriesView.cs b/Roadie.Dlna/Server/Views/SeriesView.cs new file mode 100644 index 0000000..33e88ec --- /dev/null +++ b/Roadie.Dlna/Server/Views/SeriesView.cs @@ -0,0 +1,52 @@ +using Roadie.Dlna.Utility; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class SeriesView : CascadedView + { + private static readonly Regex regSeries = new Regex( + @"^(.+?)(?:s\d+[\s_-]*e\d+|" + // S01E10 + @"\d+[\s_-]*x[\s_-]*\d+|" + // 1x01 + @"\b[\s-_]*(?:19|20|21)[0-9]{2}[\s._-](?:0[1-9]|1[012])[\s._-](?:0[1-9]|[12][0-9]|3[01])|" + // 2014.02.20 + @"\b[\s-_]*(?:0[1-9]|[12][0-9]|3[01])[\s._-](?:0[1-9]|1[012])[\s._-](?:19|20|21)[0-9]{2}|" + // 20.02.2014 (sane) + @"\b[\s-_]*(?:0[1-9]|1[012])[\s._-](?:0[1-9]|[12][0-9]|3[01])[\s._-](?:19|20|21)[0-9]{2}|" + // 02.20.2014 (US) + @"\b[1-9](?:0[1-9]|[1-3]\d)\b)", // 101 + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + public override string Description => "Try to determine (TV) series from title and categorize accordingly"; + + public override string Name => "series"; + + protected override void SortFolder(IMediaFolder folder, + SimpleKeyedVirtualFolder series) + { + foreach (var f in folder.ChildFolders.ToList()) + { + SortFolder(f, series); + } + foreach (var i in folder.ChildItems.ToList()) + { + var title = i.Title; + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + var m = regSeries.Match(title); + if (!m.Success) + { + continue; + } + var ser = m.Groups[1].Value; + if (string.IsNullOrEmpty(ser)) + { + continue; + } + series.GetFolder(ser.StemNameBase()).AddResource(i); + folder.RemoveResource(i); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/SimpleKeyedVirtualFolder.cs b/Roadie.Dlna/Server/Views/SimpleKeyedVirtualFolder.cs new file mode 100644 index 0000000..85d0fc5 --- /dev/null +++ b/Roadie.Dlna/Server/Views/SimpleKeyedVirtualFolder.cs @@ -0,0 +1,14 @@ +namespace Roadie.Dlna.Server.Views +{ + internal class SimpleKeyedVirtualFolder : KeyedVirtualFolder + { + public SimpleKeyedVirtualFolder() + { + } + + public SimpleKeyedVirtualFolder(IMediaFolder aParent, string aName) + : base(aParent, aName) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/SiteView.cs b/Roadie.Dlna/Server/Views/SiteView.cs new file mode 100644 index 0000000..fbb7aad --- /dev/null +++ b/Roadie.Dlna/Server/Views/SiteView.cs @@ -0,0 +1,71 @@ +using Roadie.Dlna.Utility; +using System; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Server.Views +{ + internal sealed class SiteView : CascadedView + { + private static readonly Regex regNumberStrip = new Regex(@"\d+$", RegexOptions.Compiled); + + private static readonly Regex regSites = new Regex( + @"^[\[\(](?.+?)[\]\)]|" + + @"^(?.+?)\s+-|" + + @"^(?.+?)[\[\]\(\)._-]|" + + @"^(?.+?)\s", + RegexOptions.Compiled + ); + + private static readonly Regex regWord = new Regex(@"\w", RegexOptions.Compiled); + + public override string Description => "Try to determine websites from title and categorize accordingly"; + + public override string Name => "sites"; + + protected override void SortFolder(IMediaFolder folder, + SimpleKeyedVirtualFolder series) + { + foreach (var f in folder.ChildFolders.ToList()) + { + SortFolder(f, series); + } + foreach (var i in folder.ChildItems.ToList()) + { + try + { + var title = i.Title; + if (string.IsNullOrWhiteSpace(title)) + { + throw new Exception("No title"); + } + var m = regSites.Match(title); + if (!m.Success) + { + throw new Exception("No match"); + } + var site = m.Groups["site"].Value; + if (string.IsNullOrEmpty(site)) + { + throw new Exception("No site"); + } + site = site.Replace(" ", "").Replace("\t", "").Replace("-", ""); + site = regNumberStrip.Replace(site, string.Empty).TrimEnd(); + if (!regWord.IsMatch(site)) + { + throw new Exception("Not a site"); + } + folder.RemoveResource(i); + series.GetFolder(site.StemNameBase()).AddResource(i); + } + catch (Exception ex) + { + Trace.WriteLine($"{ex.Message} - {i.Title}"); + folder.RemoveResource(i); + series.AddResource(i); + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Server/Views/ViewRepository.cs b/Roadie.Dlna/Server/Views/ViewRepository.cs new file mode 100644 index 0000000..e42b303 --- /dev/null +++ b/Roadie.Dlna/Server/Views/ViewRepository.cs @@ -0,0 +1,7 @@ +using Roadie.Dlna.Utility; +namespace Roadie.Dlna.Server.Views +{ + public sealed class ViewRepository : Repository + { + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/IThumbnail.cs b/Roadie.Dlna/Thumbnails/IThumbnail.cs new file mode 100644 index 0000000..b9d3342 --- /dev/null +++ b/Roadie.Dlna/Thumbnails/IThumbnail.cs @@ -0,0 +1,11 @@ +namespace Roadie.Dlna.Thumbnails +{ + public interface IThumbnail + { + int Height { get; } + + int Width { get; } + + byte[] GetData(); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/IThumbnailLoader.cs b/Roadie.Dlna/Thumbnails/IThumbnailLoader.cs new file mode 100644 index 0000000..00172dd --- /dev/null +++ b/Roadie.Dlna/Thumbnails/IThumbnailLoader.cs @@ -0,0 +1,12 @@ +using Roadie.Dlna.Server; +using System.IO; + +namespace Roadie.Dlna.Thumbnails +{ + internal interface IThumbnailLoader + { + DlnaMediaTypes Handling { get; } + + MemoryStream GetThumbnail(object item, ref int width, ref int height); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/ImageThumbnailLoader.cs b/Roadie.Dlna/Thumbnails/ImageThumbnailLoader.cs new file mode 100644 index 0000000..468757b --- /dev/null +++ b/Roadie.Dlna/Thumbnails/ImageThumbnailLoader.cs @@ -0,0 +1,56 @@ +using Roadie.Dlna.Server; +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; + +namespace Roadie.Dlna.Thumbnails +{ + internal sealed class ImageThumbnailLoader : IThumbnailLoader + { + public DlnaMediaTypes Handling => DlnaMediaTypes.Image; + + public MemoryStream GetThumbnail(object item, ref int width, + ref int height) + { + Image img; + var stream = item as Stream; + if (stream != null) + { + img = Image.FromStream(stream); + } + else + { + var fi = item as FileInfo; + if (fi != null) + { + img = Image.FromFile(fi.FullName); + } + else + { + throw new NotSupportedException(); + } + } + using (img) + { + using (var scaled = ThumbnailMaker.ResizeImage( + img, width, height, ThumbnailMakerBorder.Borderless)) + { + width = scaled.Width; + height = scaled.Height; + var rv = new MemoryStream(); + try + { + scaled.Save(rv, ImageFormat.Jpeg); + return rv; + } + catch (Exception) + { + rv.Dispose(); + throw; + } + } + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/Thumbnail.cs b/Roadie.Dlna/Thumbnails/Thumbnail.cs new file mode 100644 index 0000000..57b1669 --- /dev/null +++ b/Roadie.Dlna/Thumbnails/Thumbnail.cs @@ -0,0 +1,23 @@ +namespace Roadie.Dlna.Thumbnails +{ + internal sealed class Thumbnail : IThumbnail + { + private readonly byte[] data; + + public int Height { get; } + + public int Width { get; } + + internal Thumbnail(int width, int height, byte[] data) + { + Width = width; + Height = height; + this.data = data; + } + + public byte[] GetData() + { + return data; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/ThumbnailMaker.cs b/Roadie.Dlna/Thumbnails/ThumbnailMaker.cs new file mode 100644 index 0000000..3d8fef3 --- /dev/null +++ b/Roadie.Dlna/Thumbnails/ThumbnailMaker.cs @@ -0,0 +1,208 @@ +using Roadie.Dlna.Server; +using Roadie.Dlna.Utility; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Roadie.Dlna.Thumbnails +{ + public sealed class ThumbnailMaker + { + private static readonly LeastRecentlyUsedDictionary cache = new LeastRecentlyUsedDictionary(1 << 11); + + private static readonly Dictionary> thumbers = BuildThumbnailers(); + + public IThumbnail GetThumbnail(FileSystemInfo file, int width, int height) + { + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + var ext = file.Extension.ToUpperInvariant().Substring(1); + var mediaType = DlnaMaps.Ext2Media[ext]; + + var key = file.FullName; + byte[] rv; + if (GetThumbnailFromCache(ref key, ref width, ref height, out rv)) + { + return new Thumbnail(width, height, rv); + } + + rv = GetThumbnailInternal(key, file, mediaType, ref width, ref height); + return new Thumbnail(width, height, rv); + } + + public IThumbnail GetThumbnail(string key, DlnaMediaTypes type, + Stream stream, int width, int height) + { + byte[] rv; + if (GetThumbnailFromCache(ref key, ref width, ref height, out rv)) + { + return new Thumbnail(width, height, rv); + } + rv = GetThumbnailInternal(key, stream, type, ref width, ref height); + return new Thumbnail(width, height, rv); + } + + internal static Image ResizeImage(Image image, int width, int height, + ThumbnailMakerBorder border) + { + var nw = (float)image.Width; + var nh = (float)image.Height; + if (nw > width) + { + nh = width * nh / nw; + nw = width; + } + if (nh > height) + { + nw = height * nw / nh; + nh = height; + } + + var result = new Bitmap( + border == ThumbnailMakerBorder.Bordered ? width : (int)nw, + border == ThumbnailMakerBorder.Bordered ? height : (int)nh + ); + try + { + try + { + result.SetResolution(image.HorizontalResolution, image.VerticalResolution); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to set resolution Ex [{ ex }]"); + } + using (var graphics = Graphics.FromImage(result)) + { + if (result.Width > image.Width && result.Height > image.Height) + { + graphics.CompositingQuality = + CompositingQuality.HighQuality; + graphics.InterpolationMode = + InterpolationMode.High; + } + else + { + graphics.CompositingQuality = + CompositingQuality.HighSpeed; + graphics.InterpolationMode = InterpolationMode.Bicubic; + } + var rect = new Rectangle( + (int)(result.Width - nw) / 2, + (int)(result.Height - nh) / 2, + (int)nw, (int)nh + ); + graphics.SmoothingMode = SmoothingMode.HighSpeed; + graphics.FillRectangle( + Brushes.Black, new Rectangle(0, 0, result.Width, result.Height)); + graphics.DrawImage(image, rect); + } + return result; + } + catch (Exception) + { + result.Dispose(); + throw; + } + } + + private static Dictionary> BuildThumbnailers() + { + var types = Enum.GetValues(typeof(DlnaMediaTypes)); + var buildThumbnailers = types.Cast().ToDictionary(i => i, i => new List()); + var a = Assembly.GetExecutingAssembly(); + foreach (var t in a.GetTypes()) + { + if (t.GetInterface("IThumbnailLoader") == null) + { + continue; + } + var ctor = t.GetConstructor(new Type[] { }); + var thumber = ctor?.Invoke(new object[] { }) as IThumbnailLoader; + if (thumber == null) + { + continue; + } + foreach (DlnaMediaTypes i in types) + { + if (thumber.Handling.HasFlag(i)) + { + buildThumbnailers[i].Add(thumber); + } + } + } + return buildThumbnailers; + } + + private static bool GetThumbnailFromCache(ref string key, ref int width, + ref int height, out byte[] rv) + { + key = $"{width}x{height} {key}"; + lock (cache) + { + CacheItem ci; + if (cache.TryGetValue(key, out ci)) + { + rv = ci.Data; + width = ci.Width; + height = ci.Height; + return true; + } + } + rv = null; + return false; + } + + private byte[] GetThumbnailInternal(string key, object item, + DlnaMediaTypes type, ref int width, + ref int height) + { + var thumbnailers = thumbers[type]; + var rw = width; + var rh = height; + foreach (var thumber in thumbnailers) + { + try + { + using (var i = thumber.GetThumbnail(item, ref width, ref height)) + { + var rv = i.ToArray(); + lock (cache) + { + cache[key] = new CacheItem(rv, rw, rh); + } + return rv; + } + } + catch (Exception ex) + { + Trace.WriteLine($"{thumber.GetType()} failed to thumbnail a resource Ex [{ ex }]"); + } + } + throw new ArgumentException("Not a supported resource"); + } + + private struct CacheItem + { + public readonly byte[] Data; + + public readonly int Height; + + public readonly int Width; + + public CacheItem(byte[] aData, int aWidth, int aHeight) + { + Data = aData; + Width = aWidth; + Height = aHeight; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Thumbnails/ThumbnailMakerBorder.cs b/Roadie.Dlna/Thumbnails/ThumbnailMakerBorder.cs new file mode 100644 index 0000000..6e09375 --- /dev/null +++ b/Roadie.Dlna/Thumbnails/ThumbnailMakerBorder.cs @@ -0,0 +1,8 @@ +namespace Roadie.Dlna.Thumbnails +{ + internal enum ThumbnailMakerBorder + { + Bordered, + Borderless + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/AttributeCollection.cs b/Roadie.Dlna/Utility/AttributeCollection.cs new file mode 100644 index 0000000..a7a03cb --- /dev/null +++ b/Roadie.Dlna/Utility/AttributeCollection.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Roadie.Dlna.Utility +{ + using Attribute = KeyValuePair; + + public class AttributeCollection : IEnumerable + { + private readonly IList list = new List(); + + public int Count => list.Count; + + public ICollection Keys => (from i in list + select i.Key).ToList(); + + public ICollection Values => (from i in list + select i.Value).ToList(); + + public void Add(Attribute item) + { + list.Add(item); + } + + public void Add(string key, string value) + { + list.Add(new Attribute(key, value)); + } + + public void Clear() + { + list.Clear(); + } + + public bool Contains(Attribute item) + { + return list.Contains(item); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return list.GetEnumerator(); + } + + public IEnumerator GetEnumerator() + { + return list.GetEnumerator(); + } + + public IEnumerable GetValuesForKey(string key) + { + return GetValuesForKey(key, StringComparer.CurrentCultureIgnoreCase); + } + + public IEnumerable GetValuesForKey(string key, StringComparer comparer) + { + return from i in list + where comparer.Equals(i.Key, key) + select i.Value; + } + + public bool Has(string key) + { + return Has(key, StringComparer.CurrentCultureIgnoreCase); + } + + public bool Has(string key, StringComparer comparer) + { + return list.Any(e => comparer.Equals(key, e.Key)); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/BaseSortPart.cs b/Roadie.Dlna/Utility/BaseSortPart.cs new file mode 100644 index 0000000..3169357 --- /dev/null +++ b/Roadie.Dlna/Utility/BaseSortPart.cs @@ -0,0 +1,37 @@ +using System; + +namespace Roadie.Dlna.Utility +{ + internal abstract class BaseSortPart : IComparable + { + private readonly Type type; + + protected BaseSortPart() + { + type = GetType(); + } + + public int CompareTo(BaseSortPart other) + { + if (other == null) + { + return 1; + } + if (type != other.type) + { + if (type == typeof(StringSortPart)) + { + return 1; + } + return -1; + } + var sp = other as StringSortPart; + if (sp != null) + { + return ((StringSortPart)this).CompareTo(sp); + } + return ((NumericSortPart)this).CompareTo( + (NumericSortPart)other); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/ConcatenatedStream.cs b/Roadie.Dlna/Utility/ConcatenatedStream.cs new file mode 100644 index 0000000..40238a5 --- /dev/null +++ b/Roadie.Dlna/Utility/ConcatenatedStream.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Roadie.Dlna.Utility +{ + public sealed class ConcatenatedStream : Stream + { + private readonly Queue streams = new Queue(); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length + { + get { throw new NotSupportedException(); } + } + + public override long Position + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + public void AddStream(Stream stream) + { + streams.Enqueue(stream); + } + + public override void Close() + { + foreach (var stream in streams) + { + stream.Close(); + stream.Dispose(); + } + streams.Clear(); + base.Close(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (streams.Count == 0) + { + return 0; + } + + var read = streams.Peek().Read(buffer, offset, count); + if (read < count) + { + var sndRead = streams.Peek().Read(buffer, offset + read, count - read); + if (sndRead <= 0) + { + streams.Dequeue().Dispose(); + return read + Read(buffer, offset + read, count - read); + } + read += sndRead; + } + return read; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/ConfigParameters.cs b/Roadie.Dlna/Utility/ConfigParameters.cs new file mode 100644 index 0000000..8b458f2 --- /dev/null +++ b/Roadie.Dlna/Utility/ConfigParameters.cs @@ -0,0 +1,105 @@ +using System; +using System.ComponentModel; +using System.Linq; + +namespace Roadie.Dlna.Utility +{ + public class ConfigParameters : AttributeCollection + { + public ConfigParameters() + { + } + + public ConfigParameters(string parameters) + { + foreach (var valuesplit in parameters.Split(',').Select(p => p.Split(new[] { '=' }, 2))) + { + Add(valuesplit[0], valuesplit.Length == 2 ? valuesplit[1] : null); + } + } + + public TValue Get(string key, TValue defaultValue) where TValue : struct + { + return Get(key, defaultValue, StringComparer.CurrentCultureIgnoreCase); + } + + public TValue Get(string key, TValue defaultValue, StringComparer comparer) + where TValue : struct + { + TValue rv; + return TryGet(key, out rv, comparer) ? rv : defaultValue; + } + + public TValue? MaybeGet(string key) where TValue : struct + { + return MaybeGet(key, StringComparer.CurrentCultureIgnoreCase); + } + + public TValue? MaybeGet(string key, StringComparer comparer) where TValue : struct + { + TValue? rv = null; + TValue attempt; + if (TryGet(key, out attempt, comparer)) + { + rv = attempt; + } + return rv; + } + + public bool TryGet(string key, out TValue rv) where TValue : struct + { + return TryGet(key, out rv, StringComparer.CurrentCultureIgnoreCase); + } + + public bool TryGet(string key, out TValue rv, StringComparer comparer) where TValue : struct + { + rv = new TValue(); + var convertible = rv as IConvertible; + if (convertible == null) + { + throw new NotSupportedException("Not convertible"); + } + switch (convertible.GetTypeCode()) + { + case TypeCode.Boolean: + foreach (var val in GetValuesForKey(key, comparer)) + { + try + { + rv = (TValue)(object)Formatting.Booley(val); + return true; + } + catch (Exception) + { + // ignored + } + } + break; + + case TypeCode.Object: + throw new NotSupportedException("Non pod types are not supported"); + default: + var conv = TypeDescriptor.GetConverter(typeof(TValue)); + foreach (var val in GetValuesForKey(key, comparer)) + { + try + { + var converted = conv.ConvertFromString(val); + if (converted == null) + { + continue; + } + rv = (TValue)converted; + return true; + } + catch (Exception) + { + // ignored + } + } + break; + } + return false; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/Ffmpeg.cs b/Roadie.Dlna/Utility/Ffmpeg.cs new file mode 100644 index 0000000..d76adb8 --- /dev/null +++ b/Roadie.Dlna/Utility/Ffmpeg.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Utility +{ + using InfoCache = LeastRecentlyUsedDictionary>; + + public static class FFmpeg + { + private static readonly DirectoryInfo[] specialLocations = + { + GetFFMpegFolder(Environment.SpecialFolder.CommonProgramFiles), + GetFFMpegFolder(Environment.SpecialFolder.CommonProgramFilesX86), + GetFFMpegFolder(Environment.SpecialFolder.ProgramFiles), + GetFFMpegFolder(Environment.SpecialFolder.ProgramFilesX86), + GetFFMpegFolder(Environment.SpecialFolder.UserProfile), + new DirectoryInfo(Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile)) + }; + + private static readonly InfoCache infoCache = new InfoCache(500); + + private static readonly Regex regAssStrip = new Regex(@"^,+", RegexOptions.Compiled); + + private static readonly Regex regDuration = new Regex(@"Duration: ([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?", RegexOptions.Compiled); + + private static readonly Regex regDimensions = new Regex(@"Video: .+ ([0-9]{2,})x([0-9]{2,}) ", RegexOptions.Compiled); + + public static readonly string FFmpegExecutable = FindExecutable("ffmpeg"); + + private static DirectoryInfo GetFFMpegFolder(Environment.SpecialFolder folder) + { + return new DirectoryInfo( + Path.Combine(Environment.GetFolderPath(folder), "ffmpeg")); + } + + private static string FindExecutable(string executable) + { + var os = Environment.OSVersion.Platform.ToString().ToUpperInvariant(); + var isWin = os.Contains("WIN"); + if (isWin) + { + executable += ".exe"; + } + var places = new List(); + var assemblyLoc = Assembly.GetExecutingAssembly().Location; + if (assemblyLoc != null) + { + places.Add(new FileInfo(assemblyLoc).Directory); + } + try + { + var ffhome = @"C:\tools\ffmpeg"; // Environment.GetEnvironmentVariable("FFMPEG_HOME"); + if (!string.IsNullOrWhiteSpace(ffhome)) + { + places.Add(new DirectoryInfo(ffhome)); + } + } + catch (Exception) + { + // ignored + } + foreach (var l in specialLocations) + { + try + { + places.Add(l); + } + catch (Exception) + { + // ignored + } + } + var envpath = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrWhiteSpace(envpath)) + { + foreach (var p in envpath. + Split(isWin ? ';' : ':')) + { + try + { + places.Add(new DirectoryInfo(p.Trim())); + } + catch (Exception) + { + // ignored + } + } + } + + foreach (var i in places) + { + Trace.WriteLine($"Searching {i.FullName}"); + if (!i.Exists) + { + continue; + } + var folders = new[] + { + i, + new DirectoryInfo(Path.Combine(i.FullName, "bin")) + }; + foreach (var di in folders) + { + try + { + var r = di.GetFiles(executable, SearchOption.TopDirectoryOnly); + if (r.Length != 0) + { + var rv = r[0]; + Trace.WriteLine($"Found {executable} at {rv.FullName}"); + return rv.FullName; + } + } + catch (Exception) + { + // ignored + } + } + } + Trace.WriteLine($"Did not find {executable}"); + return null; + } + + private static IDictionary IdentifyFileInternal( + FileInfo file) + { + if (FFmpegExecutable == null) + { + throw new NotSupportedException(); + } + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + IDictionary rv; + if (infoCache.TryGetValue(file, out rv)) + { + return rv; + } + try + { + return IdentifyInternalFromProcess(file); + } + catch (Exception ex) + { + throw new NotSupportedException(ex.Message, ex); + } + } + + private static IDictionary IdentifyInternalFromProcess( + FileInfo file) + { + using (var p = new Process()) + { + var sti = p.StartInfo; +#if !DEBUG + sti.CreateNoWindow = true; +#endif + sti.UseShellExecute = false; + sti.FileName = FFmpegExecutable; + sti.Arguments = $"-i \"{file.FullName}\""; + sti.LoadUserProfile = false; + sti.RedirectStandardError = true; + p.Start(); + IDictionary rv = new Dictionary(); + + using (var reader = new StreamReader(StreamManager.GetStream())) + { + using (var pump = new StreamPump( + p.StandardError.BaseStream, reader.BaseStream, 4096)) + { + pump.Pump(null); + if (!p.WaitForExit(3000)) + { + throw new NotSupportedException("ffmpeg timed out"); + } + if (!pump.Wait(1000)) + { + throw new NotSupportedException("ffmpeg pump timed out"); + } + reader.BaseStream.Seek(0, SeekOrigin.Begin); + + var output = reader.ReadToEnd(); + var match = regDuration.Match(output); + if (match.Success) + { + int h, m, s; + if (int.TryParse(match.Groups[1].Value, out h) && + int.TryParse(match.Groups[2].Value, out m) && + int.TryParse(match.Groups[3].Value, out s)) + { + int ms; + if (match.Groups.Count < 5 || + !int.TryParse(match.Groups[4].Value, out ms)) + { + ms = 0; + } + var ts = new TimeSpan(0, h, m, s, ms * 10); + var tss = ts.TotalSeconds.ToString( + CultureInfo.InvariantCulture); + rv.Add("LENGTH", tss); + } + } + match = regDimensions.Match(output); + if (match.Success) + { + int w, h; + if (int.TryParse(match.Groups[1].Value, out w) && + int.TryParse(match.Groups[2].Value, out h)) + { + rv.Add("VIDEO_WIDTH", w.ToString()); + rv.Add("VIDEO_HEIGHT", h.ToString()); + } + } + } + } + if (rv.Count == 0) + { + throw new NotSupportedException("File not supported"); + } + return rv; + } + } + + public static Size GetFileDimensions(FileInfo file) + { + string sw, sh; + int w, h; + if (IdentifyFile(file).TryGetValue("VIDEO_WIDTH", out sw) + && IdentifyFile(file).TryGetValue("VIDEO_HEIGHT", out sh) + && int.TryParse(sw, out w) + && int.TryParse(sh, out h) + && w > 0 && h > 0) + { + return new Size(w, h); + } + throw new NotSupportedException(); + } + + public static double GetFileDuration(FileInfo file) + { + string sl; + if (IdentifyFile(file).TryGetValue("LENGTH", out sl)) + { + double dur; + var valid = double.TryParse( + sl, NumberStyles.AllowDecimalPoint, + CultureInfo.GetCultureInfo("en-US", "en"), out dur); + if (valid && dur > 0) + { + return dur; + } + } + throw new NotSupportedException(); + } + + public static string GetSubtitleSubrip(FileInfo file) + { + if (FFmpegExecutable == null) + { + throw new NotSupportedException(); + } + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + try + { + using (var p = new Process()) + { + var sti = p.StartInfo; +#if !DEBUG + sti.CreateNoWindow = true; +#endif + sti.UseShellExecute = false; + sti.FileName = FFmpegExecutable; + sti.Arguments = $"-i \"{file.FullName}\" -map s:0 -f srt pipe:"; + sti.LoadUserProfile = false; + sti.RedirectStandardOutput = true; + p.Start(); + + var lastPosition = 0L; + using (var reader = new StreamReader(StreamManager.GetStream())) + { + using (var pump = new StreamPump( + p.StandardOutput.BaseStream, reader.BaseStream, 100)) + { + pump.Pump(null); + while (!p.WaitForExit(20000)) + { + if (lastPosition != reader.BaseStream.Position) + { + lastPosition = reader.BaseStream.Position; + continue; + } + p.Kill(); + throw new NotSupportedException("ffmpeg timed out"); + } + if (!pump.Wait(2000)) + { + throw new NotSupportedException("ffmpeg pump timed out"); + } + reader.BaseStream.Seek(0, SeekOrigin.Begin); + + var rv = string.Empty; + string line; + while ((line = reader.ReadLine()) != null) + { + rv += regAssStrip.Replace(line.Trim(), string.Empty) + "\n"; + } + if (!string.IsNullOrWhiteSpace(rv)) + { + return rv; + } + } + } + } + } + catch (Exception ex) + { + throw new NotSupportedException(ex.Message, ex); + } + throw new NotSupportedException( + "File does not contain a valid subtitle"); + } + + public static IDictionary IdentifyFile(FileInfo file) + { + if (FFmpegExecutable != null) + { + return IdentifyFileInternal(file); + } + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/Formatting.cs b/Roadie.Dlna/Utility/Formatting.cs new file mode 100644 index 0000000..c553461 --- /dev/null +++ b/Roadie.Dlna/Utility/Formatting.cs @@ -0,0 +1,117 @@ +using System; +using System.Text.RegularExpressions; + +namespace Roadie.Dlna.Utility +{ + public static class Formatting + { + private static readonly Regex respace = + new Regex(@"[.+]+", RegexOptions.Compiled); + + private static readonly Regex sanitizer = new Regex( + @"\b(?:the|an?|ein(?:e[rs]?)?|der|die|das)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled + ); + + private static readonly Regex trim = new Regex( + @"\s+|^[._+)}\]-]+|[._+({\[-]+$", + RegexOptions.Compiled + ); + + private static readonly Regex trimmore = + new Regex(@"^[^\d\w]+|[^\d\w]+$", RegexOptions.Compiled); + + public static bool Booley(string maybeBoolean) + { + if (maybeBoolean == null) + { + throw new ArgumentNullException(nameof(maybeBoolean)); + } + maybeBoolean = maybeBoolean.Trim(); + var sc = StringComparer.CurrentCultureIgnoreCase; + return sc.Equals("yes", maybeBoolean) || sc.Equals("1", maybeBoolean) || sc.Equals("true", maybeBoolean); + } + + public static string FormatFileSize(this long size) + { + if (size < 900) + { + return $"{size} B"; + } + var ds = size / 1024.0; + if (ds < 900) + { + return $"{ds:F2} KB"; + } + ds /= 1024.0; + if (ds < 900) + { + return $"{ds:F2} MB"; + } + ds /= 1024.0; + if (ds < 900) + { + return $"{ds:F3} GB"; + } + ds /= 1024.0; + if (ds < 900) + { + return $"{ds:F3} TB"; + } + ds /= 1024.0; + return $"{ds:F4} PB"; + } + + public static string GetSystemName() + { + return System.Environment.MachineName; + } + + public static string StemCompareBase(this string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + var san = trimmore.Replace( + sanitizer.Replace(name, string.Empty), + string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(san)) + { + return name; + } + return san.StemNameBase(); + } + + public static string StemNameBase(this string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!name.Contains(" ")) + { + name = name.Replace('_', ' '); + if (!name.Contains(" ")) + { + name = name.Replace('-', ' '); + } + name = respace.Replace(name, " "); + } + var ws = name; + string wsprev; + do + { + wsprev = ws; + ws = trim.Replace(wsprev.Trim(), " ").Trim(); + } while (wsprev != ws); + if (string.IsNullOrWhiteSpace(ws)) + { + return name; + } + return ws; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/HttpMethod.cs b/Roadie.Dlna/Utility/HttpMethod.cs new file mode 100644 index 0000000..808bfd3 --- /dev/null +++ b/Roadie.Dlna/Utility/HttpMethod.cs @@ -0,0 +1,8 @@ +namespace Roadie.Dlna.Utility +{ + public enum HttpMethod + { + GET, + HEAD + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/HttpStream.cs b/Roadie.Dlna/Utility/HttpStream.cs new file mode 100644 index 0000000..29bfa2b --- /dev/null +++ b/Roadie.Dlna/Utility/HttpStream.cs @@ -0,0 +1,312 @@ +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Net; +using System.Reflection; + +namespace Roadie.Dlna.Utility +{ + public class HttpStream : Stream, IDisposable + { + public static readonly string UserAgent = GenerateUserAgent(); + private const int BUFFER_SIZE = 1 << 10; + + private const long SMALL_SEEK = 1 << 9; + + private const int TIMEOUT = 30000; + private readonly Uri Referrer; + + private readonly Uri StreamUri; + + private Stream bufferedStream; + + private long? length; + + private long position; + + private HttpWebRequest request; + + private HttpWebResponse response; + + private Stream responseStream; + + public override bool CanRead => true; + + public override bool CanSeek + { + get + { + if (Length <= 0) + { + return false; + } + + EnsureResponse(); + var ranges = response.Headers.Get("Accept-Ranges"); + return string.IsNullOrEmpty(ranges) + || !string.Equals(ranges, "none", StringComparison.InvariantCultureIgnoreCase); + } + } + + public override bool CanTimeout => true; + public override bool CanWrite => false; + + public string ContentType + { + get + { + EnsureResponse(); + return response.ContentType; + } + } + + public DateTime LastModified + { + get + { + EnsureResponse(); + return response.LastModified; + } + } + + public override long Length + { + get + { + if (!length.HasValue) + { + OpenAt(0, HttpMethod.HEAD); + length = response.ContentLength; + } + if (length.Value < 0) + { + throw new IOException("Stream does not feature a length"); + } + return length.Value; + } + } + + public override long Position + { + get { return position; } + set { Seek(value, SeekOrigin.Begin); } + } + + public Uri Uri => new Uri(StreamUri.ToString()); + private ILogger Logger { get; } + + public HttpStream(ILogger logger, Uri uri, Uri referrer) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + StreamUri = uri; + Referrer = referrer; + Logger = logger; + } + + public override void Close() + { + bufferedStream?.Close(); + responseStream?.Close(); + response?.Close(); + base.Close(); + } + + public new void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public override void Flush() + { + Dispose(true); + } + + public override int Read(byte[] buffer, int offset, int count) + { + try + { + if (responseStream == null) + { + OpenAt(position, HttpMethod.GET); + } + var read = bufferedStream.Read(buffer, offset, count); + if (read > 0) + { + position += read; + } + return read; + } + catch (Exception ex) + { + Logger.LogError("Failed to read", ex); + throw; + } + } + + public override long Seek(long offset, SeekOrigin origin) + { + Logger.LogDebug("Seek to {0}, {1} requested", offset, origin); + var np = 0L; + switch (origin) + { + case SeekOrigin.Begin: + np = offset; + break; + + case SeekOrigin.Current: + np = position + offset; + break; + + case SeekOrigin.End: + np = Length + np; + break; + } + if (np < 0 || np >= Length) + { + throw new IOException("Invalid seek; out of stream bounds"); + } + var off = position - np; + if (off == 0) + { + Logger.LogDebug("No seek required"); + } + else + { + if (response != null && off > 0 && off < SMALL_SEEK) + { + var buf = new byte[off]; + bufferedStream.Read(buf, 0, (int)off); + Logger.LogDebug("Did a small seek of {0}", off); + } + else + { + OpenAt(np, HttpMethod.GET); + Logger.LogDebug("Did a long seek of {0}", off); + } + } + position = np; + Logger.LogDebug("Successfully sought to {0}", position); + return position; + } + + public override void SetLength(long value) + { + length = value; + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (bufferedStream != null) + { + bufferedStream.Dispose(); + bufferedStream = null; + } + if (responseStream != null) + { + responseStream.Dispose(); + responseStream = null; + } + response = null; + request = null; + } + + base.Dispose(disposing); + } + + protected void OpenAt(long offset, HttpMethod method) + { + if (offset < 0) + { + throw new IOException("Position cannot be negative"); + } + if (offset > 0 && method == HttpMethod.HEAD) + { + throw new ArgumentException( + "Cannot use a position (seek) with HEAD request"); + } + Close(); + Dispose(); + + request = (HttpWebRequest)WebRequest.Create(Uri); + request.Method = method.ToString(); + if (Referrer != null) + { + request.Referer = Referrer.ToString(); + } + request.AllowAutoRedirect = true; + request.Timeout = TIMEOUT * 1000; + request.UserAgent = UserAgent; + if (offset > 0) + { + request.AddRange(offset); + } + response = (HttpWebResponse)request.GetResponse(); + if (method != HttpMethod.HEAD) + { + responseStream = response.GetResponseStream(); + if (responseStream == null) + { + throw new IOException("Didn't get a response stream"); + } + bufferedStream = new BufferedStream(responseStream, BUFFER_SIZE); + } + if (offset > 0 && response.StatusCode != HttpStatusCode.PartialContent) + { + throw new IOException( + "Failed to open the http stream at a specific position"); + } + if (offset == 0 && response.StatusCode != HttpStatusCode.OK) + { + throw new IOException("Failed to open the http stream"); + } + Logger.LogInformation("Opened {0} {1} at {2}", method, Uri, offset); + } + + private static string GenerateUserAgent() + { + var os = Environment.OSVersion; + string pstring; + switch (os.Platform) + { + case PlatformID.Win32NT: + case PlatformID.Win32S: + case PlatformID.Win32Windows: + pstring = "WIN"; + break; + + default: + pstring = "Unix"; + break; + } + return string.Format( + "roadie/{4}.{5} ({0}{1} {2}.{3}) like curl/7.3 like wget/1.0", + pstring, + IntPtr.Size * 8, + os.Version.Major, + os.Version.Minor, + Assembly.GetExecutingAssembly().GetName().Version.Major, + Assembly.GetExecutingAssembly().GetName().Version.Minor + ); + } + + private void EnsureResponse() + { + if (response != null) + { + return; + } + OpenAt(0, HttpMethod.HEAD); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/IConfigurable.cs b/Roadie.Dlna/Utility/IConfigurable.cs new file mode 100644 index 0000000..b23b69a --- /dev/null +++ b/Roadie.Dlna/Utility/IConfigurable.cs @@ -0,0 +1,7 @@ +namespace Roadie.Dlna.Utility +{ + public interface IConfigurable + { + void SetParameters(ConfigParameters parameters); + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/IP.cs b/Roadie.Dlna/Utility/IP.cs new file mode 100644 index 0000000..9b24708 --- /dev/null +++ b/Roadie.Dlna/Utility/IP.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Roadie.Dlna.Utility +{ + public static class IP + { + private static bool warned; + + public static IEnumerable AllIPAddresses + { + get + { + try + { + return GetIPsDefault().ToArray(); + } + catch (Exception ex) + { + if (!warned) + { + Trace.WriteLine($"Failed to retrieve IP addresses the usual way, falling back to naive mode, ex [{ ex }]"); + warned = true; + } + return GetIPsFallback(); + } + } + } + + public static IEnumerable ExternalIPAddresses => from i in AllIPAddresses + where !IPAddress.IsLoopback(i) + select i; + + private static IEnumerable GetIPsDefault() + { + var returned = false; + foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + var props = adapter.GetIPProperties(); + var gateways = from ga in props.GatewayAddresses + where !ga.Address.Equals(IPAddress.Any) + select true; + if (!gateways.Any()) + { + Trace.WriteLine("Skipping {props}. No gateways"); + continue; + } + Trace.WriteLine($"Using {props}"); + foreach (var uni in props.UnicastAddresses) + { + var address = uni.Address; + if (address.AddressFamily != AddressFamily.InterNetwork) + { + Trace.WriteLine($"Skipping {address}. Not IPv4"); + continue; + } + Trace.WriteLine($"Found {address}"); + returned = true; + yield return address; + } + } + if (!returned) + { + throw new ApplicationException("No IP"); + } + } + + private static IEnumerable GetIPsFallback() + { + var returned = false; + foreach (var i in Dns.GetHostEntry(Dns.GetHostName()).AddressList) + { + if (i.AddressFamily == AddressFamily.InterNetwork) + { + Trace.WriteLine($"Found {i}"); + returned = true; + yield return i; + } + } + if (!returned) + { + throw new ApplicationException("No IP"); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/IRepositoryItem.cs b/Roadie.Dlna/Utility/IRepositoryItem.cs new file mode 100644 index 0000000..79729e0 --- /dev/null +++ b/Roadie.Dlna/Utility/IRepositoryItem.cs @@ -0,0 +1,9 @@ +namespace Roadie.Dlna.Utility +{ + public interface IRepositoryItem + { + string Description { get; } + + string Name { get; } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/LeastRecentlyUsedDictionary.cs b/Roadie.Dlna/Utility/LeastRecentlyUsedDictionary.cs new file mode 100644 index 0000000..42a1580 --- /dev/null +++ b/Roadie.Dlna/Utility/LeastRecentlyUsedDictionary.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Roadie.Dlna.Utility +{ + public sealed class LeastRecentlyUsedDictionary : IDictionary + { + private readonly ConcurrentDictionary>> items = new ConcurrentDictionary>>(); + + private readonly LinkedList> order = new LinkedList>(); + + private readonly uint toDrop; + + public uint Capacity { get; } + + public int Count => items.Count; + + public bool IsReadOnly => false; + + public ICollection Keys => items.Keys; + + public ICollection Values => (from i in items.Values + select i.Value.Value).ToList(); + + public TValue this[TKey key] + { + get { return items[key].Value.Value; } + [MethodImpl(MethodImplOptions.Synchronized)] + set + { + Remove(key); + Add(key, value); + } + } + + public LeastRecentlyUsedDictionary(uint capacity) + { + Capacity = capacity; + toDrop = Math.Min(10, (uint)(capacity * 0.07)); + } + + public LeastRecentlyUsedDictionary(int capacity) + : this((uint)capacity) + { + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public void Add(KeyValuePair item) + { + AddAndPop(item); + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public void Add(TKey key, TValue value) + { + AddAndPop(new KeyValuePair(key, value)); + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public TValue AddAndPop(KeyValuePair item) + { + LinkedListNode> node; + lock (order) + { + node = order.AddFirst(item); + } + items.TryAdd(item.Key, node); + return MaybeDropSome(); + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public TValue AddAndPop(TKey key, TValue value) + { + return AddAndPop(new KeyValuePair(key, value)); + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public void Clear() + { + items.Clear(); + lock (order) + { + order.Clear(); + } + } + + public bool Contains(KeyValuePair item) + { + return items.ContainsKey(item.Key); + } + + public bool ContainsKey(TKey key) + { + return items.ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return items.GetEnumerator(); + } + + public IEnumerator> GetEnumerator() + { + return items.Select(i => i.Value.Value).GetEnumerator(); + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public bool Remove(TKey key) + { + LinkedListNode> node; + if (items.TryRemove(key, out node)) + { + lock (order) + { + order.Remove(node); + } + return true; + } + return false; + } + + [MethodImpl(MethodImplOptions.Synchronized)] + public bool Remove(KeyValuePair item) + { + LinkedListNode> node; + if (items.TryRemove(item.Key, out node)) + { + lock (order) + { + order.Remove(node); + } + return true; + } + return false; + } + + public bool TryGetValue(TKey key, out TValue value) + { + LinkedListNode> node; + if (items.TryGetValue(key, out node)) + { + value = node.Value.Value; + return true; + } + value = default(TValue); + return false; + } + + private TValue MaybeDropSome() + { + if (Count <= Capacity) + { + return default(TValue); + } + lock (order) + { + var rv = default(TValue); + for (var i = 0; i < toDrop; ++i) + { + LinkedListNode> item; + if (items.TryRemove(order.Last.Value.Key, out item)) + { + rv = item.Value.Value; + } + order.RemoveLast(); + } + return rv; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/MoreDom.cs b/Roadie.Dlna/Utility/MoreDom.cs new file mode 100644 index 0000000..c86fd0f --- /dev/null +++ b/Roadie.Dlna/Utility/MoreDom.cs @@ -0,0 +1,46 @@ +using System; +using System.Xml; + +namespace Roadie.Dlna.Utility +{ + public static class MoreDom + { + public static XmlElement EL(this XmlDocument doc, string name) + { + return EL(doc, name, null, null); + } + + public static XmlElement EL(this XmlDocument doc, string name, + AttributeCollection attributes) + { + return EL(doc, name, attributes, null); + } + + public static XmlElement EL(this XmlDocument doc, string name, string text) + { + return EL(doc, name, null, text); + } + + public static XmlElement EL(this XmlDocument doc, string name, + AttributeCollection attributes, string text) + { + if (doc == null) + { + throw new ArgumentNullException(nameof(doc)); + } + var rv = doc.CreateElement(name); + if (text != null) + { + rv.InnerText = text; + } + if (attributes != null) + { + foreach (var i in attributes) + { + rv.SetAttribute(i.Key, i.Value); + } + } + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/NaturalStringComparer.cs b/Roadie.Dlna/Utility/NaturalStringComparer.cs new file mode 100644 index 0000000..d74274c --- /dev/null +++ b/Roadie.Dlna/Utility/NaturalStringComparer.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +namespace Roadie.Dlna.Utility +{ + using PartsCache = LeastRecentlyUsedDictionary; + + public sealed class NaturalStringComparer : StringComparer + { + private static readonly StringComparer comparer = + CurrentCultureIgnoreCase; + + private readonly PartsCache partsCache = new PartsCache(5000); + + private readonly bool stemBase; + + public static IComparer Comparer { get; } = new NaturalStringComparer(); + + public NaturalStringComparer() + : this(false) + { + } + + public NaturalStringComparer(bool stemBase) + { + this.stemBase = stemBase; + } + + public override int Compare(string x, string y) + { + if (stemBase) + { + x = x.StemCompareBase(); + y = y.StemCompareBase(); + } + if (x == y || InvariantCulture.Compare(x, y) == 0) + { + return 0; + } + var p1 = Split(x); + var p2 = Split(y); + + int rv; + var e = Math.Min(p1.Length, p2.Length); + for (var i = 0; i < e; ++i) + { + rv = p1[i].CompareTo(p2[i]); + if (rv != 0) + { + return rv; + } + } + rv = p1.Length.CompareTo(p2.Length); + if (rv == 0) + { + return comparer.Compare(x, y); + } + return rv; + } + + public override bool Equals(string x, string y) + { + return Compare(x, y) == 0; + } + + public override int GetHashCode(string obj) + { + return comparer.GetHashCode(obj); + } + + private BaseSortPart[] Split(string str) + { + BaseSortPart[] rv; + lock (partsCache) + { + if (partsCache.TryGetValue(str, out rv)) + { + return rv; + } + } + + var parts = new List(); + var num = false; + var start = 0; + for (var i = 0; i < str.Length; ++i) + { + var c = str[i]; + var cnum = c >= '0' && c <= '9'; + if (cnum == num) + { + continue; + } + if (i != 0) + { + var p = str.Substring(start, i - start).Trim(); + if (num) + { + parts.Add(new NumericSortPart(p)); + } + else + { + if (!string.IsNullOrWhiteSpace(p)) + { + parts.Add(new StringSortPart(p, comparer)); + } + } + } + num = cnum; + start = i; + } + var pe = str.Substring(start).Trim(); + if (!string.IsNullOrWhiteSpace(pe)) + { + if (num) + { + parts.Add(new NumericSortPart(pe)); + } + else + { + parts.Add(new StringSortPart(pe, comparer)); + } + } + + rv = parts.ToArray(); + lock (partsCache) + { + partsCache[str] = rv; + } + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/NumericSortPart.cs b/Roadie.Dlna/Utility/NumericSortPart.cs new file mode 100644 index 0000000..1ae4228 --- /dev/null +++ b/Roadie.Dlna/Utility/NumericSortPart.cs @@ -0,0 +1,31 @@ +using System; + +namespace Roadie.Dlna.Utility +{ + internal sealed class NumericSortPart : BaseSortPart, IComparable + { + private readonly int len; + + private readonly ulong val; + + public NumericSortPart(string s) + { + val = ulong.Parse(s); + len = s.Length; + } + + public int CompareTo(NumericSortPart other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + var rv = val.CompareTo(other.val); + if (rv == 0) + { + return len.CompareTo(other.len); + } + return rv; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/ProductInformation.cs b/Roadie.Dlna/Utility/ProductInformation.cs new file mode 100644 index 0000000..851f90d --- /dev/null +++ b/Roadie.Dlna/Utility/ProductInformation.cs @@ -0,0 +1,71 @@ +using System.IO; +using System.Reflection; + +namespace Roadie.Dlna.Utility +{ + public static class ProductInformation + { + public static string Company + { + get + { + var attributes = Assembly.GetEntryAssembly().GetCustomAttributes( + typeof(AssemblyCompanyAttribute), false); + if (attributes.Length == 0) + { + return string.Empty; + } + return ((AssemblyCompanyAttribute)attributes[0]).Company; + } + } + + public static string Copyright + { + get + { + var attributes = Assembly.GetEntryAssembly().GetCustomAttributes( + typeof(AssemblyCopyrightAttribute), false); + if (attributes.Length == 0) + { + return string.Empty; + } + return ((AssemblyCopyrightAttribute)attributes[0]).Copyright; + } + } + + public static string ProductVersion + { + get + { + var attributes = Assembly.GetExecutingAssembly().GetCustomAttributes( + typeof(AssemblyInformationalVersionAttribute), false); + if (attributes.Length == 0) + { + return string.Empty; + } + var infoVersionAttr = + (AssemblyInformationalVersionAttribute)attributes[0]; + return infoVersionAttr.InformationalVersion; + } + } + + public static string Title + { + get + { + var attributes = Assembly.GetEntryAssembly().GetCustomAttributes( + typeof(AssemblyTitleAttribute), false); + if (attributes.Length > 0) + { + var titleAttribute = (AssemblyTitleAttribute)attributes[0]; + if (!string.IsNullOrWhiteSpace(titleAttribute.Title)) + { + return titleAttribute.Title; + } + } + return Path.GetFileNameWithoutExtension( + Assembly.GetExecutingAssembly().CodeBase); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/Repository.cs b/Roadie.Dlna/Utility/Repository.cs new file mode 100644 index 0000000..6d3cf35 --- /dev/null +++ b/Roadie.Dlna/Utility/Repository.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Roadie.Dlna.Utility +{ + public abstract class Repository where TInterface : class, IRepositoryItem + { + private static readonly Dictionary items = BuildRepository(); + + public static IDictionary ListItems() + { + return items.Values.ToDictionary(v => v.Name, v => v); + } + + public static TInterface Lookup(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Invalid repository name", nameof(name)); + } + var argsplit = name.Split(new[] { ':' }, 2); + name = argsplit[0].ToUpperInvariant().Trim(); + TInterface result; + if (!items.TryGetValue(name, out result)) + { + throw new RepositoryLookupException(name); + } + if (argsplit.Length == 1 || !(result is IConfigurable)) + { + return result; + } + var parameters = new ConfigParameters(argsplit[1]); + if (parameters.Count == 0) + { + return result; + } + var ctor = result.GetType().GetConstructor(new Type[] { }); + if (ctor == null) + { + throw new RepositoryLookupException(name); + } + try + { + var item = ctor.Invoke(new object[] { }) as TInterface; + if (item == null) + { + throw new RepositoryLookupException(name); + } + var configItem = item as IConfigurable; + configItem?.SetParameters(parameters); + return item; + } + catch (Exception ex) + { + throw new RepositoryLookupException($"Cannot construct repository item: {ex.Message}", ex); + } + } + + private static Dictionary BuildRepository() + { + var found = new Dictionary(StringComparer.CurrentCultureIgnoreCase); + var type = typeof(TInterface).Name; + var a = typeof(TInterface).Assembly; + foreach (var t in a.GetTypes()) + { + if (t.GetInterface(type) == null) + { + continue; + } + var ctor = t.GetConstructor(new Type[] { }); + if (ctor == null) + { + continue; + } + try + { + var item = ctor.Invoke(new object[] { }) as TInterface; + if (item == null) + { + continue; + } + found.Add(item.Name, item); + } + catch (Exception) + { + // ignored + } + } + return found; + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/RepositoryLookupException.cs b/Roadie.Dlna/Utility/RepositoryLookupException.cs new file mode 100644 index 0000000..2fe836b --- /dev/null +++ b/Roadie.Dlna/Utility/RepositoryLookupException.cs @@ -0,0 +1,40 @@ +using System; +using System.Runtime.Serialization; + +namespace Roadie.Dlna.Utility +{ + [Serializable] + public sealed class RepositoryLookupException : ArgumentException + { + public string Key { get; private set; } + + public RepositoryLookupException() + { + } + + public RepositoryLookupException(string key) + : base($"Failed to lookup {key}") + { + Key = key; + } + + public RepositoryLookupException(string message, Exception inner) + : base(message, inner) + { + } + + public RepositoryLookupException(string message, string paramName) : base(message, paramName) + { + } + + public RepositoryLookupException(string message, string paramName, Exception innerException) : base(message, paramName, innerException) + { + } + + private RepositoryLookupException(SerializationInfo info, + StreamingContext context) + : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/ResourceHelper.cs b/Roadie.Dlna/Utility/ResourceHelper.cs new file mode 100644 index 0000000..a133d7b --- /dev/null +++ b/Roadie.Dlna/Utility/ResourceHelper.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Roadie.Dlna.Utility +{ + public static class ResourceHelper + { + private static object _sybcRoot = new object(); + + private static Dictionary _cache = new Dictionary(); + + public static byte[] GetResourceData(string resource) + { + lock (_sybcRoot) + { + if (!_cache.ContainsKey(resource)) + { + var pathToResourceFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Server", "Resources", resource); + _cache.Add(resource, File.ReadAllBytes(pathToResourceFile)); + } + return _cache[resource]; + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/StreamManager.cs b/Roadie.Dlna/Utility/StreamManager.cs new file mode 100644 index 0000000..d8adda0 --- /dev/null +++ b/Roadie.Dlna/Utility/StreamManager.cs @@ -0,0 +1,20 @@ +using Microsoft.IO; +using System.IO; + +namespace Roadie.Dlna.Utility +{ + public static class StreamManager + { + private static readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager(); + + public static MemoryStream GetStream() + { + return manager.GetStream(); + } + + public static MemoryStream GetStream(string tag) + { + return manager.GetStream(tag); + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/StreamPump.cs b/Roadie.Dlna/Utility/StreamPump.cs new file mode 100644 index 0000000..4172387 --- /dev/null +++ b/Roadie.Dlna/Utility/StreamPump.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Roadie.Dlna.Utility +{ + public sealed class StreamPump : IDisposable + { + private readonly byte[] buffer; + + private readonly SemaphoreSlim sem = new SemaphoreSlim(0, 1); + + public Stream Input { get; } + + public Stream Output { get; } + + public StreamPump(Stream inputStream, Stream outputStream, int bufferSize) + { + buffer = new byte[bufferSize]; + Input = inputStream; + Output = outputStream; + } + + public void Dispose() + { + sem.Dispose(); + } + + public void Pump(StreamPumpCallback callback) + { + try + { + Input.BeginRead(buffer, 0, buffer.Length, readResult => + { + try + { + var read = Input.EndRead(readResult); + if (read <= 0) + { + Finish(StreamPumpResult.Delivered, callback); + return; + } + + try + { + Output.BeginWrite(buffer, 0, read, writeResult => + { + try + { + Output.EndWrite(writeResult); + Pump(callback); + } + catch (Exception) + { + Finish(StreamPumpResult.Aborted, callback); + } + }, null); + } + catch (Exception) + { + Finish(StreamPumpResult.Aborted, callback); + } + } + catch (Exception) + { + Finish(StreamPumpResult.Aborted, callback); + } + }, null); + } + catch (Exception) + { + Finish(StreamPumpResult.Aborted, callback); + } + } + + public bool Wait(int timeout) + { + return sem.Wait(timeout); + } + + private void Finish(StreamPumpResult result, StreamPumpCallback callback) + { + //https://stackoverflow.com/a/55516918/74071 + var task = Task.Run(() => callback(this, result)); + task.Wait(); + + try + { + sem.Release(); + } + catch (ObjectDisposedException) + { + // ignore + } + catch (Exception ex) + { + Trace.WriteLine($"StreamPump.Finish Ex [{ ex.Message }]"); + } + } + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/StreamPumpCallback.cs b/Roadie.Dlna/Utility/StreamPumpCallback.cs new file mode 100644 index 0000000..6fbdb04 --- /dev/null +++ b/Roadie.Dlna/Utility/StreamPumpCallback.cs @@ -0,0 +1,4 @@ +namespace Roadie.Dlna.Utility +{ + public delegate void StreamPumpCallback(StreamPump pump, StreamPumpResult result); +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/StreamPumpResult.cs b/Roadie.Dlna/Utility/StreamPumpResult.cs new file mode 100644 index 0000000..68a471e --- /dev/null +++ b/Roadie.Dlna/Utility/StreamPumpResult.cs @@ -0,0 +1,8 @@ +namespace Roadie.Dlna.Utility +{ + public enum StreamPumpResult + { + Aborted, + Delivered + } +} \ No newline at end of file diff --git a/Roadie.Dlna/Utility/StringSortPart.cs b/Roadie.Dlna/Utility/StringSortPart.cs new file mode 100644 index 0000000..91440e5 --- /dev/null +++ b/Roadie.Dlna/Utility/StringSortPart.cs @@ -0,0 +1,26 @@ +using System; + +namespace Roadie.Dlna.Utility +{ + internal sealed class StringSortPart : BaseSortPart, IComparable + { + private readonly StringComparer comparer; + + private readonly string str; + + internal StringSortPart(string str, StringComparer comparer) + { + this.str = str; + this.comparer = comparer; + } + + public int CompareTo(StringSortPart other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + return comparer.Compare(str, other.str); + } + } +} \ No newline at end of file diff --git a/Roadie.sln b/Roadie.sln index c13fa31..a9e641c 100644 --- a/Roadie.sln +++ b/Roadie.sln @@ -26,6 +26,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Roadie.Api.Hubs", "Roadie.A EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Inspector", "Inspector\Inspector.csproj", "{9A0831DC-343A-4E0C-8617-AF62426F3BA8}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Roadie.Dlna", "Roadie.Dlna\Roadie.Dlna.csproj", "{FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Roadie.Dlna.Services", "Roadie.Dlna.Services\Roadie.Dlna.Services.csproj", "{7345CBBD-0D21-43E6-9435-DBCDBDFB4516}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -76,12 +80,28 @@ Global {E740C89E-3363-4577-873B-0871823E252C}.Release|x64.Build.0 = Release|x64 {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|x64.ActiveCfg = Debug|Any CPU - {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|x64.Build.0 = Debug|Any CPU + {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|x64.ActiveCfg = Debug|x64 + {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Debug|x64.Build.0 = Debug|x64 {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|Any CPU.ActiveCfg = Release|Any CPU {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|Any CPU.Build.0 = Release|Any CPU - {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|x64.ActiveCfg = Release|Any CPU - {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|x64.Build.0 = Release|Any CPU + {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|x64.ActiveCfg = Release|x64 + {9A0831DC-343A-4E0C-8617-AF62426F3BA8}.Release|x64.Build.0 = Release|x64 + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Debug|x64.ActiveCfg = Debug|x64 + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Debug|x64.Build.0 = Debug|x64 + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Release|Any CPU.Build.0 = Release|Any CPU + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Release|x64.ActiveCfg = Release|x64 + {FF2D3B18-DC47-4FAA-8F14-CB3B1B3C81D2}.Release|x64.Build.0 = Release|x64 + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Debug|x64.ActiveCfg = Debug|x64 + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Debug|x64.Build.0 = Debug|x64 + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Release|Any CPU.Build.0 = Release|Any CPU + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Release|x64.ActiveCfg = Release|x64 + {7345CBBD-0D21-43E6-9435-DBCDBDFB4516}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE