mirror of
https://github.com/sphildreth/roadie
synced 2024-11-21 19:53:11 +00:00
resolves #10
This commit is contained in:
parent
89710c6bbc
commit
c08ce7676c
166 changed files with 10136 additions and 166 deletions
|
@ -3,6 +3,7 @@
|
|||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
28
Roadie.Api.Library/Configuration/Dlna.cs
Normal file
28
Roadie.Api.Library/Configuration/Dlna.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
14
Roadie.Api.Library/Configuration/IDlna.cs
Normal file
14
Roadie.Api.Library/Configuration/IDlna.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
43
Roadie.Api.Library/Utility/AsyncHelper.cs
Normal file
43
Roadie.Api.Library/Utility/AsyncHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
71
Roadie.Api.Library/Utility/MimeTypeHelper.cs
Normal file
71
Roadie.Api.Library/Utility/MimeTypeHelper.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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,
|
||||
|
|
47
Roadie.Dlna.Services/CoverArt.cs
Normal file
47
Roadie.Dlna.Services/CoverArt.cs
Normal 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();
|
||||
}
|
||||
}
|
131
Roadie.Dlna.Services/DlnaHostService.cs
Normal file
131
Roadie.Dlna.Services/DlnaHostService.cs
Normal 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
|
||||
}
|
||||
}
|
713
Roadie.Dlna.Services/DlnaService.cs
Normal file
713
Roadie.Dlna.Services/DlnaService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
14
Roadie.Dlna.Services/Roadie.Dlna.Services.csproj
Normal file
14
Roadie.Dlna.Services/Roadie.Dlna.Services.csproj
Normal 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>
|
131
Roadie.Dlna.Services/Track.cs
Normal file
131
Roadie.Dlna.Services/Track.cs
Normal 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();
|
||||
}
|
||||
}
|
48
Roadie.Dlna/Roadie.Dlna.csproj
Normal file
48
Roadie.Dlna/Roadie.Dlna.csproj
Normal 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>
|
13
Roadie.Dlna/Server/Comparers/BaseComparer.cs
Normal file
13
Roadie.Dlna/Server/Comparers/BaseComparer.cs
Normal 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}";
|
||||
}
|
||||
}
|
26
Roadie.Dlna/Server/Comparers/DateComparer.cs
Normal file
26
Roadie.Dlna/Server/Comparers/DateComparer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
23
Roadie.Dlna/Server/Comparers/FileSizeComparer.cs
Normal file
23
Roadie.Dlna/Server/Comparers/FileSizeComparer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Comparers/IItemComparer.cs
Normal file
8
Roadie.Dlna/Server/Comparers/IItemComparer.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Roadie.Dlna.Server.Comparers
|
||||
{
|
||||
public interface IItemComparer : IComparer<IMediaItem>
|
||||
{
|
||||
}
|
||||
}
|
31
Roadie.Dlna/Server/Comparers/TitleComparer.cs
Normal file
31
Roadie.Dlna/Server/Comparers/TitleComparer.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
20
Roadie.Dlna/Server/Handlers/IconHandler.cs
Normal file
20
Roadie.Dlna/Server/Handlers/IconHandler.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
43
Roadie.Dlna/Server/Handlers/IndexHandler.cs
Normal file
43
Roadie.Dlna/Server/Handlers/IndexHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
221
Roadie.Dlna/Server/Handlers/MediaMount.cs
Normal file
221
Roadie.Dlna/Server/Handlers/MediaMount.cs
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
133
Roadie.Dlna/Server/Handlers/MediaMount_HTML.cs
Normal file
133
Roadie.Dlna/Server/Handlers/MediaMount_HTML.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
629
Roadie.Dlna/Server/Handlers/MediaMount_SOAP.cs
Normal file
629
Roadie.Dlna/Server/Handlers/MediaMount_SOAP.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
25
Roadie.Dlna/Server/Handlers/StaticHandler.cs
Normal file
25
Roadie.Dlna/Server/Handlers/StaticHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
338
Roadie.Dlna/Server/Http/HTTPServer.cs
Normal file
338
Roadie.Dlna/Server/Http/HTTPServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
Roadie.Dlna/Server/Http/HttpAuthorizationEventArgs.cs
Normal file
21
Roadie.Dlna/Server/Http/HttpAuthorizationEventArgs.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
72
Roadie.Dlna/Server/Http/HttpAuthorizer.cs
Normal file
72
Roadie.Dlna/Server/Http/HttpAuthorizer.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
514
Roadie.Dlna/Server/Http/HttpClient.cs
Normal file
514
Roadie.Dlna/Server/Http/HttpClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
16
Roadie.Dlna/Server/Http/HttpCode.cs
Normal file
16
Roadie.Dlna/Server/Http/HttpCode.cs
Normal 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
|
||||
}
|
||||
}
|
21
Roadie.Dlna/Server/Http/HttpPhrases.cs
Normal file
21
Roadie.Dlna/Server/Http/HttpPhrases.cs
Normal 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"}
|
||||
};
|
||||
}
|
||||
}
|
15
Roadie.Dlna/Server/Http/IHttpAuthorizationMethod.cs
Normal file
15
Roadie.Dlna/Server/Http/IHttpAuthorizationMethod.cs
Normal 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);
|
||||
}
|
||||
}
|
43
Roadie.Dlna/Server/Http/IPAddressAuthorizer.cs
Normal file
43
Roadie.Dlna/Server/Http/IPAddressAuthorizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
23
Roadie.Dlna/Server/Http/ResponseHeaders.cs
Normal file
23
Roadie.Dlna/Server/Http/ResponseHeaders.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
48
Roadie.Dlna/Server/Http/UserAgentAuthorizer.cs
Normal file
48
Roadie.Dlna/Server/Http/UserAgentAuthorizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/IBookmarkable.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/IBookmarkable.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IBookmarkable
|
||||
{
|
||||
long? Bookmark { get; set; }
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/IHandler.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/IHandler.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
internal interface IHandler
|
||||
{
|
||||
IResponse HandleRequest(IRequest request);
|
||||
}
|
||||
}
|
12
Roadie.Dlna/Server/Interfaces/IHeaders.cs
Normal file
12
Roadie.Dlna/Server/Interfaces/IHeaders.cs
Normal 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; }
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Interfaces/IMediaAudioResource.cs
Normal file
8
Roadie.Dlna/Server/Interfaces/IMediaAudioResource.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Roadie.Dlna.Server.Metadata;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IMediaAudioResource : IMediaResource, IMetaAudioItem, IMetaInfo
|
||||
{
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/IMediaCover.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/IMediaCover.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IMediaCover
|
||||
{
|
||||
IMediaCoverResource Cover { get; }
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Interfaces/IMediaCoverResource.cs
Normal file
8
Roadie.Dlna/Server/Interfaces/IMediaCoverResource.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Roadie.Dlna.Server.Metadata;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IMediaCoverResource : IMediaResource, IMetaResolution
|
||||
{
|
||||
}
|
||||
}
|
22
Roadie.Dlna/Server/Interfaces/IMediaFolder.cs
Normal file
22
Roadie.Dlna/Server/Interfaces/IMediaFolder.cs
Normal 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);
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Interfaces/IMediaImageResource.cs
Normal file
8
Roadie.Dlna/Server/Interfaces/IMediaImageResource.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Roadie.Dlna.Server.Metadata;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IMediaImageResource : IMediaResource, IMetaImageItem
|
||||
{
|
||||
}
|
||||
}
|
15
Roadie.Dlna/Server/Interfaces/IMediaItem.cs
Normal file
15
Roadie.Dlna/Server/Interfaces/IMediaItem.cs
Normal 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; }
|
||||
}
|
||||
}
|
15
Roadie.Dlna/Server/Interfaces/IMediaResource.cs
Normal file
15
Roadie.Dlna/Server/Interfaces/IMediaResource.cs
Normal 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();
|
||||
}
|
||||
}
|
17
Roadie.Dlna/Server/Interfaces/IMediaServer.cs
Normal file
17
Roadie.Dlna/Server/Interfaces/IMediaServer.cs
Normal 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);
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Interfaces/IMediaVideoResource.cs
Normal file
8
Roadie.Dlna/Server/Interfaces/IMediaVideoResource.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using Roadie.Dlna.Server.Metadata;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IMediaVideoResource : IMediaResource, IMetaVideoItem
|
||||
{
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/IPrefixHandler.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/IPrefixHandler.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
internal interface IPrefixHandler : IHandler
|
||||
{
|
||||
string Prefix { get; }
|
||||
}
|
||||
}
|
19
Roadie.Dlna/Server/Interfaces/IRequest.cs
Normal file
19
Roadie.Dlna/Server/Interfaces/IRequest.cs
Normal 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; }
|
||||
}
|
||||
}
|
13
Roadie.Dlna/Server/Interfaces/IResponse.cs
Normal file
13
Roadie.Dlna/Server/Interfaces/IResponse.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System.IO;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
internal interface IResponse
|
||||
{
|
||||
Stream Body { get; }
|
||||
|
||||
IHeaders Headers { get; }
|
||||
|
||||
HttpCode Status { get; }
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/ITitleComparable.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/ITitleComparable.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface ITitleComparable
|
||||
{
|
||||
string ToComparableTitle();
|
||||
}
|
||||
}
|
13
Roadie.Dlna/Server/Interfaces/IVolatileMediaServer.cs
Normal file
13
Roadie.Dlna/Server/Interfaces/IVolatileMediaServer.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
|
||||
namespace Roadie.Dlna.Server
|
||||
{
|
||||
public interface IVolatileMediaServer
|
||||
{
|
||||
bool Rescanning { get; set; }
|
||||
|
||||
event EventHandler Changed;
|
||||
|
||||
void Rescan();
|
||||
}
|
||||
}
|
17
Roadie.Dlna/Server/Interfaces/Metadata/IMetaAudioItem.cs
Normal file
17
Roadie.Dlna/Server/Interfaces/Metadata/IMetaAudioItem.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaDescription
|
||||
{
|
||||
string MetaDescription { get; }
|
||||
}
|
||||
}
|
9
Roadie.Dlna/Server/Interfaces/Metadata/IMetaDuration.cs
Normal file
9
Roadie.Dlna/Server/Interfaces/Metadata/IMetaDuration.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using System;
|
||||
|
||||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaDuration
|
||||
{
|
||||
TimeSpan? MetaDuration { get; }
|
||||
}
|
||||
}
|
7
Roadie.Dlna/Server/Interfaces/Metadata/IMetaGenre.cs
Normal file
7
Roadie.Dlna/Server/Interfaces/Metadata/IMetaGenre.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaGenre
|
||||
{
|
||||
string MetaGenre { get; }
|
||||
}
|
||||
}
|
8
Roadie.Dlna/Server/Interfaces/Metadata/IMetaImageItem.cs
Normal file
8
Roadie.Dlna/Server/Interfaces/Metadata/IMetaImageItem.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaImageItem
|
||||
: IMetaInfo, IMetaResolution, IMetaDescription
|
||||
{
|
||||
string MetaCreator { get; }
|
||||
}
|
||||
}
|
11
Roadie.Dlna/Server/Interfaces/Metadata/IMetaInfo.cs
Normal file
11
Roadie.Dlna/Server/Interfaces/Metadata/IMetaInfo.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaInfo
|
||||
{
|
||||
DateTime InfoDate { get; }
|
||||
|
||||
long? InfoSize { get; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace Roadie.Dlna.Server.Metadata
|
||||
{
|
||||
public interface IMetaResolution
|
||||
{
|
||||
int? MetaHeight { get; }
|
||||
|
||||
int? MetaWidth { get; }
|
||||
}
|
||||
}
|
14
Roadie.Dlna/Server/Interfaces/Metadata/IMetaVideoItem.cs
Normal file
14
Roadie.Dlna/Server/Interfaces/Metadata/IMetaVideoItem.cs
Normal 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; }
|
||||
}
|
||||
}
|
88
Roadie.Dlna/Server/Resources/MSMediaReceiverRegistrar.xml
Normal file
88
Roadie.Dlna/Server/Resources/MSMediaReceiverRegistrar.xml
Normal 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>
|
164
Roadie.Dlna/Server/Resources/browse.css
Normal file
164
Roadie.Dlna/Server/Resources/browse.css
Normal 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;
|
||||
}
|
136
Roadie.Dlna/Server/Resources/connectionmanager.xml
Normal file
136
Roadie.Dlna/Server/Resources/connectionmanager.xml
Normal 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>
|
207
Roadie.Dlna/Server/Resources/contentdirectory.xml
Normal file
207
Roadie.Dlna/Server/Resources/contentdirectory.xml
Normal 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>
|
77
Roadie.Dlna/Server/Resources/description.xml
Normal file
77
Roadie.Dlna/Server/Resources/description.xml
Normal 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>
|
BIN
Roadie.Dlna/Server/Resources/favicon.ico
Normal file
BIN
Roadie.Dlna/Server/Resources/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
BIN
Roadie.Dlna/Server/Resources/large.jpg
Normal file
BIN
Roadie.Dlna/Server/Resources/large.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
BIN
Roadie.Dlna/Server/Resources/large.png
Normal file
BIN
Roadie.Dlna/Server/Resources/large.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
BIN
Roadie.Dlna/Server/Resources/small.jpg
Normal file
BIN
Roadie.Dlna/Server/Resources/small.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
BIN
Roadie.Dlna/Server/Resources/small.png
Normal file
BIN
Roadie.Dlna/Server/Resources/small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
7
Roadie.Dlna/Server/Resources/x_featurelist.xml
Normal file
7
Roadie.Dlna/Server/Resources/x_featurelist.xml
Normal 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>
|
29
Roadie.Dlna/Server/Responses/FileResponse.cs
Normal file
29
Roadie.Dlna/Server/Responses/FileResponse.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
74
Roadie.Dlna/Server/Responses/ItemResponse.cs
Normal file
74
Roadie.Dlna/Server/Responses/ItemResponse.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
38
Roadie.Dlna/Server/Responses/Redirect.cs
Normal file
38
Roadie.Dlna/Server/Responses/Redirect.cs
Normal 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}")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
36
Roadie.Dlna/Server/Responses/ResourceResponse.cs
Normal file
36
Roadie.Dlna/Server/Responses/ResourceResponse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
30
Roadie.Dlna/Server/Responses/StringResponse.cs
Normal file
30
Roadie.Dlna/Server/Responses/StringResponse.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
70
Roadie.Dlna/Server/Ssdp/Datagram.cs
Normal file
70
Roadie.Dlna/Server/Ssdp/Datagram.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
343
Roadie.Dlna/Server/Ssdp/SsdpHandler.cs
Normal file
343
Roadie.Dlna/Server/Ssdp/SsdpHandler.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
26
Roadie.Dlna/Server/Types/AudioResourceDecorator.cs
Normal file
26
Roadie.Dlna/Server/Types/AudioResourceDecorator.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
21
Roadie.Dlna/Server/Types/DlnaFlags.cs
Normal file
21
Roadie.Dlna/Server/Types/DlnaFlags.cs
Normal 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
Loading…
Reference in a new issue