This commit is contained in:
Steven Hildreth 2019-09-04 21:04:20 -05:00
parent 89710c6bbc
commit c08ce7676c
166 changed files with 10136 additions and 166 deletions

View file

@ -3,6 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>

View file

@ -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<string> AllowedIps { get; set; } = Enumerable.Empty<string>();
public IEnumerable<string> AllowedUserAgents { get; set; } = Enumerable.Empty<string>();
public Dlna()
{
IsEnabled = true;
FriendlyName = "Roadie Music Server";
}
}
}

View file

@ -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<string> AllowedIps { get; set; }
IEnumerable<string> AllowedUserAgents { get; set; }
}
}

View file

@ -6,30 +6,33 @@ namespace Roadie.Library.Configuration
{
Dictionary<string, IEnumerable<string>> 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<string> DontDoMetaDataProvidersSearchArtists { get; set; }
IEnumerable<string> 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<string, string> 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; }
}
}

View file

@ -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");
}
}
/// <summary>
/// Set to the Roadie Database for DbDataReader operations
/// </summary>
@ -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<string> DontDoMetaDataProvidersSearchArtists { get; set; }
public IEnumerable<string> 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; }
/// <summary>
/// If true then don't allow new registrations
/// </summary>
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; }
/// <summary>
/// Place to hold cache repositories used by SearchEngine and MetaData engines
/// </summary>
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<string, string> TrackPathReplace { get; set; }
public bool UseSSLBehindProxy { get; set; }
public string WebsocketAddress { get; set; }
public short? SubsonicRatingBoost { get; set; }
/// <summary>
/// When true require a "invite" token to exist for a user to register.
/// </summary>
public bool UseRegistrationTokens { get; set; }
/// <summary>
/// If true then don't allow new registrations
/// </summary>
public bool IsRegistrationClosed { get; set; }
/// <summary>
/// Place to hold cache repositories used by SearchEngine and MetaData engines
/// </summary>
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<string> { "Various Artists", "Sound Tracks" };
FileExtensionsToDelete = new List<string>{ ".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<string> { ".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();
}
}
}

View file

@ -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,

View file

@ -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();

View file

@ -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();
}

View file

@ -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; }

View file

@ -11,7 +11,7 @@
<PackageReference Include="AutoCompare.Core" Version="1.0.0" />
<PackageReference Include="CsvHelper" Version="12.1.2" />
<PackageReference Include="EFCore.BulkExtensions" Version="2.6.0" />
<PackageReference Include="FluentFTP" Version="27.0.2" />
<PackageReference Include="FluentFTP" Version="27.0.3" />
<PackageReference Include="Hashids.net" Version="1.2.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.12" />
<PackageReference Include="IdSharp.Common" Version="1.0.1" />
@ -23,8 +23,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Redis" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="1.2.2" />
<PackageReference Include="Microsoft.PowerShell.SDK" Version="6.2.2" />
<PackageReference Include="MimeMapping" Version="1.0.1.14" />
<PackageReference Include="MimeMapping" Version="1.0.1.15" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="NodaTime" Version="2.4.6" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="2.2.0" />

View file

@ -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<RoadieScrobbler> logger, data.IRoadieDbContext dbContext,
ICacheManager cacheManager, IHttpContext httpContext)
ICacheManager cacheManager, IHttpContext httpContext)
: base(configuration, logger, dbContext, cacheManager, httpContext)
{
}
/// <summary>
/// For Roadie we only add a user play on the full scrobble event, otherwise we get double track play numbers.
/// </summary>
@ -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<bool>
{
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<bool>($"Scrobble: Unable To Find Track [{scrobble.TrackId}]");
}
if (!track.IsValid)
return new OperationResult<bool>(
$"Scrobble: Invalid Track. Track Id [{scrobble.TrackId}], FilePath [{track.FilePath}], Filename [{track.FileName}]");
{
return new OperationResult<bool>($"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<bool>
{
Data = success,

View file

@ -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<IScrobblerIntegration>
{
roadieScrobbler
};
Scrobblers = scrobblers;
}
/// <summary>
/// Send Now Playing Requests
/// </summary>
@ -70,7 +82,10 @@ namespace Roadie.Library.Scrobble
public async Task<OperationResult<bool>> 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<bool>
{
Data = true,

View file

@ -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

View file

@ -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<ID3TagsHelper> 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<string>();
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<AudioMetaData>();
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;

View file

@ -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);
/// <summary>
/// Executes an async Task method which has a void return value synchronously
/// USAGE: AsyncHelper.RunSync(() => AsyncMethod());
/// </summary>
/// <param name="task">Task method to execute</param>
public static void RunSync(Func<Task> task)
=> taskFactory
.StartNew(task)
.Unwrap()
.GetAwaiter()
.GetResult();
/// <summary>
/// Executes an async Task<T> method which has a T return type synchronously
/// USAGE: T result = AsyncHelper.RunSync(() => AsyncMethod<T>());
/// </summary>
/// <typeparam name="TResult">Return Type</typeparam>
/// <param name="task">Task<T> method to execute</param>
/// <returns></returns>
public static TResult RunSync<TResult>(Func<Task<TResult>> task)
=> taskFactory
.StartNew(task)
.Unwrap()
.GetAwaiter()
.GetResult();
}
}

View file

@ -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;

View file

@ -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<string, string> AudioMimeTypes = new Dictionary<string, string>(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<string, string> ImageMimeTypes = new Dictionary<string, string>(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 _);
}
}
}

View file

@ -10,8 +10,7 @@ namespace Roadie.Api.Services
{
public interface IPlayActivityService
{
Task<PagedResult<PlayActivityList>> List(PagedRequest request, User roadieUser = null,
DateTime? newerThan = null);
Task<PagedResult<PlayActivityList>> List(PagedRequest request, User roadieUser = null, DateTime? newerThan = null);
Task<OperationResult<bool>> NowPlaying(User roadieUser, ScrobbleInfo scrobble);

View file

@ -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<FileOperationResult<Image>> 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<Image>(OperationMessages.NotModified);
if (!image?.Bytes?.Any() ?? false)
}
if (image?.Bytes?.Any() != true)
{
return new FileOperationResult<Image>(string.Format("ImageById Not Set [{0}]", id));
}
return new FileOperationResult<Image>(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<FileOperationResult<Image>>();
if (!result.IsSuccess) return new FileOperationResult<Image>(result.IsNotFoundResult, result.Messages);
if (result.ETag == etag) return new FileOperationResult<Image>(OperationMessages.NotModified);
if (!result.IsSuccess)
{
return new FileOperationResult<Image>(result.IsNotFoundResult, result.Messages);
}
if (result.ETag == etag && etag != null)
{
return new FileOperationResult<Image>(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;
}

View file

@ -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<PlayActivityService> logger,
IScrobbleHandler scrobbleHandler,
IHubContext<PlayActivityHub> playActivityHub)
public PlayActivityService(IRoadieSettings configuration, IHttpEncoder httpEncoder,IHttpContext httpContext,
data.IRoadieDbContext dbContext, ICacheManager cacheManager,ILogger<PlayActivityService> logger,
IScrobbleHandler scrobbleHandler, IHubContext<PlayActivityHub> playActivityHub)
: base(configuration, httpEncoder, dbContext, cacheManager, logger, httpContext)
{
PlayActivityHub = playActivityHub;
ScrobblerHandler = scrobbleHandler;
}
public Task<Library.Models.Pagination.PagedResult<PlayActivityList>> 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<Library.Models.Pagination.PagedResult<PlayActivityList>> 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<OperationResult<bool>> 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();

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
@ -11,12 +11,13 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.5.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.18" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.19" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Roadie.Api.Hubs\Roadie.Api.Hubs.csproj" />
<ProjectReference Include="..\Roadie.Api.Library\Roadie.Library.csproj" />
<ProjectReference Include="..\Roadie.Dlna\Roadie.Dlna.csproj" />
</ItemGroup>
</Project>

View file

@ -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,

View file

@ -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<OperationResult<TrackStreamInfo>> TrackStreamInfo(Guid trackId, long beginBytes,
long endBytes, User roadieUser)
public async Task<OperationResult<TrackStreamInfo>> 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;

View file

@ -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)
{

View file

@ -33,7 +33,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.2.3" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Serilog.AspNetCore" Version="2.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.1.3" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Exceptions" Version="5.3.1" />
@ -42,7 +42,7 @@
<PackageReference Include="Serilog.Sinks.LiteDB.NetStandard" Version="1.0.14" />
<PackageReference Include="Serilog.Sinks.RollingFileAlternate" Version="2.0.9" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.5.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.18" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.0.19" />
</ItemGroup>
<ItemGroup>
@ -53,6 +53,7 @@
<ProjectReference Include="..\Roadie.Api.Hubs\Roadie.Api.Hubs.csproj" />
<ProjectReference Include="..\Roadie.Api.Library\Roadie.Library.csproj" />
<ProjectReference Include="..\Roadie.Api.Services\Roadie.Api.Services.csproj" />
<ProjectReference Include="..\Roadie.Dlna.Services\Roadie.Dlna.Services.csproj" />
</ItemGroup>
</Project>

View file

@ -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<ILookupService, LookupService>();
services.AddScoped<ICommentService, CommentService>();
services.AddSingleton<Microsoft.Extensions.Hosting.IHostedService, DlnaHostService>();
var securityKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(_configuration["Tokens:PrivateKey"]));
services.AddAuthentication(options =>

View file

@ -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": [

View file

@ -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,

View file

@ -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();
}
}

View file

@ -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
{
/// <summary>
/// Hosted Service for Dlna Service (not the actual Dlna Service)
/// </summary>
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<data.RoadieDbContext>();
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
}
}

View file

@ -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<string, DateTimeOffset> LastTimePlayedForToken = new Dictionary<string, DateTimeOffset>();
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<string, data.Artist[]> 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");
}
}
/// <summary>
/// Returns groups of artists for level 2
/// </summary>
/// <returns></returns>
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;
}
/// <summary>
/// Returns artists for group letter (level 3)
/// </summary>
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<data.Artist> 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<data.Collection> 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<data.Collection> 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<data.Playlist> 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<double>(randomTrack.Duration)), isRated ? $"Rating: { randomTrack.Rating }" : randomTrack.PartTitles, randomTrack.LastUpdated ?? randomTrack.CreatedDate, ReleaseCoverArt(randomTrack.ReleaseMedia.Release.RoadieId));
result.AddResource(t);
}
return result;
}
private IEnumerable<data.Track> 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<string, data.Release[]> 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<data.Release> 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");
}
}
/// <summary>
/// Return releases for an artist (level 4)
/// </summary>
private IMediaItem ReleasesForArtist(string id)
{
var artistId = SafeParser.ToNumber<int>(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<double>(releaseTrack.Duration)), releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, null);
fr.AddResource(t);
}
result.AddFolder(fr);
}
return result;
}
private IEnumerable<data.Release> 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<int>(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<double>(releaseTrack.Duration)), releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, null);
fr.AddResource(t);
}
result.AddFolder(fr);
}
return result;
}
/// <summary>
/// Returns releases for group letter (level 3)
/// </summary>
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<data.Release> 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");
}
}
/// <summary>
/// Returns items to display at top level (level 1)
/// </summary>
/// <returns></returns>
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<byte[]> TrackBytesAndMarkPlayed(int releaseId, data.Track track, string trackToken)
{
var results = await TrackService.TrackStreamInfo(track.RoadieId, 0, SafeParser.ToNumber<long>(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<int>(id.Replace("r:t:tk:", "").Split(':')[0]);
var trackId = SafeParser.ToNumber<int>(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<double>(track.Duration)),
track.PartTitles, track.LastUpdated ?? track.CreatedDate, ReleaseCoverArt(track.ReleaseMedia.Release.RoadieId), trackbytes);
}
}
private IEnumerable<data.Track> 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<int>(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<double>(playlistTrack.Duration)), playlistTrack.PartTitles, playlistTrack.LastUpdated ?? playlistTrack.CreatedDate, ReleaseCoverArt(playlistTrack.ReleaseMedia.Release.RoadieId));
result.AddResource(t);
}
return result;
}
private IEnumerable<data.Track> 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<int>(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<double>(releaseTrack.Duration)),
releaseTrack.PartTitles, releaseTrack.LastUpdated ?? releaseTrack.CreatedDate, ReleaseCoverArt(release.RoadieId));
result.AddResource(t);
}
return result;
}
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Roadie.Api.Library\Roadie.Library.csproj" />
<ProjectReference Include="..\Roadie.Api.Services\Roadie.Api.Services.csproj" />
<ProjectReference Include="..\Roadie.Dlna\Roadie.Dlna.csproj" />
</ItemGroup>
</Project>

View file

@ -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();
}
}

View file

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Roadie.Api.Library\Roadie.Library.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Server\Resources\browse.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\connectionmanager.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\contentdirectory.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\description.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\favicon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\large.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\large.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\MSMediaReceiverRegistrar.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\small.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\small.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Server\Resources\x_featurelist.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -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}";
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace Roadie.Dlna.Server.Comparers
{
public interface IItemComparer : IComparer<IMediaItem>
{
}
}

View file

@ -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());
}
}
}

View file

@ -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
);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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<IPAddress, Guid> guidsForAddresses = new Dictionary<IPAddress, Guid>();
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()
{
}
}
}

View file

@ -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<string> htmlItemProperties = new List<string>
{
"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);
}
}
}

View file

@ -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<string, AttributeCollection> soapCache = new LeastRecentlyUsedDictionary<string, AttributeCollection>(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<KeyValuePair<string, string>> 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<KeyValuePair<string, string>> 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 =
"<detail><UPnPError xmlns=\"urn:schemas-upnp-org:control-1-0\"><errorCode>401</errorCode><errorDescription>Invalid Action</errorDescription></UPnPError></detail>";
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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<HttpClient, DateTime> clients = new ConcurrentDictionary<HttpClient, DateTime>();
private readonly ConcurrentDictionary<Guid, List<Guid>> devicesForServers = new ConcurrentDictionary<Guid, List<Guid>>();
private readonly TcpListener listener;
private readonly ConcurrentDictionary<string, IPrefixHandler> prefixes = new ConcurrentDictionary<string, IPrefixHandler>();
private readonly ConcurrentDictionary<Guid, MediaMount> servers = new ConcurrentDictionary<Guid, MediaMount>();
private readonly SsdpHandler ssdpServer;
private readonly Timer timeouter = new Timer(10 * 1000);
public ILogger Logger { get; }
public Dictionary<string, string> MediaMounts
{
get
{
var rv = new Dictionary<string, string>();
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<HttpAuthorizationEventArgs> 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<Guid>());
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<Guid> 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();
}
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<IHttpAuthorizationMethod> methods =
new List<IHttpAuthorizationMethod>();
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
);
}
}
}

View file

@ -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,
"<!doctype html><title>Access denied!</title><h1>Access denied!</h1><p>You're not allowed to access the requested resource.</p>"
)
);
private static readonly IHandler error404 =
new StaticHandler(new StringResponse(
HttpCode.NotFound,
"<!doctype html><title>Not found!</title><h1>Not found!</h1><p>The requested resource was not found!</p>"
)
);
private static readonly IHandler error416 =
new StaticHandler(new StringResponse(
HttpCode.RangeNotSatisfiable,
"<!doctype html><title>Requested Range not satisfiable!</title><h1>Requested Range not satisfiable!</h1><p>Nice try, but do not try again :p</p>"
)
);
private static readonly IHandler error500 =
new StaticHandler(new StringResponse(
HttpCode.InternalError,
"<!doctype html><title>Internal Server Error</title><h1>Internal Server Error</h1><p>Something is very rotten in the State of Denmark!</p>"
)
);
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();
}
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace Roadie.Dlna.Server
{
internal static class HttpPhrases
{
public static readonly IDictionary<HttpCode, string> Phrases =
new Dictionary<HttpCode, string>
{
{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"}
};
}
}

View file

@ -0,0 +1,15 @@
using System.Net;
namespace Roadie.Dlna.Server
{
public interface IHttpAuthorizationMethod
{
/// <summary>
/// Checks if a request is authorized.
/// </summary>
/// <param name="headers">Client supplied HttpHeaders.</param>
/// <param name="endPoint">Client EndPoint</param>
/// <returns>true if authorized</returns>
bool Authorize(IHeaders headers, IPEndPoint endPoint);
}
}

View file

@ -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<IPAddress, object> ips =
new Dictionary<IPAddress, object>();
public IPAddressAuthorizer(IEnumerable<IPAddress> addresses)
{
if (addresses == null)
{
throw new ArgumentNullException(nameof(addresses));
}
foreach (var ip in addresses)
{
ips.Add(ip, null);
}
}
public IPAddressAuthorizer(IEnumerable<string> 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;
}
}
}

View file

@ -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";
}
}
}
}

View file

@ -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<string, object> userAgents = new Dictionary<string, object>();
public UserAgentAuthorizer(IEnumerable<string> 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;
}
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server
{
public interface IBookmarkable
{
long? Bookmark { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server
{
internal interface IHandler
{
IResponse HandleRequest(IRequest request);
}
}

View file

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.IO;
namespace Roadie.Dlna.Server
{
public interface IHeaders : IDictionary<string, string>
{
string HeaderBlock { get; }
Stream HeaderStream { get; }
}
}

View file

@ -0,0 +1,8 @@
using Roadie.Dlna.Server.Metadata;
namespace Roadie.Dlna.Server
{
public interface IMediaAudioResource : IMediaResource, IMetaAudioItem, IMetaInfo
{
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server
{
public interface IMediaCover
{
IMediaCoverResource Cover { get; }
}
}

View file

@ -0,0 +1,8 @@
using Roadie.Dlna.Server.Metadata;
namespace Roadie.Dlna.Server
{
public interface IMediaCoverResource : IMediaResource, IMetaResolution
{
}
}

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace Roadie.Dlna.Server
{
public interface IMediaFolder : IMediaItem
{
int ChildCount { get; }
IEnumerable<IMediaFolder> ChildFolders { get; }
IEnumerable<IMediaResource> ChildItems { get; }
int FullChildCount { get; }
IMediaFolder Parent { get; set; }
void AddResource(IMediaResource res);
void Cleanup();
bool RemoveResource(IMediaResource res);
void Sort(IComparer<IMediaItem> sortComparer, bool descending);
}
}

View file

@ -0,0 +1,8 @@
using Roadie.Dlna.Server.Metadata;
namespace Roadie.Dlna.Server
{
public interface IMediaImageResource : IMediaResource, IMetaImageItem
{
}
}

View file

@ -0,0 +1,15 @@
using System;
namespace Roadie.Dlna.Server
{
public interface IMediaItem : IComparable<IMediaItem>, IEquatable<IMediaItem>, ITitleComparable
{
string Id { get; set; }
string Path { get; }
IHeaders Properties { get; }
string Title { get; }
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,8 @@
using Roadie.Dlna.Server.Metadata;
namespace Roadie.Dlna.Server
{
public interface IMediaVideoResource : IMediaResource, IMetaVideoItem
{
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server
{
internal interface IPrefixHandler : IHandler
{
string Prefix { get; }
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,13 @@
using System.IO;
namespace Roadie.Dlna.Server
{
internal interface IResponse
{
Stream Body { get; }
IHeaders Headers { get; }
HttpCode Status { get; }
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server
{
public interface ITitleComparable
{
string ToComparableTitle();
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace Roadie.Dlna.Server
{
public interface IVolatileMediaServer
{
bool Rescanning { get; set; }
event EventHandler Changed;
void Rescan();
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaDescription
{
string MetaDescription { get; }
}
}

View file

@ -0,0 +1,9 @@
using System;
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaDuration
{
TimeSpan? MetaDuration { get; }
}
}

View file

@ -0,0 +1,7 @@
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaGenre
{
string MetaGenre { get; }
}
}

View file

@ -0,0 +1,8 @@
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaImageItem
: IMetaInfo, IMetaResolution, IMetaDescription
{
string MetaCreator { get; }
}
}

View file

@ -0,0 +1,11 @@
using System;
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaInfo
{
DateTime InfoDate { get; }
long? InfoSize { get; }
}
}

View file

@ -0,0 +1,9 @@
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaResolution
{
int? MetaHeight { get; }
int? MetaWidth { get; }
}
}

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Roadie.Dlna.Server.Metadata
{
public interface IMetaVideoItem
: IMetaInfo, IMetaDescription, IMetaGenre, IMetaDuration, IMetaResolution
{
IEnumerable<string> MetaActors { get; }
string MetaDirector { get; }
Subtitle Subtitle { get; }
}
}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" ?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>IsAuthorized</name>
<argumentList>
<argument>
<name>DeviceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>RegisterDevice</name>
<argumentList>
<argument>
<name>RegistrationReqMsg</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_RegistrationReqMsg</relatedStateVariable>
</argument>
<argument>
<name>RegistrationRespMsg</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RegistrationRespMsg</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>IsValidated</name>
<argumentList>
<argument>
<name>DeviceID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_DeviceID</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_DeviceID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>int</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RegistrationReqMsg</name>
<dataType>bin.base64</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RegistrationRespMsg</name>
<dataType>bin.base64</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>AuthorizationGrantedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>AuthorizationDeniedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ValidationSucceededUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ValidationRevokedUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -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;
}

View file

@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetCurrentConnectionInfo</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>ProtocolInfo</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>Status</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionIDs</name>
<argumentList>
<argument>
<name>ConnectionIDs</name>
<direction>out</direction>
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>OK</allowedValue>
<allowedValue>ContentFormatMismatch</allowedValue>
<allowedValue>InsufficientBandwidth</allowedValue>
<allowedValue>UnreliableChannel</allowedValue>
<allowedValue>Unknown</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_AVTransportID</name>
<dataType>i4</dataType>
<defaultValue>0</defaultValue>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RcsID</name>
<dataType>i4</dataType>
<defaultValue>0</defaultValue>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionID</name>
<dataType>i4</dataType>
<defaultValue>0</defaultValue>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionManager</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Direction</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Input</allowedValue>
<allowedValue>Output</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="yes">
<name>CurrentConnectionIDs</name>
<dataType>string</dataType>
<defaultValue>0</defaultValue>
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetSystemUpdateID</name>
<argumentList>
<argument>
<name>Id</name>
<direction>out</direction>
<relatedStateVariable>SystemUpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSearchCapabilities</name>
<argumentList>
<argument>
<name>SearchCaps</name>
<direction>out</direction>
<relatedStateVariable>SearchCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSortCapabilities</name>
<argumentList>
<argument>
<name>SortCaps</name>
<direction>out</direction>
<relatedStateVariable>SortCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>Browse</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>BrowseFlag</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
</argument>
<argument>
<name>Filter</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
</argument>
<argument>
<name>StartingIndex</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
</argument>
<argument>
<name>RequestedCount</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>SortCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>NumberReturned</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>TotalMatches</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>UpdateID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>X_GetFeatureList</name>
<argumentList>
<argument>
<name>FeatureList</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Featurelist</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>X_SetBookmark</name>
<argumentList>
<argument>
<name>CategoryType</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_CategoryType</relatedStateVariable>
</argument>
<argument>
<name>RID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_RID</relatedStateVariable>
</argument>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>PosSecond</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_PosSec</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SortCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_UpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SearchCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Filter</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Index</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ObjectID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SearchCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Count</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_BrowseFlag</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>BrowseMetadata</allowedValue>
<allowedValue>BrowseDirectChildren</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SystemUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_BrowseLetter</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_CategoryType</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RID</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_PosSec</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Featurelist</name>
<dataType>string</dataType>
<defaultValue />
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,77 @@
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns:sec="http://www.sec.co.kr/dlna">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<dlna:X_DLNACAP />
<dlna:X_DLNADOC>DMS-1.50</dlna:X_DLNADOC>
<UDN></UDN>
<dlna:X_DLNADOC>M-DMS-1.50</dlna:X_DLNADOC>
<friendlyName />
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<manufacturer>Roadie</manufacturer>
<manufacturerURL>https://github.com/sphildreth/roadie</manufacturerURL>
<modelName>Roadie Music Server</modelName>
<modelDescription></modelDescription>
<modelNumber></modelNumber>
<modelURL>https://github.com/sphildreth/roadie/</modelURL>
<serialNumber></serialNumber>
<sec:ProductCap>smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec</sec:ProductCap>
<sec:X_ProductCap>smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec</sec:X_ProductCap>
<iconList>
<icon>
<mimetype>image/jpeg</mimetype>
<width>48</width>
<height>48</height>
<depth>24</depth>
<url>/icon/small.jpg</url>
</icon>
<icon>
<mimetype>image/png</mimetype>
<width>48</width>
<height>48</height>
<depth>24</depth>
<url>/icon/small.png</url>
</icon>
<icon>
<mimetype>image/png</mimetype>
<width>120</width>
<height>120</height>
<depth>24</depth>
<url>/icon/large.png</url>
</icon>
<icon>
<mimetype>image/jpeg</mimetype>
<width>120</width>
<height>120</height>
<depth>24</depth>
<url>/icon/large.jpg</url>
</icon>
</iconList>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/contentDirectory.xml</SCPDURL>
<controlURL>/serviceControl</controlURL>
<eventSubURL></eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/connectionManager.xml</SCPDURL>
<controlURL>/serviceControl</controlURL>
<eventSubURL></eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:X_MS_MediaReceiverRegistrar:1</serviceType>
<serviceId>urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar</serviceId>
<SCPDURL>/MSMediaReceiverRegistrar.xml</SCPDURL>
<controlURL>/serviceControl</controlURL>
<eventSubURL></eventSubURL>
</service>
</serviceList>
</device>
</root>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Features xmlns="urn:schemas-upnp-org:av:avs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:schemas-upnp-org:av:avs http://www.upnp.org/schemas/av/avs.xsd">
<Feature name="samsung.com_BASICVIEW" version="1">
<container id="I" type="object.item.imageItem" />
<container id="A" type="object.item.audioItem" />
</Feature>
</Features>

View file

@ -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();
}
}
}

View file

@ -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);
}
}
}

View file

@ -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}")
{
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<Guid, List<UpnpDevice>> devices = new Dictionary<Guid, List<UpnpDevice>>();
private readonly ConcurrentQueue<Datagram> messageQueue = new ConcurrentQueue<Datagram>();
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<UpnpDevice> list;
lock (devices)
{
if (!devices.TryGetValue(uuid, out list))
{
devices.Add(uuid, list = new List<UpnpDevice>());
}
}
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<UpnpDevice> 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();
}
}
}

View file

@ -0,0 +1,26 @@
using System;
namespace Roadie.Dlna.Server
{
internal class AudioResourceDecorator
: MediaResourceDecorator<IMediaAudioResource>
{
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)
{
}
}
}

View file

@ -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
}
}

Some files were not shown because too many files have changed in this diff Show more