Initial commit
331
.gitignore
vendored
Normal file
@ -0,0 +1,331 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# CodeRush
|
||||
.cr/
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
43
Jellyfin.Plugin.Dlna.sln
Normal file
@ -0,0 +1,43 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Dlna", "src\Jellyfin.Plugin.Dlna\Jellyfin.Plugin.Dlna.csproj", "{E9649D61-9797-46E2-B1B2-D22C17066BDB}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{297324E0-E9EF-4C5A-B7AF-21CA67437D95} = {297324E0-E9EF-4C5A-B7AF-21CA67437D95}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Dlna.Model", "src\Jellyfin.Plugin.Dlna.Model\Jellyfin.Plugin.Dlna.Model.csproj", "{297324E0-E9EF-4C5A-B7AF-21CA67437D95}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rssdp", "src\Rssdp\Rssdp.csproj", "{80B12BB3-24B3-47B1-93C4-5ADAF786E55B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Dlna.Playback", "src\Jellyfin.Plugin.Dlna.Playback\Jellyfin.Plugin.Dlna.Playback.csproj", "{D5627E87-BB93-426A-B067-AE84DFAD1E84}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E9649D61-9797-46E2-B1B2-D22C17066BDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E9649D61-9797-46E2-B1B2-D22C17066BDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E9649D61-9797-46E2-B1B2-D22C17066BDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E9649D61-9797-46E2-B1B2-D22C17066BDB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{297324E0-E9EF-4C5A-B7AF-21CA67437D95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{297324E0-E9EF-4C5A-B7AF-21CA67437D95}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{297324E0-E9EF-4C5A-B7AF-21CA67437D95}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{297324E0-E9EF-4C5A-B7AF-21CA67437D95}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{80B12BB3-24B3-47B1-93C4-5ADAF786E55B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{80B12BB3-24B3-47B1-93C4-5ADAF786E55B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{80B12BB3-24B3-47B1-93C4-5ADAF786E55B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{80B12BB3-24B3-47B1-93C4-5ADAF786E55B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D5627E87-BB93-426A-B067-AE84DFAD1E84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D5627E87-BB93-426A-B067-AE84DFAD1E84}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D5627E87-BB93-426A-B067-AE84DFAD1E84}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D5627E87-BB93-426A-B067-AE84DFAD1E84}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
7
src/Directory.Build.Props
Normal file
@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.0.0.0</Version>
|
||||
<AssemblyVersion>0.0.0.0</AssemblyVersion>
|
||||
<FileVersion>0.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
259
src/Jellyfin.Plugin.Dlna.Model/ContentFeatureBuilder.cs
Normal file
@ -0,0 +1,259 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model;
|
||||
|
||||
public static class ContentFeatureBuilder
|
||||
{
|
||||
public static string BuildImageHeader(
|
||||
DeviceProfile profile,
|
||||
string container,
|
||||
int? width,
|
||||
int? height,
|
||||
bool isDirectStream,
|
||||
string orgPn = null)
|
||||
{
|
||||
string orgOp = ";DLNA.ORG_OP=" + DlnaMaps.GetImageOrgOpValue();
|
||||
|
||||
// 0 = native, 1 = transcoded
|
||||
var orgCi = isDirectStream ? ";DLNA.ORG_CI=0" : ";DLNA.ORG_CI=1";
|
||||
|
||||
var flagValue = DlnaFlags.BackgroundTransferMode |
|
||||
DlnaFlags.InteractiveTransferMode |
|
||||
DlnaFlags.DlnaV15;
|
||||
|
||||
string dlnaflags = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
";DLNA.ORG_FLAGS={0}",
|
||||
DlnaMaps.FlagsToString(flagValue));
|
||||
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
ResponseProfile mediaProfile = profile.GetImageMediaProfile(
|
||||
container,
|
||||
width,
|
||||
height);
|
||||
|
||||
orgPn = mediaProfile?.OrgPn;
|
||||
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
orgPn = GetImageOrgPnValue(container, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
return orgOp.TrimStart(';') + orgCi + dlnaflags;
|
||||
}
|
||||
|
||||
return "DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags;
|
||||
}
|
||||
|
||||
public static string BuildAudioHeader(
|
||||
DeviceProfile profile,
|
||||
string container,
|
||||
string audioCodec,
|
||||
int? audioBitrate,
|
||||
int? audioSampleRate,
|
||||
int? audioChannels,
|
||||
int? audioBitDepth,
|
||||
bool isDirectStream,
|
||||
long? runtimeTicks,
|
||||
TranscodeSeekInfo transcodeSeekInfo)
|
||||
{
|
||||
// first bit means Time based seek supported, second byte range seek supported (not sure about the order now), so 01 = only byte seek, 10 = time based, 11 = both, 00 = none
|
||||
string orgOp = ";DLNA.ORG_OP=" + DlnaMaps.GetOrgOpValue(runtimeTicks > 0, isDirectStream, transcodeSeekInfo);
|
||||
|
||||
// 0 = native, 1 = transcoded
|
||||
string orgCi = isDirectStream ? ";DLNA.ORG_CI=0" : ";DLNA.ORG_CI=1";
|
||||
|
||||
var flagValue = DlnaFlags.StreamingTransferMode |
|
||||
DlnaFlags.BackgroundTransferMode |
|
||||
DlnaFlags.InteractiveTransferMode |
|
||||
DlnaFlags.DlnaV15;
|
||||
|
||||
// if (isDirectStream)
|
||||
// {
|
||||
// flagValue = flagValue | DlnaFlags.ByteBasedSeek;
|
||||
// }
|
||||
// else if (runtimeTicks.HasValue)
|
||||
// {
|
||||
// flagValue = flagValue | DlnaFlags.TimeBasedSeek;
|
||||
// }
|
||||
|
||||
string dlnaflags = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
";DLNA.ORG_FLAGS={0}",
|
||||
DlnaMaps.FlagsToString(flagValue));
|
||||
|
||||
ResponseProfile mediaProfile = profile.GetAudioMediaProfile(
|
||||
container,
|
||||
audioCodec,
|
||||
audioChannels,
|
||||
audioBitrate,
|
||||
audioSampleRate,
|
||||
audioBitDepth);
|
||||
|
||||
string orgPn = mediaProfile?.OrgPn;
|
||||
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
orgPn = GetAudioOrgPnValue(container, audioBitrate, audioSampleRate, audioChannels);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
return orgOp.TrimStart(';') + orgCi + dlnaflags;
|
||||
}
|
||||
|
||||
return "DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> BuildVideoHeader(
|
||||
DeviceProfile profile,
|
||||
string container,
|
||||
string videoCodec,
|
||||
string audioCodec,
|
||||
int? width,
|
||||
int? height,
|
||||
int? bitDepth,
|
||||
int? videoBitrate,
|
||||
TransportStreamTimestamp timestamp,
|
||||
bool isDirectStream,
|
||||
long? runtimeTicks,
|
||||
string videoProfile,
|
||||
VideoRangeType videoRangeType,
|
||||
double? videoLevel,
|
||||
float? videoFramerate,
|
||||
int? packetLength,
|
||||
TranscodeSeekInfo transcodeSeekInfo,
|
||||
bool? isAnamorphic,
|
||||
bool? isInterlaced,
|
||||
int? refFrames,
|
||||
int? numVideoStreams,
|
||||
int? numAudioStreams,
|
||||
string videoCodecTag,
|
||||
bool? isAvc)
|
||||
{
|
||||
// first bit means Time based seek supported, second byte range seek supported (not sure about the order now), so 01 = only byte seek, 10 = time based, 11 = both, 00 = none
|
||||
string orgOp = ";DLNA.ORG_OP=" + DlnaMaps.GetOrgOpValue(runtimeTicks > 0, isDirectStream, transcodeSeekInfo);
|
||||
|
||||
// 0 = native, 1 = transcoded
|
||||
string orgCi = isDirectStream ? ";DLNA.ORG_CI=0" : ";DLNA.ORG_CI=1";
|
||||
|
||||
var flagValue = DlnaFlags.StreamingTransferMode |
|
||||
DlnaFlags.BackgroundTransferMode |
|
||||
DlnaFlags.InteractiveTransferMode |
|
||||
DlnaFlags.DlnaV15;
|
||||
|
||||
if (isDirectStream)
|
||||
{
|
||||
flagValue |= DlnaFlags.ByteBasedSeek;
|
||||
}
|
||||
|
||||
// Time based seek is currently disabled when streaming. On LG CX3 adding DlnaFlags.TimeBasedSeek and orgPn causes the DLNA playback to fail (format not supported). Further investigations are needed before enabling the remaining code paths.
|
||||
// else if (runtimeTicks.HasValue)
|
||||
// {
|
||||
// flagValue = flagValue | DlnaFlags.TimeBasedSeek;
|
||||
// }
|
||||
|
||||
string dlnaflags = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
";DLNA.ORG_FLAGS={0}",
|
||||
DlnaMaps.FlagsToString(flagValue));
|
||||
|
||||
ResponseProfile mediaProfile = profile.GetVideoMediaProfile(
|
||||
container,
|
||||
audioCodec,
|
||||
videoCodec,
|
||||
width,
|
||||
height,
|
||||
bitDepth,
|
||||
videoBitrate,
|
||||
videoProfile,
|
||||
videoRangeType,
|
||||
videoLevel,
|
||||
videoFramerate,
|
||||
packetLength,
|
||||
timestamp,
|
||||
isAnamorphic,
|
||||
isInterlaced,
|
||||
refFrames,
|
||||
numVideoStreams,
|
||||
numAudioStreams,
|
||||
videoCodecTag,
|
||||
isAvc);
|
||||
|
||||
var orgPnValues = new List<string>();
|
||||
|
||||
if (mediaProfile is not null && !string.IsNullOrEmpty(mediaProfile.OrgPn))
|
||||
{
|
||||
orgPnValues.AddRange(mediaProfile.OrgPn.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var s in GetVideoOrgPnValue(container, videoCodec, audioCodec, width, height, timestamp))
|
||||
{
|
||||
orgPnValues.Add(s.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var contentFeatureList = new List<string>();
|
||||
|
||||
foreach (string orgPn in orgPnValues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(orgPn))
|
||||
{
|
||||
contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags);
|
||||
}
|
||||
else if (isDirectStream)
|
||||
{
|
||||
// orgOp should be added all the time once the time based seek is resolved for transcoded streams
|
||||
contentFeatureList.Add("DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags);
|
||||
}
|
||||
else
|
||||
{
|
||||
contentFeatureList.Add("DLNA.ORG_PN=" + orgPn + orgCi + dlnaflags);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgPnValues.Count == 0)
|
||||
{
|
||||
contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags);
|
||||
}
|
||||
|
||||
return contentFeatureList;
|
||||
}
|
||||
|
||||
private static string GetImageOrgPnValue(string container, int? width, int? height)
|
||||
{
|
||||
MediaFormatProfile? format = MediaFormatProfileResolver.ResolveImageFormat(container, width, height);
|
||||
|
||||
return format.HasValue ? format.Value.ToString() : null;
|
||||
}
|
||||
|
||||
private static string GetAudioOrgPnValue(string container, int? audioBitrate, int? audioSampleRate, int? audioChannels)
|
||||
{
|
||||
MediaFormatProfile? format = MediaFormatProfileResolver.ResolveAudioFormat(
|
||||
container,
|
||||
audioBitrate,
|
||||
audioSampleRate,
|
||||
audioChannels);
|
||||
|
||||
return format.HasValue ? format.Value.ToString() : null;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile[] GetVideoOrgPnValue(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestamp)
|
||||
{
|
||||
return MediaFormatProfileResolver.ResolveVideoFormat(container, videoCodec, audioCodec, width, height, timestamp);
|
||||
}
|
||||
}
|
50
src/Jellyfin.Plugin.Dlna.Model/DlnaFlags.cs
Normal file
@ -0,0 +1,50 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
[Flags]
|
||||
public enum DlnaFlags : ulong
|
||||
{
|
||||
/*! <i>Background</i> transfer mode.
|
||||
For use with upload and download transfers to and from the server.
|
||||
The primary difference between \ref DH_TransferMode_Interactive and
|
||||
\ref DH_TransferMode_Bulk is that the latter assumes that the user
|
||||
is not relying on the transfer for immediately rendering the content
|
||||
and there are no issues with causing a buffer overflow if the
|
||||
receiver uses TCP flow control to reduce total throughput.
|
||||
*/
|
||||
BackgroundTransferMode = 1 << 22,
|
||||
|
||||
ByteBasedSeek = 1 << 29,
|
||||
ConnectionStall = 1 << 21,
|
||||
|
||||
DlnaV15 = 1 << 20,
|
||||
|
||||
/*! <i>Interactive</i> transfer mode.
|
||||
For best effort transfer of images and non-real-time transfers.
|
||||
URIs with image content usually support \ref DH_TransferMode_Bulk too.
|
||||
The primary difference between \ref DH_TransferMode_Interactive and
|
||||
\ref DH_TransferMode_Bulk is that the former assumes that the
|
||||
transfer is intended for immediate rendering.
|
||||
*/
|
||||
InteractiveTransferMode = 1 << 23,
|
||||
|
||||
PlayContainer = 1 << 28,
|
||||
RtspPause = 1 << 25,
|
||||
S0Increase = 1 << 27,
|
||||
SenderPaced = 1L << 31,
|
||||
SnIncrease = 1 << 26,
|
||||
|
||||
/*! <i>Streaming</i> transfer mode.
|
||||
The server transmits at a throughput sufficient for real-time playback of
|
||||
audio or video. URIs with audio or video often support the
|
||||
\ref DH_TransferMode_Interactive and \ref DH_TransferMode_Bulk transfer modes.
|
||||
The most well-known exception to this general claim is for live streams.
|
||||
*/
|
||||
StreamingTransferMode = 1 << 24,
|
||||
|
||||
TimeBasedSeek = 1 << 30
|
||||
}
|
||||
}
|
47
src/Jellyfin.Plugin.Dlna.Model/DlnaMaps.cs
Normal file
@ -0,0 +1,47 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
public static class DlnaMaps
|
||||
{
|
||||
public static string FlagsToString(DlnaFlags flags)
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:X8}{1:D24}", (ulong)flags, 0);
|
||||
}
|
||||
|
||||
public static string GetOrgOpValue(bool hasKnownRuntime, bool isDirectStream, TranscodeSeekInfo profileTranscodeSeekInfo)
|
||||
{
|
||||
if (hasKnownRuntime)
|
||||
{
|
||||
string orgOp = string.Empty;
|
||||
|
||||
// Time-based seeking currently only possible when transcoding
|
||||
orgOp += isDirectStream ? "0" : "1";
|
||||
|
||||
// Byte-based seeking only possible when not transcoding
|
||||
orgOp += isDirectStream || profileTranscodeSeekInfo == TranscodeSeekInfo.Bytes ? "1" : "0";
|
||||
|
||||
return orgOp;
|
||||
}
|
||||
|
||||
// No seeking is available if we don't know the content runtime
|
||||
return "00";
|
||||
}
|
||||
|
||||
public static string GetImageOrgOpValue()
|
||||
{
|
||||
string orgOp = string.Empty;
|
||||
|
||||
// Time-based seeking currently only possible when transcoding
|
||||
orgOp += "0";
|
||||
|
||||
// Byte-based seeking only possible when not transcoding
|
||||
orgOp += "0";
|
||||
|
||||
return orgOp;
|
||||
}
|
||||
}
|
||||
}
|
13
src/Jellyfin.Plugin.Dlna.Model/IDeviceDiscovery.cs
Normal file
@ -0,0 +1,13 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using Jellyfin.Data.Events;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model;
|
||||
|
||||
public interface IDeviceDiscovery
|
||||
{
|
||||
event EventHandler<GenericEventArgs<UpnpDeviceInfo>> DeviceDiscovered;
|
||||
|
||||
event EventHandler<GenericEventArgs<UpnpDeviceInfo>> DeviceLeft;
|
||||
}
|
80
src/Jellyfin.Plugin.Dlna.Model/IDlnaManager.cs
Normal file
@ -0,0 +1,80 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
public interface IDlnaManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the profile infos.
|
||||
/// </summary>
|
||||
/// <returns>IEnumerable{DeviceProfileInfo}.</returns>
|
||||
IEnumerable<DeviceProfileInfo> GetProfileInfos();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile.
|
||||
/// </summary>
|
||||
/// <param name="headers">The headers.</param>
|
||||
/// <returns>DeviceProfile.</returns>
|
||||
DeviceProfile? GetProfile(IHeaderDictionary headers);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
/// <returns>DeviceProfile.</returns>
|
||||
DeviceProfile GetDefaultProfile();
|
||||
|
||||
/// <summary>
|
||||
/// Creates the profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile.</param>
|
||||
void CreateProfile(DeviceProfile profile);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile id.</param>
|
||||
/// <param name="profile">The profile.</param>
|
||||
void UpdateProfile(string profileId, DeviceProfile profile);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the profile.
|
||||
/// </summary>
|
||||
/// <param name="id">The identifier.</param>
|
||||
void DeleteProfile(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile.
|
||||
/// </summary>
|
||||
/// <param name="id">The identifier.</param>
|
||||
/// <returns>DeviceProfile.</returns>
|
||||
DeviceProfile? GetProfile(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile.
|
||||
/// </summary>
|
||||
/// <param name="deviceInfo">The device information.</param>
|
||||
/// <returns>DeviceProfile.</returns>
|
||||
DeviceProfile? GetProfile(DeviceIdentification deviceInfo);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server description XML.
|
||||
/// </summary>
|
||||
/// <param name="headers">The headers.</param>
|
||||
/// <param name="serverUuId">The server uu identifier.</param>
|
||||
/// <param name="serverAddress">The server address.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the icon.
|
||||
/// </summary>
|
||||
/// <param name="filename">The filename.</param>
|
||||
/// <returns>DlnaIconResponse.</returns>
|
||||
ImageStream? GetIcon(string filename);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.0-20231109.7" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.0-20231109.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
113
src/Jellyfin.Plugin.Dlna.Model/MediaFormatProfile.cs
Normal file
@ -0,0 +1,113 @@
|
||||
#pragma warning disable CS1591, CA1707
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model;
|
||||
|
||||
public enum MediaFormatProfile
|
||||
{
|
||||
MP3,
|
||||
WMA_BASE,
|
||||
WMA_FULL,
|
||||
LPCM16_44_MONO,
|
||||
LPCM16_44_STEREO,
|
||||
LPCM16_48_MONO,
|
||||
LPCM16_48_STEREO,
|
||||
AAC_ISO,
|
||||
AAC_ISO_320,
|
||||
AAC_ADTS,
|
||||
AAC_ADTS_320,
|
||||
FLAC,
|
||||
OGG,
|
||||
|
||||
JPEG_SM,
|
||||
JPEG_MED,
|
||||
JPEG_LRG,
|
||||
JPEG_TN,
|
||||
PNG_LRG,
|
||||
PNG_TN,
|
||||
GIF_LRG,
|
||||
RAW,
|
||||
|
||||
MPEG1,
|
||||
MPEG_PS_PAL,
|
||||
MPEG_PS_NTSC,
|
||||
MPEG_TS_SD_EU,
|
||||
MPEG_TS_SD_EU_ISO,
|
||||
MPEG_TS_SD_EU_T,
|
||||
MPEG_TS_SD_NA,
|
||||
MPEG_TS_SD_NA_ISO,
|
||||
MPEG_TS_SD_NA_T,
|
||||
MPEG_TS_SD_KO,
|
||||
MPEG_TS_SD_KO_ISO,
|
||||
MPEG_TS_SD_KO_T,
|
||||
MPEG_TS_JP_T,
|
||||
AVI,
|
||||
MATROSKA,
|
||||
FLV,
|
||||
DVR_MS,
|
||||
WTV,
|
||||
OGV,
|
||||
AVC_MP4_MP_SD_AAC_MULT5,
|
||||
AVC_MP4_MP_SD_MPEG1_L3,
|
||||
AVC_MP4_MP_SD_AC3,
|
||||
AVC_MP4_MP_HD_720p_AAC,
|
||||
AVC_MP4_MP_HD_1080i_AAC,
|
||||
AVC_MP4_HP_HD_AAC,
|
||||
AVC_TS_MP_HD_AAC_MULT5,
|
||||
AVC_TS_MP_HD_AAC_MULT5_T,
|
||||
AVC_TS_MP_HD_AAC_MULT5_ISO,
|
||||
AVC_TS_MP_HD_MPEG1_L3,
|
||||
AVC_TS_MP_HD_MPEG1_L3_T,
|
||||
AVC_TS_MP_HD_MPEG1_L3_ISO,
|
||||
AVC_TS_MP_HD_AC3,
|
||||
AVC_TS_MP_HD_AC3_T,
|
||||
AVC_TS_MP_HD_AC3_ISO,
|
||||
AVC_TS_HP_HD_MPEG1_L2_T,
|
||||
AVC_TS_HP_HD_MPEG1_L2_ISO,
|
||||
AVC_TS_MP_SD_AAC_MULT5,
|
||||
AVC_TS_MP_SD_AAC_MULT5_T,
|
||||
AVC_TS_MP_SD_AAC_MULT5_ISO,
|
||||
AVC_TS_MP_SD_MPEG1_L3,
|
||||
AVC_TS_MP_SD_MPEG1_L3_T,
|
||||
AVC_TS_MP_SD_MPEG1_L3_ISO,
|
||||
AVC_TS_HP_SD_MPEG1_L2_T,
|
||||
AVC_TS_HP_SD_MPEG1_L2_ISO,
|
||||
AVC_TS_MP_SD_AC3,
|
||||
AVC_TS_MP_SD_AC3_T,
|
||||
AVC_TS_MP_SD_AC3_ISO,
|
||||
AVC_TS_HD_DTS_T,
|
||||
AVC_TS_HD_DTS_ISO,
|
||||
WMVMED_BASE,
|
||||
WMVMED_FULL,
|
||||
WMVMED_PRO,
|
||||
WMVHIGH_FULL,
|
||||
WMVHIGH_PRO,
|
||||
VC1_ASF_AP_L1_WMA,
|
||||
VC1_ASF_AP_L2_WMA,
|
||||
VC1_ASF_AP_L3_WMA,
|
||||
VC1_TS_AP_L1_AC3_ISO,
|
||||
VC1_TS_AP_L2_AC3_ISO,
|
||||
VC1_TS_HD_DTS_ISO,
|
||||
VC1_TS_HD_DTS_T,
|
||||
MPEG4_P2_MP4_ASP_AAC,
|
||||
MPEG4_P2_MP4_SP_L6_AAC,
|
||||
MPEG4_P2_MP4_NDSD,
|
||||
MPEG4_P2_TS_ASP_AAC,
|
||||
MPEG4_P2_TS_ASP_AAC_T,
|
||||
MPEG4_P2_TS_ASP_AAC_ISO,
|
||||
MPEG4_P2_TS_ASP_MPEG1_L3,
|
||||
MPEG4_P2_TS_ASP_MPEG1_L3_T,
|
||||
MPEG4_P2_TS_ASP_MPEG1_L3_ISO,
|
||||
MPEG4_P2_TS_ASP_MPEG2_L2,
|
||||
MPEG4_P2_TS_ASP_MPEG2_L2_T,
|
||||
MPEG4_P2_TS_ASP_MPEG2_L2_ISO,
|
||||
MPEG4_P2_TS_ASP_AC3,
|
||||
MPEG4_P2_TS_ASP_AC3_T,
|
||||
MPEG4_P2_TS_ASP_AC3_ISO,
|
||||
AVC_TS_HD_50_LPCM_T,
|
||||
AVC_MP4_LPCM,
|
||||
MPEG4_P2_3GPP_SP_L0B_AAC,
|
||||
MPEG4_P2_3GPP_SP_L0B_AMR,
|
||||
AVC_3GPP_BL_QCIF15_AAC,
|
||||
MPEG4_H263_3GPP_P0_L10_AMR,
|
||||
MPEG4_H263_MP4_P0_L10_AAC
|
||||
}
|
531
src/Jellyfin.Plugin.Dlna.Model/MediaFormatProfileResolver.cs
Normal file
@ -0,0 +1,531 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model;
|
||||
|
||||
public static class MediaFormatProfileResolver
|
||||
{
|
||||
public static MediaFormatProfile[] ResolveVideoFormat(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType)
|
||||
{
|
||||
if (string.Equals(container, "asf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MediaFormatProfile? val = ResolveVideoASFFormat(videoCodec, audioCodec, width, height);
|
||||
return val.HasValue ? new MediaFormatProfile[] { val.Value } : Array.Empty<MediaFormatProfile>();
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MediaFormatProfile? val = ResolveVideoMP4Format(videoCodec, audioCodec, width, height);
|
||||
return val.HasValue ? new MediaFormatProfile[] { val.Value } : Array.Empty<MediaFormatProfile>();
|
||||
}
|
||||
|
||||
if (string.Equals(container, "avi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.AVI };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mkv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.MATROSKA };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mpeg2ps", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.MPEG_PS_NTSC, MediaFormatProfile.MPEG_PS_PAL };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mpeg1video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.MPEG1 };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "m2ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveVideoMPEG2TSFormat(videoCodec, audioCodec, width, height, timestampType);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "flv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.FLV };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "wtv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.WTV };
|
||||
}
|
||||
|
||||
if (string.Equals(container, "3gp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MediaFormatProfile? val = ResolveVideo3GPFormat(videoCodec, audioCodec);
|
||||
return val.HasValue ? new MediaFormatProfile[] { val.Value } : Array.Empty<MediaFormatProfile>();
|
||||
}
|
||||
|
||||
if (string.Equals(container, "ogv", StringComparison.OrdinalIgnoreCase) || string.Equals(container, "ogg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.OGV };
|
||||
}
|
||||
|
||||
return Array.Empty<MediaFormatProfile>();
|
||||
}
|
||||
|
||||
private static MediaFormatProfile[] ResolveVideoMPEG2TSFormat(string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType)
|
||||
{
|
||||
string suffix = string.Empty;
|
||||
|
||||
switch (timestampType)
|
||||
{
|
||||
case TransportStreamTimestamp.None:
|
||||
suffix = "_ISO";
|
||||
break;
|
||||
case TransportStreamTimestamp.Valid:
|
||||
suffix = "_T";
|
||||
break;
|
||||
}
|
||||
|
||||
string resolution = "S";
|
||||
if ((width.HasValue && width.Value > 720) || (height.HasValue && height.Value > 576))
|
||||
{
|
||||
resolution = "H";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "mpeg2video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var list = new List<MediaFormatProfile>
|
||||
{
|
||||
ValueOf("MPEG_TS_SD_NA" + suffix),
|
||||
ValueOf("MPEG_TS_SD_EU" + suffix),
|
||||
ValueOf("MPEG_TS_SD_KO" + suffix)
|
||||
};
|
||||
|
||||
if ((timestampType == TransportStreamTimestamp.Valid) && string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
list.Add(MediaFormatProfile.MPEG_TS_JP_T);
|
||||
}
|
||||
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(audioCodec, "lpcm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.AVC_TS_HD_50_LPCM_T };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "dts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (timestampType == TransportStreamTimestamp.None)
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.AVC_TS_HD_DTS_ISO };
|
||||
}
|
||||
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.AVC_TS_HD_DTS_T };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "mp2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (timestampType == TransportStreamTimestamp.None)
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "AVC_TS_HP_{0}D_MPEG1_L2_ISO", resolution)) };
|
||||
}
|
||||
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "AVC_TS_HP_{0}D_MPEG1_L2_T", resolution)) };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "AVC_TS_MP_{0}D_AAC_MULT5{1}", resolution, suffix)) };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "AVC_TS_MP_{0}D_MPEG1_L3{1}", resolution, suffix)) };
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(audioCodec) ||
|
||||
string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "AVC_TS_MP_{0}D_AC3{1}", resolution, suffix)) };
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "vc1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if ((width.HasValue && width.Value > 720) || (height.HasValue && height.Value > 576))
|
||||
{
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.VC1_TS_AP_L2_AC3_ISO };
|
||||
}
|
||||
|
||||
return new MediaFormatProfile[] { MediaFormatProfile.VC1_TS_AP_L1_AC3_ISO };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "dts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
suffix = string.Equals(suffix, "_ISO", StringComparison.OrdinalIgnoreCase) ? suffix : "_T";
|
||||
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "VC1_TS_HD_DTS{0}", suffix)) };
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "mpeg4", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "MPEG4_P2_TS_ASP_AAC{0}", suffix)) };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "MPEG4_P2_TS_ASP_MPEG1_L3{0}", suffix)) };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "mp2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "MPEG4_P2_TS_ASP_MPEG2_L2{0}", suffix)) };
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MediaFormatProfile[] { ValueOf(string.Format(CultureInfo.InvariantCulture, "MPEG4_P2_TS_ASP_AC3{0}", suffix)) };
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<MediaFormatProfile>();
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ValueOf(string value)
|
||||
{
|
||||
return (MediaFormatProfile)Enum.Parse(typeof(MediaFormatProfile), value, true);
|
||||
}
|
||||
|
||||
private static MediaFormatProfile? ResolveVideoMP4Format(string videoCodec, string audioCodec, int? width, int? height)
|
||||
{
|
||||
if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(audioCodec, "lpcm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_LPCM;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(audioCodec) ||
|
||||
string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_MP_SD_AC3;
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_MP_SD_MPEG1_L3;
|
||||
}
|
||||
|
||||
if (width.HasValue && height.HasValue)
|
||||
{
|
||||
if ((width.Value <= 720) && (height.Value <= 576))
|
||||
{
|
||||
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_MP_SD_AAC_MULT5;
|
||||
}
|
||||
}
|
||||
else if ((width.Value <= 1280) && (height.Value <= 720))
|
||||
{
|
||||
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_MP_HD_720p_AAC;
|
||||
}
|
||||
}
|
||||
else if ((width.Value <= 1920) && (height.Value <= 1080))
|
||||
{
|
||||
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_MP4_MP_HD_1080i_AAC;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "mpeg4", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (width.HasValue && height.HasValue && width.Value <= 720 && height.Value <= 576)
|
||||
{
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_P2_MP4_ASP_AAC;
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_P2_MP4_NDSD;
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_P2_MP4_SP_L6_AAC;
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "h263", StringComparison.OrdinalIgnoreCase) && string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_H263_MP4_P0_L10_AAC;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile? ResolveVideo3GPFormat(string videoCodec, string audioCodec)
|
||||
{
|
||||
if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.AVC_3GPP_BL_QCIF15_AAC;
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "mpeg4", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "wma", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_P2_3GPP_SP_L0B_AAC;
|
||||
}
|
||||
|
||||
if (string.Equals(audioCodec, "amrnb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_P2_3GPP_SP_L0B_AMR;
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "h263", StringComparison.OrdinalIgnoreCase) && string.Equals(audioCodec, "amrnb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MPEG4_H263_3GPP_P0_L10_AMR;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile? ResolveVideoASFFormat(string videoCodec, string audioCodec, int? width, int? height)
|
||||
{
|
||||
if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase) &&
|
||||
(string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "wma", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "wmapro", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (width.HasValue && height.HasValue)
|
||||
{
|
||||
if ((width.Value <= 720) && (height.Value <= 576))
|
||||
{
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "wma", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.WMVMED_FULL;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.WMVMED_PRO;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "wma", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.WMVHIGH_FULL;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.WMVHIGH_PRO;
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "vc1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (width.HasValue && height.HasValue)
|
||||
{
|
||||
if ((width.Value <= 720) && (height.Value <= 576))
|
||||
{
|
||||
return MediaFormatProfile.VC1_ASF_AP_L1_WMA;
|
||||
}
|
||||
|
||||
if ((width.Value <= 1280) && (height.Value <= 720))
|
||||
{
|
||||
return MediaFormatProfile.VC1_ASF_AP_L2_WMA;
|
||||
}
|
||||
|
||||
if ((width.Value <= 1920) && (height.Value <= 1080))
|
||||
{
|
||||
return MediaFormatProfile.VC1_ASF_AP_L3_WMA;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoCodec, "mpeg2video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.DVR_MS;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static MediaFormatProfile? ResolveAudioFormat(string container, int? bitrate, int? frequency, int? channels)
|
||||
{
|
||||
if (string.Equals(container, "asf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveAudioASFFormat(bitrate);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.MP3;
|
||||
}
|
||||
|
||||
if (string.Equals(container, "lpcm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveAudioLPCMFormat(frequency, channels);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveAudioMP4Format(bitrate);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "adts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveAudioADTSFormat(bitrate);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.FLAC;
|
||||
}
|
||||
|
||||
if (string.Equals(container, "oga", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "ogg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.OGG;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ResolveAudioASFFormat(int? bitrate)
|
||||
{
|
||||
if (bitrate.HasValue && bitrate.Value <= 193)
|
||||
{
|
||||
return MediaFormatProfile.WMA_BASE;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.WMA_FULL;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile? ResolveAudioLPCMFormat(int? frequency, int? channels)
|
||||
{
|
||||
if (frequency.HasValue && channels.HasValue)
|
||||
{
|
||||
if (frequency.Value == 44100 && channels.Value == 1)
|
||||
{
|
||||
return MediaFormatProfile.LPCM16_44_MONO;
|
||||
}
|
||||
|
||||
if (frequency.Value == 44100 && channels.Value == 2)
|
||||
{
|
||||
return MediaFormatProfile.LPCM16_44_STEREO;
|
||||
}
|
||||
|
||||
if (frequency.Value == 48000 && channels.Value == 1)
|
||||
{
|
||||
return MediaFormatProfile.LPCM16_48_MONO;
|
||||
}
|
||||
|
||||
if (frequency.Value == 48000 && channels.Value == 2)
|
||||
{
|
||||
return MediaFormatProfile.LPCM16_48_STEREO;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.LPCM16_48_STEREO;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ResolveAudioMP4Format(int? bitrate)
|
||||
{
|
||||
if (bitrate.HasValue && bitrate.Value <= 320)
|
||||
{
|
||||
return MediaFormatProfile.AAC_ISO_320;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.AAC_ISO;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ResolveAudioADTSFormat(int? bitrate)
|
||||
{
|
||||
if (bitrate.HasValue && bitrate.Value <= 320)
|
||||
{
|
||||
return MediaFormatProfile.AAC_ADTS_320;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.AAC_ADTS;
|
||||
}
|
||||
|
||||
public static MediaFormatProfile? ResolveImageFormat(string container, int? width, int? height)
|
||||
{
|
||||
if (string.Equals(container, "jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(container, "jpg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveImageJPGFormat(width, height);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResolveImagePNGFormat(width, height);
|
||||
}
|
||||
|
||||
if (string.Equals(container, "gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.GIF_LRG;
|
||||
}
|
||||
|
||||
if (string.Equals(container, "raw", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaFormatProfile.RAW;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ResolveImageJPGFormat(int? width, int? height)
|
||||
{
|
||||
if (width.HasValue && height.HasValue)
|
||||
{
|
||||
if ((width.Value <= 160) && (height.Value <= 160))
|
||||
{
|
||||
return MediaFormatProfile.JPEG_TN;
|
||||
}
|
||||
|
||||
if ((width.Value <= 640) && (height.Value <= 480))
|
||||
{
|
||||
return MediaFormatProfile.JPEG_SM;
|
||||
}
|
||||
|
||||
if ((width.Value <= 1024) && (height.Value <= 768))
|
||||
{
|
||||
return MediaFormatProfile.JPEG_MED;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.JPEG_LRG;
|
||||
}
|
||||
|
||||
return MediaFormatProfile.JPEG_SM;
|
||||
}
|
||||
|
||||
private static MediaFormatProfile ResolveImagePNGFormat(int? width, int? height)
|
||||
{
|
||||
if (width.HasValue && height.HasValue)
|
||||
{
|
||||
if ((width.Value <= 160) && (height.Value <= 160))
|
||||
{
|
||||
return MediaFormatProfile.PNG_TN;
|
||||
}
|
||||
}
|
||||
|
||||
return MediaFormatProfile.PNG_LRG;
|
||||
}
|
||||
}
|
55
src/Jellyfin.Plugin.Dlna.Model/SearchCriteria.cs
Normal file
@ -0,0 +1,55 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
public partial class SearchCriteria
|
||||
{
|
||||
public SearchCriteria(string search)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(search);
|
||||
|
||||
SearchType = SearchType.Unknown;
|
||||
|
||||
string[] factors = AndOrRegex().Split(search);
|
||||
foreach (string factor in factors)
|
||||
{
|
||||
string[] subFactors = WhiteSpaceRegex().Split(factor.Trim().Trim('(').Trim(')').Trim(), 3);
|
||||
|
||||
if (subFactors.Length == 3)
|
||||
{
|
||||
if (string.Equals("upnp:class", subFactors[0], StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals("=", subFactors[1], StringComparison.Ordinal) || string.Equals("derivedfrom", subFactors[1], StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (string.Equals("\"object.item.imageItem\"", subFactors[2], StringComparison.Ordinal) || string.Equals("\"object.item.imageItem.photo\"", subFactors[2], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SearchType = SearchType.Image;
|
||||
}
|
||||
else if (string.Equals("\"object.item.videoItem\"", subFactors[2], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SearchType = SearchType.Video;
|
||||
}
|
||||
else if (string.Equals("\"object.container.playlistContainer\"", subFactors[2], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SearchType = SearchType.Playlist;
|
||||
}
|
||||
else if (string.Equals("\"object.container.album.musicAlbum\"", subFactors[2], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SearchType = SearchType.MusicAlbum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SearchType SearchType { get; set; }
|
||||
|
||||
[GeneratedRegex("\\s")]
|
||||
private static partial Regex WhiteSpaceRegex();
|
||||
|
||||
[GeneratedRegex("(and|or)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex AndOrRegex();
|
||||
}
|
||||
}
|
14
src/Jellyfin.Plugin.Dlna.Model/SearchType.cs
Normal file
@ -0,0 +1,14 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
public enum SearchType
|
||||
{
|
||||
Unknown = 0,
|
||||
Audio = 1,
|
||||
Image = 2,
|
||||
Video = 3,
|
||||
Playlist = 4,
|
||||
MusicAlbum = 5
|
||||
}
|
||||
}
|
24
src/Jellyfin.Plugin.Dlna.Model/SortCriteria.cs
Normal file
@ -0,0 +1,24 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using Jellyfin.Data.Enums;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model
|
||||
{
|
||||
public class SortCriteria
|
||||
{
|
||||
public SortCriteria(string sortOrder)
|
||||
{
|
||||
if (Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue))
|
||||
{
|
||||
SortOrder = sortOrderValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
SortOrder = SortOrder.Ascending;
|
||||
}
|
||||
}
|
||||
|
||||
public SortOrder SortOrder { get; }
|
||||
}
|
||||
}
|
21
src/Jellyfin.Plugin.Dlna.Model/UpnpDeviceInfo.cs
Normal file
@ -0,0 +1,21 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Model;
|
||||
|
||||
public class UpnpDeviceInfo
|
||||
{
|
||||
public Uri Location { get; set; }
|
||||
|
||||
public Dictionary<string, string> Headers { get; set; }
|
||||
|
||||
public IPAddress LocalIPAddress { get; set; }
|
||||
|
||||
public int LocalPort { get; set; }
|
||||
|
||||
public IPAddress RemoteIPAddress { get; set; }
|
||||
}
|
359
src/Jellyfin.Plugin.Dlna.Playback/Api/DlnaAudioController.cs
Normal file
@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The audio controller.
|
||||
/// </summary>
|
||||
[Route("Dlna/Audio")]
|
||||
public class DlnaAudioController : ControllerBase
|
||||
{
|
||||
private readonly AudioHelper _audioHelper;
|
||||
|
||||
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaAudioController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
|
||||
public DlnaAudioController(AudioHelper audioHelper)
|
||||
{
|
||||
_audioHelper = audioHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The audio container.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream", Name = "GetDlnaAudioStream")]
|
||||
[HttpHead("{itemId}/stream", Name = "HeadDlnaAudioStream")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult> GetAudioStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string>? streamOptions)
|
||||
{
|
||||
StreamingRequestDto streamingRequest = new StreamingRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
DeviceProfileId = deviceProfileId,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Static,
|
||||
StreamOptions = streamOptions
|
||||
};
|
||||
|
||||
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The audio container.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream.{container}", Name = "GetDlnaAudioStreamByContainer")]
|
||||
[HttpHead("{itemId}/stream.{container}", Name = "HeadDlnaAudioStreamByContainer")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string>? streamOptions)
|
||||
{
|
||||
StreamingRequestDto streamingRequest = new StreamingRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
DeviceProfileId = deviceProfileId,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Static,
|
||||
StreamOptions = streamOptions
|
||||
};
|
||||
|
||||
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
|
||||
}
|
||||
}
|
372
src/Jellyfin.Plugin.Dlna.Playback/Api/DlnaHlsController.cs
Normal file
@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic hls controller.
|
||||
/// </summary>
|
||||
[Route("Dlna")]
|
||||
[Authorize]
|
||||
public class DynamicHlsController : ControllerBase
|
||||
{
|
||||
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
|
||||
|
||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||
public DynamicHlsController(DynamicHlsHelper dynamicHlsHelper)
|
||||
{
|
||||
_dynamicHlsHelper = dynamicHlsHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a video hls playlist stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
|
||||
/// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
||||
/// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
|
||||
/// <response code="200">Video stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
|
||||
[HttpGet("Videos/{itemId}/master.m3u8")]
|
||||
[HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadDlnaMasterHlsVideoPlaylist")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult> GetMasterHlsVideoPlaylist(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery, Required] string mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions,
|
||||
[FromQuery] bool enableAdaptiveBitrateStreaming = true,
|
||||
[FromQuery] bool enableTrickplay = true)
|
||||
{
|
||||
var streamingRequest = new HlsVideoRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
DeviceProfileId = deviceProfileId,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
MaxWidth = maxWidth,
|
||||
MaxHeight = maxHeight,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Streaming,
|
||||
StreamOptions = streamOptions,
|
||||
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
|
||||
EnableTrickplay = enableTrickplay
|
||||
};
|
||||
|
||||
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio hls playlist stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
|
||||
[HttpGet("Audio/{itemId}/master.m3u8")]
|
||||
[HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadDlnaMasterHlsAudioPlaylist")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult> GetMasterHlsAudioPlaylist(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery, Required] string mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? maxStreamingBitrate,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions,
|
||||
[FromQuery] bool enableAdaptiveBitrateStreaming = true)
|
||||
{
|
||||
var streamingRequest = new HlsAudioRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
DeviceProfileId = deviceProfileId,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Streaming,
|
||||
StreamOptions = streamOptions,
|
||||
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
|
||||
};
|
||||
|
||||
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
|
||||
}
|
||||
}
|
496
src/Jellyfin.Plugin.Dlna.Playback/Api/DlnaVideosController.cs
Normal file
@ -0,0 +1,496 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The videos controller.
|
||||
/// </summary>
|
||||
[Route("Dlna/Videos")]
|
||||
public class DlnaVideosController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly TranscodingJobHelper _transcodingJobHelper;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
|
||||
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaVideosController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
public DlnaVideosController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDlnaManager dlnaManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IDeviceManager deviceManager,
|
||||
TranscodingJobHelper transcodingJobHelper,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
EncodingHelper encodingHelper)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dlnaManager = dlnaManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_deviceManager = deviceManager;
|
||||
_transcodingJobHelper = transcodingJobHelper;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_encodingHelper = encodingHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a video stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
|
||||
/// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <response code="200">Video stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream")]
|
||||
[HttpHead("{itemId}/stream", Name = "HeadDlnaVideoStream")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult> GetVideoStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions)
|
||||
{
|
||||
var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
var streamingRequest = new VideoRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
DeviceProfileId = deviceProfileId,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
MaxWidth = maxWidth,
|
||||
MaxHeight = maxHeight,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Streaming,
|
||||
StreamOptions = streamOptions
|
||||
};
|
||||
|
||||
var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_dlnaManager,
|
||||
_deviceManager,
|
||||
_transcodingJobHelper,
|
||||
_transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null)
|
||||
{
|
||||
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
|
||||
|
||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
|
||||
if (liveStreamInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
|
||||
// TODO (moved from MediaBrowser.Api): Don't hardcode contentType
|
||||
return File(liveStream, MimeTypes.GetMimeType("file.ts"));
|
||||
}
|
||||
|
||||
// Static remote stream
|
||||
if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
|
||||
{
|
||||
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager);
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
|
||||
{
|
||||
return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
|
||||
}
|
||||
|
||||
var outputPath = state.OutputFilePath;
|
||||
var outputPathExists = System.IO.File.Exists(outputPath);
|
||||
|
||||
var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
|
||||
var isTranscodeCached = outputPathExists && transcodingJob is not null;
|
||||
|
||||
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager);
|
||||
|
||||
// Static stream
|
||||
if (@static.HasValue && @static.Value)
|
||||
{
|
||||
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
|
||||
|
||||
if (state.MediaSource.IsInfiniteStream)
|
||||
{
|
||||
var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
|
||||
return File(liveStream, contentType);
|
||||
}
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(
|
||||
state.MediaPath,
|
||||
contentType);
|
||||
}
|
||||
|
||||
// Need to start ffmpeg (because media can't be returned directly)
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast");
|
||||
return await FileStreamResponseHelpers.GetTranscodedFile(
|
||||
state,
|
||||
isHeadRequest,
|
||||
HttpContext,
|
||||
_transcodingJobHelper,
|
||||
ffmpegCommandLineArguments,
|
||||
_transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a video stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
|
||||
/// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <response code="200">Video stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream.{container}")]
|
||||
[HttpHead("{itemId}/stream.{container}", Name = "HeadDlnaVideoStreamByContainer")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public Task<ActionResult> GetVideoStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] bool? breakOnNonKeyFrames,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] string? videoCodec,
|
||||
[FromQuery] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions)
|
||||
{
|
||||
return GetVideoStream(
|
||||
itemId,
|
||||
container,
|
||||
@static,
|
||||
@params,
|
||||
tag,
|
||||
deviceProfileId,
|
||||
playSessionId,
|
||||
segmentContainer,
|
||||
segmentLength,
|
||||
minSegments,
|
||||
mediaSourceId,
|
||||
deviceId,
|
||||
audioCodec,
|
||||
enableAutoStreamCopy,
|
||||
allowVideoStreamCopy,
|
||||
allowAudioStreamCopy,
|
||||
breakOnNonKeyFrames,
|
||||
audioSampleRate,
|
||||
maxAudioBitDepth,
|
||||
audioBitRate,
|
||||
audioChannels,
|
||||
maxAudioChannels,
|
||||
profile,
|
||||
level,
|
||||
framerate,
|
||||
maxFramerate,
|
||||
copyTimestamps,
|
||||
startTimeTicks,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
videoBitRate,
|
||||
subtitleStreamIndex,
|
||||
subtitleMethod,
|
||||
maxRefFrames,
|
||||
maxVideoBitDepth,
|
||||
requireAvc,
|
||||
deInterlace,
|
||||
requireNonAnamorphic,
|
||||
transcodingMaxAudioChannels,
|
||||
cpuCoreLimit,
|
||||
liveStreamId,
|
||||
enableMpegtsM2TsMode,
|
||||
videoCodec,
|
||||
subtitleCodec,
|
||||
transcodeReasons,
|
||||
audioStreamIndex,
|
||||
videoStreamIndex,
|
||||
context,
|
||||
streamOptions);
|
||||
}
|
||||
}
|
179
src/Jellyfin.Plugin.Dlna.Playback/AudioHelper.cs
Normal file
@ -0,0 +1,179 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Audio helper.
|
||||
/// </summary>
|
||||
public class AudioHelper
|
||||
{
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly TranscodingJobHelper _transcodingJobHelper;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
public AudioHelper(
|
||||
IDlnaManager dlnaManager,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IDeviceManager deviceManager,
|
||||
TranscodingJobHelper transcodingJobHelper,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
EncodingHelper encodingHelper)
|
||||
{
|
||||
_dlnaManager = dlnaManager;
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_deviceManager = deviceManager;
|
||||
_transcodingJobHelper = transcodingJobHelper;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_encodingHelper = encodingHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audio stream.
|
||||
/// </summary>
|
||||
/// <param name="transcodingJobType">Transcoding job type.</param>
|
||||
/// <param name="streamingRequest">Streaming controller.Request dto.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
|
||||
public async Task<ActionResult> GetAudioStream(
|
||||
TranscodingJobType transcodingJobType,
|
||||
StreamingRequestDto streamingRequest)
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
|
||||
}
|
||||
|
||||
bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
|
||||
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
using var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_dlnaManager,
|
||||
_deviceManager,
|
||||
_transcodingJobHelper,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (streamingRequest.Static && state.DirectStreamProvider is not null)
|
||||
{
|
||||
StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
|
||||
|
||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
|
||||
if (liveStreamInfo is null)
|
||||
{
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
|
||||
// TODO (moved from MediaBrowser.Api): Don't hardcode contentType
|
||||
return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts"));
|
||||
}
|
||||
|
||||
// Static remote stream
|
||||
if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http)
|
||||
{
|
||||
StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File)
|
||||
{
|
||||
return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically");
|
||||
}
|
||||
|
||||
var outputPath = state.OutputFilePath;
|
||||
var outputPathExists = File.Exists(outputPath);
|
||||
|
||||
var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
|
||||
var isTranscodeCached = outputPathExists && transcodingJob is not null;
|
||||
|
||||
StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager);
|
||||
|
||||
// Static stream
|
||||
if (streamingRequest.Static)
|
||||
{
|
||||
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
|
||||
|
||||
if (state.MediaSource.IsInfiniteStream)
|
||||
{
|
||||
var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(
|
||||
state.MediaPath,
|
||||
contentType);
|
||||
}
|
||||
|
||||
// Need to start ffmpeg (because media can't be returned directly)
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
|
||||
return await FileStreamResponseHelpers.GetTranscodedFile(
|
||||
state,
|
||||
isHeadRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_transcodingJobHelper,
|
||||
ffmpegCommandLineArguments,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
}
|
797
src/Jellyfin.Plugin.Dlna.Playback/DynamicHlsHelper.cs
Normal file
@ -0,0 +1,797 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Extensions;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic hls helper.
|
||||
/// </summary>
|
||||
public class DynamicHlsHelper
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly TranscodingJobHelper _transcodingJobHelper;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly ILogger<DynamicHlsHelper> _logger;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
private readonly ITrickplayManager _trickplayManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
|
||||
public DynamicHlsHelper(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDlnaManager dlnaManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IDeviceManager deviceManager,
|
||||
TranscodingJobHelper transcodingJobHelper,
|
||||
INetworkManager networkManager,
|
||||
ILogger<DynamicHlsHelper> logger,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
EncodingHelper encodingHelper,
|
||||
ITrickplayManager trickplayManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dlnaManager = dlnaManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_deviceManager = deviceManager;
|
||||
_transcodingJobHelper = transcodingJobHelper;
|
||||
_networkManager = networkManager;
|
||||
_logger = logger;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_encodingHelper = encodingHelper;
|
||||
_trickplayManager = trickplayManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get master hls playlist.
|
||||
/// </summary>
|
||||
/// <param name="transcodingJobType">Transcoding job type.</param>
|
||||
/// <param name="streamingRequest">Streaming request dto.</param>
|
||||
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
|
||||
public async Task<ActionResult> GetMasterHlsPlaylist(
|
||||
TranscodingJobType transcodingJobType,
|
||||
StreamingRequestDto streamingRequest,
|
||||
bool enableAdaptiveBitrateStreaming)
|
||||
{
|
||||
var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head;
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
return await GetMasterPlaylistInternal(
|
||||
streamingRequest,
|
||||
isHeadRequest,
|
||||
enableAdaptiveBitrateStreaming,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> GetMasterPlaylistInternal(
|
||||
StreamingRequestDto streamingRequest,
|
||||
bool isHeadRequest,
|
||||
bool enableAdaptiveBitrateStreaming,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
|
||||
}
|
||||
|
||||
using var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_dlnaManager,
|
||||
_deviceManager,
|
||||
_transcodingJobHelper,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0");
|
||||
if (isHeadRequest)
|
||||
{
|
||||
return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
|
||||
}
|
||||
|
||||
var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine("#EXTM3U");
|
||||
|
||||
var isLiveStream = state.IsSegmentedLiveStream;
|
||||
|
||||
var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
|
||||
|
||||
// from universal audio service
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
|
||||
}
|
||||
|
||||
// from universal audio service
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons)
|
||||
&& !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||
}
|
||||
|
||||
// Main stream
|
||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
|
||||
playlistUrl += queryString;
|
||||
|
||||
var subtitleStreams = state.MediaSource
|
||||
.MediaStreams
|
||||
.Where(i => i.IsTextSubtitleStream)
|
||||
.ToList();
|
||||
|
||||
var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
|
||||
? "subs"
|
||||
: null;
|
||||
|
||||
// If we're burning in subtitles then don't add additional subs to the manifest
|
||||
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
|
||||
{
|
||||
subtitleGroup = null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitleGroup))
|
||||
{
|
||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
// Provide SDR HEVC entrance for backward compatibility.
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
||||
{
|
||||
// Force HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = "hevc";
|
||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
||||
|
||||
var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
|
||||
var sdrOutputAudioBitrate = 0;
|
||||
if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0;
|
||||
}
|
||||
|
||||
var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
|
||||
|
||||
// Restore the video codec
|
||||
state.OutputVideoCodec = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
// Provide Level 5.0 entrance for backward compatibility.
|
||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||
// but in fact it is capable of playing videos up to Level 6.1.
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.Level.HasValue
|
||||
&& state.VideoStream.Level > 150
|
||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var playlistCodecsField = new StringBuilder();
|
||||
AppendPlaylistCodecsField(playlistCodecsField, state);
|
||||
|
||||
// Force the video level to 5.0.
|
||||
var originalLevel = state.VideoStream.Level;
|
||||
state.VideoStream.Level = 150;
|
||||
var newPlaylistCodecsField = new StringBuilder();
|
||||
AppendPlaylistCodecsField(newPlaylistCodecsField, state);
|
||||
|
||||
// Restore the video level.
|
||||
state.VideoStream.Level = originalLevel;
|
||||
var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
|
||||
builder.Append(newPlaylist);
|
||||
}
|
||||
}
|
||||
|
||||
if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIP()))
|
||||
{
|
||||
var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
|
||||
|
||||
// By default, vary by just 200k
|
||||
var variation = GetBitrateVariation(totalBitrate);
|
||||
|
||||
var newBitrate = totalBitrate - variation;
|
||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
|
||||
variation *= 2;
|
||||
newBitrate = totalBitrate - variation;
|
||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
}
|
||||
|
||||
if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false))
|
||||
{
|
||||
var sourceId = Guid.Parse(state.Request.MediaSourceId);
|
||||
var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false);
|
||||
AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
|
||||
}
|
||||
|
||||
private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
|
||||
{
|
||||
var playlistBuilder = new StringBuilder();
|
||||
playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
|
||||
.Append(bitrate.ToString(CultureInfo.InvariantCulture))
|
||||
.Append(",AVERAGE-BANDWIDTH=")
|
||||
.Append(bitrate.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
AppendPlaylistVideoRangeField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistCodecsField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistResolutionField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistFramerateField(playlistBuilder, state);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitleGroup))
|
||||
{
|
||||
playlistBuilder.Append(",SUBTITLES=\"")
|
||||
.Append(subtitleGroup)
|
||||
.Append('"');
|
||||
}
|
||||
|
||||
playlistBuilder.Append(Environment.NewLine);
|
||||
playlistBuilder.AppendLine(url);
|
||||
builder.Append(playlistBuilder);
|
||||
|
||||
return playlistBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a VIDEO-RANGE field containing the range of the output video stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown)
|
||||
{
|
||||
var videoRange = state.VideoStream.VideoRange;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
if (videoRange == VideoRange.SDR)
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=SDR");
|
||||
}
|
||||
|
||||
if (videoRange == VideoRange.HDR)
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=PQ");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Currently we only encode to SDR.
|
||||
builder.Append(",VIDEO-RANGE=SDR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a CODECS field containing formatted strings of
|
||||
/// the active streams output video and audio codecs.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
|
||||
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
// Video
|
||||
string videoCodecs = string.Empty;
|
||||
int? videoCodecLevel = GetOutputVideoCodecLevel(state);
|
||||
if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
|
||||
{
|
||||
videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
|
||||
}
|
||||
|
||||
// Audio
|
||||
string audioCodecs = string.Empty;
|
||||
if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
|
||||
{
|
||||
audioCodecs = GetPlaylistAudioCodecs(state);
|
||||
}
|
||||
|
||||
StringBuilder codecs = new StringBuilder();
|
||||
|
||||
codecs.Append(videoCodecs);
|
||||
|
||||
if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
|
||||
{
|
||||
codecs.Append(',');
|
||||
}
|
||||
|
||||
codecs.Append(audioCodecs);
|
||||
|
||||
if (codecs.Length > 1)
|
||||
{
|
||||
builder.Append(",CODECS=\"")
|
||||
.Append(codecs)
|
||||
.Append('"');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a RESOLUTION field containing the resolution of the output stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
|
||||
{
|
||||
builder.Append(",RESOLUTION=")
|
||||
.Append(state.OutputWidth.GetValueOrDefault())
|
||||
.Append('x')
|
||||
.Append(state.OutputHeight.GetValueOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a FRAME-RATE field containing the framerate of the output stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
double? framerate = null;
|
||||
if (state.TargetFramerate.HasValue)
|
||||
{
|
||||
framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
|
||||
}
|
||||
else if (state.VideoStream?.RealFrameRate is not null)
|
||||
{
|
||||
framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
|
||||
}
|
||||
|
||||
if (framerate.HasValue)
|
||||
{
|
||||
builder.Append(",FRAME-RATE=")
|
||||
.Append(framerate.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress)
|
||||
{
|
||||
// Within the local network this will likely do more harm than good.
|
||||
if (_networkManager.IsInLocalNetwork(ipAddress))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!enableAdaptiveBitrateStreaming)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
|
||||
{
|
||||
// Opening live streams is so slow it's not even worth it
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.IsOutputVideo)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Having problems in android
|
||||
return false;
|
||||
// return state.VideoRequest.VideoBitRate.HasValue;
|
||||
}
|
||||
|
||||
private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user)
|
||||
{
|
||||
if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
|
||||
const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
|
||||
|
||||
foreach (var stream in subtitles)
|
||||
{
|
||||
var name = stream.DisplayTitle;
|
||||
|
||||
var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
|
||||
var isForced = stream.IsForced;
|
||||
|
||||
var url = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}",
|
||||
state.Request.MediaSourceId,
|
||||
stream.Index.ToString(CultureInfo.InvariantCulture),
|
||||
30.ToString(CultureInfo.InvariantCulture),
|
||||
user.GetToken());
|
||||
|
||||
var line = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
Format,
|
||||
name,
|
||||
isDefault ? "YES" : "NO",
|
||||
isForced ? "YES" : "NO",
|
||||
url,
|
||||
stream.Language ?? "Unknown");
|
||||
|
||||
builder.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="user">Http user context.</param>
|
||||
private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> trickplayResolutions, StringBuilder builder, ClaimsPrincipal user)
|
||||
{
|
||||
const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\"";
|
||||
|
||||
foreach (var resolution in trickplayResolutions)
|
||||
{
|
||||
var width = resolution.Key;
|
||||
var trickplayInfo = resolution.Value;
|
||||
|
||||
var url = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}",
|
||||
width.ToString(CultureInfo.InvariantCulture),
|
||||
state.Request.MediaSourceId,
|
||||
user.GetToken());
|
||||
|
||||
builder.AppendFormat(
|
||||
CultureInfo.InvariantCulture,
|
||||
playlistFormat,
|
||||
trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture),
|
||||
trickplayInfo.Width.ToString(CultureInfo.InvariantCulture),
|
||||
trickplayInfo.Height.ToString(CultureInfo.InvariantCulture),
|
||||
url);
|
||||
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the H.26X level of the output video stream.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <returns>H.26X level of the output video stream.</returns>
|
||||
private int? GetOutputVideoCodecLevel(StreamState state)
|
||||
{
|
||||
string levelString = string.Empty;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoStream.Level.HasValue)
|
||||
{
|
||||
levelString = state.VideoStream.Level.ToString() ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel("av1") ?? "19";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
}
|
||||
|
||||
if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
|
||||
{
|
||||
return parsedLevel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the profile of the output video stream.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="codec">Video codec.</param>
|
||||
/// <returns>Profile of the output video stream.</returns>
|
||||
private string GetOutputVideoCodecProfile(StreamState state, string codec)
|
||||
{
|
||||
string profileString = string.Empty;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrEmpty(state.VideoStream.Profile))
|
||||
{
|
||||
profileString = state.VideoStream.Profile;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profileString ??= "high";
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profileString ??= "main";
|
||||
}
|
||||
}
|
||||
|
||||
return profileString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
|
||||
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <returns>Formatted audio codec string.</returns>
|
||||
private string GetPlaylistAudioCodecs(StreamState state)
|
||||
{
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
|
||||
return HlsCodecStringHelpers.GetAACString(profile);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetMP3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetAC3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetEAC3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetFLACString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetALACString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetOPUSString();
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatted string of the output video codec, for use in the CODECS field.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
|
||||
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="codec">Video codec.</param>
|
||||
/// <param name="level">Video level.</param>
|
||||
/// <returns>Formatted video codec string.</returns>
|
||||
private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
|
||||
{
|
||||
if (level == 0)
|
||||
{
|
||||
// This is 0 when there's no requested level in the device profile
|
||||
// and the source is not encoded in H.26X or AV1
|
||||
_logger.LogError("Got invalid level when building CODECS field for HLS master playlist");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "h264");
|
||||
return HlsCodecStringHelpers.GetH264String(profile, level);
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "hevc");
|
||||
return HlsCodecStringHelpers.GetH265String(profile, level);
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "av1");
|
||||
|
||||
// Currently we only transcode to 8 bits AV1
|
||||
int bitDepth = 8;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoStream.BitDepth.HasValue)
|
||||
{
|
||||
bitDepth = state.VideoStream.BitDepth.Value;
|
||||
}
|
||||
|
||||
return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private int GetBitrateVariation(int bitrate)
|
||||
{
|
||||
// By default, vary by just 50k
|
||||
var variation = 50000;
|
||||
|
||||
if (bitrate >= 10000000)
|
||||
{
|
||||
variation = 2000000;
|
||||
}
|
||||
else if (bitrate >= 5000000)
|
||||
{
|
||||
variation = 1500000;
|
||||
}
|
||||
else if (bitrate >= 3000000)
|
||||
{
|
||||
variation = 1000000;
|
||||
}
|
||||
else if (bitrate >= 2000000)
|
||||
{
|
||||
variation = 500000;
|
||||
}
|
||||
else if (bitrate >= 1000000)
|
||||
{
|
||||
variation = 300000;
|
||||
}
|
||||
else if (bitrate >= 600000)
|
||||
{
|
||||
variation = 200000;
|
||||
}
|
||||
else if (bitrate >= 400000)
|
||||
{
|
||||
variation = 100000;
|
||||
}
|
||||
|
||||
return variation;
|
||||
}
|
||||
|
||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
||||
{
|
||||
return url.Replace(
|
||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
||||
{
|
||||
string profileStr = codec + "-profile=";
|
||||
return url.Replace(
|
||||
profileStr + oldValue,
|
||||
profileStr + newValue,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||
{
|
||||
var oldPlaylist = playlist.ToString();
|
||||
return oldPlaylist.Replace(
|
||||
oldValue.ToString(),
|
||||
newValue.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for <see cref="ClaimsPrincipal"/>.
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get user id from claims.
|
||||
/// </summary>
|
||||
/// <param name="user">Current claims principal.</param>
|
||||
/// <returns>User id.</returns>
|
||||
public static Guid GetUserId(this ClaimsPrincipal user)
|
||||
{
|
||||
var value = GetClaimValue(user, "Jellyfin-UserId");
|
||||
return string.IsNullOrEmpty(value)
|
||||
? default
|
||||
: Guid.Parse(value);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Get token from claims.
|
||||
/// </summary>
|
||||
/// <param name="user">Current claims principal.</param>
|
||||
/// <returns>Token.</returns>
|
||||
public static string? GetToken(this ClaimsPrincipal user)
|
||||
=> GetClaimValue(user, "Jellyfin-Token");
|
||||
|
||||
private static string? GetClaimValue(in ClaimsPrincipal user, string name)
|
||||
=> user.Claims.FirstOrDefault(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
}
|
118
src/Jellyfin.Plugin.Dlna.Playback/FileStreamResponseHelpers.cs
Normal file
@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// The stream response helpers.
|
||||
/// </summary>
|
||||
public static class FileStreamResponseHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a static file from a remote source.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
|
||||
/// <param name="httpContext">The current http context.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
|
||||
public static async Task<ActionResult> GetStaticRemoteStreamResult(
|
||||
StreamState state,
|
||||
HttpClient httpClient,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
|
||||
}
|
||||
|
||||
// Can't dispose the response as it's required up the call chain.
|
||||
var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false);
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
|
||||
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
|
||||
|
||||
return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a static file from the server.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the file.</param>
|
||||
/// <param name="contentType">The content type of the file.</param>
|
||||
/// <returns>An <see cref="ActionResult"/> the file.</returns>
|
||||
public static ActionResult GetStaticFileResult(
|
||||
string path,
|
||||
string contentType)
|
||||
{
|
||||
return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a transcoded file from the server.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
|
||||
/// <param name="httpContext">The current http context.</param>
|
||||
/// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
|
||||
/// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
|
||||
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
|
||||
/// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
|
||||
public static async Task<ActionResult> GetTranscodedFile(
|
||||
StreamState state,
|
||||
bool isHeadRequest,
|
||||
HttpContext httpContext,
|
||||
TranscodingJobHelper transcodingJobHelper,
|
||||
string ffmpegCommandLineArguments,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
// Use the command line args with a dummy playlist path
|
||||
var outputPath = state.OutputFilePath;
|
||||
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
|
||||
|
||||
var contentType = state.GetMimeType(outputPath);
|
||||
|
||||
// Headers only
|
||||
if (isHeadRequest)
|
||||
{
|
||||
httpContext.Response.Headers[HeaderNames.ContentType] = contentType;
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath);
|
||||
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
TranscodingJobDto? job;
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
transcodingLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
242
src/Jellyfin.Plugin.Dlna.Playback/HlsCodecStringHelpers.cs
Normal file
@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers to generate HLS codec strings according to
|
||||
/// <a href="https://datatracker.ietf.org/doc/html/rfc6381#section-3.3">RFC 6381 section 3.3</a>
|
||||
/// and the <a href="https://mp4ra.org">MP4 Registration Authority</a>.
|
||||
/// </summary>
|
||||
public static class HlsCodecStringHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Codec name for MP3.
|
||||
/// </summary>
|
||||
public const string MP3 = "mp4a.40.34";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for AC-3.
|
||||
/// </summary>
|
||||
public const string AC3 = "mp4a.a5";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for E-AC-3.
|
||||
/// </summary>
|
||||
public const string EAC3 = "mp4a.a6";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for FLAC.
|
||||
/// </summary>
|
||||
public const string FLAC = "fLaC";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for ALAC.
|
||||
/// </summary>
|
||||
public const string ALAC = "alac";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for OPUS.
|
||||
/// </summary>
|
||||
public const string OPUS = "Opus";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a MP3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>MP3 codec string.</returns>
|
||||
public static string GetMP3String()
|
||||
{
|
||||
return MP3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AAC codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">AAC profile.</param>
|
||||
/// <returns>AAC codec string.</returns>
|
||||
public static string GetAACString(string? profile)
|
||||
{
|
||||
StringBuilder result = new StringBuilder("mp4a", 9);
|
||||
|
||||
if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".40.5");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to LC if profile is invalid
|
||||
result.Append(".40.2");
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AC-3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>AC-3 codec string.</returns>
|
||||
public static string GetAC3String()
|
||||
{
|
||||
return AC3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an E-AC-3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>E-AC-3 codec string.</returns>
|
||||
public static string GetEAC3String()
|
||||
{
|
||||
return EAC3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an FLAC codec string.
|
||||
/// </summary>
|
||||
/// <returns>FLAC codec string.</returns>
|
||||
public static string GetFLACString()
|
||||
{
|
||||
return FLAC;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an ALAC codec string.
|
||||
/// </summary>
|
||||
/// <returns>ALAC codec string.</returns>
|
||||
public static string GetALACString()
|
||||
{
|
||||
return ALAC;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an OPUS codec string.
|
||||
/// </summary>
|
||||
/// <returns>OPUS codec string.</returns>
|
||||
public static string GetOPUSString()
|
||||
{
|
||||
return OPUS;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a H.264 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">H.264 profile.</param>
|
||||
/// <param name="level">H.264 level.</param>
|
||||
/// <returns>H.264 string.</returns>
|
||||
public static string GetH264String(string? profile, int level)
|
||||
{
|
||||
StringBuilder result = new StringBuilder("avc1", 11);
|
||||
|
||||
if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".6400");
|
||||
}
|
||||
else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".4D40");
|
||||
}
|
||||
else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".42E0");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to constrained baseline if profile is invalid
|
||||
result.Append(".4240");
|
||||
}
|
||||
|
||||
string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
|
||||
result.Append(levelHex);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a H.265 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">H.265 profile.</param>
|
||||
/// <param name="level">H.265 level.</param>
|
||||
/// <returns>H.265 string.</returns>
|
||||
public static string GetH265String(string? profile, int level)
|
||||
{
|
||||
// The h265 syntax is a bit of a mystery at the time this comment was written.
|
||||
// This is what I've found through various sources:
|
||||
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
|
||||
StringBuilder result = new StringBuilder("hvc1", 16);
|
||||
|
||||
if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".2.4");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to main if profile is invalid
|
||||
result.Append(".1.4");
|
||||
}
|
||||
|
||||
result.Append(".L")
|
||||
.Append(level)
|
||||
.Append(".B0");
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AV1 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">AV1 profile.</param>
|
||||
/// <param name="level">AV1 level.</param>
|
||||
/// <param name="tierFlag">AV1 tier flag.</param>
|
||||
/// <param name="bitDepth">AV1 bit depth.</param>
|
||||
/// <returns>The AV1 codec string.</returns>
|
||||
public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth)
|
||||
{
|
||||
// https://aomedia.org/av1/specification/annex-a/
|
||||
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
|
||||
StringBuilder result = new StringBuilder("av01", 13);
|
||||
|
||||
if (string.Equals(profile, "Main", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".0");
|
||||
}
|
||||
else if (string.Equals(profile, "High", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".1");
|
||||
}
|
||||
else if (string.Equals(profile, "Professional", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".2");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to Main
|
||||
result.Append(".0");
|
||||
}
|
||||
|
||||
if (level <= 0
|
||||
|| level > 31)
|
||||
{
|
||||
// Default to the maximum defined level 6.3
|
||||
level = 19;
|
||||
}
|
||||
|
||||
if (bitDepth != 8
|
||||
&& bitDepth != 10
|
||||
&& bitDepth != 12)
|
||||
{
|
||||
// Default to 8 bits
|
||||
bitDepth = 8;
|
||||
}
|
||||
|
||||
result.Append('.')
|
||||
.Append(level)
|
||||
.Append(tierFlag ? 'H' : 'M');
|
||||
|
||||
string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
|
||||
result.Append('.')
|
||||
.Append(bitDepthD2);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.0-20231109.7" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.0-20231109.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.Dlna.Model\Jellyfin.Plugin.Dlna.Model.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -0,0 +1,12 @@
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// The hls video request dto.
|
||||
/// </summary>
|
||||
public class HlsAudioRequestDto : StreamingRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether enable adaptive bitrate streaming.
|
||||
/// </summary>
|
||||
public bool EnableAdaptiveBitrateStreaming { get; set; }
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// The hls video request dto.
|
||||
/// </summary>
|
||||
public class HlsVideoRequestDto : VideoRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether enable adaptive bitrate streaming.
|
||||
/// </summary>
|
||||
public bool EnableAdaptiveBitrateStreaming { get; set; }
|
||||
}
|
193
src/Jellyfin.Plugin.Dlna.Playback/Model/StreamState.cs
Normal file
@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// The stream state dto.
|
||||
/// </summary>
|
||||
public class StreamState : EncodingJobInfo, IDisposable
|
||||
{
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly TranscodingJobHelper _transcodingJobHelper;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamState" /> class.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param>
|
||||
/// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param>
|
||||
/// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param>
|
||||
public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper)
|
||||
: base(transcodingType)
|
||||
{
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_transcodingJobHelper = transcodingJobHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested url.
|
||||
/// </summary>
|
||||
public string? RequestedUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the request.
|
||||
/// </summary>
|
||||
public StreamingRequestDto Request
|
||||
{
|
||||
get => (StreamingRequestDto)BaseRequest;
|
||||
set
|
||||
{
|
||||
BaseRequest = value;
|
||||
IsVideoRequest = VideoRequest is not null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the video request.
|
||||
/// </summary>
|
||||
public VideoRequestDto? VideoRequest => Request as VideoRequestDto;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direct stream provicer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deprecated.
|
||||
/// </remarks>
|
||||
public IDirectStreamProvider? DirectStreamProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to wait for.
|
||||
/// </summary>
|
||||
public string? WaitForPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the request outputs video.
|
||||
/// </summary>
|
||||
public bool IsOutputVideo => Request is VideoRequestDto;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment length.
|
||||
/// </summary>
|
||||
public int SegmentLength
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Request.SegmentLength.HasValue)
|
||||
{
|
||||
return Request.SegmentLength.Value;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
|
||||
{
|
||||
var userAgent = UserAgent ?? string.Empty;
|
||||
|
||||
if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1
|
||||
|| userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1
|
||||
|| userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1
|
||||
|| userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1
|
||||
|| userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1)
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
|
||||
if (IsSegmentedLiveStream)
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of segments.
|
||||
/// </summary>
|
||||
public int MinSegments
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Request.MinSegments.HasValue)
|
||||
{
|
||||
return Request.MinSegments.Value;
|
||||
}
|
||||
|
||||
return SegmentLength >= 10 ? 2 : 3;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user agent.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to estimate the content length.
|
||||
/// </summary>
|
||||
public bool EstimateContentLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transcode seek info.
|
||||
/// </summary>
|
||||
public TranscodeSeekInfo TranscodeSeekInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable dlna headers.
|
||||
/// </summary>
|
||||
public bool EnableDlnaHeaders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device profile.
|
||||
/// </summary>
|
||||
public DeviceProfile? DeviceProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transcoding job.
|
||||
/// </summary>
|
||||
public TranscodingJobDto? TranscodingJob { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate)
|
||||
{
|
||||
_transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the stream state.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether the object is currently being disposed.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
// REVIEW: Is this the right place for this?
|
||||
if (MediaSource.RequiresClosing
|
||||
&& string.IsNullOrWhiteSpace(Request.LiveStreamId)
|
||||
&& !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId))
|
||||
{
|
||||
_mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
TranscodingJob = null;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// The audio streaming request dto.
|
||||
/// </summary>
|
||||
public class StreamingRequestDto : BaseEncodingJobOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the device profile.
|
||||
/// </summary>
|
||||
public string? DeviceProfileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the params.
|
||||
/// </summary>
|
||||
public string? Params { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the play session id.
|
||||
/// </summary>
|
||||
public string? PlaySessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag.
|
||||
/// </summary>
|
||||
public string? Tag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the segment container.
|
||||
/// </summary>
|
||||
public string? SegmentContainer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the segment length.
|
||||
/// </summary>
|
||||
public int? SegmentLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the min segments.
|
||||
/// </summary>
|
||||
public int? MinSegments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position of the requested segment in ticks.
|
||||
/// </summary>
|
||||
public long CurrentRuntimeTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the actual segment length in ticks.
|
||||
/// </summary>
|
||||
public long ActualSegmentLengthTicks { get; set; }
|
||||
}
|
283
src/Jellyfin.Plugin.Dlna.Playback/Model/TranscodingJobDto.cs
Normal file
@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Class TranscodingJob.
|
||||
/// </summary>
|
||||
public class TranscodingJobDto : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The process lock.
|
||||
/// </summary>
|
||||
[SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
|
||||
[SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
|
||||
public readonly object ProcessLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Timer lock.
|
||||
/// </summary>
|
||||
private readonly object _timerLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodingJobDto"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param>
|
||||
public TranscodingJobDto(ILogger<TranscodingJobDto> logger)
|
||||
{
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the play session identifier.
|
||||
/// </summary>
|
||||
/// <value>The play session identifier.</value>
|
||||
public string? PlaySessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the live stream identifier.
|
||||
/// </summary>
|
||||
/// <value>The live stream identifier.</value>
|
||||
public string? LiveStreamId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether is live output.
|
||||
/// </summary>
|
||||
public bool IsLiveOutput { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path.
|
||||
/// </summary>
|
||||
/// <value>The path.</value>
|
||||
public MediaSourceInfo? MediaSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets path.
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type.
|
||||
/// </summary>
|
||||
/// <value>The type.</value>
|
||||
public TranscodingJobType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the process.
|
||||
/// </summary>
|
||||
/// <value>The process.</value>
|
||||
public Process? Process { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets logger.
|
||||
/// </summary>
|
||||
public ILogger<TranscodingJobDto> Logger { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active request count.
|
||||
/// </summary>
|
||||
/// <value>The active request count.</value>
|
||||
public int ActiveRequestCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the kill timer.
|
||||
/// </summary>
|
||||
/// <value>The kill timer.</value>
|
||||
private Timer? KillTimer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets device id.
|
||||
/// </summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets cancellation token source.
|
||||
/// </summary>
|
||||
public CancellationTokenSource? CancellationTokenSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether has exited.
|
||||
/// </summary>
|
||||
public bool HasExited { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets exit code.
|
||||
/// </summary>
|
||||
public int ExitCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether is user paused.
|
||||
/// </summary>
|
||||
public bool IsUserPaused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets id.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets framerate.
|
||||
/// </summary>
|
||||
public float? Framerate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets completion percentage.
|
||||
/// </summary>
|
||||
public double? CompletionPercentage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets bytes downloaded.
|
||||
/// </summary>
|
||||
public long BytesDownloaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets bytes transcoded.
|
||||
/// </summary>
|
||||
public long? BytesTranscoded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets bit rate.
|
||||
/// </summary>
|
||||
public int? BitRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets transcoding position ticks.
|
||||
/// </summary>
|
||||
public long? TranscodingPositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets download position ticks.
|
||||
/// </summary>
|
||||
public long? DownloadPositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets transcoding throttler.
|
||||
/// </summary>
|
||||
public TranscodingThrottler? TranscodingThrottler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets last ping date.
|
||||
/// </summary>
|
||||
public DateTime LastPingDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets ping timeout.
|
||||
/// </summary>
|
||||
public int PingTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stop kill timer.
|
||||
/// </summary>
|
||||
public void StopKillTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
KillTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose kill timer.
|
||||
/// </summary>
|
||||
public void DisposeKillTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (KillTimer is not null)
|
||||
{
|
||||
KillTimer.Dispose();
|
||||
KillTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start kill timer.
|
||||
/// </summary>
|
||||
/// <param name="callback">Callback action.</param>
|
||||
public void StartKillTimer(Action<object?> callback)
|
||||
{
|
||||
StartKillTimer(callback, PingTimeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start kill timer.
|
||||
/// </summary>
|
||||
/// <param name="callback">Callback action.</param>
|
||||
/// <param name="intervalMs">Callback interval.</param>
|
||||
public void StartKillTimer(Action<object?> callback, int intervalMs)
|
||||
{
|
||||
if (HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (KillTimer is null)
|
||||
{
|
||||
Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
|
||||
KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
|
||||
KillTimer.Change(intervalMs, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change kill timer if started.
|
||||
/// </summary>
|
||||
public void ChangeKillTimerIfStarted()
|
||||
{
|
||||
if (HasExited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (KillTimer is not null)
|
||||
{
|
||||
var intervalMs = PingTimeout;
|
||||
|
||||
Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
|
||||
KillTimer.Change(intervalMs, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose all resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether to dispose all resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Process?.Dispose();
|
||||
Process = null;
|
||||
KillTimer?.Dispose();
|
||||
KillTimer = null;
|
||||
CancellationTokenSource?.Dispose();
|
||||
CancellationTokenSource = null;
|
||||
TranscodingThrottler?.Dispose();
|
||||
TranscodingThrottler = null;
|
||||
}
|
||||
}
|
||||
}
|
23
src/Jellyfin.Plugin.Dlna.Playback/Model/VideoRequestDto.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
|
||||
/// <summary>
|
||||
/// The video request dto.
|
||||
/// </summary>
|
||||
public class VideoRequestDto : StreamingRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this instance has fixed resolution.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value>
|
||||
public bool HasFixedResolution => Width.HasValue || Height.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable subtitles in the manifest.
|
||||
/// </summary>
|
||||
public bool EnableSubtitlesInManifest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable trickplay images.
|
||||
/// </summary>
|
||||
public bool EnableTrickplay { get; set; }
|
||||
}
|
182
src/Jellyfin.Plugin.Dlna.Playback/ProgressiveFileStream.cs
Normal file
@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Model.IO;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// A progressive file stream for transferring transcoded files as they are written to.
|
||||
/// </summary>
|
||||
public class ProgressiveFileStream : Stream
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly TranscodingJobDto? _job;
|
||||
private readonly TranscodingJobHelper? _transcodingJobHelper;
|
||||
private readonly int _timeoutMs;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path to the transcoded file.</param>
|
||||
/// <param name="job">The transcoding job information.</param>
|
||||
/// <param name="transcodingJobHelper">The transcoding job helper.</param>
|
||||
/// <param name="timeoutMs">The timeout duration in milliseconds.</param>
|
||||
public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000)
|
||||
{
|
||||
_job = job;
|
||||
_transcodingJobHelper = transcodingJobHelper;
|
||||
_timeoutMs = timeoutMs;
|
||||
|
||||
_stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream to progressively copy.</param>
|
||||
/// <param name="timeoutMs">The timeout duration in milliseconds.</param>
|
||||
public ProgressiveFileStream(Stream stream, int timeoutMs = 30000)
|
||||
{
|
||||
_job = null;
|
||||
_transcodingJobHelper = null;
|
||||
_timeoutMs = timeoutMs;
|
||||
_stream = stream;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => _stream.CanRead;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Flush()
|
||||
{
|
||||
// Not supported
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> Read(buffer.AsSpan(offset, count));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
int totalBytesRead = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (true)
|
||||
{
|
||||
totalBytesRead += _stream.Read(buffer);
|
||||
if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
|
||||
UpdateBytesWritten(totalBytesRead);
|
||||
|
||||
return totalBytesRead;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
int totalBytesRead = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (true)
|
||||
{
|
||||
totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
UpdateBytesWritten(totalBytesRead);
|
||||
|
||||
return totalBytesRead;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_stream.Dispose();
|
||||
|
||||
if (_job is not null)
|
||||
{
|
||||
_transcodingJobHelper?.OnTranscodeEndRequest(_job);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateBytesWritten(int totalBytesRead)
|
||||
{
|
||||
if (_job is not null)
|
||||
{
|
||||
_job.BytesDownloaded += totalBytesRead;
|
||||
}
|
||||
}
|
||||
|
||||
private bool StopReading(int bytesRead, long elapsed)
|
||||
{
|
||||
// It should stop reading when anything has been successfully read or if the job has exited
|
||||
// If the job is null, however, it's a live stream and will require user action to close,
|
||||
// but don't keep it open indefinitely if it isn't reading anything
|
||||
return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs);
|
||||
}
|
||||
}
|
797
src/Jellyfin.Plugin.Dlna.Playback/StreamingHelpers.cs
Normal file
@ -0,0 +1,797 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Extensions;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using ContentFeatureBuilder = Jellyfin.Plugin.Dlna.Model.ContentFeatureBuilder;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// The streaming helpers.
|
||||
/// </summary>
|
||||
public static class StreamingHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current streaming state.
|
||||
/// </summary>
|
||||
/// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
|
||||
/// <param name="httpContext">The <see cref="HttpContext"/>.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param>
|
||||
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
|
||||
public static async Task<StreamState> GetStreamingState(
|
||||
StreamingRequestDto streamingRequest,
|
||||
HttpContext httpContext,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
EncodingHelper encodingHelper,
|
||||
IDlnaManager dlnaManager,
|
||||
IDeviceManager deviceManager,
|
||||
TranscodingJobHelper transcodingJobHelper,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var httpRequest = httpContext.Request;
|
||||
// Parse the DLNA time seek header
|
||||
if (!streamingRequest.StartTimeTicks.HasValue)
|
||||
{
|
||||
var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"];
|
||||
|
||||
streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
|
||||
{
|
||||
ParseParams(streamingRequest);
|
||||
}
|
||||
|
||||
streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
|
||||
if (httpRequest.Path.Value is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(httpRequest.Path));
|
||||
}
|
||||
|
||||
var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
|
||||
{
|
||||
streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
|
||||
}
|
||||
|
||||
var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) ||
|
||||
streamingRequest.StreamOptions.ContainsKey("dlnaheaders") ||
|
||||
string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper)
|
||||
{
|
||||
Request = streamingRequest,
|
||||
RequestedUrl = url,
|
||||
UserAgent = httpRequest.Headers[HeaderNames.UserAgent],
|
||||
EnableDlnaHeaders = enableDlnaHeaders
|
||||
};
|
||||
|
||||
var userId = httpContext.User.GetUserId();
|
||||
if (!userId.Equals(default))
|
||||
{
|
||||
state.User = userManager.GetUserById(userId);
|
||||
}
|
||||
|
||||
if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
|
||||
{
|
||||
state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
|
||||
{
|
||||
state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec)
|
||||
?? state.SupportedAudioCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
|
||||
{
|
||||
state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec)
|
||||
?? state.SupportedSubtitleCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
var item = libraryManager.GetItemById(streamingRequest.Id);
|
||||
|
||||
state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
MediaSourceInfo? mediaSource = null;
|
||||
if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
|
||||
{
|
||||
var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
|
||||
? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId)
|
||||
: null;
|
||||
|
||||
if (currentJob is not null)
|
||||
{
|
||||
mediaSource = currentJob.MediaSource;
|
||||
}
|
||||
|
||||
if (mediaSource is null)
|
||||
{
|
||||
var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
|
||||
? mediaSources[0]
|
||||
: mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal));
|
||||
|
||||
if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id))
|
||||
{
|
||||
mediaSource = mediaSources[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
|
||||
mediaSource = liveStreamInfo.Item1;
|
||||
state.DirectStreamProvider = liveStreamInfo.Item2;
|
||||
}
|
||||
|
||||
var encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
|
||||
|
||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||
|
||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||
{
|
||||
containerInternal = streamingRequest.Container;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(containerInternal))
|
||||
{
|
||||
containerInternal = streamingRequest.Static ?
|
||||
StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio)
|
||||
: GetOutputFileExtension(state, mediaSource);
|
||||
}
|
||||
|
||||
var outputAudioCodec = streamingRequest.AudioCodec;
|
||||
if (EncodingHelper.LosslessAudioCodecs.Contains(outputAudioCodec))
|
||||
{
|
||||
state.OutputAudioBitrate = state.AudioStream.BitRate ?? 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
|
||||
}
|
||||
|
||||
if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
|
||||
{
|
||||
containerInternal = ".pcm";
|
||||
}
|
||||
|
||||
state.OutputAudioCodec = outputAudioCodec;
|
||||
state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
|
||||
state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
state.OutputVideoCodec = state.Request.VideoCodec;
|
||||
state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
|
||||
|
||||
encodingHelper.TryStreamCopy(state);
|
||||
|
||||
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
|
||||
{
|
||||
var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
|
||||
&& !state.VideoRequest.Height.HasValue
|
||||
&& !state.VideoRequest.MaxWidth.HasValue
|
||||
&& !state.VideoRequest.MaxHeight.HasValue;
|
||||
|
||||
if (isVideoResolutionNotRequested
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoRequest.VideoBitRate.HasValue
|
||||
&& state.VideoStream.BitRate.HasValue
|
||||
&& state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
|
||||
{
|
||||
// Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
|
||||
// and the requested video bitrate is higher than source video bitrate.
|
||||
if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
|
||||
{
|
||||
state.VideoRequest.MaxWidth = state.VideoStream?.Width;
|
||||
state.VideoRequest.MaxHeight = state.VideoStream?.Height;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolution = ResolutionNormalizer.Normalize(
|
||||
state.VideoStream?.BitRate,
|
||||
state.OutputVideoBitrate.Value,
|
||||
state.VideoRequest.MaxWidth,
|
||||
state.VideoRequest.MaxHeight);
|
||||
|
||||
state.VideoRequest.MaxWidth = resolution.MaxWidth;
|
||||
state.VideoRequest.MaxHeight = resolution.MaxHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static);
|
||||
|
||||
var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
|
||||
? GetOutputFileExtension(state, mediaSource)
|
||||
: ("." + state.OutputContainer);
|
||||
|
||||
state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the dlna headers.
|
||||
/// </summary>
|
||||
/// <param name="state">The state.</param>
|
||||
/// <param name="responseHeaders">The response headers.</param>
|
||||
/// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param>
|
||||
/// <param name="startTimeTicks">The start time in ticks.</param>
|
||||
/// <param name="request">The <see cref="HttpRequest"/>.</param>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
public static void AddDlnaHeaders(
|
||||
StreamState state,
|
||||
IHeaderDictionary responseHeaders,
|
||||
bool isStaticallyStreamed,
|
||||
long? startTimeTicks,
|
||||
HttpRequest request,
|
||||
IDlnaManager dlnaManager)
|
||||
{
|
||||
if (!state.EnableDlnaHeaders)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var profile = state.DeviceProfile;
|
||||
|
||||
StringValues transferMode = request.Headers["transferMode.dlna.org"];
|
||||
responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString());
|
||||
responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*");
|
||||
|
||||
if (state.RunTimeTicks.HasValue)
|
||||
{
|
||||
if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds;
|
||||
responseHeaders.Add("MediaInfo.sec", string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"SEC_Duration={0};",
|
||||
Convert.ToInt32(ms)));
|
||||
}
|
||||
|
||||
if (!isStaticallyStreamed && profile is not null)
|
||||
{
|
||||
AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks);
|
||||
}
|
||||
}
|
||||
|
||||
profile ??= dlnaManager.GetDefaultProfile();
|
||||
|
||||
var audioCodec = state.ActualOutputAudioCodec;
|
||||
|
||||
if (!state.IsVideoRequest)
|
||||
{
|
||||
responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader(
|
||||
profile,
|
||||
state.OutputContainer,
|
||||
audioCodec,
|
||||
state.OutputAudioBitrate,
|
||||
state.OutputAudioSampleRate,
|
||||
state.OutputAudioChannels,
|
||||
state.OutputAudioBitDepth,
|
||||
isStaticallyStreamed,
|
||||
state.RunTimeTicks,
|
||||
state.TranscodeSeekInfo));
|
||||
}
|
||||
else
|
||||
{
|
||||
var videoCodec = state.ActualOutputVideoCodec;
|
||||
|
||||
responseHeaders.Add(
|
||||
"contentFeatures.dlna.org",
|
||||
ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the time seek header.
|
||||
/// </summary>
|
||||
/// <param name="value">The time seek header string.</param>
|
||||
/// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns>
|
||||
private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value)
|
||||
{
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string npt = "npt=";
|
||||
if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Invalid timeseek header");
|
||||
}
|
||||
|
||||
var index = value.IndexOf('-');
|
||||
value = index == -1
|
||||
? value.Slice(npt.Length)
|
||||
: value.Slice(npt.Length, index - npt.Length);
|
||||
if (!value.Contains(':'))
|
||||
{
|
||||
// Parses npt times in the format of '417.33'
|
||||
if (double.TryParse(value, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
return TimeSpan.FromSeconds(seconds).Ticks;
|
||||
}
|
||||
|
||||
throw new ArgumentException("Invalid timeseek header");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parses npt times in the format of '10:19:25.7'
|
||||
return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new ArgumentException("Invalid timeseek header");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses query parameters as StreamOptions.
|
||||
/// </summary>
|
||||
/// <param name="queryString">The query string.</param>
|
||||
/// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
|
||||
private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString)
|
||||
{
|
||||
Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
|
||||
foreach (var param in queryString)
|
||||
{
|
||||
if (char.IsLower(param.Key[0]))
|
||||
{
|
||||
// This was probably not parsed initially and should be a StreamOptions
|
||||
// or the generated URL should correctly serialize it
|
||||
// TODO: This should be incorporated either in the lower framework for parsing requests
|
||||
streamOptions[param.Key] = param.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return streamOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the dlna time seek headers to the response.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param>
|
||||
/// <param name="startTimeTicks">The start time in ticks.</param>
|
||||
private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks)
|
||||
{
|
||||
var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
responseHeaders.Add("TimeSeekRange.dlna.org", string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"npt={0}-{1}/{1}",
|
||||
startSeconds,
|
||||
runtimeSeconds));
|
||||
responseHeaders.Add("X-AvailableSeekRange", string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"1 npt={0}-{1}",
|
||||
startSeconds,
|
||||
runtimeSeconds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the output file extension.
|
||||
/// </summary>
|
||||
/// <param name="state">The state.</param>
|
||||
/// <param name="mediaSource">The mediaSource.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
private static string GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
|
||||
{
|
||||
var ext = Path.GetExtension(state.RequestedUrl);
|
||||
if (!string.IsNullOrEmpty(ext))
|
||||
{
|
||||
return ext;
|
||||
}
|
||||
|
||||
// Try to infer based on the desired video codec
|
||||
if (state.IsVideoRequest)
|
||||
{
|
||||
var videoCodec = state.Request.VideoCodec;
|
||||
|
||||
if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ts";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ogv";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".webm";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".asf";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to infer based on the desired audio codec
|
||||
var audioCodec = state.Request.AudioCodec;
|
||||
|
||||
if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".aac";
|
||||
}
|
||||
|
||||
if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp3";
|
||||
}
|
||||
|
||||
if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ogg";
|
||||
}
|
||||
|
||||
if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".wma";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the container of mediaSource
|
||||
if (!string.IsNullOrEmpty(mediaSource?.Container))
|
||||
{
|
||||
var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase);
|
||||
return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to find an appropriate file extension");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the output file path for transcoding.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="outputFileExtension">The file extension of the output file.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <returns>The complete file path, including the folder, for the transcoding file.</returns>
|
||||
private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
|
||||
{
|
||||
var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
|
||||
|
||||
var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
var ext = outputFileExtension.ToLowerInvariant();
|
||||
var folder = serverConfigurationManager.GetTranscodePath();
|
||||
|
||||
return Path.Combine(folder, filename + ext);
|
||||
}
|
||||
|
||||
private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(deviceProfileId))
|
||||
{
|
||||
state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId);
|
||||
|
||||
if (state.DeviceProfile is null)
|
||||
{
|
||||
var caps = deviceManager.GetCapabilities(deviceProfileId);
|
||||
state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = state.DeviceProfile;
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
// Don't use settings from the default profile.
|
||||
// Only use a specific profile if it was requested.
|
||||
return;
|
||||
}
|
||||
|
||||
var audioCodec = state.ActualOutputAudioCodec;
|
||||
var videoCodec = state.ActualOutputVideoCodec;
|
||||
|
||||
var mediaProfile = !state.IsVideoRequest
|
||||
? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth)
|
||||
: profile.GetVideoMediaProfile(
|
||||
state.OutputContainer,
|
||||
audioCodec,
|
||||
videoCodec,
|
||||
state.OutputWidth,
|
||||
state.OutputHeight,
|
||||
state.TargetVideoBitDepth,
|
||||
state.OutputVideoBitrate,
|
||||
state.TargetVideoProfile,
|
||||
state.TargetVideoRangeType,
|
||||
state.TargetVideoLevel,
|
||||
state.TargetFramerate,
|
||||
state.TargetPacketLength,
|
||||
state.TargetTimestamp,
|
||||
state.IsTargetAnamorphic,
|
||||
state.IsTargetInterlaced,
|
||||
state.TargetRefFrames,
|
||||
state.TargetVideoStreamCount,
|
||||
state.TargetAudioStreamCount,
|
||||
state.TargetVideoCodecTag,
|
||||
state.IsTargetAVC);
|
||||
|
||||
if (mediaProfile is not null)
|
||||
{
|
||||
state.MimeType = mediaProfile.MimeType;
|
||||
}
|
||||
|
||||
if (!(@static.HasValue && @static.Value))
|
||||
{
|
||||
var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec);
|
||||
|
||||
if (transcodingProfile is not null)
|
||||
{
|
||||
state.EstimateContentLength = transcodingProfile.EstimateContentLength;
|
||||
// state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
|
||||
state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps;
|
||||
state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the parameters.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
private static void ParseParams(StreamingRequestDto request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Params))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vals = request.Params.Split(';');
|
||||
|
||||
var videoRequest = request as VideoRequestDto;
|
||||
|
||||
for (var i = 0; i < vals.Length; i++)
|
||||
{
|
||||
var val = vals[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(val))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (i)
|
||||
{
|
||||
case 0:
|
||||
request.DeviceProfileId = val;
|
||||
break;
|
||||
case 1:
|
||||
request.DeviceId = val;
|
||||
break;
|
||||
case 2:
|
||||
request.MediaSourceId = val;
|
||||
break;
|
||||
case 3:
|
||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
break;
|
||||
case 4:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.VideoCodec = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 5:
|
||||
request.AudioCodec = val;
|
||||
break;
|
||||
case 6:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 7:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 8:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 9:
|
||||
request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 10:
|
||||
request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 11:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 12:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 13:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 14:
|
||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 15:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.Level = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 16:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 17:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 18:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.Profile = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 19:
|
||||
// cabac no longer used
|
||||
break;
|
||||
case 20:
|
||||
request.PlaySessionId = val;
|
||||
break;
|
||||
case 21:
|
||||
// api_key
|
||||
break;
|
||||
case 22:
|
||||
request.LiveStreamId = val;
|
||||
break;
|
||||
case 23:
|
||||
// Duplicating ItemId because of MediaMonkey
|
||||
break;
|
||||
case 24:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 25:
|
||||
if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null)
|
||||
{
|
||||
if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
|
||||
{
|
||||
videoRequest.SubtitleMethod = method;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case 26:
|
||||
request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 27:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 28:
|
||||
request.Tag = val;
|
||||
break;
|
||||
case 29:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 30:
|
||||
request.SubtitleCodec = val;
|
||||
break;
|
||||
case 31:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 32:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 33:
|
||||
request.TranscodeReasons = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
874
src/Jellyfin.Plugin.Dlna.Playback/TranscodingJobHelper.cs
Normal file
@ -0,0 +1,874 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Extensions;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Transcoding job helpers.
|
||||
/// </summary>
|
||||
public class TranscodingJobHelper : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The active transcoding jobs.
|
||||
/// </summary>
|
||||
private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
|
||||
|
||||
/// <summary>
|
||||
/// The transcoding locks.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
|
||||
|
||||
private readonly IAttachmentExtractor _attachmentExtractor;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<TranscodingJobHelper> _logger;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public TranscodingJobHelper(
|
||||
IAttachmentExtractor attachmentExtractor,
|
||||
IApplicationPaths appPaths,
|
||||
ILogger<TranscodingJobHelper> logger,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IFileSystem fileSystem,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ISessionManager sessionManager,
|
||||
EncodingHelper encodingHelper,
|
||||
ILoggerFactory loggerFactory,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_attachmentExtractor = attachmentExtractor;
|
||||
_appPaths = appPaths;
|
||||
_logger = logger;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_fileSystem = fileSystem;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_sessionManager = sessionManager;
|
||||
_encodingHelper = encodingHelper;
|
||||
_loggerFactory = loggerFactory;
|
||||
_userManager = userManager;
|
||||
|
||||
DeleteEncodedMediaCache();
|
||||
|
||||
sessionManager.PlaybackProgress += OnPlaybackProgress;
|
||||
sessionManager.PlaybackStart += OnPlaybackProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get transcoding job.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">Playback session id.</param>
|
||||
/// <returns>The transcoding job.</returns>
|
||||
public TranscodingJobDto? GetTranscodingJob(string playSessionId)
|
||||
{
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get transcoding job.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the transcoding file.</param>
|
||||
/// <param name="type">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <returns>The transcoding job.</returns>
|
||||
public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type)
|
||||
{
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ping transcoding job.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">Play session id.</param>
|
||||
/// <param name="isUserPaused">Is user paused.</param>
|
||||
/// <exception cref="ArgumentNullException">Play session id is null.</exception>
|
||||
public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(playSessionId);
|
||||
|
||||
_logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
|
||||
|
||||
List<TranscodingJobDto> jobs;
|
||||
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
// This is really only needed for HLS.
|
||||
// Progressive streams can stop on their own reliably.
|
||||
jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
if (isUserPaused.HasValue)
|
||||
{
|
||||
_logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
|
||||
job.IsUserPaused = isUserPaused.Value;
|
||||
}
|
||||
|
||||
PingTimer(job, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
|
||||
{
|
||||
if (job.HasExited)
|
||||
{
|
||||
job.StopKillTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
var timerDuration = 10000;
|
||||
|
||||
if (job.Type != TranscodingJobType.Progressive)
|
||||
{
|
||||
timerDuration = 60000;
|
||||
}
|
||||
|
||||
job.PingTimeout = timerDuration;
|
||||
job.LastPingDate = DateTime.UtcNow;
|
||||
|
||||
// Don't start the timer for playback checkins with progressive streaming
|
||||
if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
|
||||
{
|
||||
job.StartKillTimer(OnTranscodeKillTimerStopped);
|
||||
}
|
||||
else
|
||||
{
|
||||
job.ChangeKillTimerIfStarted();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when [transcode kill timer stopped].
|
||||
/// </summary>
|
||||
/// <param name="state">The state.</param>
|
||||
private async void OnTranscodeKillTimerStopped(object? state)
|
||||
{
|
||||
var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state));
|
||||
if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
|
||||
{
|
||||
var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastPing < job.PingTimeout)
|
||||
{
|
||||
job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
|
||||
|
||||
await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kills the transcoding job.
|
||||
/// </summary>
|
||||
/// <param name="job">The job.</param>
|
||||
/// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
|
||||
/// <param name="delete">The delete.</param>
|
||||
private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
|
||||
{
|
||||
job.DisposeKillTimer();
|
||||
|
||||
_logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
|
||||
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
_activeTranscodingJobs.Remove(job);
|
||||
|
||||
if (job.CancellationTokenSource?.IsCancellationRequested == false)
|
||||
{
|
||||
job.CancellationTokenSource.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
_transcodingLocks.Remove(job.Path!);
|
||||
}
|
||||
|
||||
lock (job.ProcessLock!)
|
||||
{
|
||||
#pragma warning disable CA1849 // Can't await in lock block
|
||||
job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
|
||||
|
||||
var process = job.Process;
|
||||
|
||||
var hasExited = job.HasExited;
|
||||
|
||||
if (!hasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
|
||||
|
||||
process!.StandardInput.WriteLine("q");
|
||||
|
||||
// Need to wait because killing is asynchronous.
|
||||
if (!process.WaitForExit(5000))
|
||||
{
|
||||
_logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
|
||||
process.Kill();
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
#pragma warning restore CA1849
|
||||
}
|
||||
|
||||
if (delete(job.Path!))
|
||||
{
|
||||
await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
|
||||
if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay)
|
||||
{
|
||||
var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat");
|
||||
if (File.Exists(concatFilePath))
|
||||
{
|
||||
_logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath);
|
||||
File.Delete(concatFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
|
||||
{
|
||||
if (retryCount >= 10)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Deleting partial stream file(s) {Path}", path);
|
||||
|
||||
await Task.Delay(delayMs).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (jobType == TranscodingJobType.Progressive)
|
||||
{
|
||||
DeleteProgressivePartialStreamFiles(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteHlsPartialStreamFiles(path);
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
|
||||
|
||||
await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the progressive partial stream files.
|
||||
/// </summary>
|
||||
/// <param name="outputFilePath">The output file path.</param>
|
||||
private void DeleteProgressivePartialStreamFiles(string outputFilePath)
|
||||
{
|
||||
if (File.Exists(outputFilePath))
|
||||
{
|
||||
_fileSystem.DeleteFile(outputFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the HLS partial stream files.
|
||||
/// </summary>
|
||||
/// <param name="outputFilePath">The output file path.</param>
|
||||
private void DeleteHlsPartialStreamFiles(string outputFilePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputFilePath)
|
||||
?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath));
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(outputFilePath);
|
||||
|
||||
var filesToDelete = _fileSystem.GetFilePaths(directory)
|
||||
.Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
|
||||
|
||||
List<Exception>? exs = null;
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Deleting HLS file {0}", file);
|
||||
_fileSystem.DeleteFile(file);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
(exs ??= new List<Exception>(4)).Add(ex);
|
||||
_logger.LogError(ex, "Error deleting HLS file {Path}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (exs is not null)
|
||||
{
|
||||
throw new AggregateException("Error deleting HLS files", exs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report the transcoding progress to the session manager.
|
||||
/// </summary>
|
||||
/// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param>
|
||||
/// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param>
|
||||
/// <param name="transcodingPosition">The current transcoding position.</param>
|
||||
/// <param name="framerate">The framerate of the transcoding job.</param>
|
||||
/// <param name="percentComplete">The completion percentage of the transcode.</param>
|
||||
/// <param name="bytesTranscoded">The number of bytes transcoded.</param>
|
||||
/// <param name="bitRate">The bitrate of the transcoding job.</param>
|
||||
public void ReportTranscodingProgress(
|
||||
TranscodingJobDto job,
|
||||
StreamState state,
|
||||
TimeSpan? transcodingPosition,
|
||||
float? framerate,
|
||||
double? percentComplete,
|
||||
long? bytesTranscoded,
|
||||
int? bitRate)
|
||||
{
|
||||
var ticks = transcodingPosition?.Ticks;
|
||||
|
||||
if (job is not null)
|
||||
{
|
||||
job.Framerate = framerate;
|
||||
job.CompletionPercentage = percentComplete;
|
||||
job.TranscodingPositionTicks = ticks;
|
||||
job.BytesTranscoded = bytesTranscoded;
|
||||
job.BitRate = bitRate;
|
||||
}
|
||||
|
||||
var deviceId = state.Request.DeviceId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
var audioCodec = state.ActualOutputAudioCodec;
|
||||
var videoCodec = state.ActualOutputVideoCodec;
|
||||
var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType;
|
||||
HardwareEncodingType? hardwareAccelerationType = null;
|
||||
if (Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType))
|
||||
{
|
||||
hardwareAccelerationType = parsedHardwareAccelerationType;
|
||||
}
|
||||
|
||||
_sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo
|
||||
{
|
||||
Bitrate = bitRate ?? state.TotalOutputBitrate,
|
||||
AudioCodec = audioCodec,
|
||||
VideoCodec = videoCodec,
|
||||
Container = state.OutputContainer,
|
||||
Framerate = framerate,
|
||||
CompletionPercentage = percentComplete,
|
||||
Width = state.OutputWidth,
|
||||
Height = state.OutputHeight,
|
||||
AudioChannels = state.OutputAudioChannels,
|
||||
IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec),
|
||||
IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec),
|
||||
HardwareAccelerationType = hardwareAccelerationType,
|
||||
TranscodeReasons = state.TranscodeReasons
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts FFmpeg.
|
||||
/// </summary>
|
||||
/// <param name="state">The state.</param>
|
||||
/// <param name="outputPath">The output path.</param>
|
||||
/// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
|
||||
/// <param name="request">The <see cref="HttpRequest"/>.</param>
|
||||
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <param name="cancellationTokenSource">The cancellation token source.</param>
|
||||
/// <param name="workingDirectory">The working directory.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task<TranscodingJobDto> StartFfMpeg(
|
||||
StreamState state,
|
||||
string outputPath,
|
||||
string commandLineArguments,
|
||||
HttpRequest request,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationTokenSource cancellationTokenSource,
|
||||
string? workingDirectory = null)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false);
|
||||
|
||||
if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
var userId = request.HttpContext.User.GetUserId();
|
||||
var user = userId.Equals(default) ? null : _userManager.GetUserById(userId);
|
||||
if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding))
|
||||
{
|
||||
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
|
||||
|
||||
throw new ArgumentException("User does not have access to video transcoding.");
|
||||
}
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
|
||||
|
||||
// If subtitles get burned in fonts may need to be extracted from the media file
|
||||
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
|
||||
{
|
||||
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
|
||||
if (state.VideoType != VideoType.Dvd)
|
||||
{
|
||||
await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string subtitlePath = state.SubtitleStream.Path;
|
||||
string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));
|
||||
string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
|
||||
// Must consume both stdout and stderr or deadlocks may occur
|
||||
// RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = commandLineArguments,
|
||||
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
var transcodingJob = this.OnTranscodeBeginning(
|
||||
outputPath,
|
||||
state.Request.PlaySessionId,
|
||||
state.MediaSource.LiveStreamId,
|
||||
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
transcodingJobType,
|
||||
process,
|
||||
state.Request.DeviceId,
|
||||
state,
|
||||
cancellationTokenSource);
|
||||
|
||||
_logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
var logFilePrefix = "FFmpeg.Transcode-";
|
||||
if (state.VideoRequest is not null
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
|
||||
? "FFmpeg.Remux-"
|
||||
: "FFmpeg.DirectStream-";
|
||||
}
|
||||
|
||||
var logFilePath = Path.Combine(
|
||||
_serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
|
||||
$"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
|
||||
|
||||
// FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
|
||||
Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
|
||||
|
||||
var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
|
||||
var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
|
||||
await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting FFmpeg");
|
||||
|
||||
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Launched FFmpeg process");
|
||||
state.TranscodingJob = transcodingJob;
|
||||
|
||||
// Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback
|
||||
_ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError, logStream);
|
||||
|
||||
// Wait for the file to exist before proceeding
|
||||
var ffmpegTargetFile = state.WaitForPath ?? outputPath;
|
||||
_logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile);
|
||||
while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited)
|
||||
{
|
||||
await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile);
|
||||
|
||||
if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited)
|
||||
{
|
||||
await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited)
|
||||
{
|
||||
await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!transcodingJob.HasExited)
|
||||
{
|
||||
StartThrottler(state, transcodingJob);
|
||||
}
|
||||
else if (transcodingJob.ExitCode != 0)
|
||||
{
|
||||
throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode));
|
||||
}
|
||||
|
||||
_logger.LogDebug("StartFfMpeg() finished successfully");
|
||||
|
||||
return transcodingJob;
|
||||
}
|
||||
|
||||
private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob)
|
||||
{
|
||||
if (EnableThrottling(state))
|
||||
{
|
||||
transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, _loggerFactory.CreateLogger<TranscodingThrottler>(), _serverConfigurationManager, _fileSystem, _mediaEncoder);
|
||||
transcodingJob.TranscodingThrottler.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableThrottling(StreamState state)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
return state.InputProtocol == MediaProtocol.File &&
|
||||
state.RunTimeTicks.HasValue &&
|
||||
state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks &&
|
||||
state.IsInputVideo &&
|
||||
state.VideoType == VideoType.VideoFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when [transcode beginning].
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="liveStreamId">The live stream identifier.</param>
|
||||
/// <param name="transcodingJobId">The transcoding job identifier.</param>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <param name="process">The process.</param>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="state">The state.</param>
|
||||
/// <param name="cancellationTokenSource">The cancellation token source.</param>
|
||||
/// <returns>TranscodingJob.</returns>
|
||||
public TranscodingJobDto OnTranscodeBeginning(
|
||||
string path,
|
||||
string? playSessionId,
|
||||
string? liveStreamId,
|
||||
string transcodingJobId,
|
||||
TranscodingJobType type,
|
||||
Process process,
|
||||
string? deviceId,
|
||||
StreamState state,
|
||||
CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>())
|
||||
{
|
||||
Type = type,
|
||||
Path = path,
|
||||
Process = process,
|
||||
ActiveRequestCount = 1,
|
||||
DeviceId = deviceId,
|
||||
CancellationTokenSource = cancellationTokenSource,
|
||||
Id = transcodingJobId,
|
||||
PlaySessionId = playSessionId,
|
||||
LiveStreamId = liveStreamId,
|
||||
MediaSource = state.MediaSource
|
||||
};
|
||||
|
||||
_activeTranscodingJobs.Add(job);
|
||||
|
||||
ReportTranscodingProgress(job, state, null, null, null, null, null);
|
||||
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when [transcode end].
|
||||
/// </summary>
|
||||
/// <param name="job">The transcode job.</param>
|
||||
public void OnTranscodeEndRequest(TranscodingJobDto job)
|
||||
{
|
||||
job.ActiveRequestCount--;
|
||||
_logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount);
|
||||
if (job.ActiveRequestCount <= 0)
|
||||
{
|
||||
PingTimer(job, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// The progressive
|
||||
/// </summary>
|
||||
/// Called when [transcode failed to start].
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <param name="state">The state.</param>
|
||||
public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state)
|
||||
{
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (job is not null)
|
||||
{
|
||||
_activeTranscodingJobs.Remove(job);
|
||||
}
|
||||
}
|
||||
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
_transcodingLocks.Remove(path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.DeviceId))
|
||||
{
|
||||
_sessionManager.ClearTranscodingInfo(state.Request.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the exited.
|
||||
/// </summary>
|
||||
/// <param name="process">The process.</param>
|
||||
/// <param name="job">The job.</param>
|
||||
/// <param name="state">The state.</param>
|
||||
private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state)
|
||||
{
|
||||
job.HasExited = true;
|
||||
job.ExitCode = process.ExitCode;
|
||||
|
||||
ReportTranscodingProgress(job, state, null, null, null, null, null);
|
||||
|
||||
_logger.LogDebug("Disposing stream resources");
|
||||
state.Dispose();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("FFmpeg exited with code 0");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
|
||||
}
|
||||
|
||||
job.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
|
||||
{
|
||||
var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
|
||||
new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
_encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
_encodingHelper.TryStreamCopy(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.MediaSource.BufferMs.HasValue)
|
||||
{
|
||||
await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when [transcode begin request].
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <returns>The <see cref="TranscodingJobDto"/>.</returns>
|
||||
public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type)
|
||||
{
|
||||
lock (_activeTranscodingJobs)
|
||||
{
|
||||
var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
OnTranscodeBeginRequest(job);
|
||||
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTranscodeBeginRequest(TranscodingJobDto job)
|
||||
{
|
||||
job.ActiveRequestCount++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
|
||||
{
|
||||
job.StopKillTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transcoding lock.
|
||||
/// </summary>
|
||||
/// <param name="outputPath">The output path of the transcoded file.</param>
|
||||
/// <returns>A <see cref="SemaphoreSlim"/>.</returns>
|
||||
public SemaphoreSlim GetTranscodingLock(string outputPath)
|
||||
{
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result))
|
||||
{
|
||||
result = new SemaphoreSlim(1, 1);
|
||||
_transcodingLocks[outputPath] = result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(e.PlaySessionId))
|
||||
{
|
||||
PingTranscodingJob(e.PlaySessionId, e.IsPaused);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the encoded media cache.
|
||||
/// </summary>
|
||||
private void DeleteEncodedMediaCache()
|
||||
{
|
||||
var path = _serverConfigurationManager.GetTranscodePath();
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in _fileSystem.GetFilePaths(path, true))
|
||||
{
|
||||
_fileSystem.DeleteFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose transcoding job helper.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose throttler.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Disposing.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_loggerFactory.Dispose();
|
||||
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
||||
_sessionManager.PlaybackStart -= OnPlaybackProgress;
|
||||
}
|
||||
}
|
||||
}
|
220
src/Jellyfin.Plugin.Dlna.Playback/TranscodingThrottler.cs
Normal file
@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Playback.Model;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Playback;
|
||||
|
||||
/// <summary>
|
||||
/// Transcoding throttler.
|
||||
/// </summary>
|
||||
public class TranscodingThrottler : IDisposable
|
||||
{
|
||||
private readonly TranscodingJobDto _job;
|
||||
private readonly ILogger<TranscodingThrottler> _logger;
|
||||
private readonly IConfigurationManager _config;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private Timer? _timer;
|
||||
private bool _isPaused;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodingThrottler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="job">Transcoding job dto.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param>
|
||||
/// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder)
|
||||
{
|
||||
_job = job;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_fileSystem = fileSystem;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start timer.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_timer = new Timer(TimerCallback, null, 5000, 5000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpause transcoding.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/>.</returns>
|
||||
public async Task UnpauseTranscoding()
|
||||
{
|
||||
if (_isPaused)
|
||||
{
|
||||
_logger.LogDebug("Sending resume command to ffmpeg");
|
||||
|
||||
try
|
||||
{
|
||||
var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine;
|
||||
await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false);
|
||||
_isPaused = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resuming transcoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop throttler.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/>.</returns>
|
||||
public async Task Stop()
|
||||
{
|
||||
DisposeTimer();
|
||||
await UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose throttler.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose throttler.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Disposing.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
DisposeTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private EncodingOptions GetOptions()
|
||||
{
|
||||
return _config.GetEncodingOptions();
|
||||
}
|
||||
|
||||
private async void TimerCallback(object? state)
|
||||
{
|
||||
if (_job.HasExited)
|
||||
{
|
||||
DisposeTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
var options = GetOptions();
|
||||
|
||||
if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds))
|
||||
{
|
||||
await PauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PauseTranscoding()
|
||||
{
|
||||
if (!_isPaused)
|
||||
{
|
||||
var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c";
|
||||
|
||||
_logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey);
|
||||
|
||||
try
|
||||
{
|
||||
await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false);
|
||||
_isPaused = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error pausing transcoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds)
|
||||
{
|
||||
var bytesDownloaded = job.BytesDownloaded;
|
||||
var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0;
|
||||
var downloadPositionTicks = job.DownloadPositionTicks ?? 0;
|
||||
|
||||
var path = job.Path ?? throw new ArgumentException("Path can't be null.");
|
||||
|
||||
var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks;
|
||||
|
||||
if (downloadPositionTicks > 0 && transcodingPositionTicks > 0)
|
||||
{
|
||||
// HLS - time-based consideration
|
||||
|
||||
var targetGap = gapLengthInTicks;
|
||||
var gap = transcodingPositionTicks - downloadPositionTicks;
|
||||
|
||||
if (gap < targetGap)
|
||||
{
|
||||
_logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bytesDownloaded > 0 && transcodingPositionTicks > 0)
|
||||
{
|
||||
// Progressive Streaming - byte-based consideration
|
||||
|
||||
try
|
||||
{
|
||||
var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length;
|
||||
|
||||
// Estimate the bytes the transcoder should be ahead
|
||||
double gapFactor = gapLengthInTicks;
|
||||
gapFactor /= transcodingPositionTicks;
|
||||
var targetGap = bytesTranscoded * gapFactor;
|
||||
|
||||
var gap = bytesTranscoded - bytesDownloaded;
|
||||
|
||||
if (gap < targetGap)
|
||||
{
|
||||
_logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting output size");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("No throttle data for {Path}", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void DisposeTimer()
|
||||
{
|
||||
if (_timer is not null)
|
||||
{
|
||||
_timer.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
}
|
132
src/Jellyfin.Plugin.Dlna/Api/DlnaController.cs
Normal file
@ -0,0 +1,132 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Dlna Controller.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
// TODO: [Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class DlnaController : ControllerBase
|
||||
{
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
public DlnaController(IDlnaManager dlnaManager)
|
||||
{
|
||||
System.Console.WriteLine("Creating DlnaController");
|
||||
_dlnaManager = dlnaManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get profile infos.
|
||||
/// </summary>
|
||||
/// <response code="200">Device profile infos returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns>
|
||||
[HttpGet("ProfileInfos")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos()
|
||||
{
|
||||
return Ok(_dlnaManager.GetProfileInfos());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default profile.
|
||||
/// </summary>
|
||||
/// <response code="200">Default device profile returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the default profile.</returns>
|
||||
[HttpGet("Profiles/Default")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<DeviceProfile> GetDefaultProfile()
|
||||
{
|
||||
return _dlnaManager.GetDefaultProfile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile Id.</param>
|
||||
/// <response code="200">Device profile returned.</response>
|
||||
/// <response code="404">Device profile not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns>
|
||||
[HttpGet("Profiles/{profileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
|
||||
{
|
||||
var profile = _dlnaManager.GetProfile(profileId);
|
||||
if (profile is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile id.</param>
|
||||
/// <response code="204">Device profile deleted.</response>
|
||||
/// <response code="404">Device profile not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
|
||||
[HttpDelete("Profiles/{profileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult DeleteProfile([FromRoute, Required] string profileId)
|
||||
{
|
||||
var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
|
||||
if (existingDeviceProfile is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_dlnaManager.DeleteProfile(profileId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a profile.
|
||||
/// </summary>
|
||||
/// <param name="deviceProfile">Device profile.</param>
|
||||
/// <response code="204">Device profile created.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Profiles")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile)
|
||||
{
|
||||
_dlnaManager.CreateProfile(deviceProfile);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Profile id.</param>
|
||||
/// <param name="deviceProfile">Device profile.</param>
|
||||
/// <response code="204">Device profile updated.</response>
|
||||
/// <response code="404">Device profile not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns>
|
||||
[HttpPost("Profiles/{profileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
|
||||
{
|
||||
var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
|
||||
if (existingDeviceProfile is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_dlnaManager.UpdateProfile(profileId, deviceProfile);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
313
src/Jellyfin.Plugin.Dlna/Api/DlnaServerController.cs
Normal file
@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Dlna Server Controller.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("Dlna")]
|
||||
// TODO: [Authorize(Policy = Policies.AnonymousLanAccessPolicy)]
|
||||
public class DlnaServerController : ControllerBase
|
||||
{
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IContentDirectory _contentDirectory;
|
||||
private readonly IConnectionManager _connectionManager;
|
||||
private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaServerController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
|
||||
/// <param name="contentDirectory">Instance of the <see cref="IContentDirectory"/> interface.</param>
|
||||
/// <param name="connectionManager">Instance of the <see cref="IConnectionManager"/> interface.</param>
|
||||
/// <param name="mediaReceiverRegistrar">Instance of the <see cref="IMediaReceiverRegistrar"/> interface.</param>
|
||||
public DlnaServerController(
|
||||
IDlnaManager dlnaManager,
|
||||
IContentDirectory contentDirectory,
|
||||
IConnectionManager connectionManager,
|
||||
IMediaReceiverRegistrar mediaReceiverRegistrar)
|
||||
{
|
||||
_dlnaManager = dlnaManager;
|
||||
_contentDirectory = contentDirectory;
|
||||
_connectionManager = connectionManager;
|
||||
_mediaReceiverRegistrar = mediaReceiverRegistrar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get Description Xml.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Description xml returned.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
|
||||
[HttpGet("{serverId}/description")]
|
||||
[HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId)
|
||||
{
|
||||
var url = GetAbsoluteUri();
|
||||
var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
|
||||
var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
|
||||
return Ok(xml);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Dlna content directory xml.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Dlna content directory returned.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
|
||||
[HttpGet("{serverId}/ContentDirectory")]
|
||||
[HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
|
||||
[HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId)
|
||||
{
|
||||
return Ok(_contentDirectory.GetServiceXml());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Dlna media receiver registrar xml.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Dlna media receiver registrar xml returned.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Dlna media receiver registrar xml.</returns>
|
||||
[HttpGet("{serverId}/MediaReceiverRegistrar")]
|
||||
[HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
|
||||
[HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
|
||||
{
|
||||
return Ok(_mediaReceiverRegistrar.GetServiceXml());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets Dlna media receiver registrar xml.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Dlna media receiver registrar xml returned.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Dlna media receiver registrar xml.</returns>
|
||||
[HttpGet("{serverId}/ConnectionManager")]
|
||||
[HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
|
||||
[HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId)
|
||||
{
|
||||
return Ok(_connectionManager.GetServiceXml());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a content directory control request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Control response.</returns>
|
||||
[HttpPost("{serverId}/ContentDirectory/Control")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
|
||||
{
|
||||
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a connection manager control request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Control response.</returns>
|
||||
[HttpPost("{serverId}/ConnectionManager/Control")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
|
||||
{
|
||||
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a media receiver registrar control request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Control response.</returns>
|
||||
[HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
|
||||
{
|
||||
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an event subscription request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Event subscription response.</returns>
|
||||
[HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
|
||||
[HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
|
||||
{
|
||||
return ProcessEventRequest(_mediaReceiverRegistrar);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an event subscription request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Event subscription response.</returns>
|
||||
[HttpSubscribe("{serverId}/ContentDirectory/Events")]
|
||||
[HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
|
||||
{
|
||||
return ProcessEventRequest(_contentDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an event subscription request.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Event subscription response.</returns>
|
||||
[HttpSubscribe("{serverId}/ConnectionManager/Events")]
|
||||
[HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
[Produces(MediaTypeNames.Text.Xml)]
|
||||
public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
|
||||
{
|
||||
return ProcessEventRequest(_connectionManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a server icon.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server UUID.</param>
|
||||
/// <param name="fileName">The icon filename.</param>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="404">Not Found.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
/// <returns>Icon stream.</returns>
|
||||
[HttpGet("{serverId}/icons/{fileName}")]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
|
||||
{
|
||||
return GetIconInternal(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a server icon.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The icon filename.</param>
|
||||
/// <returns>Icon stream.</returns>
|
||||
/// <response code="200">Request processed.</response>
|
||||
/// <response code="404">Not Found.</response>
|
||||
/// <response code="503">DLNA is disabled.</response>
|
||||
[HttpGet("icons/{fileName}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public ActionResult GetIcon([FromRoute, Required] string fileName)
|
||||
{
|
||||
return GetIconInternal(fileName);
|
||||
}
|
||||
|
||||
private ActionResult GetIconInternal(string fileName)
|
||||
{
|
||||
var icon = _dlnaManager.GetIcon(fileName);
|
||||
if (icon is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return File(icon.Stream, MimeTypes.GetMimeType(fileName));
|
||||
}
|
||||
|
||||
private string GetAbsoluteUri()
|
||||
{
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
|
||||
}
|
||||
|
||||
private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
|
||||
{
|
||||
return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
|
||||
{
|
||||
InputXml = requestStream,
|
||||
TargetServerUuId = id,
|
||||
RequestedUrl = GetAbsoluteUri()
|
||||
});
|
||||
}
|
||||
|
||||
private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
|
||||
{
|
||||
var subscriptionId = Request.Headers["SID"];
|
||||
if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var notificationType = Request.Headers["NT"];
|
||||
var callback = Request.Headers["CALLBACK"];
|
||||
var timeoutString = Request.Headers["TIMEOUT"];
|
||||
|
||||
if (string.IsNullOrEmpty(notificationType))
|
||||
{
|
||||
return dlnaEventManager.RenewEventSubscription(
|
||||
subscriptionId,
|
||||
notificationType,
|
||||
timeoutString,
|
||||
callback);
|
||||
}
|
||||
|
||||
return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
|
||||
}
|
||||
|
||||
return dlnaEventManager.CancelEventSubscription(subscriptionId);
|
||||
}
|
||||
}
|
29
src/Jellyfin.Plugin.Dlna/Api/HttpSubscribeAttribute.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies an action that supports the HTTP GET method.
|
||||
/// </summary>
|
||||
public sealed class HttpSubscribeAttribute : HttpMethodAttribute
|
||||
{
|
||||
private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" };
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
|
||||
/// </summary>
|
||||
public HttpSubscribeAttribute()
|
||||
: base(_supportedMethods)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="template">The route template. May not be null.</param>
|
||||
public HttpSubscribeAttribute(string template)
|
||||
: base(_supportedMethods, template)
|
||||
=> ArgumentNullException.ThrowIfNull(template);
|
||||
}
|
29
src/Jellyfin.Plugin.Dlna/Api/HttpUnsubscribeAttribute.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies an action that supports the HTTP GET method.
|
||||
/// </summary>
|
||||
public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute
|
||||
{
|
||||
private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" };
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
|
||||
/// </summary>
|
||||
public HttpUnsubscribeAttribute()
|
||||
: base(_supportedMethods)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="template">The route template. May not be null.</param>
|
||||
public HttpUnsubscribeAttribute(string template)
|
||||
: base(_supportedMethods, template)
|
||||
=> ArgumentNullException.ThrowIfNull(template);
|
||||
}
|
23
src/Jellyfin.Plugin.Dlna/Common/Argument.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace Jellyfin.Plugin.Dlna.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// DLNA Query parameter type, used when querying DLNA devices via SOAP.
|
||||
/// </summary>
|
||||
public class Argument
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets name of the DLNA argument.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direction of the parameter.
|
||||
/// </summary>
|
||||
public string Direction { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the related DLNA state variable for this argument.
|
||||
/// </summary>
|
||||
public string RelatedStateVariable { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
41
src/Jellyfin.Plugin.Dlna/Common/DeviceIcon.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="DeviceIcon" />.
|
||||
/// </summary>
|
||||
public class DeviceIcon
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Url.
|
||||
/// </summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MimeType.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Width.
|
||||
/// </summary>
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Height.
|
||||
/// </summary>
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Depth.
|
||||
/// </summary>
|
||||
public string Depth { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", Height, Width);
|
||||
}
|
||||
}
|
||||
}
|
36
src/Jellyfin.Plugin.Dlna/Common/DeviceService.cs
Normal file
@ -0,0 +1,36 @@
|
||||
namespace Jellyfin.Plugin.Dlna.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="DeviceService" />.
|
||||
/// </summary>
|
||||
public class DeviceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Service Type.
|
||||
/// </summary>
|
||||
public string ServiceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Service Id.
|
||||
/// </summary>
|
||||
public string ServiceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Scpd Url.
|
||||
/// </summary>
|
||||
public string ScpdUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Control Url.
|
||||
/// </summary>
|
||||
public string ControlUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the EventSubUrl.
|
||||
/// </summary>
|
||||
public string EventSubUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => ServiceId;
|
||||
}
|
||||
}
|
31
src/Jellyfin.Plugin.Dlna/Common/ServiceAction.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ServiceAction" />.
|
||||
/// </summary>
|
||||
public class ServiceAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServiceAction"/> class.
|
||||
/// </summary>
|
||||
public ServiceAction()
|
||||
{
|
||||
ArgumentList = new List<Argument>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the action.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ArgumentList.
|
||||
/// </summary>
|
||||
public List<Argument> ArgumentList { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
34
src/Jellyfin.Plugin.Dlna/Common/StateVariable.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="StateVariable" />.
|
||||
/// </summary>
|
||||
public class StateVariable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the state variable.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type of the state variable.
|
||||
/// </summary>
|
||||
public string DataType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether it sends events.
|
||||
/// </summary>
|
||||
public bool SendsEvents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the allowed values range.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedValues { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
92
src/Jellyfin.Plugin.Dlna/Configuration/DlnaOptions.cs
Normal file
@ -0,0 +1,92 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// The DlnaOptions class contains the user definable parameters for the dlna subsystems.
|
||||
/// </summary>
|
||||
public class DlnaOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaOptions"/> class.
|
||||
/// </summary>
|
||||
public DlnaOptions()
|
||||
{
|
||||
EnablePlayTo = true;
|
||||
EnableServer = false;
|
||||
BlastAliveMessages = true;
|
||||
SendOnlyMatchedHost = true;
|
||||
ClientDiscoveryIntervalSeconds = 60;
|
||||
AliveMessageIntervalSeconds = 180;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem.
|
||||
/// </summary>
|
||||
public bool EnablePlayTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem.
|
||||
/// </summary>
|
||||
public bool EnableServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether detailed dlna server logs are sent to the console/log.
|
||||
/// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
|
||||
/// </summary>
|
||||
public bool EnableDebugLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether whether detailed playTo debug logs are sent to the console/log.
|
||||
/// If the setting "Emby.Dlna.PlayTo": "Debug" msut be set in logging.default.json for this property to work.
|
||||
/// </summary>
|
||||
public bool EnablePlayToTracing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ssdp client discovery interval time (in seconds).
|
||||
/// This is the time after which the server will send a ssdp search request.
|
||||
/// </summary>
|
||||
public int ClientDiscoveryIntervalSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the frequency at which ssdp alive notifications are transmitted.
|
||||
/// </summary>
|
||||
public int AliveMessageIntervalSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED.
|
||||
/// </summary>
|
||||
public int BlastAliveMessageIntervalSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
return AliveMessageIntervalSeconds;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
AliveMessageIntervalSeconds = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default user account that the dlna server uses.
|
||||
/// </summary>
|
||||
public string? DefaultUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playTo device profiles should be created.
|
||||
/// </summary>
|
||||
public bool AutoCreatePlayToProfiles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to blast alive messages.
|
||||
/// </summary>
|
||||
public bool BlastAliveMessages { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// gets or sets a value indicating whether to send only matched host.
|
||||
/// </summary>
|
||||
public bool SendOnlyMatchedHost { get; set; } = true;
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Configuration;
|
||||
|
||||
public class DlnaPluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
}
|
15
src/Jellyfin.Plugin.Dlna/ConfigurationExtension.cs
Normal file
@ -0,0 +1,15 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using Jellyfin.Plugin.Dlna.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public static class ConfigurationExtension
|
||||
{
|
||||
public static DlnaOptions GetDlnaConfiguration(this IConfigurationManager manager)
|
||||
{
|
||||
return manager.GetConfiguration<DlnaOptions>("dlna");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dlna;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IDlnaManager = Jellyfin.Plugin.Dlna.Model.IDlnaManager;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ConnectionManagerService" />.
|
||||
/// </summary>
|
||||
public class ConnectionManagerService : BaseService, IConnectionManager
|
||||
{
|
||||
private readonly IDlnaManager _dlna;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConnectionManagerService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dlna">The <see cref="IDlnaManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
|
||||
/// <param name="logger">The <see cref="ILogger{ConnectionManagerService}"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
|
||||
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
|
||||
public ConnectionManagerService(
|
||||
IDlnaManager dlna,
|
||||
IServerConfigurationManager config,
|
||||
ILogger<ConnectionManagerService> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(logger, httpClientFactory)
|
||||
{
|
||||
_dlna = dlna;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServiceXml()
|
||||
{
|
||||
return ConnectionManagerXmlBuilder.GetXml();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ControlResponse> ProcessControlRequestAsync(ControlRequest request)
|
||||
{
|
||||
var profile = _dlna.GetProfile(request.Headers) ??
|
||||
_dlna.GetDefaultProfile();
|
||||
|
||||
return new ControlHandler(_config, Logger, profile).ProcessControlRequestAsync(request);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ConnectionManagerXmlBuilder" />.
|
||||
/// </summary>
|
||||
public static class ConnectionManagerXmlBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ConnectionManager:1 service template.
|
||||
/// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf.
|
||||
/// </summary>
|
||||
/// <returns>An XML description of this service.</returns>
|
||||
public static string GetXml()
|
||||
{
|
||||
return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the list of state variables for this invocation.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
|
||||
private static IEnumerable<StateVariable> GetStateVariables()
|
||||
{
|
||||
return new StateVariable[]
|
||||
{
|
||||
new StateVariable
|
||||
{
|
||||
Name = "SourceProtocolInfo",
|
||||
DataType = "string",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "SinkProtocolInfo",
|
||||
DataType = "string",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "CurrentConnectionIDs",
|
||||
DataType = "string",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_ConnectionStatus",
|
||||
DataType = "string",
|
||||
SendsEvents = false,
|
||||
|
||||
AllowedValues = new[]
|
||||
{
|
||||
"OK",
|
||||
"ContentFormatMismatch",
|
||||
"InsufficientBandwidth",
|
||||
"UnreliableChannel",
|
||||
"Unknown"
|
||||
}
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_ConnectionManager",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Direction",
|
||||
DataType = "string",
|
||||
SendsEvents = false,
|
||||
|
||||
AllowedValues = new[]
|
||||
{
|
||||
"Output",
|
||||
"Input"
|
||||
}
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_ProtocolInfo",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_ConnectionID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_AVTransportID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_RcsID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
55
src/Jellyfin.Plugin.Dlna/ConnectionManager/ControlHandler.cs
Normal file
@ -0,0 +1,55 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ControlHandler" />.
|
||||
/// </summary>
|
||||
public class ControlHandler : BaseControlHandler
|
||||
{
|
||||
private readonly DeviceProfile _profile;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ControlHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ControlHandler"/> instance.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for use with the <see cref="ControlHandler"/> instance.</param>
|
||||
/// <param name="profile">The <see cref="DeviceProfile"/> for use with the <see cref="ControlHandler"/> instance.</param>
|
||||
public ControlHandler(IServerConfigurationManager config, ILogger logger, DeviceProfile profile)
|
||||
: base(config, logger)
|
||||
{
|
||||
_profile = profile;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void WriteResult(string methodName, IReadOnlyDictionary<string, string> methodParams, XmlWriter xmlWriter)
|
||||
{
|
||||
if (string.Equals(methodName, "GetProtocolInfo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleGetProtocolInfo(xmlWriter);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceNotFoundException("Unexpected control request name: " + methodName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the response to the GetProtocolInfo request.
|
||||
/// </summary>
|
||||
/// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
|
||||
private void HandleGetProtocolInfo(XmlWriter xmlWriter)
|
||||
{
|
||||
xmlWriter.WriteElementString("Source", _profile.ProtocolInfo);
|
||||
xmlWriter.WriteElementString("Sink", string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,234 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ServiceActionListBuilder" />.
|
||||
/// </summary>
|
||||
public static class ServiceActionListBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an enumerable of the ConnectionManagar:1 DLNA actions.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
|
||||
public static IEnumerable<ServiceAction> GetActions()
|
||||
{
|
||||
var list = new List<ServiceAction>
|
||||
{
|
||||
GetCurrentConnectionInfo(),
|
||||
GetProtocolInfo(),
|
||||
GetCurrentConnectionIDs(),
|
||||
ConnectionComplete(),
|
||||
PrepareForConnection()
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "PrepareForConnection".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction PrepareForConnection()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "PrepareForConnection"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RemoteProtocolInfo",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ProtocolInfo"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "PeerConnectionManager",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionManager"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "PeerConnectionID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Direction",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Direction"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ConnectionID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "AVTransportID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_AVTransportID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RcsID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_RcsID"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetCurrentConnectionInfo".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetCurrentConnectionInfo()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetCurrentConnectionInfo"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ConnectionID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RcsID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_RcsID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "AVTransportID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_AVTransportID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ProtocolInfo",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ProtocolInfo"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "PeerConnectionManager",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionManager"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "PeerConnectionID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Direction",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Direction"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Status",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionStatus"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetProtocolInfo".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetProtocolInfo()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetProtocolInfo"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Source",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "SourceProtocolInfo"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Sink",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "SinkProtocolInfo"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetCurrentConnectionIDs".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetCurrentConnectionIDs()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetCurrentConnectionIDs"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ConnectionIDs",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "CurrentConnectionIDs"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "ConnectionComplete".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction ConnectionComplete()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "ConnectionComplete"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ConnectionID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ConnectionID"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,174 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dlna;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.TV;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IDlnaManager = Jellyfin.Plugin.Dlna.Model.IDlnaManager;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ContentDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ContentDirectoryService" />.
|
||||
/// </summary>
|
||||
public class ContentDirectoryService : BaseService, IContentDirectory
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly IDlnaManager _dlna;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IUserViewManager _userViewManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ITVSeriesManager _tvSeriesManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentDirectoryService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dlna">The <see cref="IDlnaManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="userDataManager">The <see cref="IUserDataManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="imageProcessor">The <see cref="IImageProcessor"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="libraryManager">The <see cref="ILibraryManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="userManager">The <see cref="IUserManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="logger">The <see cref="ILogger{ContentDirectoryService}"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="httpClient">The <see cref="IHttpClientFactory"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="localization">The <see cref="ILocalizationManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="userViewManager">The <see cref="IUserViewManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="mediaEncoder">The <see cref="IMediaEncoder"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
/// <param name="tvSeriesManager">The <see cref="ITVSeriesManager"/> to use in the <see cref="ContentDirectoryService"/> instance.</param>
|
||||
public ContentDirectoryService(
|
||||
IDlnaManager dlna,
|
||||
IUserDataManager userDataManager,
|
||||
IImageProcessor imageProcessor,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager config,
|
||||
IUserManager userManager,
|
||||
ILogger<ContentDirectoryService> logger,
|
||||
IHttpClientFactory httpClient,
|
||||
ILocalizationManager localization,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IUserViewManager userViewManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ITVSeriesManager tvSeriesManager)
|
||||
: base(logger, httpClient)
|
||||
{
|
||||
_dlna = dlna;
|
||||
_userDataManager = userDataManager;
|
||||
_imageProcessor = imageProcessor;
|
||||
_libraryManager = libraryManager;
|
||||
_config = config;
|
||||
_userManager = userManager;
|
||||
_localization = localization;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_userViewManager = userViewManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_tvSeriesManager = tvSeriesManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system id. (A unique id which changes on when our definition changes.)
|
||||
/// </summary>
|
||||
private static int SystemUpdateId
|
||||
{
|
||||
get
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return now.Year + now.DayOfYear + now.Hour;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServiceXml()
|
||||
{
|
||||
return ContentDirectoryXmlBuilder.GetXml();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ControlResponse> ProcessControlRequestAsync(ControlRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var profile = _dlna.GetProfile(request.Headers) ?? _dlna.GetDefaultProfile();
|
||||
|
||||
var serverAddress = request.RequestedUrl.Substring(0, request.RequestedUrl.IndexOf("/dlna", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var user = GetUser(profile);
|
||||
|
||||
return new ControlHandler(
|
||||
Logger,
|
||||
_libraryManager,
|
||||
profile,
|
||||
serverAddress,
|
||||
null,
|
||||
_imageProcessor,
|
||||
_userDataManager,
|
||||
user,
|
||||
SystemUpdateId,
|
||||
_config,
|
||||
_localization,
|
||||
_mediaSourceManager,
|
||||
_userViewManager,
|
||||
_mediaEncoder,
|
||||
_tvSeriesManager)
|
||||
.ProcessControlRequestAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the user stored in the device profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The <see cref="DeviceProfile"/>.</param>
|
||||
/// <returns>The <see cref="User"/>.</returns>
|
||||
private User? GetUser(DeviceProfile profile)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(profile.UserId))
|
||||
{
|
||||
var user = _userManager.GetUserById(Guid.Parse(profile.UserId));
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
var userId = _config.GetDlnaConfiguration().DefaultUserId;
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var user = _userManager.GetUserById(Guid.Parse(userId));
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var user in _userManager.Users)
|
||||
{
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
return _userManager.Users.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ContentDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ContentDirectoryXmlBuilder" />.
|
||||
/// </summary>
|
||||
public static class ContentDirectoryXmlBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ContentDirectory:1 service template.
|
||||
/// See http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf.
|
||||
/// </summary>
|
||||
/// <returns>An XML description of this service.</returns>
|
||||
public static string GetXml()
|
||||
{
|
||||
return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the list of state variables for this invocation.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
|
||||
private static IEnumerable<StateVariable> GetStateVariables()
|
||||
{
|
||||
return new StateVariable[]
|
||||
{
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Filter",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_SortCriteria",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Index",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Count",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_UpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "SearchCapabilities",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "SortCapabilities",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "SystemUpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_SearchCriteria",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Result",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_ObjectID",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_BrowseFlag",
|
||||
DataType = "string",
|
||||
SendsEvents = false,
|
||||
|
||||
AllowedValues = new[]
|
||||
{
|
||||
"BrowseMetadata",
|
||||
"BrowseDirectChildren"
|
||||
}
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_BrowseLetter",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_CategoryType",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_RID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_PosSec",
|
||||
DataType = "ui4",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Featurelist",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
1250
src/Jellyfin.Plugin.Dlna/ContentDirectory/ControlHandler.cs
Normal file
39
src/Jellyfin.Plugin.Dlna/ContentDirectory/ServerItem.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ContentDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ServerItem" />.
|
||||
/// </summary>
|
||||
internal class ServerItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServerItem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="item">The <see cref="BaseItem"/>.</param>
|
||||
/// <param name="stubType">The stub type.</param>
|
||||
public ServerItem(BaseItem item, StubType? stubType)
|
||||
{
|
||||
Item = item;
|
||||
|
||||
if (stubType.HasValue)
|
||||
{
|
||||
StubType = stubType;
|
||||
}
|
||||
else if (item is IItemByName and not Folder)
|
||||
{
|
||||
StubType = ContentDirectory.StubType.Folder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying base item.
|
||||
/// </summary>
|
||||
public BaseItem Item { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DLNA item type.
|
||||
/// </summary>
|
||||
public StubType? StubType { get; }
|
||||
}
|
||||
}
|
@ -0,0 +1,415 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ContentDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ServiceActionListBuilder" />.
|
||||
/// </summary>
|
||||
public static class ServiceActionListBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a list of services that this instance provides.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
|
||||
public static IEnumerable<ServiceAction> GetActions()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
GetSearchCapabilitiesAction(),
|
||||
GetSortCapabilitiesAction(),
|
||||
GetGetSystemUpdateIDAction(),
|
||||
GetBrowseAction(),
|
||||
GetSearchAction(),
|
||||
GetX_GetFeatureListAction(),
|
||||
GetXSetBookmarkAction(),
|
||||
GetBrowseByLetterAction()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetSystemUpdateID".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetGetSystemUpdateIDAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetSystemUpdateID"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Id",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "SystemUpdateID"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetSearchCapabilities".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetSearchCapabilitiesAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetSearchCapabilities"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SearchCaps",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "SearchCapabilities"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetSortCapabilities".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetSortCapabilitiesAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetSortCapabilities"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SortCaps",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "SortCapabilities"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "X_GetFeatureList".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetX_GetFeatureListAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "X_GetFeatureList"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "FeatureList",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Featurelist"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "Search".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetSearchAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "Search"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ContainerID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ObjectID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SearchCriteria",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_SearchCriteria"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Filter",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Filter"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "StartingIndex",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Index"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RequestedCount",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SortCriteria",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_SortCriteria"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Result",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Result"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "NumberReturned",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "TotalMatches",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "UpdateID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_UpdateID"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "Browse".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetBrowseAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "Browse"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ObjectID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ObjectID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "BrowseFlag",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_BrowseFlag"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Filter",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Filter"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "StartingIndex",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Index"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RequestedCount",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SortCriteria",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_SortCriteria"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Result",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Result"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "NumberReturned",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "TotalMatches",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "UpdateID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_UpdateID"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "X_BrowseByLetter".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetBrowseByLetterAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "X_BrowseByLetter"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ObjectID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ObjectID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "BrowseFlag",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_BrowseFlag"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Filter",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Filter"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "StartingLetter",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_BrowseLetter"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RequestedCount",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "SortCriteria",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_SortCriteria"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Result",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Result"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "NumberReturned",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "TotalMatches",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Count"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "UpdateID",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_UpdateID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "StartingIndex",
|
||||
Direction = "out",
|
||||
RelatedStateVariable = "A_ARG_TYPE_Index"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "X_SetBookmark".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetXSetBookmarkAction()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "X_SetBookmark"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "CategoryType",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_CategoryType"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_RID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ObjectID",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_ObjectID"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "PosSecond",
|
||||
Direction = "in",
|
||||
RelatedStateVariable = "A_ARG_TYPE_PosSec"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
30
src/Jellyfin.Plugin.Dlna/ContentDirectory/StubType.cs
Normal file
@ -0,0 +1,30 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.ContentDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the DLNA item types.
|
||||
/// </summary>
|
||||
public enum StubType
|
||||
{
|
||||
Folder = 0,
|
||||
Latest = 2,
|
||||
Playlists = 3,
|
||||
Albums = 4,
|
||||
AlbumArtists = 5,
|
||||
Artists = 6,
|
||||
Songs = 7,
|
||||
Genres = 8,
|
||||
FavoriteSongs = 9,
|
||||
FavoriteArtists = 10,
|
||||
FavoriteAlbums = 11,
|
||||
ContinueWatching = 12,
|
||||
Movies = 13,
|
||||
Collections = 14,
|
||||
Favorites = 15,
|
||||
NextUp = 16,
|
||||
Series = 17,
|
||||
FavoriteSeries = 18,
|
||||
FavoriteEpisodes = 19
|
||||
}
|
||||
}
|
25
src/Jellyfin.Plugin.Dlna/ControlRequest.cs
Normal file
@ -0,0 +1,25 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public class ControlRequest
|
||||
{
|
||||
public ControlRequest(IHeaderDictionary headers)
|
||||
{
|
||||
Headers = headers;
|
||||
}
|
||||
|
||||
public IHeaderDictionary Headers { get; }
|
||||
|
||||
public Stream InputXml { get; set; }
|
||||
|
||||
public string TargetServerUuId { get; set; }
|
||||
|
||||
public string RequestedUrl { get; set; }
|
||||
}
|
||||
}
|
28
src/Jellyfin.Plugin.Dlna/ControlResponse.cs
Normal file
@ -0,0 +1,28 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public class ControlResponse
|
||||
{
|
||||
public ControlResponse(string xml, bool isSuccessful)
|
||||
{
|
||||
Headers = new Dictionary<string, string>();
|
||||
Xml = xml;
|
||||
IsSuccessful = isSuccessful;
|
||||
}
|
||||
|
||||
public IDictionary<string, string> Headers { get; }
|
||||
|
||||
public string Xml { get; set; }
|
||||
|
||||
public bool IsSuccessful { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return Xml;
|
||||
}
|
||||
}
|
||||
}
|
1273
src/Jellyfin.Plugin.Dlna/Didl/DidlBuilder.cs
Normal file
28
src/Jellyfin.Plugin.Dlna/Didl/Filter.cs
Normal file
@ -0,0 +1,28 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Didl
|
||||
{
|
||||
public class Filter
|
||||
{
|
||||
private readonly string[] _fields;
|
||||
private readonly bool _all;
|
||||
|
||||
public Filter()
|
||||
: this("*")
|
||||
{
|
||||
}
|
||||
|
||||
public Filter(string filter)
|
||||
{
|
||||
_all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase);
|
||||
_fields = filter.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
public bool Contains(string field)
|
||||
{
|
||||
return _all || Array.Exists(_fields, x => x.Equals(field, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
58
src/Jellyfin.Plugin.Dlna/Didl/StringWriterWithEncoding.cs
Normal file
@ -0,0 +1,58 @@
|
||||
#pragma warning disable CS1591
|
||||
#pragma warning disable CA1305
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Didl
|
||||
{
|
||||
public class StringWriterWithEncoding : StringWriter
|
||||
{
|
||||
private readonly Encoding? _encoding;
|
||||
|
||||
public StringWriterWithEncoding()
|
||||
{
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(IFormatProvider formatProvider)
|
||||
: base(formatProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(StringBuilder sb)
|
||||
: base(sb)
|
||||
{
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(StringBuilder sb, IFormatProvider formatProvider)
|
||||
: base(sb, formatProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(Encoding encoding)
|
||||
{
|
||||
_encoding = encoding;
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(IFormatProvider formatProvider, Encoding encoding)
|
||||
: base(formatProvider)
|
||||
{
|
||||
_encoding = encoding;
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(StringBuilder sb, Encoding encoding)
|
||||
: base(sb)
|
||||
{
|
||||
_encoding = encoding;
|
||||
}
|
||||
|
||||
public StringWriterWithEncoding(StringBuilder sb, IFormatProvider formatProvider, Encoding encoding)
|
||||
: base(sb, formatProvider)
|
||||
{
|
||||
_encoding = encoding;
|
||||
}
|
||||
|
||||
public override Encoding Encoding => _encoding ?? base.Encoding;
|
||||
}
|
||||
}
|
23
src/Jellyfin.Plugin.Dlna/DlnaConfigurationFactory.cs
Normal file
@ -0,0 +1,23 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public class DlnaConfigurationFactory : IConfigurationFactory
|
||||
{
|
||||
public IEnumerable<ConfigurationStore> GetConfigurations()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new ConfigurationStore
|
||||
{
|
||||
Key = "dlna",
|
||||
ConfigurationType = typeof(DlnaOptions)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
492
src/Jellyfin.Plugin.Dlna/DlnaManager.cs
Normal file
@ -0,0 +1,492 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Jellyfin.Plugin.Dlna.Profiles;
|
||||
using Jellyfin.Plugin.Dlna.Server;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Dlna;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using IDlnaManager = Jellyfin.Plugin.Dlna.Model.IDlnaManager;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public class DlnaManager : IDlnaManager
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly IXmlSerializer _xmlSerializer;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<DlnaManager> _logger;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private static readonly Assembly _assembly = typeof(DlnaManager).Assembly;
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal);
|
||||
|
||||
public DlnaManager(
|
||||
IXmlSerializer xmlSerializer,
|
||||
IFileSystem fileSystem,
|
||||
IApplicationPaths appPaths,
|
||||
ILoggerFactory loggerFactory,
|
||||
IServerApplicationHost appHost)
|
||||
{
|
||||
_xmlSerializer = xmlSerializer;
|
||||
_fileSystem = fileSystem;
|
||||
_appPaths = appPaths;
|
||||
_logger = loggerFactory.CreateLogger<DlnaManager>();
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
private string UserProfilesPath => Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "user");
|
||||
|
||||
private string SystemProfilesPath => Path.Combine(_appPaths.ConfigurationDirectoryPath, "dlna", "system");
|
||||
|
||||
public async Task InitProfilesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExtractSystemProfilesAsync().ConfigureAwait(false);
|
||||
Directory.CreateDirectory(UserProfilesPath);
|
||||
LoadProfiles();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error extracting DLNA profiles.");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadProfiles()
|
||||
{
|
||||
var list = GetProfiles(UserProfilesPath, DeviceProfileType.User)
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
list.AddRange(GetProfiles(SystemProfilesPath, DeviceProfileType.System)
|
||||
.OrderBy(i => i.Name));
|
||||
}
|
||||
|
||||
public IEnumerable<DeviceProfile> GetProfiles()
|
||||
{
|
||||
lock (_profiles)
|
||||
{
|
||||
return _profiles.Values
|
||||
.OrderBy(i => i.Item1.Info.Type == DeviceProfileType.User ? 0 : 1)
|
||||
.ThenBy(i => i.Item1.Info.Name)
|
||||
.Select(i => i.Item2)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceProfile GetDefaultProfile()
|
||||
{
|
||||
return new DefaultProfile();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceProfile? GetProfile(DeviceIdentification deviceInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceInfo);
|
||||
|
||||
var profile = GetProfiles()
|
||||
.FirstOrDefault(i => i.Identification is not null && IsMatch(deviceInfo, i.Identification));
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
_logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a device with a profile.
|
||||
/// Rules:
|
||||
/// - If the profile field has no value, the field matches regardless of its contents.
|
||||
/// - the profile field can be an exact match, or a reg exp.
|
||||
/// </summary>
|
||||
/// <param name="deviceInfo">The <see cref="DeviceIdentification"/> of the device.</param>
|
||||
/// <param name="profileInfo">The <see cref="DeviceIdentification"/> of the profile.</param>
|
||||
/// <returns><b>True</b> if they match.</returns>
|
||||
public bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo)
|
||||
{
|
||||
return IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)
|
||||
&& IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber);
|
||||
}
|
||||
|
||||
private bool IsRegexOrSubstringMatch(string input, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pattern))
|
||||
{
|
||||
// In profile identification: An empty pattern matches anything.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
// The profile contains a value, and the device doesn't.
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return input.Equals(pattern, StringComparison.OrdinalIgnoreCase)
|
||||
|| Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error evaluating regex pattern {Pattern}", pattern);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceProfile? GetProfile(IHeaderDictionary headers)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(headers);
|
||||
|
||||
var profile = GetProfiles().FirstOrDefault(i => i.Identification is not null && IsMatch(headers, i.Identification));
|
||||
if (profile is null)
|
||||
{
|
||||
_logger.LogDebug("No matching device profile found. {@Headers}", headers);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Found matching device profile: {0}", profile.Name);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private bool IsMatch(IHeaderDictionary headers, DeviceIdentification profileInfo)
|
||||
{
|
||||
return profileInfo.Headers.Any(i => IsMatch(headers, i));
|
||||
}
|
||||
|
||||
private bool IsMatch(IHeaderDictionary headers, HttpHeaderInfo header)
|
||||
{
|
||||
// Handle invalid user setup
|
||||
if (string.IsNullOrEmpty(header.Name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(header.Name, out StringValues value))
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (header.Match)
|
||||
{
|
||||
case HeaderMatchType.Equals:
|
||||
return string.Equals(value, header.Value, StringComparison.OrdinalIgnoreCase);
|
||||
case HeaderMatchType.Substring:
|
||||
var isMatch = value.ToString().IndexOf(header.Value, StringComparison.OrdinalIgnoreCase) != -1;
|
||||
// _logger.LogDebug("IsMatch-Substring value: {0} testValue: {1} isMatch: {2}", value, header.Value, isMatch);
|
||||
return isMatch;
|
||||
case HeaderMatchType.Regex:
|
||||
// Can't be null, we checked above the switch statement
|
||||
return Regex.IsMatch(value!, header.Value, RegexOptions.IgnoreCase);
|
||||
default:
|
||||
throw new ArgumentException("Unrecognized HeaderMatchType");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private IEnumerable<DeviceProfile> GetProfiles(string path, DeviceProfileType type)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _fileSystem.GetFilePaths(path)
|
||||
.Where(i => Path.GetExtension(i.AsSpan()).Equals(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(i => ParseProfileFile(i, type))
|
||||
.Where(i => i is not null)
|
||||
.ToList()!; // We just filtered out all the nulls
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Array.Empty<DeviceProfile>();
|
||||
}
|
||||
}
|
||||
|
||||
private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type)
|
||||
{
|
||||
lock (_profiles)
|
||||
{
|
||||
if (_profiles.TryGetValue(path, out Tuple<InternalProfileInfo, DeviceProfile>? profileTuple))
|
||||
{
|
||||
return profileTuple.Item2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tempProfile = (DeviceProfile)_xmlSerializer.DeserializeFromFile(typeof(DeviceProfile), path);
|
||||
var profile = ReserializeProfile(tempProfile);
|
||||
|
||||
profile.Id = path.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
_profiles[path] = new Tuple<InternalProfileInfo, DeviceProfile>(GetInternalProfileInfo(_fileSystem.GetFileInfo(path), type), profile);
|
||||
|
||||
return profile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing profile file: {Path}", path);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceProfile? GetProfile(string id)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(id);
|
||||
|
||||
var info = GetProfileInfosInternal().FirstOrDefault(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (info is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseProfileFile(info.Path, info.Info.Type);
|
||||
}
|
||||
|
||||
private IEnumerable<InternalProfileInfo> GetProfileInfosInternal()
|
||||
{
|
||||
lock (_profiles)
|
||||
{
|
||||
return _profiles.Values
|
||||
.Select(i => i.Item1)
|
||||
.OrderBy(i => i.Info.Type == DeviceProfileType.User ? 0 : 1)
|
||||
.ThenBy(i => i.Info.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<DeviceProfileInfo> GetProfileInfos()
|
||||
{
|
||||
return GetProfileInfosInternal().Select(i => i.Info);
|
||||
}
|
||||
|
||||
private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type)
|
||||
{
|
||||
return new InternalProfileInfo(
|
||||
new DeviceProfileInfo
|
||||
{
|
||||
Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture),
|
||||
Name = _fileSystem.GetFileNameWithoutExtension(file),
|
||||
Type = type
|
||||
},
|
||||
file.FullName);
|
||||
}
|
||||
|
||||
private async Task ExtractSystemProfilesAsync()
|
||||
{
|
||||
var namespaceName = GetType().Namespace + ".Profiles.Xml.";
|
||||
|
||||
var systemProfilesPath = SystemProfilesPath;
|
||||
|
||||
foreach (var name in _assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (!name.StartsWith(namespaceName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = Path.Join(
|
||||
systemProfilesPath,
|
||||
Path.GetFileName(name.AsSpan())[namespaceName.Length..]);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The stream should exist as we just got its name from GetManifestResourceNames
|
||||
using (var stream = _assembly.GetManifestResourceStream(name)!)
|
||||
{
|
||||
Directory.CreateDirectory(systemProfilesPath);
|
||||
|
||||
var fileOptions = AsyncFile.WriteOptions;
|
||||
fileOptions.Mode = FileMode.CreateNew;
|
||||
fileOptions.PreallocationSize = stream.Length;
|
||||
var fileStream = new FileStream(path, fileOptions);
|
||||
await using (fileStream.ConfigureAwait(false))
|
||||
{
|
||||
await stream.CopyToAsync(fileStream).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteProfile(string id)
|
||||
{
|
||||
var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (info.Info.Type == DeviceProfileType.System)
|
||||
{
|
||||
throw new ArgumentException("System profiles cannot be deleted.");
|
||||
}
|
||||
|
||||
_fileSystem.DeleteFile(info.Path);
|
||||
|
||||
lock (_profiles)
|
||||
{
|
||||
_profiles.Remove(info.Path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CreateProfile(DeviceProfile profile)
|
||||
{
|
||||
profile = ReserializeProfile(profile);
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.Name);
|
||||
|
||||
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
|
||||
var path = Path.Combine(UserProfilesPath, newFilename);
|
||||
|
||||
SaveProfile(profile, path, DeviceProfileType.User);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdateProfile(string profileId, DeviceProfile profile)
|
||||
{
|
||||
profile = ReserializeProfile(profile);
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.Id);
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.Name);
|
||||
|
||||
var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profileId, StringComparison.OrdinalIgnoreCase));
|
||||
if (current.Info.Type == DeviceProfileType.System)
|
||||
{
|
||||
throw new ArgumentException("System profiles can't be edited");
|
||||
}
|
||||
|
||||
var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml";
|
||||
var path = Path.Join(UserProfilesPath, newFilename);
|
||||
|
||||
if (!string.Equals(path, current.Path, StringComparison.Ordinal))
|
||||
{
|
||||
lock (_profiles)
|
||||
{
|
||||
_profiles.Remove(current.Path);
|
||||
}
|
||||
}
|
||||
|
||||
SaveProfile(profile, path, DeviceProfileType.User);
|
||||
}
|
||||
|
||||
private void SaveProfile(DeviceProfile profile, string path, DeviceProfileType type)
|
||||
{
|
||||
lock (_profiles)
|
||||
{
|
||||
_profiles[path] = new Tuple<InternalProfileInfo, DeviceProfile>(GetInternalProfileInfo(_fileSystem.GetFileInfo(path), type), profile);
|
||||
}
|
||||
|
||||
SerializeToXml(profile, path);
|
||||
}
|
||||
|
||||
internal void SerializeToXml(DeviceProfile profile, string path)
|
||||
{
|
||||
_xmlSerializer.SerializeToFile(profile, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates the object using serialization, to ensure it's not a subclass.
|
||||
/// If it's a subclass it may not serialize properly to xml (different root element tag name).
|
||||
/// </summary>
|
||||
/// <param name="profile">The device profile.</param>
|
||||
/// <returns>The re-serialized device profile.</returns>
|
||||
private DeviceProfile ReserializeProfile(DeviceProfile profile)
|
||||
{
|
||||
if (profile.GetType() == typeof(DeviceProfile))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(profile, _jsonOptions);
|
||||
|
||||
// Output can't be null if the input isn't null
|
||||
return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions)!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress)
|
||||
{
|
||||
var profile = GetProfile(headers) ?? GetDefaultProfile();
|
||||
|
||||
var serverId = _appHost.SystemId;
|
||||
|
||||
return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImageStream? GetIcon(string filename)
|
||||
{
|
||||
var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
||||
? ImageFormat.Png
|
||||
: ImageFormat.Jpg;
|
||||
|
||||
var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant();
|
||||
var stream = _assembly.GetManifestResourceStream(resource);
|
||||
if (stream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ImageStream(stream)
|
||||
{
|
||||
Format = format
|
||||
};
|
||||
}
|
||||
|
||||
private class InternalProfileInfo
|
||||
{
|
||||
internal InternalProfileInfo(DeviceProfileInfo info, string path)
|
||||
{
|
||||
Info = info;
|
||||
Path = path;
|
||||
}
|
||||
|
||||
internal DeviceProfileInfo Info { get; }
|
||||
|
||||
internal string Path { get; }
|
||||
}
|
||||
}
|
||||
}
|
27
src/Jellyfin.Plugin.Dlna/DlnaPlugin.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.Dlna.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna;
|
||||
|
||||
/// <summary>
|
||||
/// DLNA plugin for Jellyfin.
|
||||
/// </summary>
|
||||
public class DlnaPlugin : BasePlugin<DlnaPluginConfiguration>
|
||||
{
|
||||
public DlnaPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => new("33EBA9CD-7DA1-4720-967F-DD7DAE7B74A1");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "DLNA";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Use Jellyfin as a DLNA server.";
|
||||
}
|
90
src/Jellyfin.Plugin.Dlna/DlnaServiceRegistrator.cs
Normal file
@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Jellyfin.Plugin.Dlna.ConnectionManager;
|
||||
using Jellyfin.Plugin.Dlna.ContentDirectory;
|
||||
using Jellyfin.Plugin.Dlna.Main;
|
||||
using Jellyfin.Plugin.Dlna.MediaReceiverRegistrar;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.Playback;
|
||||
using Jellyfin.Plugin.Dlna.Ssdp;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Rssdp.Infrastructure;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna;
|
||||
|
||||
public class DlnaServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
public void RegisterServices(IServiceCollection services)
|
||||
{
|
||||
// TODO
|
||||
/*services.AddHttpClient(NamedClient.Dlna, c =>
|
||||
{
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}/{1} UPnP/1.0 {2}/{3}",
|
||||
Environment.OSVersion.Platform,
|
||||
Environment.OSVersion,
|
||||
applicationHost.Name,
|
||||
applicationHost.ApplicationVersionString));
|
||||
|
||||
c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", applicationHost.FriendlyName); // Required for UPnP DeviceArchitecture v2.0
|
||||
c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", applicationHost.FriendlyName); // REVIEW: where does this come from?
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
||||
});*/
|
||||
|
||||
var appName = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly()!.Location).ProductName;
|
||||
var appFriendlyName = Environment.MachineName;
|
||||
var appVersion = Assembly.GetEntryAssembly()!.GetName().Version!.ToString(3);
|
||||
services.AddHttpClient(NamedClient.Dlna, c =>
|
||||
{
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}/{1} UPnP/1.0 {2}/{3}",
|
||||
Environment.OSVersion.Platform,
|
||||
Environment.OSVersion,
|
||||
appName,
|
||||
appVersion));
|
||||
|
||||
c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", appFriendlyName); // Required for UPnP DeviceArchitecture v2.0
|
||||
c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", appFriendlyName); // REVIEW: where does this come from?
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
|
||||
});
|
||||
|
||||
services.AddSingleton<IDlnaManager, DlnaManager>();
|
||||
services.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
|
||||
services.AddSingleton<IContentDirectory, ContentDirectoryService>();
|
||||
services.AddSingleton<IConnectionManager, ConnectionManagerService>();
|
||||
services.AddSingleton<IMediaReceiverRegistrar, MediaReceiverRegistrarService>();
|
||||
|
||||
services.AddSingleton<TranscodingJobHelper>();
|
||||
services.AddScoped<AudioHelper>();
|
||||
services.AddScoped<DynamicHlsHelper>();
|
||||
|
||||
services.AddSingleton<ISsdpCommunicationsServer>(provider => new SsdpCommunicationsServer(
|
||||
provider.GetRequiredService<INetworkManager>(),
|
||||
provider.GetRequiredService<ILogger<SsdpCommunicationsServer>>())
|
||||
{
|
||||
IsShared = true
|
||||
});
|
||||
|
||||
services.AddHostedService<DlnaHost>();
|
||||
}
|
||||
}
|
22
src/Jellyfin.Plugin.Dlna/EventSubscriptionResponse.cs
Normal file
@ -0,0 +1,22 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public class EventSubscriptionResponse
|
||||
{
|
||||
public EventSubscriptionResponse(string content, string contentType)
|
||||
{
|
||||
Content = content;
|
||||
ContentType = contentType;
|
||||
Headers = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public string Content { get; set; }
|
||||
|
||||
public string ContentType { get; set; }
|
||||
|
||||
public Dictionary<string, string> Headers { get; }
|
||||
}
|
||||
}
|
183
src/Jellyfin.Plugin.Dlna/Eventing/DlnaEventManager.cs
Normal file
@ -0,0 +1,183 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Eventing
|
||||
{
|
||||
public class DlnaEventManager : IDlnaEventManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, EventSubscription> _subscriptions =
|
||||
new ConcurrentDictionary<string, EventSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public DlnaEventManager(ILogger logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string requestedTimeoutString, string callbackUrl)
|
||||
{
|
||||
var subscription = GetSubscription(subscriptionId, false);
|
||||
if (subscription is not null)
|
||||
{
|
||||
subscription.TimeoutSeconds = ParseTimeout(requestedTimeoutString) ?? 300;
|
||||
int timeoutSeconds = subscription.TimeoutSeconds;
|
||||
subscription.SubscriptionTime = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewing event subscription for {0} with timeout of {1} to {2}",
|
||||
subscription.NotificationType,
|
||||
timeoutSeconds,
|
||||
subscription.CallbackUrl);
|
||||
|
||||
return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds);
|
||||
}
|
||||
|
||||
return new EventSubscriptionResponse(string.Empty, "text/plain");
|
||||
}
|
||||
|
||||
public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl)
|
||||
{
|
||||
var timeout = ParseTimeout(requestedTimeoutString) ?? 300;
|
||||
var id = "uuid:" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Creating event subscription for {0} with timeout of {1} to {2}",
|
||||
notificationType,
|
||||
timeout,
|
||||
callbackUrl);
|
||||
|
||||
_subscriptions.TryAdd(id, new EventSubscription
|
||||
{
|
||||
Id = id,
|
||||
CallbackUrl = callbackUrl,
|
||||
SubscriptionTime = DateTime.UtcNow,
|
||||
TimeoutSeconds = timeout,
|
||||
NotificationType = notificationType
|
||||
});
|
||||
|
||||
return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout);
|
||||
}
|
||||
|
||||
private int? ParseTimeout(string header)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(header))
|
||||
{
|
||||
// Starts with SECOND-
|
||||
if (int.TryParse(header.AsSpan().RightPart('-'), NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public EventSubscriptionResponse CancelEventSubscription(string subscriptionId)
|
||||
{
|
||||
_logger.LogDebug("Cancelling event subscription {0}", subscriptionId);
|
||||
|
||||
_subscriptions.TryRemove(subscriptionId, out _);
|
||||
|
||||
return new EventSubscriptionResponse(string.Empty, "text/plain");
|
||||
}
|
||||
|
||||
private EventSubscriptionResponse GetEventSubscriptionResponse(string subscriptionId, string requestedTimeoutString, int timeoutSeconds)
|
||||
{
|
||||
var response = new EventSubscriptionResponse(string.Empty, "text/plain");
|
||||
|
||||
response.Headers["SID"] = subscriptionId;
|
||||
response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(CultureInfo.InvariantCulture)) : requestedTimeoutString;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public EventSubscription GetSubscription(string id)
|
||||
{
|
||||
return GetSubscription(id, false);
|
||||
}
|
||||
|
||||
private EventSubscription GetSubscription(string id, bool throwOnMissing)
|
||||
{
|
||||
if (!_subscriptions.TryGetValue(id, out EventSubscription e) && throwOnMissing)
|
||||
{
|
||||
throw new ResourceNotFoundException("Event with Id " + id + " not found.");
|
||||
}
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
public Task TriggerEvent(string notificationType, IDictionary<string, string> stateVariables)
|
||||
{
|
||||
var subs = _subscriptions.Values
|
||||
.Where(i => !i.IsExpired && string.Equals(notificationType, i.NotificationType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var tasks = subs.Select(i => TriggerEvent(i, stateVariables));
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task TriggerEvent(EventSubscription subscription, IDictionary<string, string> stateVariables)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.Append("<?xml version=\"1.0\"?>");
|
||||
builder.Append("<e:propertyset xmlns:e=\"urn:schemas-upnp-org:event-1-0\">");
|
||||
foreach (var key in stateVariables.Keys)
|
||||
{
|
||||
builder.Append("<e:property>")
|
||||
.Append('<')
|
||||
.Append(key)
|
||||
.Append('>')
|
||||
.Append(stateVariables[key])
|
||||
.Append("</")
|
||||
.Append(key)
|
||||
.Append('>')
|
||||
.Append("</e:property>");
|
||||
}
|
||||
|
||||
builder.Append("</e:propertyset>");
|
||||
|
||||
using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl);
|
||||
options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml);
|
||||
options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
|
||||
options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
|
||||
options.Headers.TryAddWithoutValidation("SID", subscription.Id);
|
||||
options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.DirectIp)
|
||||
.SendAsync(options, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Already logged at lower levels
|
||||
}
|
||||
finally
|
||||
{
|
||||
subscription.IncrementTriggerCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
35
src/Jellyfin.Plugin.Dlna/Eventing/EventSubscription.cs
Normal file
@ -0,0 +1,35 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Eventing
|
||||
{
|
||||
public class EventSubscription
|
||||
{
|
||||
public string Id { get; set; }
|
||||
|
||||
public string CallbackUrl { get; set; }
|
||||
|
||||
public string NotificationType { get; set; }
|
||||
|
||||
public DateTime SubscriptionTime { get; set; }
|
||||
|
||||
public int TimeoutSeconds { get; set; }
|
||||
|
||||
public long TriggerCount { get; set; }
|
||||
|
||||
public bool IsExpired => SubscriptionTime.AddSeconds(TimeoutSeconds) >= DateTime.UtcNow;
|
||||
|
||||
public void IncrementTriggerCount()
|
||||
{
|
||||
if (TriggerCount == long.MaxValue)
|
||||
{
|
||||
TriggerCount = 0;
|
||||
}
|
||||
|
||||
TriggerCount++;
|
||||
}
|
||||
}
|
||||
}
|
207
src/Jellyfin.Plugin.Dlna/Extensions/StreamInfoExtensions.cs
Normal file
@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Extensions;
|
||||
|
||||
public static class StreamInfoExtensions
|
||||
{
|
||||
public static string ToDlnaUrl(this StreamInfo streamInfo, string baseUrl, string? accessToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(baseUrl);
|
||||
|
||||
var list = new List<string>();
|
||||
foreach (NameValuePair pair in BuildParams(streamInfo, accessToken))
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to keep the url clean by omitting defaults
|
||||
if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal);
|
||||
|
||||
list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue));
|
||||
}
|
||||
|
||||
string queryString = string.Join('&', list);
|
||||
|
||||
return GetUrl(streamInfo, baseUrl, queryString);
|
||||
}
|
||||
|
||||
private static string GetUrl(StreamInfo streamInfo, string baseUrl, string queryString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(baseUrl);
|
||||
|
||||
string extension = string.IsNullOrEmpty(streamInfo.Container) ? string.Empty : "." + streamInfo.Container;
|
||||
|
||||
baseUrl = baseUrl.TrimEnd('/');
|
||||
|
||||
var itemId = streamInfo.ItemId;
|
||||
if (streamInfo.MediaType == DlnaProfileType.Audio)
|
||||
{
|
||||
if (string.Equals(streamInfo.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0}/dlna/audio/{1}/master.m3u8?{2}", baseUrl, itemId, queryString);
|
||||
}
|
||||
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0}/dlna/audio/{1}/stream{2}?{3}", baseUrl, itemId, extension, queryString);
|
||||
}
|
||||
|
||||
if (string.Equals(streamInfo.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0}/dlna/videos/{1}/master.m3u8?{2}", baseUrl, itemId, queryString);
|
||||
}
|
||||
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0}/dlna/videos/{1}/stream{2}?{3}", baseUrl, itemId, extension, queryString);
|
||||
}
|
||||
|
||||
private static IEnumerable<NameValuePair> BuildParams(StreamInfo item, string? accessToken)
|
||||
{
|
||||
var list = new List<NameValuePair>();
|
||||
|
||||
string audioCodecs = item.AudioCodecs.Length == 0 ?
|
||||
string.Empty :
|
||||
string.Join(',', item.AudioCodecs);
|
||||
|
||||
string videoCodecs = item.VideoCodecs.Length == 0 ?
|
||||
string.Empty :
|
||||
string.Join(',', item.VideoCodecs);
|
||||
|
||||
list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty));
|
||||
list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty));
|
||||
list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty));
|
||||
list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
list.Add(new NameValuePair("VideoCodec", videoCodecs));
|
||||
list.Add(new NameValuePair("AudioCodec", audioCodecs));
|
||||
list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
|
||||
list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
|
||||
long startPositionTicks = item.StartPositionTicks;
|
||||
var isHls = string.Equals(item.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isHls)
|
||||
{
|
||||
list.Add(new NameValuePair("StartTimeTicks", string.Empty));
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty));
|
||||
list.Add(new NameValuePair("api_key", accessToken ?? string.Empty));
|
||||
|
||||
string? liveStreamId = item.MediaSource?.LiveStreamId;
|
||||
list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty));
|
||||
|
||||
list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty));
|
||||
|
||||
if (!item.IsDirectStream)
|
||||
{
|
||||
if (item.RequireNonAnamorphic)
|
||||
{
|
||||
list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
|
||||
|
||||
if (item.EnableSubtitlesInManifest)
|
||||
{
|
||||
list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (item.EnableMpegtsM2TsMode)
|
||||
{
|
||||
list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (item.EstimateContentLength)
|
||||
{
|
||||
list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto)
|
||||
{
|
||||
list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (item.CopyTimestamps)
|
||||
{
|
||||
list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty));
|
||||
|
||||
string subtitleCodecs = item.SubtitleCodecs.Length == 0 ?
|
||||
string.Empty :
|
||||
string.Join(",", item.SubtitleCodecs);
|
||||
|
||||
list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty));
|
||||
|
||||
if (isHls)
|
||||
{
|
||||
list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty));
|
||||
|
||||
if (item.SegmentLength.HasValue)
|
||||
{
|
||||
list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (item.MinSegments.HasValue)
|
||||
{
|
||||
list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
foreach (var pair in item.StreamOptions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// strip spaces to avoid having to encode h264 profile names
|
||||
list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
if (!item.IsDirectStream)
|
||||
{
|
||||
list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString()));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
8
src/Jellyfin.Plugin.Dlna/IConnectionManager.cs
Normal file
@ -0,0 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public interface IConnectionManager : IDlnaEventManager, IUpnpService
|
||||
{
|
||||
}
|
||||
}
|
8
src/Jellyfin.Plugin.Dlna/IContentDirectory.cs
Normal file
@ -0,0 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public interface IContentDirectory : IDlnaEventManager, IUpnpService
|
||||
{
|
||||
}
|
||||
}
|
34
src/Jellyfin.Plugin.Dlna/IDlnaEventManager.cs
Normal file
@ -0,0 +1,34 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public interface IDlnaEventManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Cancels the event subscription.
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">The subscription identifier.</param>
|
||||
/// <returns>The response.</returns>
|
||||
EventSubscriptionResponse CancelEventSubscription(string subscriptionId);
|
||||
|
||||
/// <summary>
|
||||
/// Renews the event subscription.
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">The subscription identifier.</param>
|
||||
/// <param name="notificationType">The notification type.</param>
|
||||
/// <param name="requestedTimeoutString">The requested timeout as a string.</param>
|
||||
/// <param name="callbackUrl">The callback url.</param>
|
||||
/// <returns>The response.</returns>
|
||||
EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string requestedTimeoutString, string callbackUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the event subscription.
|
||||
/// </summary>
|
||||
/// <param name="notificationType">The notification type.</param>
|
||||
/// <param name="requestedTimeoutString">The requested timeout as a string.</param>
|
||||
/// <param name="callbackUrl">The callback url.</param>
|
||||
/// <returns>The response.</returns>
|
||||
EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl);
|
||||
}
|
||||
}
|
8
src/Jellyfin.Plugin.Dlna/IMediaReceiverRegistrar.cs
Normal file
@ -0,0 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public interface IMediaReceiverRegistrar : IDlnaEventManager, IUpnpService
|
||||
{
|
||||
}
|
||||
}
|
22
src/Jellyfin.Plugin.Dlna/IUpnpService.cs
Normal file
@ -0,0 +1,22 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna
|
||||
{
|
||||
public interface IUpnpService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the content directory XML.
|
||||
/// </summary>
|
||||
/// <returns>System.String.</returns>
|
||||
string GetServiceXml();
|
||||
|
||||
/// <summary>
|
||||
/// Processes the control request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <returns>ControlResponse.</returns>
|
||||
Task<ControlResponse> ProcessControlRequestAsync(ControlRequest request);
|
||||
}
|
||||
}
|
BIN
src/Jellyfin.Plugin.Dlna/Images/logo120.jpg
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/logo120.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/logo240.jpg
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/logo240.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/logo48.jpg
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/logo48.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/people48.jpg
Normal file
After Width: | Height: | Size: 740 B |
BIN
src/Jellyfin.Plugin.Dlna/Images/people48.png
Normal file
After Width: | Height: | Size: 278 B |
BIN
src/Jellyfin.Plugin.Dlna/Images/people480.jpg
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
src/Jellyfin.Plugin.Dlna/Images/people480.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
17
src/Jellyfin.Plugin.Dlna/Jellyfin.Plugin.Dlna.csproj
Normal file
@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.0-20231109.7" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.0-20231109.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.Dlna.Model\Jellyfin.Plugin.Dlna.Model.csproj" />
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.Dlna.Playback\Jellyfin.Plugin.Dlna.Playback.csproj" />
|
||||
<ProjectReference Include="..\Rssdp\Rssdp.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
421
src/Jellyfin.Plugin.Dlna/Main/DlnaHost.cs
Normal file
@ -0,0 +1,421 @@
|
||||
#pragma warning disable CA1031 // Do not catch general exception types.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using Jellyfin.Plugin.Dlna.PlayTo;
|
||||
using Jellyfin.Plugin.Dlna.Ssdp;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Rssdp;
|
||||
using Rssdp.Infrastructure;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.Main;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="IHostedService"/> that manages a DLNA server.
|
||||
/// </summary>
|
||||
public sealed class DlnaHost : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ILogger<DlnaHost> _logger;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IDeviceDiscovery _deviceDiscovery;
|
||||
private readonly ISsdpCommunicationsServer _communicationsServer;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly object _syncLock = new();
|
||||
|
||||
private SsdpDevicePublisher? _publisher;
|
||||
private PlayToManager? _manager;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DlnaHost"/> class.
|
||||
/// </summary>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/>.</param>
|
||||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
|
||||
/// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param>
|
||||
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
|
||||
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param>
|
||||
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
|
||||
/// <param name="dlnaManager">The <see cref="IDlnaManager"/>.</param>
|
||||
/// <param name="imageProcessor">The <see cref="IImageProcessor"/>.</param>
|
||||
/// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param>
|
||||
/// <param name="localizationManager">The <see cref="ILocalizationManager"/>.</param>
|
||||
/// <param name="mediaSourceManager">The <see cref="IMediaSourceManager"/>.</param>
|
||||
/// <param name="deviceDiscovery">The <see cref="IDeviceDiscovery"/>.</param>
|
||||
/// <param name="mediaEncoder">The <see cref="IMediaEncoder"/>.</param>
|
||||
/// <param name="communicationsServer">The <see cref="ISsdpCommunicationsServer"/>.</param>
|
||||
/// <param name="networkManager">The <see cref="INetworkManager"/>.</param>
|
||||
public DlnaHost(
|
||||
IServerConfigurationManager config,
|
||||
ILoggerFactory loggerFactory,
|
||||
IServerApplicationHost appHost,
|
||||
ISessionManager sessionManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDlnaManager dlnaManager,
|
||||
IImageProcessor imageProcessor,
|
||||
IUserDataManager userDataManager,
|
||||
ILocalizationManager localizationManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IDeviceDiscovery deviceDiscovery,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ISsdpCommunicationsServer communicationsServer,
|
||||
INetworkManager networkManager)
|
||||
{
|
||||
Console.WriteLine("Creating DlnaHost");
|
||||
|
||||
_config = config;
|
||||
_appHost = appHost;
|
||||
_sessionManager = sessionManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dlnaManager = dlnaManager;
|
||||
_imageProcessor = imageProcessor;
|
||||
_userDataManager = userDataManager;
|
||||
_localization = localizationManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_deviceDiscovery = deviceDiscovery;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_communicationsServer = communicationsServer;
|
||||
_networkManager = networkManager;
|
||||
_logger = loggerFactory.CreateLogger<DlnaHost>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO
|
||||
/*var netConfig = _config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey);
|
||||
if (_appHost.ListenWithHttps && netConfig.RequireHttps)
|
||||
{
|
||||
// No use starting as dlna won't work, as we're running purely on HTTPS.
|
||||
_logger.LogError("The DLNA specification does not support HTTPS.");
|
||||
return;
|
||||
}*/
|
||||
|
||||
await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
|
||||
ReloadComponents();
|
||||
|
||||
_config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Stop();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
Stop();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ReloadComponents();
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadComponents()
|
||||
{
|
||||
var options = _config.GetDlnaConfiguration();
|
||||
StartDeviceDiscovery();
|
||||
StartDevicePublisher(options);
|
||||
|
||||
if (options.EnablePlayTo)
|
||||
{
|
||||
StartPlayToManager();
|
||||
}
|
||||
else
|
||||
{
|
||||
DisposePlayToManager();
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateUuid(string text)
|
||||
{
|
||||
if (!Guid.TryParse(text, out var guid))
|
||||
{
|
||||
guid = text.GetMD5();
|
||||
}
|
||||
|
||||
return guid.ToString("D", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void SetProperties(SsdpDevice device, string fullDeviceType)
|
||||
{
|
||||
var serviceParts = fullDeviceType
|
||||
.Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(":1", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Split(':');
|
||||
|
||||
device.DeviceTypeNamespace = serviceParts[0].Replace('.', '-');
|
||||
device.DeviceClass = serviceParts[1];
|
||||
device.DeviceType = serviceParts[2];
|
||||
}
|
||||
|
||||
private void StartDeviceDiscovery()
|
||||
{
|
||||
try
|
||||
{
|
||||
((DeviceDiscovery)_deviceDiscovery).Start(_communicationsServer);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting device discovery");
|
||||
}
|
||||
}
|
||||
|
||||
private void StartDevicePublisher(Configuration.DlnaOptions options)
|
||||
{
|
||||
if (_publisher is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_publisher = new SsdpDevicePublisher(
|
||||
_communicationsServer,
|
||||
Environment.OSVersion.Platform.ToString(),
|
||||
// Can not use VersionString here since that includes OS and version
|
||||
Environment.OSVersion.Version.ToString(),
|
||||
_config.GetDlnaConfiguration().SendOnlyMatchedHost)
|
||||
{
|
||||
LogFunction = msg => _logger.LogDebug("{Msg}", msg),
|
||||
SupportPnpRootDevice = false
|
||||
};
|
||||
|
||||
RegisterServerEndpoints();
|
||||
|
||||
if (options.BlastAliveMessages)
|
||||
{
|
||||
_publisher.StartSendingAliveNotifications(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error registering endpoint");
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterServerEndpoints()
|
||||
{
|
||||
var udn = CreateUuid(_appHost.SystemId);
|
||||
var descriptorUri = "/dlna/" + udn + "/description.xml";
|
||||
|
||||
// Only get bind addresses in LAN
|
||||
// IPv6 is currently unsupported
|
||||
var validInterfaces = _networkManager.GetInternalBindAddresses()
|
||||
.Where(x => x.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
.ToList();
|
||||
|
||||
if (validInterfaces.Count == 0)
|
||||
{
|
||||
// No interfaces returned, fall back to loopback
|
||||
validInterfaces = _networkManager.GetLoopbacks().ToList();
|
||||
}
|
||||
|
||||
foreach (var intf in validInterfaces)
|
||||
{
|
||||
var fullService = "urn:schemas-upnp-org:device:MediaServer:1";
|
||||
|
||||
_logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, intf.Address);
|
||||
|
||||
var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(intf.Address, false) + descriptorUri);
|
||||
|
||||
var device = new SsdpRootDevice
|
||||
{
|
||||
CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
|
||||
Location = uri.Uri, // Must point to the URL that serves your devices UPnP description document.
|
||||
Address = intf.Address,
|
||||
PrefixLength = MaskToCidr(intf.Subnet.Prefix),
|
||||
FriendlyName = "Jellyfin",
|
||||
Manufacturer = "Jellyfin",
|
||||
ModelName = "Jellyfin Server",
|
||||
Uuid = udn
|
||||
// This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc.
|
||||
};
|
||||
|
||||
SetProperties(device, fullService);
|
||||
_publisher!.AddDevice(device);
|
||||
|
||||
var embeddedDevices = new[]
|
||||
{
|
||||
"urn:schemas-upnp-org:service:ContentDirectory:1",
|
||||
"urn:schemas-upnp-org:service:ConnectionManager:1",
|
||||
// "urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1"
|
||||
};
|
||||
|
||||
foreach (var subDevice in embeddedDevices)
|
||||
{
|
||||
var embeddedDevice = new SsdpEmbeddedDevice
|
||||
{
|
||||
FriendlyName = device.FriendlyName,
|
||||
Manufacturer = device.Manufacturer,
|
||||
ModelName = device.ModelName,
|
||||
Uuid = udn
|
||||
// This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc.
|
||||
};
|
||||
|
||||
SetProperties(embeddedDevice, subDevice);
|
||||
device.AddDevice(embeddedDevice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartPlayToManager()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (_manager is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_manager = new PlayToManager(
|
||||
_logger,
|
||||
_sessionManager,
|
||||
_libraryManager,
|
||||
_userManager,
|
||||
_dlnaManager,
|
||||
_appHost,
|
||||
_imageProcessor,
|
||||
_deviceDiscovery,
|
||||
_httpClientFactory,
|
||||
_userDataManager,
|
||||
_localization,
|
||||
_mediaSourceManager,
|
||||
_mediaEncoder);
|
||||
|
||||
_manager.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting PlayTo manager");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposePlayToManager()
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
if (_manager is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Disposing PlayToManager");
|
||||
_manager.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error disposing PlayTo manager");
|
||||
}
|
||||
|
||||
_manager = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeDevicePublisher()
|
||||
{
|
||||
if (_publisher is not null)
|
||||
{
|
||||
_logger.LogInformation("Disposing SsdpDevicePublisher");
|
||||
_publisher.Dispose();
|
||||
_publisher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
DisposeDevicePublisher();
|
||||
DisposePlayToManager();
|
||||
}
|
||||
|
||||
// TODO
|
||||
private static byte MaskToCidr(IPAddress mask)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mask);
|
||||
|
||||
byte cidrnet = 0;
|
||||
if (mask.Equals(IPAddress.Any))
|
||||
{
|
||||
return cidrnet;
|
||||
}
|
||||
|
||||
// GetAddressBytes
|
||||
Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
|
||||
if (!mask.TryWriteBytes(bytes, out var bytesWritten))
|
||||
{
|
||||
Console.WriteLine("Unable to write address bytes, only ${bytesWritten} bytes written.");
|
||||
}
|
||||
|
||||
var zeroed = false;
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1)
|
||||
{
|
||||
if (zeroed)
|
||||
{
|
||||
// Invalid netmask.
|
||||
return (byte)~cidrnet;
|
||||
}
|
||||
|
||||
if ((v & 0x80) == 0)
|
||||
{
|
||||
zeroed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
cidrnet++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cidrnet;
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.MediaReceiverRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ControlHandler" />.
|
||||
/// </summary>
|
||||
public class ControlHandler : BaseControlHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ControlHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ControlHandler"/> instance.</param>
|
||||
/// <param name="logger">The <see cref="ILogger"/> for use with the <see cref="ControlHandler"/> instance.</param>
|
||||
public ControlHandler(IServerConfigurationManager config, ILogger logger)
|
||||
: base(config, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void WriteResult(string methodName, IReadOnlyDictionary<string, string> methodParams, XmlWriter xmlWriter)
|
||||
{
|
||||
if (string.Equals(methodName, "IsAuthorized", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleIsAuthorized(xmlWriter);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(methodName, "IsValidated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleIsValidated(xmlWriter);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ResourceNotFoundException("Unexpected control request name: " + methodName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the handle is authorized in the xml stream.
|
||||
/// </summary>
|
||||
/// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
|
||||
private static void HandleIsAuthorized(XmlWriter xmlWriter)
|
||||
=> xmlWriter.WriteElementString("Result", "1");
|
||||
|
||||
/// <summary>
|
||||
/// Records that the handle is validated in the xml stream.
|
||||
/// </summary>
|
||||
/// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
|
||||
private static void HandleIsValidated(XmlWriter xmlWriter)
|
||||
=> xmlWriter.WriteElementString("Result", "1");
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.MediaReceiverRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MediaReceiverRegistrarService" />.
|
||||
/// </summary>
|
||||
public class MediaReceiverRegistrarService : BaseService, IMediaReceiverRegistrar
|
||||
{
|
||||
private readonly IServerConfigurationManager _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaReceiverRegistrarService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger{MediaReceiverRegistrarService}"/> for use with the <see cref="MediaReceiverRegistrarService"/> instance.</param>
|
||||
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for use with the <see cref="MediaReceiverRegistrarService"/> instance.</param>
|
||||
/// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="MediaReceiverRegistrarService"/> instance.</param>
|
||||
public MediaReceiverRegistrarService(
|
||||
ILogger<MediaReceiverRegistrarService> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServerConfigurationManager config)
|
||||
: base(logger, httpClientFactory)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetServiceXml()
|
||||
{
|
||||
return MediaReceiverRegistrarXmlBuilder.GetXml();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ControlResponse> ProcessControlRequestAsync(ControlRequest request)
|
||||
{
|
||||
return new ControlHandler(
|
||||
_config,
|
||||
Logger)
|
||||
.ProcessControlRequestAsync(request);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
using Jellyfin.Plugin.Dlna.Service;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.MediaReceiverRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MediaReceiverRegistrarXmlBuilder" />.
|
||||
/// See https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-drmnd/5d37515e-7a63-4709-8258-8fd4e0ed4482.
|
||||
/// </summary>
|
||||
public static class MediaReceiverRegistrarXmlBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an XML description of the X_MS_MediaReceiverRegistrar.
|
||||
/// </summary>
|
||||
/// <returns>An XML representation of this service.</returns>
|
||||
public static string GetXml()
|
||||
{
|
||||
return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The a list of all the state variables for this invocation.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
|
||||
private static IEnumerable<StateVariable> GetStateVariables()
|
||||
{
|
||||
var list = new List<StateVariable>
|
||||
{
|
||||
new StateVariable
|
||||
{
|
||||
Name = "AuthorizationGrantedUpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_DeviceID",
|
||||
DataType = "string",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "AuthorizationDeniedUpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "ValidationSucceededUpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_RegistrationRespMsg",
|
||||
DataType = "bin.base64",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_RegistrationReqMsg",
|
||||
DataType = "bin.base64",
|
||||
SendsEvents = false
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "ValidationRevokedUpdateID",
|
||||
DataType = "ui4",
|
||||
SendsEvents = true
|
||||
},
|
||||
|
||||
new StateVariable
|
||||
{
|
||||
Name = "A_ARG_TYPE_Result",
|
||||
DataType = "int",
|
||||
SendsEvents = false
|
||||
}
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,187 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.MediaReceiverRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the <see cref="ServiceActionListBuilder" />.
|
||||
/// </summary>
|
||||
public static class ServiceActionListBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a list of services that this instance provides.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
|
||||
public static IEnumerable<ServiceAction> GetActions()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
GetIsValidated(),
|
||||
GetIsAuthorized(),
|
||||
GetRegisterDevice(),
|
||||
GetGetAuthorizationDeniedUpdateID(),
|
||||
GetGetAuthorizationGrantedUpdateID(),
|
||||
GetGetValidationRevokedUpdateID(),
|
||||
GetGetValidationSucceededUpdateID()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "IsValidated".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetIsValidated()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "IsValidated"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "DeviceID",
|
||||
Direction = "in"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Result",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "IsAuthorized".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetIsAuthorized()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "IsAuthorized"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "DeviceID",
|
||||
Direction = "in"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "Result",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "RegisterDevice".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetRegisterDevice()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "RegisterDevice"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RegistrationReqMsg",
|
||||
Direction = "in"
|
||||
});
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "RegistrationRespMsg",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetValidationSucceededUpdateID".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetGetValidationSucceededUpdateID()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetValidationSucceededUpdateID"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ValidationSucceededUpdateID",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetGetAuthorizationDeniedUpdateID".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetGetAuthorizationDeniedUpdateID()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetAuthorizationDeniedUpdateID"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "AuthorizationDeniedUpdateID",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetValidationRevokedUpdateID".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetGetValidationRevokedUpdateID()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetValidationRevokedUpdateID"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "ValidationRevokedUpdateID",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the action details for "GetAuthorizationGrantedUpdateID".
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="ServiceAction"/>.</returns>
|
||||
private static ServiceAction GetGetAuthorizationGrantedUpdateID()
|
||||
{
|
||||
var action = new ServiceAction
|
||||
{
|
||||
Name = "GetAuthorizationGrantedUpdateID"
|
||||
};
|
||||
|
||||
action.ArgumentList.Add(new Argument
|
||||
{
|
||||
Name = "AuthorizationGrantedUpdateID",
|
||||
Direction = "out"
|
||||
});
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
1264
src/Jellyfin.Plugin.Dlna/PlayTo/Device.cs
Normal file
66
src/Jellyfin.Plugin.Dlna/PlayTo/DeviceInfo.cs
Normal file
@ -0,0 +1,66 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class DeviceInfo
|
||||
{
|
||||
private readonly List<DeviceService> _services = new List<DeviceService>();
|
||||
private string _baseUrl = string.Empty;
|
||||
|
||||
public DeviceInfo()
|
||||
{
|
||||
Name = "Generic Device";
|
||||
}
|
||||
|
||||
public string UUID { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public string ModelName { get; set; }
|
||||
|
||||
public string ModelNumber { get; set; }
|
||||
|
||||
public string ModelDescription { get; set; }
|
||||
|
||||
public string ModelUrl { get; set; }
|
||||
|
||||
public string Manufacturer { get; set; }
|
||||
|
||||
public string SerialNumber { get; set; }
|
||||
|
||||
public string ManufacturerUrl { get; set; }
|
||||
|
||||
public string PresentationUrl { get; set; }
|
||||
|
||||
public string BaseUrl
|
||||
{
|
||||
get => _baseUrl;
|
||||
set => _baseUrl = value;
|
||||
}
|
||||
|
||||
public DeviceIcon Icon { get; set; }
|
||||
|
||||
public List<DeviceService> Services => _services;
|
||||
|
||||
public DeviceIdentification ToDeviceIdentification()
|
||||
{
|
||||
return new DeviceIdentification
|
||||
{
|
||||
Manufacturer = Manufacturer,
|
||||
ModelName = ModelName,
|
||||
ModelNumber = ModelNumber,
|
||||
FriendlyName = Name,
|
||||
ManufacturerUrl = ManufacturerUrl,
|
||||
ModelUrl = ModelUrl,
|
||||
ModelDescription = ModelDescription,
|
||||
SerialNumber = SerialNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
137
src/Jellyfin.Plugin.Dlna/PlayTo/DlnaHttpClient.cs
Normal file
@ -0,0 +1,137 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Jellyfin.Plugin.Dlna.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
/// <summary>
|
||||
/// Http client for Dlna PlayTo function.
|
||||
/// </summary>
|
||||
public partial class DlnaHttpClient
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public DlnaHttpClient(ILogger logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[GeneratedRegex("(&(?![a-z]*;))")]
|
||||
private static partial Regex EscapeAmpersandRegex();
|
||||
|
||||
private static string NormalizeServiceUrl(string baseUrl, string serviceUrl)
|
||||
{
|
||||
// If it's already a complete url, don't stick anything onto the front of it
|
||||
if (serviceUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return serviceUrl;
|
||||
}
|
||||
|
||||
if (!serviceUrl.StartsWith('/'))
|
||||
{
|
||||
serviceUrl = "/" + serviceUrl;
|
||||
}
|
||||
|
||||
return baseUrl + serviceUrl;
|
||||
}
|
||||
|
||||
private async Task<XDocument?> SendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(NamedClient.Dlna);
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
return await XDocument.LoadAsync(
|
||||
stream,
|
||||
LoadOptions.None,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
// try correcting the Xml response with common errors
|
||||
stream.Position = 0;
|
||||
using StreamReader sr = new StreamReader(stream);
|
||||
var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// find and replace unescaped ampersands (&)
|
||||
xmlString = EscapeAmpersandRegex().Replace(xmlString, "&");
|
||||
|
||||
try
|
||||
{
|
||||
// retry reading Xml
|
||||
using var xmlReader = new StringReader(xmlString);
|
||||
return await XDocument.LoadAsync(
|
||||
xmlReader,
|
||||
LoadOptions.None,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse response");
|
||||
_logger.LogDebug("Malformed response: {Content}\n", xmlString);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<XDocument?> GetDataAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Have to await here instead of returning the Task directly, otherwise request would be disposed too soon
|
||||
return await SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<XDocument?> SendCommandAsync(
|
||||
string baseUrl,
|
||||
DeviceService service,
|
||||
string command,
|
||||
string postData,
|
||||
string? header = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, NormalizeServiceUrl(baseUrl, service.ControlUrl))
|
||||
{
|
||||
Content = new StringContent(postData, Encoding.UTF8, MediaTypeNames.Text.Xml)
|
||||
};
|
||||
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"SOAPACTION",
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"\"{0}#{1}\"",
|
||||
service.ServiceType,
|
||||
command));
|
||||
request.Headers.Pragma.ParseAdd("no-cache");
|
||||
|
||||
if (!string.IsNullOrEmpty(header))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("contentFeatures.dlna.org", header);
|
||||
}
|
||||
|
||||
// Have to await here instead of returning the Task directly, otherwise request would be disposed too soon
|
||||
return await SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
19
src/Jellyfin.Plugin.Dlna/PlayTo/MediaChangedEventArgs.cs
Normal file
@ -0,0 +1,19 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class MediaChangedEventArgs : EventArgs
|
||||
{
|
||||
public MediaChangedEventArgs(UBaseObject oldMediaInfo, UBaseObject newMediaInfo)
|
||||
{
|
||||
OldMediaInfo = oldMediaInfo;
|
||||
NewMediaInfo = newMediaInfo;
|
||||
}
|
||||
|
||||
public UBaseObject OldMediaInfo { get; set; }
|
||||
|
||||
public UBaseObject NewMediaInfo { get; set; }
|
||||
}
|
||||
}
|
983
src/Jellyfin.Plugin.Dlna/PlayTo/PlayToController.cs
Normal file
@ -0,0 +1,983 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Plugin.Dlna.Didl;
|
||||
using Jellyfin.Plugin.Dlna.Extensions;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ContentFeatureBuilder = Jellyfin.Plugin.Dlna.Model.ContentFeatureBuilder;
|
||||
using IDeviceDiscovery = Jellyfin.Plugin.Dlna.Model.IDeviceDiscovery;
|
||||
using IDlnaManager = Jellyfin.Plugin.Dlna.Model.IDlnaManager;
|
||||
using Photo = MediaBrowser.Controller.Entities.Photo;
|
||||
using UpnpDeviceInfo = Jellyfin.Plugin.Dlna.Model.UpnpDeviceInfo;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class PlayToController2 : ISessionController, IDisposable
|
||||
{
|
||||
private readonly SessionInfo _session;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
|
||||
private readonly IDeviceDiscovery _deviceDiscovery;
|
||||
private readonly string _serverAddress;
|
||||
private readonly string? _accessToken;
|
||||
|
||||
private readonly List<PlaylistItem> _playlist = new List<PlaylistItem>();
|
||||
private Device _device;
|
||||
private int _currentPlaylistIndex;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public PlayToController2(
|
||||
SessionInfo session,
|
||||
ISessionManager sessionManager,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger logger,
|
||||
IDlnaManager dlnaManager,
|
||||
IUserManager userManager,
|
||||
IImageProcessor imageProcessor,
|
||||
string serverAddress,
|
||||
string? accessToken,
|
||||
IDeviceDiscovery deviceDiscovery,
|
||||
IUserDataManager userDataManager,
|
||||
ILocalizationManager localization,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
Device device)
|
||||
{
|
||||
_session = session;
|
||||
_sessionManager = sessionManager;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_dlnaManager = dlnaManager;
|
||||
_userManager = userManager;
|
||||
_imageProcessor = imageProcessor;
|
||||
_serverAddress = serverAddress;
|
||||
_accessToken = accessToken;
|
||||
_deviceDiscovery = deviceDiscovery;
|
||||
_userDataManager = userDataManager;
|
||||
_localization = localization;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
|
||||
_device = device;
|
||||
_device.OnDeviceUnavailable = OnDeviceUnavailable;
|
||||
_device.PlaybackStart += OnDevicePlaybackStart;
|
||||
_device.PlaybackProgress += OnDevicePlaybackProgress;
|
||||
_device.PlaybackStopped += OnDevicePlaybackStopped;
|
||||
_device.MediaChanged += OnDeviceMediaChanged;
|
||||
|
||||
_device.Start();
|
||||
|
||||
_deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft;
|
||||
}
|
||||
|
||||
public bool IsSessionActive => !_disposed;
|
||||
|
||||
public bool SupportsMediaControl => IsSessionActive;
|
||||
|
||||
/*
|
||||
* Send a message to the DLNA device to notify what is the next track in the playlist.
|
||||
*/
|
||||
private async Task SendNextTrackMessage(int currentPlayListItemIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentPlayListItemIndex >= 0 && currentPlayListItemIndex < _playlist.Count - 1)
|
||||
{
|
||||
// The current playing item is indeed in the play list and we are not yet at the end of the playlist.
|
||||
var nextItemIndex = currentPlayListItemIndex + 1;
|
||||
var nextItem = _playlist[nextItemIndex];
|
||||
|
||||
// Send the SetNextAvTransport message.
|
||||
await _device.SetNextAvTransport(nextItem.StreamUrl, GetDlnaHeaders(nextItem), nextItem.Didl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceUnavailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sessionManager.ReportSessionEnded(_session.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Could throw if the session is already gone
|
||||
_logger.LogError(ex, "Error reporting the end of session {Id}", _session.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceDiscoveryDeviceLeft(object? sender, GenericEventArgs<UpnpDeviceInfo> e)
|
||||
{
|
||||
var info = e.Argument;
|
||||
|
||||
if (!_disposed
|
||||
&& info.Headers.TryGetValue("USN", out string? usn)
|
||||
&& usn.IndexOf(_device.Properties.UUID, StringComparison.OrdinalIgnoreCase) != -1
|
||||
&& (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1
|
||||
|| (info.Headers.TryGetValue("NT", out string? nt)
|
||||
&& nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1)))
|
||||
{
|
||||
OnDeviceUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDeviceMediaChanged(object? sender, MediaChangedEventArgs e)
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var streamInfo = StreamParams.ParseFromUrl(e.OldMediaInfo.Url, _libraryManager, _mediaSourceManager);
|
||||
if (streamInfo.Item is not null)
|
||||
{
|
||||
var positionTicks = GetProgressPositionTicks(streamInfo);
|
||||
|
||||
await ReportPlaybackStopped(streamInfo, positionTicks).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
streamInfo = StreamParams.ParseFromUrl(e.NewMediaInfo.Url, _libraryManager, _mediaSourceManager);
|
||||
if (streamInfo.Item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newItemProgress = GetProgressInfo(streamInfo);
|
||||
|
||||
await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false);
|
||||
|
||||
// Send a message to the DLNA device to notify what is the next track in the playlist.
|
||||
var currentItemIndex = _playlist.FindIndex(item => item.StreamInfo.ItemId.Equals(streamInfo.ItemId));
|
||||
if (currentItemIndex >= 0)
|
||||
{
|
||||
_currentPlaylistIndex = currentItemIndex;
|
||||
}
|
||||
|
||||
await SendNextTrackMessage(currentItemIndex, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reporting progress");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDevicePlaybackStopped(object? sender, PlaybackStoppedEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var streamInfo = StreamParams.ParseFromUrl(e.MediaInfo.Url, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (streamInfo.Item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var positionTicks = GetProgressPositionTicks(streamInfo);
|
||||
|
||||
await ReportPlaybackStopped(streamInfo, positionTicks).ConfigureAwait(false);
|
||||
|
||||
var mediaSource = await streamInfo.GetMediaSource(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var duration = mediaSource is null
|
||||
? _device.Duration?.Ticks
|
||||
: mediaSource.RunTimeTicks;
|
||||
|
||||
var playedToCompletion = positionTicks.HasValue && positionTicks.Value == 0;
|
||||
|
||||
if (!playedToCompletion && duration.HasValue && positionTicks.HasValue)
|
||||
{
|
||||
double percent = positionTicks.Value;
|
||||
percent /= duration.Value;
|
||||
|
||||
playedToCompletion = Math.Abs(1 - percent) <= .1;
|
||||
}
|
||||
|
||||
if (playedToCompletion)
|
||||
{
|
||||
await SetPlaylistIndex(_currentPlaylistIndex + 1).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_playlist.Clear();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reporting playback stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportPlaybackStopped(StreamParams streamInfo, long? positionTicks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sessionManager.OnPlaybackStopped(new PlaybackStopInfo
|
||||
{
|
||||
ItemId = streamInfo.ItemId,
|
||||
SessionId = _session.Id,
|
||||
PositionTicks = positionTicks,
|
||||
MediaSourceId = streamInfo.MediaSourceId
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reporting progress");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDevicePlaybackStart(object? sender, PlaybackStartEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var info = StreamParams.ParseFromUrl(e.MediaInfo.Url, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (info.Item is not null)
|
||||
{
|
||||
var progress = GetProgressInfo(info);
|
||||
|
||||
await _sessionManager.OnPlaybackStart(progress).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reporting progress");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDevicePlaybackProgress(object? sender, PlaybackProgressEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mediaUrl = e.MediaInfo.Url;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var info = StreamParams.ParseFromUrl(mediaUrl, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (info.Item is not null)
|
||||
{
|
||||
var progress = GetProgressInfo(info);
|
||||
|
||||
await _sessionManager.OnPlaybackProgress(progress).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error reporting progress");
|
||||
}
|
||||
}
|
||||
|
||||
private long? GetProgressPositionTicks(StreamParams info)
|
||||
{
|
||||
var ticks = _device.Position.Ticks;
|
||||
|
||||
if (!EnableClientSideSeek(info))
|
||||
{
|
||||
ticks += info.StartPositionTicks;
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
private PlaybackStartInfo GetProgressInfo(StreamParams info)
|
||||
{
|
||||
return new PlaybackStartInfo
|
||||
{
|
||||
ItemId = info.ItemId,
|
||||
SessionId = _session.Id,
|
||||
PositionTicks = GetProgressPositionTicks(info),
|
||||
IsMuted = _device.IsMuted,
|
||||
IsPaused = _device.IsPaused,
|
||||
MediaSourceId = info.MediaSourceId,
|
||||
AudioStreamIndex = info.AudioStreamIndex,
|
||||
SubtitleStreamIndex = info.SubtitleStreamIndex,
|
||||
VolumeLevel = _device.Volume,
|
||||
|
||||
CanSeek = true,
|
||||
|
||||
PlayMethod = info.IsDirectStream ? PlayMethod.DirectStream : PlayMethod.Transcode
|
||||
};
|
||||
}
|
||||
|
||||
public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand);
|
||||
|
||||
var user = command.ControllingUserId.Equals(default)
|
||||
? null :
|
||||
_userManager.GetUserById(command.ControllingUserId);
|
||||
|
||||
var items = new List<BaseItem>();
|
||||
foreach (var id in command.ItemIds)
|
||||
{
|
||||
AddItemFromId(id, items);
|
||||
}
|
||||
|
||||
var startIndex = command.StartIndex ?? 0;
|
||||
int len = items.Count - startIndex;
|
||||
if (startIndex > 0)
|
||||
{
|
||||
items = items.GetRange(startIndex, len);
|
||||
}
|
||||
|
||||
var playlist = new PlaylistItem[len];
|
||||
|
||||
// Not nullable enabled - so this is required.
|
||||
playlist[0] = CreatePlaylistItem(
|
||||
items[0],
|
||||
user,
|
||||
command.StartPositionTicks ?? 0,
|
||||
command.MediaSourceId ?? string.Empty,
|
||||
command.AudioStreamIndex,
|
||||
command.SubtitleStreamIndex);
|
||||
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
playlist[i] = CreatePlaylistItem(items[i], user, 0, string.Empty, null, null);
|
||||
}
|
||||
|
||||
_logger.LogDebug("{0} - Playlist created", _session.DeviceName);
|
||||
|
||||
if (command.PlayCommand == PlayCommand.PlayLast)
|
||||
{
|
||||
_playlist.AddRange(playlist);
|
||||
}
|
||||
|
||||
if (command.PlayCommand == PlayCommand.PlayNext)
|
||||
{
|
||||
_playlist.AddRange(playlist);
|
||||
}
|
||||
|
||||
if (!command.ControllingUserId.Equals(default))
|
||||
{
|
||||
_sessionManager.LogSessionActivity(
|
||||
_session.Client,
|
||||
_session.ApplicationVersion,
|
||||
_session.DeviceId,
|
||||
_session.DeviceName,
|
||||
_session.RemoteEndPoint,
|
||||
user);
|
||||
}
|
||||
|
||||
return PlayItems(playlist, cancellationToken);
|
||||
}
|
||||
|
||||
private Task SendPlaystateCommand(PlaystateRequest command, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (command.Command)
|
||||
{
|
||||
case PlaystateCommand.Stop:
|
||||
_playlist.Clear();
|
||||
return _device.SetStop(CancellationToken.None);
|
||||
|
||||
case PlaystateCommand.Pause:
|
||||
return _device.SetPause(CancellationToken.None);
|
||||
|
||||
case PlaystateCommand.Unpause:
|
||||
return _device.SetPlay(CancellationToken.None);
|
||||
|
||||
case PlaystateCommand.PlayPause:
|
||||
return _device.IsPaused ? _device.SetPlay(CancellationToken.None) : _device.SetPause(CancellationToken.None);
|
||||
|
||||
case PlaystateCommand.Seek:
|
||||
return Seek(command.SeekPositionTicks ?? 0);
|
||||
|
||||
case PlaystateCommand.NextTrack:
|
||||
return SetPlaylistIndex(_currentPlaylistIndex + 1, cancellationToken);
|
||||
|
||||
case PlaystateCommand.PreviousTrack:
|
||||
return SetPlaylistIndex(_currentPlaylistIndex - 1, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task Seek(long newPosition)
|
||||
{
|
||||
var media = _device.CurrentMediaInfo;
|
||||
|
||||
if (media is not null)
|
||||
{
|
||||
var info = StreamParams.ParseFromUrl(media.Url, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (info.Item is not null && !EnableClientSideSeek(info))
|
||||
{
|
||||
var user = _session.UserId.Equals(default)
|
||||
? null
|
||||
: _userManager.GetUserById(_session.UserId);
|
||||
var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex);
|
||||
|
||||
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send a message to the DLNA device to notify what is the next track in the play list.
|
||||
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
|
||||
await SendNextTrackMessage(newItemIndex, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableClientSideSeek(StreamParams info)
|
||||
{
|
||||
return info.IsDirectStream;
|
||||
}
|
||||
|
||||
private bool EnableClientSideSeek(StreamInfo info)
|
||||
{
|
||||
return info.IsDirectStream;
|
||||
}
|
||||
|
||||
private void AddItemFromId(Guid id, List<BaseItem> list)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
if (item.MediaType == MediaType.Audio || item.MediaType == MediaType.Video)
|
||||
{
|
||||
list.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private PlaylistItem CreatePlaylistItem(
|
||||
BaseItem item,
|
||||
User? user,
|
||||
long startPostionTicks,
|
||||
string? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex)
|
||||
{
|
||||
var deviceInfo = _device.Properties;
|
||||
|
||||
var profile = _dlnaManager.GetProfile(deviceInfo.ToDeviceIdentification()) ??
|
||||
_dlnaManager.GetDefaultProfile();
|
||||
|
||||
var mediaSources = item is IHasMediaSources
|
||||
? _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray()
|
||||
: Array.Empty<MediaSourceInfo>();
|
||||
|
||||
var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex);
|
||||
playlistItem.StreamInfo.StartPositionTicks = startPostionTicks;
|
||||
|
||||
playlistItem.StreamUrl = DidlBuilder.NormalizeDlnaMediaUrl(playlistItem.StreamInfo.ToDlnaUrl(_serverAddress, _accessToken));
|
||||
|
||||
var itemXml = new DidlBuilder(
|
||||
profile,
|
||||
user,
|
||||
_imageProcessor,
|
||||
_serverAddress,
|
||||
_accessToken,
|
||||
_userDataManager,
|
||||
_localization,
|
||||
_mediaSourceManager,
|
||||
_logger,
|
||||
_mediaEncoder,
|
||||
_libraryManager)
|
||||
.GetItemDidl(item, user, null, _session.DeviceId, new Filter(), playlistItem.StreamInfo);
|
||||
|
||||
playlistItem.Didl = itemXml;
|
||||
|
||||
return playlistItem;
|
||||
}
|
||||
|
||||
private string? GetDlnaHeaders(PlaylistItem item)
|
||||
{
|
||||
var profile = item.Profile;
|
||||
var streamInfo = item.StreamInfo;
|
||||
|
||||
if (streamInfo.MediaType == DlnaProfileType.Audio)
|
||||
{
|
||||
return ContentFeatureBuilder.BuildAudioHeader(
|
||||
profile,
|
||||
streamInfo.Container,
|
||||
streamInfo.TargetAudioCodec.FirstOrDefault(),
|
||||
streamInfo.TargetAudioBitrate,
|
||||
streamInfo.TargetAudioSampleRate,
|
||||
streamInfo.TargetAudioChannels,
|
||||
streamInfo.TargetAudioBitDepth,
|
||||
streamInfo.IsDirectStream,
|
||||
streamInfo.RunTimeTicks ?? 0,
|
||||
streamInfo.TranscodeSeekInfo);
|
||||
}
|
||||
|
||||
if (streamInfo.MediaType == DlnaProfileType.Video)
|
||||
{
|
||||
var list = ContentFeatureBuilder.BuildVideoHeader(
|
||||
profile,
|
||||
streamInfo.Container,
|
||||
streamInfo.TargetVideoCodec.FirstOrDefault(),
|
||||
streamInfo.TargetAudioCodec.FirstOrDefault(),
|
||||
streamInfo.TargetWidth,
|
||||
streamInfo.TargetHeight,
|
||||
streamInfo.TargetVideoBitDepth,
|
||||
streamInfo.TargetVideoBitrate,
|
||||
streamInfo.TargetTimestamp,
|
||||
streamInfo.IsDirectStream,
|
||||
streamInfo.RunTimeTicks ?? 0,
|
||||
streamInfo.TargetVideoProfile,
|
||||
streamInfo.TargetVideoRangeType,
|
||||
streamInfo.TargetVideoLevel,
|
||||
streamInfo.TargetFramerate ?? 0,
|
||||
streamInfo.TargetPacketLength,
|
||||
streamInfo.TranscodeSeekInfo,
|
||||
streamInfo.IsTargetAnamorphic,
|
||||
streamInfo.IsTargetInterlaced,
|
||||
streamInfo.TargetRefFrames,
|
||||
streamInfo.TargetVideoStreamCount,
|
||||
streamInfo.TargetAudioStreamCount,
|
||||
streamInfo.TargetVideoCodecTag,
|
||||
streamInfo.IsTargetAVC);
|
||||
|
||||
return list.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
|
||||
{
|
||||
if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PlaylistItem
|
||||
{
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalVideoStream(new MediaOptions
|
||||
{
|
||||
ItemId = item.Id,
|
||||
MediaSources = mediaSources,
|
||||
Profile = profile,
|
||||
DeviceId = deviceId,
|
||||
MaxBitrate = profile.MaxStreamingBitrate,
|
||||
MediaSourceId = mediaSourceId,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
SubtitleStreamIndex = subtitleStreamIndex
|
||||
}),
|
||||
|
||||
Profile = profile
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PlaylistItem
|
||||
{
|
||||
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalAudioStream(new MediaOptions
|
||||
{
|
||||
ItemId = item.Id,
|
||||
MediaSources = mediaSources,
|
||||
Profile = profile,
|
||||
DeviceId = deviceId,
|
||||
MaxBitrate = profile.MaxStreamingBitrate,
|
||||
MediaSourceId = mediaSourceId
|
||||
}),
|
||||
|
||||
Profile = profile
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PlaylistItemFactory.Create((Photo)item, profile);
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unrecognized item type.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays the items.
|
||||
/// </summary>
|
||||
/// <param name="items">The items.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns><c>true</c> on success.</returns>
|
||||
private async Task<bool> PlayItems(IEnumerable<PlaylistItem> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_playlist.Clear();
|
||||
_playlist.AddRange(items);
|
||||
_logger.LogDebug("{0} - Playing {1} items", _session.DeviceName, _playlist.Count);
|
||||
|
||||
await SetPlaylistIndex(0, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task SetPlaylistIndex(int index, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (index < 0 || index >= _playlist.Count)
|
||||
{
|
||||
_playlist.Clear();
|
||||
await _device.SetStop(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_currentPlaylistIndex = index;
|
||||
var currentitem = _playlist[index];
|
||||
|
||||
await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Send a message to the DLNA device to notify what is the next track in the play list.
|
||||
await SendNextTrackMessage(index, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var streamInfo = currentitem.StreamInfo;
|
||||
if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo))
|
||||
{
|
||||
await SeekAfterTransportChange(streamInfo.StartPositionTicks, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and optionally managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_device.PlaybackStart -= OnDevicePlaybackStart;
|
||||
_device.PlaybackProgress -= OnDevicePlaybackProgress;
|
||||
_device.PlaybackStopped -= OnDevicePlaybackStopped;
|
||||
_device.MediaChanged -= OnDeviceMediaChanged;
|
||||
_deviceDiscovery.DeviceLeft -= OnDeviceDiscoveryDeviceLeft;
|
||||
_device.OnDeviceUnavailable = null;
|
||||
_device.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private Task SendGeneralCommand(GeneralCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (command.Name)
|
||||
{
|
||||
case GeneralCommandType.VolumeDown:
|
||||
return _device.VolumeDown(cancellationToken);
|
||||
case GeneralCommandType.VolumeUp:
|
||||
return _device.VolumeUp(cancellationToken);
|
||||
case GeneralCommandType.Mute:
|
||||
return _device.Mute(cancellationToken);
|
||||
case GeneralCommandType.Unmute:
|
||||
return _device.Unmute(cancellationToken);
|
||||
case GeneralCommandType.ToggleMute:
|
||||
return _device.ToggleMute(cancellationToken);
|
||||
case GeneralCommandType.SetAudioStreamIndex:
|
||||
if (command.Arguments.TryGetValue("Index", out string? index))
|
||||
{
|
||||
if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return SetAudioStreamIndex(val);
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported SetAudioStreamIndex value supplied.");
|
||||
}
|
||||
|
||||
throw new ArgumentException("SetAudioStreamIndex argument cannot be null");
|
||||
case GeneralCommandType.SetSubtitleStreamIndex:
|
||||
if (command.Arguments.TryGetValue("Index", out index))
|
||||
{
|
||||
if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return SetSubtitleStreamIndex(val);
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported SetSubtitleStreamIndex value supplied.");
|
||||
}
|
||||
|
||||
throw new ArgumentException("SetSubtitleStreamIndex argument cannot be null");
|
||||
case GeneralCommandType.SetVolume:
|
||||
if (command.Arguments.TryGetValue("Volume", out string? vol))
|
||||
{
|
||||
if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume))
|
||||
{
|
||||
return _device.SetVolume(volume, cancellationToken);
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported volume value supplied.");
|
||||
}
|
||||
|
||||
throw new ArgumentException("Volume argument cannot be null");
|
||||
default:
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetAudioStreamIndex(int? newIndex)
|
||||
{
|
||||
var media = _device.CurrentMediaInfo;
|
||||
|
||||
if (media is not null)
|
||||
{
|
||||
var info = StreamParams.ParseFromUrl(media.Url, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (info.Item is not null)
|
||||
{
|
||||
var newPosition = GetProgressPositionTicks(info) ?? 0;
|
||||
|
||||
var user = _session.UserId.Equals(default)
|
||||
? null
|
||||
: _userManager.GetUserById(_session.UserId);
|
||||
var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, newIndex, info.SubtitleStreamIndex);
|
||||
|
||||
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send a message to the DLNA device to notify what is the next track in the play list.
|
||||
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
|
||||
await SendNextTrackMessage(newItemIndex, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (EnableClientSideSeek(newItem.StreamInfo))
|
||||
{
|
||||
await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSubtitleStreamIndex(int? newIndex)
|
||||
{
|
||||
var media = _device.CurrentMediaInfo;
|
||||
|
||||
if (media is not null)
|
||||
{
|
||||
var info = StreamParams.ParseFromUrl(media.Url, _libraryManager, _mediaSourceManager);
|
||||
|
||||
if (info.Item is not null)
|
||||
{
|
||||
var newPosition = GetProgressPositionTicks(info) ?? 0;
|
||||
|
||||
var user = _session.UserId.Equals(default)
|
||||
? null
|
||||
: _userManager.GetUserById(_session.UserId);
|
||||
var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, newIndex);
|
||||
|
||||
await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send a message to the DLNA device to notify what is the next track in the play list.
|
||||
var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl);
|
||||
await SendNextTrackMessage(newItemIndex, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0)
|
||||
{
|
||||
await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken)
|
||||
{
|
||||
const int MaxWait = 15000000;
|
||||
const int Interval = 500;
|
||||
|
||||
var currentWait = 0;
|
||||
while (_device.TransportState != TransportState.PLAYING && currentWait < MaxWait)
|
||||
{
|
||||
await Task.Delay(Interval, cancellationToken).ConfigureAwait(false);
|
||||
currentWait += Interval;
|
||||
}
|
||||
|
||||
await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int? GetIntValue(IReadOnlyDictionary<string, string> values, string name)
|
||||
{
|
||||
var value = values.GetValueOrDefault(name);
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long GetLongValue(IReadOnlyDictionary<string, string> values, string name)
|
||||
{
|
||||
var value = values.GetValueOrDefault(name);
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(GetType().Name);
|
||||
}
|
||||
|
||||
return name switch
|
||||
{
|
||||
SessionMessageType.Play => SendPlayCommand((data as PlayRequest)!, cancellationToken),
|
||||
SessionMessageType.Playstate => SendPlaystateCommand((data as PlaystateRequest)!, cancellationToken),
|
||||
SessionMessageType.GeneralCommand => SendGeneralCommand((data as GeneralCommand)!, cancellationToken),
|
||||
_ => Task.CompletedTask // Not supported or needed right now
|
||||
};
|
||||
}
|
||||
|
||||
private class StreamParams
|
||||
{
|
||||
private MediaSourceInfo? _mediaSource;
|
||||
private IMediaSourceManager? _mediaSourceManager;
|
||||
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
public bool IsDirectStream { get; set; }
|
||||
|
||||
public long StartPositionTicks { get; set; }
|
||||
|
||||
public int? AudioStreamIndex { get; set; }
|
||||
|
||||
public int? SubtitleStreamIndex { get; set; }
|
||||
|
||||
public string? DeviceProfileId { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
public string? MediaSourceId { get; set; }
|
||||
|
||||
public string? LiveStreamId { get; set; }
|
||||
|
||||
public BaseItem? Item { get; set; }
|
||||
|
||||
public async Task<MediaSourceInfo?> GetMediaSource(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_mediaSource is not null)
|
||||
{
|
||||
return _mediaSource;
|
||||
}
|
||||
|
||||
if (Item is not IHasMediaSources)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_mediaSourceManager is not null)
|
||||
{
|
||||
_mediaSource = await _mediaSourceManager.GetMediaSource(Item, MediaSourceId, LiveStreamId, false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _mediaSource;
|
||||
}
|
||||
|
||||
private static Guid GetItemId(string url)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(url);
|
||||
|
||||
var parts = url.Split('/');
|
||||
|
||||
for (var i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
|
||||
if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (Guid.TryParse(parts[i + 1], out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public static StreamParams ParseFromUrl(string url, ILibraryManager libraryManager, IMediaSourceManager mediaSourceManager)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(url);
|
||||
|
||||
var request = new StreamParams
|
||||
{
|
||||
ItemId = GetItemId(url)
|
||||
};
|
||||
|
||||
if (request.ItemId.Equals(default))
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var index = url.IndexOf('?', StringComparison.Ordinal);
|
||||
if (index == -1)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var query = url.Substring(index + 1);
|
||||
Dictionary<string, string> values = QueryHelpers.ParseQuery(query).ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
|
||||
|
||||
request.DeviceProfileId = values.GetValueOrDefault("DeviceProfileId");
|
||||
request.DeviceId = values.GetValueOrDefault("DeviceId");
|
||||
request.MediaSourceId = values.GetValueOrDefault("MediaSourceId");
|
||||
request.LiveStreamId = values.GetValueOrDefault("LiveStreamId");
|
||||
request.IsDirectStream = string.Equals("true", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase);
|
||||
request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex");
|
||||
request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex");
|
||||
request.StartPositionTicks = GetLongValue(values, "StartPositionTicks");
|
||||
|
||||
request.Item = libraryManager.GetItemById(request.ItemId);
|
||||
|
||||
request._mediaSourceManager = mediaSourceManager;
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
258
src/Jellyfin.Plugin.Dlna/PlayTo/PlayToManager.cs
Normal file
@ -0,0 +1,258 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Plugin.Dlna.Model;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IDlnaManager = Jellyfin.Plugin.Dlna.Model.IDlnaManager;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public sealed class PlayToManager : IDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDlnaManager _dlnaManager;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
|
||||
private readonly IDeviceDiscovery _deviceDiscovery;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
|
||||
private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
|
||||
private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
|
||||
private bool _disposed;
|
||||
|
||||
public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dlnaManager = dlnaManager;
|
||||
_appHost = appHost;
|
||||
_imageProcessor = imageProcessor;
|
||||
_deviceDiscovery = deviceDiscovery;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_userDataManager = userDataManager;
|
||||
_localization = localization;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_deviceDiscovery.DeviceDiscovered += OnDeviceDiscoveryDeviceDiscovered;
|
||||
}
|
||||
|
||||
private async void OnDeviceDiscoveryDeviceDiscovered(object? sender, GenericEventArgs<UpnpDeviceInfo> e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var info = e.Argument;
|
||||
|
||||
if (!info.Headers.TryGetValue("USN", out string? usn))
|
||||
{
|
||||
usn = string.Empty;
|
||||
}
|
||||
|
||||
if (!info.Headers.TryGetValue("NT", out string? nt))
|
||||
{
|
||||
nt = string.Empty;
|
||||
}
|
||||
|
||||
// It has to report that it's a media renderer
|
||||
if (!usn.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase)
|
||||
&& !nt.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = _disposeCancellationTokenSource.Token;
|
||||
|
||||
await _sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sessionManager.Sessions.Any(i => usn.IndexOf(i.DeviceId, StringComparison.OrdinalIgnoreCase) != -1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AddDevice(info, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating PlayTo device.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetUuid(string usn)
|
||||
{
|
||||
const string UuidStr = "uuid:";
|
||||
const string UuidColonStr = "::";
|
||||
|
||||
var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase);
|
||||
if (index == -1)
|
||||
{
|
||||
return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> tmp = usn.AsSpan()[(index + UuidStr.Length)..];
|
||||
|
||||
index = tmp.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
|
||||
if (index != -1)
|
||||
{
|
||||
tmp = tmp[..index];
|
||||
}
|
||||
|
||||
index = tmp.IndexOf('{');
|
||||
if (index != -1)
|
||||
{
|
||||
int endIndex = tmp.IndexOf('}');
|
||||
if (endIndex != -1)
|
||||
{
|
||||
tmp = tmp[(index + 1)..endIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return tmp.ToString();
|
||||
}
|
||||
|
||||
private async Task AddDevice(UpnpDeviceInfo info, CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = info.Location;
|
||||
_logger.LogDebug("Attempting to create PlayToController from location {0}", uri);
|
||||
|
||||
if (info.Headers.TryGetValue("USN", out string? uuid))
|
||||
{
|
||||
uuid = GetUuid(uuid);
|
||||
}
|
||||
else
|
||||
{
|
||||
uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var sessionInfo = await _sessionManager
|
||||
.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var controller = sessionInfo.SessionControllers.OfType<PlayToController2>().FirstOrDefault();
|
||||
|
||||
if (controller is null)
|
||||
{
|
||||
var device = await Device.CreateuPnpDeviceAsync(uri, _httpClientFactory, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (device is null)
|
||||
{
|
||||
_logger.LogError("Ignoring device as xml response is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
string deviceName = device.Properties.Name;
|
||||
|
||||
_sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
|
||||
|
||||
string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIPAddress);
|
||||
|
||||
controller = new PlayToController2(
|
||||
sessionInfo,
|
||||
_sessionManager,
|
||||
_libraryManager,
|
||||
_logger,
|
||||
_dlnaManager,
|
||||
_userManager,
|
||||
_imageProcessor,
|
||||
serverAddress,
|
||||
null,
|
||||
_deviceDiscovery,
|
||||
_userDataManager,
|
||||
_localization,
|
||||
_mediaSourceManager,
|
||||
_mediaEncoder,
|
||||
device);
|
||||
|
||||
sessionInfo.AddController(controller);
|
||||
|
||||
var profile = _dlnaManager.GetProfile(device.Properties.ToDeviceIdentification()) ??
|
||||
_dlnaManager.GetDefaultProfile();
|
||||
|
||||
_sessionManager.ReportCapabilities(sessionInfo.Id, new ClientCapabilities
|
||||
{
|
||||
PlayableMediaTypes = profile.GetSupportedMediaTypes(),
|
||||
|
||||
SupportedCommands = new[]
|
||||
{
|
||||
GeneralCommandType.VolumeDown,
|
||||
GeneralCommandType.VolumeUp,
|
||||
GeneralCommandType.Mute,
|
||||
GeneralCommandType.Unmute,
|
||||
GeneralCommandType.ToggleMute,
|
||||
GeneralCommandType.SetVolume,
|
||||
GeneralCommandType.SetAudioStreamIndex,
|
||||
GeneralCommandType.SetSubtitleStreamIndex,
|
||||
GeneralCommandType.PlayMediaSource
|
||||
},
|
||||
|
||||
SupportsMediaControl = true
|
||||
});
|
||||
|
||||
_logger.LogInformation("DLNA Session created for {0} - {1}", device.Properties.Name, device.Properties.ModelName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_deviceDiscovery.DeviceDiscovered -= OnDeviceDiscoveryDeviceDiscovered;
|
||||
|
||||
try
|
||||
{
|
||||
_disposeCancellationTokenSource.Cancel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error while disposing PlayToManager");
|
||||
}
|
||||
|
||||
_sessionLock.Dispose();
|
||||
_disposeCancellationTokenSource.Dispose();
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
16
src/Jellyfin.Plugin.Dlna/PlayTo/PlaybackProgressEventArgs.cs
Normal file
@ -0,0 +1,16 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class PlaybackProgressEventArgs : EventArgs
|
||||
{
|
||||
public PlaybackProgressEventArgs(UBaseObject mediaInfo)
|
||||
{
|
||||
MediaInfo = mediaInfo;
|
||||
}
|
||||
|
||||
public UBaseObject MediaInfo { get; set; }
|
||||
}
|
||||
}
|
16
src/Jellyfin.Plugin.Dlna/PlayTo/PlaybackStartEventArgs.cs
Normal file
@ -0,0 +1,16 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class PlaybackStartEventArgs : EventArgs
|
||||
{
|
||||
public PlaybackStartEventArgs(UBaseObject mediaInfo)
|
||||
{
|
||||
MediaInfo = mediaInfo;
|
||||
}
|
||||
|
||||
public UBaseObject MediaInfo { get; set; }
|
||||
}
|
||||
}
|
16
src/Jellyfin.Plugin.Dlna/PlayTo/PlaybackStoppedEventArgs.cs
Normal file
@ -0,0 +1,16 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class PlaybackStoppedEventArgs : EventArgs
|
||||
{
|
||||
public PlaybackStoppedEventArgs(UBaseObject mediaInfo)
|
||||
{
|
||||
MediaInfo = mediaInfo;
|
||||
}
|
||||
|
||||
public UBaseObject MediaInfo { get; set; }
|
||||
}
|
||||
}
|
19
src/Jellyfin.Plugin.Dlna/PlayTo/PlaylistItem.cs
Normal file
@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
namespace Jellyfin.Plugin.Dlna.PlayTo
|
||||
{
|
||||
public class PlaylistItem
|
||||
{
|
||||
public string StreamUrl { get; set; }
|
||||
|
||||
public string Didl { get; set; }
|
||||
|
||||
public StreamInfo StreamInfo { get; set; }
|
||||
|
||||
public DeviceProfile Profile { get; set; }
|
||||
}
|
||||
}
|