Merge branch 'master' into library_scan_speed

This commit is contained in:
BaronGreenback 2020-11-21 22:54:40 +00:00 committed by GitHub
commit e8cb9cea7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 7464 additions and 1408 deletions

View File

@ -30,11 +30,11 @@ jobs:
# This is required for the SonarCloud analyzer # This is required for the SonarCloud analyzer
- task: UseDotNet@2 - task: UseDotNet@2
displayName: "Install .NET Core SDK 2.1" displayName: "Install .NET SDK 5.x"
condition: eq(variables['ImageName'], 'ubuntu-latest') condition: eq(variables['ImageName'], 'ubuntu-latest')
inputs: inputs:
packageType: sdk packageType: sdk
version: '2.1.805' version: '5.x'
- task: UseDotNet@2 - task: UseDotNet@2
displayName: "Update DotNet" displayName: "Update DotNet"

36
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '24 2 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.100'
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels
{ {
var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray(); var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
if (query.ChannelIds.Length > 0) if (query.ChannelIds.Count > 0)
{ {
// Avoid implicitly captured closure // Avoid implicitly captured closure
var ids = query.ChannelIds; var ids = query.ChannelIds;

View File

@ -3611,12 +3611,12 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add($"type in ({inClause})"); whereClauses.Add($"type in ({inClause})");
} }
if (query.ChannelIds.Length == 1) if (query.ChannelIds.Count == 1)
{ {
whereClauses.Add("ChannelId=@ChannelId"); whereClauses.Add("ChannelId=@ChannelId");
statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture)); statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture));
} }
else if (query.ChannelIds.Length > 1) else if (query.ChannelIds.Count > 1)
{ {
var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'"));
whereClauses.Add($"ChannelId in ({inClause})"); whereClauses.Add($"ChannelId in ({inClause})");
@ -4076,7 +4076,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause); whereClauses.Add(clause);
} }
if (query.GenreIds.Length > 0) if (query.GenreIds.Count > 0)
{ {
var clauses = new List<string>(); var clauses = new List<string>();
var index = 0; var index = 0;
@ -4097,7 +4097,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause); whereClauses.Add(clause);
} }
if (query.Genres.Length > 0) if (query.Genres.Count > 0)
{ {
var clauses = new List<string>(); var clauses = new List<string>();
var index = 0; var index = 0;

View File

@ -1,61 +1,38 @@
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Data.Events; using Jellyfin.Data.Events;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Security;
using MediaBrowser.Model.Devices; using MediaBrowser.Model.Devices;
using MediaBrowser.Model.Querying; using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Session; using MediaBrowser.Model.Session;
using Microsoft.Extensions.Caching.Memory;
namespace Emby.Server.Implementations.Devices namespace Emby.Server.Implementations.Devices
{ {
public class DeviceManager : IDeviceManager public class DeviceManager : IDeviceManager
{ {
private readonly IMemoryCache _memoryCache;
private readonly IJsonSerializer _json;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IServerConfigurationManager _config;
private readonly IAuthenticationRepository _authRepo; private readonly IAuthenticationRepository _authRepo;
private readonly object _capabilitiesSyncLock = new object(); private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new ();
public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager)
public DeviceManager(
IAuthenticationRepository authRepo,
IJsonSerializer json,
IUserManager userManager,
IServerConfigurationManager config,
IMemoryCache memoryCache)
{ {
_json = json;
_userManager = userManager; _userManager = userManager;
_config = config;
_memoryCache = memoryCache;
_authRepo = authRepo; _authRepo = authRepo;
} }
public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
{ {
var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json"); _capabilitiesMap[deviceId] = capabilities;
Directory.CreateDirectory(Path.GetDirectoryName(path));
lock (_capabilitiesSyncLock)
{
_memoryCache.Set(deviceId, capabilities);
_json.SerializeToFile(capabilities, path);
}
} }
public void UpdateDeviceOptions(string deviceId, DeviceOptions options) public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
@ -72,32 +49,12 @@ namespace Emby.Server.Implementations.Devices
public ClientCapabilities GetCapabilities(string id) public ClientCapabilities GetCapabilities(string id)
{ {
if (_memoryCache.TryGetValue(id, out ClientCapabilities result)) return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result)
{ ? result
return result; : new ClientCapabilities();
}
lock (_capabilitiesSyncLock)
{
var path = Path.Combine(GetDevicePath(id), "capabilities.json");
try
{
return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities();
}
catch
{
}
}
return new ClientCapabilities();
} }
public DeviceInfo GetDevice(string id) public DeviceInfo GetDevice(string id)
{
return GetDevice(id, true);
}
private DeviceInfo GetDevice(string id, bool includeCapabilities)
{ {
var session = _authRepo.Get(new AuthenticationInfoQuery var session = _authRepo.Get(new AuthenticationInfoQuery
{ {
@ -154,16 +111,6 @@ namespace Emby.Server.Implementations.Devices
}; };
} }
private string GetDevicesPath()
{
return Path.Combine(_config.ApplicationPaths.DataPath, "devices");
}
private string GetDevicePath(string id)
{
return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture));
}
public bool CanAccessDevice(User user, string deviceId) public bool CanAccessDevice(User user, string deviceId)
{ {
if (user == null) if (user == null)

View File

@ -1503,7 +1503,7 @@ namespace Emby.Server.Implementations.Library
{ {
if (query.AncestorIds.Length == 0 && if (query.AncestorIds.Length == 0 &&
query.ParentId.Equals(Guid.Empty) && query.ParentId.Equals(Guid.Empty) &&
query.ChannelIds.Length == 0 && query.ChannelIds.Count == 0 &&
query.TopParentIds.Length == 0 && query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&

View File

@ -111,11 +111,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
{ {
using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort))) using var client = new TcpClient();
using (var stream = client.GetStream()) client.Connect(remoteIp, HdHomeRunPort);
{
return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); using var stream = client.GetStream();
} return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
} }
private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken) private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
@ -142,7 +142,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{ {
_remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
_tcpClient = new TcpClient(_remoteEndPoint); _tcpClient = new TcpClient();
_tcpClient.Connect(_remoteEndPoint);
if (!_lockkey.HasValue) if (!_lockkey.HasValue)
{ {
@ -221,30 +222,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
return; return;
} }
using (var tcpClient = new TcpClient(_remoteEndPoint)) using var tcpClient = new TcpClient();
using (var stream = tcpClient.GetStream()) tcpClient.Connect(_remoteEndPoint);
{
var commandList = commands.GetCommands();
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
foreach (var command in commandList)
{
var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked using var stream = tcpClient.GetStream();
if (!ParseReturnMessage(buffer, receivedBytes, out _)) var commandList = commands.GetCommands();
{ byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
return; try
} {
foreach (var command in commandList)
{
var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked
if (!ParseReturnMessage(buffer, receivedBytes, out _))
{
return;
} }
} }
finally }
{ finally
ArrayPool<byte>.Shared.Return(buffer); {
} ArrayPool<byte>.Shared.Return(buffer);
} }
} }

View File

@ -70,7 +70,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
try try
{ {
await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false); await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address; localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
tcpClient.Close(); tcpClient.Close();
} }
catch (Exception ex) catch (Exception ex)
@ -80,6 +80,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
} }
} }
if (localAddress.IsIPv4MappedToIPv6) {
localAddress = localAddress.MapToIPv4();
}
var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork); var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
var hdHomerunManager = new HdHomerunManager(); var hdHomerunManager = new HdHomerunManager();
@ -110,12 +114,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
var taskCompletionSource = new TaskCompletionSource<bool>(); var taskCompletionSource = new TaskCompletionSource<bool>();
await StartStreaming( _ = StartStreaming(
udpClient, udpClient,
hdHomerunManager, hdHomerunManager,
remoteAddress, remoteAddress,
taskCompletionSource, taskCompletionSource,
LiveStreamCancellationTokenSource.Token).ConfigureAwait(false); LiveStreamCancellationTokenSource.Token);
// OpenedMediaSource.Protocol = MediaProtocol.File; // OpenedMediaSource.Protocol = MediaProtocol.File;
// OpenedMediaSource.Path = tempFile; // OpenedMediaSource.Path = tempFile;
@ -136,33 +140,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
return TempFilePath; return TempFilePath;
} }
private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
{ {
return Task.Run(async () => using (udpClient)
using (hdHomerunManager)
{ {
using (udpClient) try
using (hdHomerunManager)
{ {
try await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
{ }
await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); catch (OperationCanceledException ex)
} {
catch (OperationCanceledException ex) Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
{ openTaskCompletionSource.TrySetException(ex);
Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); }
openTaskCompletionSource.TrySetException(ex); catch (Exception ex)
} {
catch (Exception ex) Logger.LogError(ex, "Error opening live stream:");
{ openTaskCompletionSource.TrySetException(ex);
Logger.LogError(ex, "Error opening live stream:");
openTaskCompletionSource.TrySetException(ex);
}
EnableStreamSharing = false;
} }
await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); EnableStreamSharing = false;
}); }
await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
} }
private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)

View File

@ -113,5 +113,10 @@
"TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή", "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
"TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.", "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
"TaskUpdatePlugins": "Ενημέρωση Προσθηκών", "TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
"TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας." "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.",
"TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής δραστηριοτήτων παλαιότερες από την ηλικία που έχει διαμορφωθεί.",
"TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων",
"Undefined": "Απροσδιόριστο",
"Forced": "Εξαναγκασμένο",
"Default": "Προεπιλογή"
} }

View File

@ -115,5 +115,8 @@
"TaskRefreshChannels": "Csatornák frissítése", "TaskRefreshChannels": "Csatornák frissítése",
"TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.", "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
"TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.", "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
"TaskCleanActivityLog": "Tevékenységnapló törlése" "TaskCleanActivityLog": "Tevékenységnapló törlése",
"Undefined": "Meghatározatlan",
"Forced": "Kényszerített",
"Default": "Alapértelmezett"
} }

View File

@ -18,13 +18,12 @@ namespace Emby.Server.Implementations.Networking
public class NetworkManager : INetworkManager public class NetworkManager : INetworkManager
{ {
private readonly ILogger<NetworkManager> _logger; private readonly ILogger<NetworkManager> _logger;
private IPAddress[] _localIpAddresses;
private readonly object _localIpAddressSyncLock = new object(); private readonly object _localIpAddressSyncLock = new object();
private readonly object _subnetLookupLock = new object(); private readonly object _subnetLookupLock = new object();
private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal); private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal);
private IPAddress[] _localIpAddresses;
private List<PhysicalAddress> _macAddresses; private List<PhysicalAddress> _macAddresses;
/// <summary> /// <summary>
@ -157,7 +156,9 @@ namespace Emby.Server.Implementations.Networking
return false; return false;
} }
byte[] octet = ipAddress.GetAddressBytes(); // GetAddressBytes
Span<byte> octet = stackalloc byte[ipAddress.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
ipAddress.TryWriteBytes(octet, out _);
if ((octet[0] == 10) || if ((octet[0] == 10) ||
(octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918 (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918
@ -260,7 +261,9 @@ namespace Emby.Server.Implementations.Networking
/// <inheritdoc/> /// <inheritdoc/>
public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC) public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC)
{ {
byte[] octet = address.GetAddressBytes(); // GetAddressBytes
Span<byte> octet = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
address.TryWriteBytes(octet, out _);
if ((octet[0] == 127) || // RFC1122 if ((octet[0] == 127) || // RFC1122
(octet[0] == 169 && octet[1] == 254)) // RFC3927 (octet[0] == 169 && octet[1] == 254)) // RFC3927
@ -503,18 +506,25 @@ namespace Emby.Server.Implementations.Networking
private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask) private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask)
{ {
byte[] ipAdressBytes = address.GetAddressBytes(); int size = address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16;
byte[] subnetMaskBytes = subnetMask.GetAddressBytes();
if (ipAdressBytes.Length != subnetMaskBytes.Length) // GetAddressBytes
Span<byte> ipAddressBytes = stackalloc byte[size];
address.TryWriteBytes(ipAddressBytes, out _);
// GetAddressBytes
Span<byte> subnetMaskBytes = stackalloc byte[size];
subnetMask.TryWriteBytes(subnetMaskBytes, out _);
if (ipAddressBytes.Length != subnetMaskBytes.Length)
{ {
throw new ArgumentException("Lengths of IP address and subnet mask do not match."); throw new ArgumentException("Lengths of IP address and subnet mask do not match.");
} }
byte[] broadcastAddress = new byte[ipAdressBytes.Length]; byte[] broadcastAddress = new byte[ipAddressBytes.Length];
for (int i = 0; i < broadcastAddress.Length; i++) for (int i = 0; i < broadcastAddress.Length; i++)
{ {
broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]); broadcastAddress[i] = (byte)(ipAddressBytes[i] & subnetMaskBytes[i]);
} }
return new IPAddress(broadcastAddress); return new IPAddress(broadcastAddress);

View File

@ -150,7 +150,7 @@ namespace Emby.Server.Implementations.Playlists
await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
.ConfigureAwait(false); .ConfigureAwait(false);
if (options.ItemIdList.Length > 0) if (options.ItemIdList.Count > 0)
{ {
await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
{ {
@ -184,7 +184,7 @@ namespace Emby.Server.Implementations.Playlists
return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
} }
public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId) public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
{ {
var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId); var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId);
@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Playlists
}); });
} }
private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options) private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options)
{ {
// Retrieve the existing playlist // Retrieve the existing playlist
var playlist = _libraryManager.GetItemById(playlistId) as Playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist

View File

@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session
/// <summary> /// <summary>
/// The active connections. /// The active connections.
/// </summary> /// </summary>
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase);
new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
private Timer _idleTimer; private Timer _idleTimer;
@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session
{ {
if (!string.IsNullOrEmpty(info.DeviceId)) if (!string.IsNullOrEmpty(info.DeviceId))
{ {
var capabilities = GetSavedCapabilities(info.DeviceId); var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
if (capabilities != null) if (capabilities != null)
{ {
@ -1677,27 +1676,10 @@ namespace Emby.Server.Implementations.Session
SessionInfo = session SessionInfo = session
}); });
try _deviceManager.SaveCapabilities(session.DeviceId, capabilities);
{
SaveCapabilities(session.DeviceId, capabilities);
}
catch (Exception ex)
{
_logger.LogError("Error saving device capabilities", ex);
}
} }
} }
private ClientCapabilities GetSavedCapabilities(string deviceId)
{
return _deviceManager.GetCapabilities(deviceId);
}
private void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
{
_deviceManager.SaveCapabilities(deviceId, capabilities);
}
/// <summary> /// <summary>
/// Converts a BaseItem to a BaseItemInfo. /// Converts a BaseItem to a BaseItemInfo.
/// </summary> /// </summary>

View File

@ -93,17 +93,29 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal; public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default) public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
{ {
try try
{ {
using var response = await _httpClientFactory.CreateClient(NamedClient.Default) using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(manifest, cancellationToken).ConfigureAwait(false); .GetAsync(new Uri(manifest), cancellationToken).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false); var package = await _jsonSerializer.DeserializeFromStreamAsync<IList<PackageInfo>>(stream).ConfigureAwait(false);
// Store the repository and repository url with each version, as they may be spread apart.
foreach (var entry in package)
{
foreach (var ver in entry.versions)
{
ver.repositoryName = manifestName;
ver.repositoryUrl = manifest;
}
}
return package;
} }
catch (SerializationException ex) catch (SerializationException ex)
{ {
@ -123,17 +135,69 @@ namespace Emby.Server.Implementations.Updates
} }
} }
private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> dest)
{
int sLength = source.Count - 1;
int dLength = dest.Count;
int s = 0, d = 0;
var sourceVersion = source[0].VersionNumber;
var destVersion = dest[0].VersionNumber;
while (d < dLength)
{
if (sourceVersion.CompareTo(destVersion) >= 0)
{
if (s < sLength)
{
sourceVersion = source[++s].VersionNumber;
}
else
{
// Append all of destination to the end of source.
while (d < dLength)
{
source.Add(dest[d++]);
}
break;
}
}
else
{
source.Insert(s++, dest[d++]);
if (d >= dLength)
{
break;
}
sLength++;
destVersion = dest[d].VersionNumber;
}
}
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
{ {
var result = new List<PackageInfo>(); var result = new List<PackageInfo>();
foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories) foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
{ {
foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true)) if (repository.Enabled)
{ {
package.repositoryName = repository.Name; // Where repositories have the same content, the details of the first is taken.
package.repositoryUrl = repository.Url; foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
result.Add(package); {
var existing = FilterPackages(result, package.name, Guid.Parse(package.guid)).FirstOrDefault();
if (existing != null)
{
// Assumption is both lists are ordered, so slot these into the correct place.
MergeSort(existing.versions, package.versions);
}
else
{
result.Add(package);
}
}
} }
} }
@ -144,7 +208,8 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable<PackageInfo> FilterPackages( public IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string name = null,
Guid guid = default) Guid guid = default,
Version specificVersion = null)
{ {
if (name != null) if (name != null)
{ {
@ -156,6 +221,11 @@ namespace Emby.Server.Implementations.Updates
availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid); availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
} }
if (specificVersion != null)
{
availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
}
return availablePackages; return availablePackages;
} }
@ -167,7 +237,7 @@ namespace Emby.Server.Implementations.Updates
Version minVersion = null, Version minVersion = null,
Version specificVersion = null) Version specificVersion = null)
{ {
var package = FilterPackages(availablePackages, name, guid).FirstOrDefault(); var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
// Package not found in repository // Package not found in repository
if (package == null) if (package == null)
@ -181,21 +251,21 @@ namespace Emby.Server.Implementations.Updates
if (specificVersion != null) if (specificVersion != null)
{ {
availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion); availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
} }
else if (minVersion != null) else if (minVersion != null)
{ {
availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion); availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
} }
foreach (var v in availableVersions.OrderByDescending(x => x.version)) foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
{ {
yield return new InstallationInfo yield return new InstallationInfo
{ {
Changelog = v.changelog, Changelog = v.changelog,
Guid = new Guid(package.guid), Guid = new Guid(package.guid),
Name = package.name, Name = package.name,
Version = new Version(v.version), Version = v.VersionNumber,
SourceUrl = v.sourceUrl, SourceUrl = v.sourceUrl,
Checksum = v.checksum Checksum = v.checksum
}; };
@ -333,7 +403,7 @@ namespace Emby.Server.Implementations.Updates
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default) using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false); .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
// CA5351: Do Not Use Broken Cryptographic Algorithms // CA5351: Do Not Use Broken Cryptographic Algorithms

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
@ -89,24 +89,24 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? genres, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] string? officialRatings, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
[FromQuery] string? tags, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
[FromQuery] string? years, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person, [FromQuery] string? person,
[FromQuery] string? personIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
[FromQuery] string? personTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? studios, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
[FromQuery] string? studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith, [FromQuery] string? nameStartsWith,
@ -131,30 +131,26 @@ namespace Jellyfin.Api.Controllers
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
} }
var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = excludeItemTypesArr, ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = includeItemTypesArr, IncludeItemTypes = includeItemTypes,
MediaTypes = mediaTypesArr, MediaTypes = mediaTypes,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
IsFavorite = isFavorite, IsFavorite = isFavorite,
NameLessThan = nameLessThan, NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith, NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater, NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, '|', true), Tags = tags,
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), OfficialRatings = officialRatings,
Genres = RequestHelpers.Split(genres, '|', true), Genres = genres,
GenreIds = RequestHelpers.GetGuids(genreIds), GenreIds = genreIds,
StudioIds = RequestHelpers.GetGuids(studioIds), StudioIds = studioIds,
Person = person, Person = person,
PersonIds = RequestHelpers.GetGuids(personIds), PersonIds = personIds,
PersonTypes = RequestHelpers.Split(personTypes, ',', true), PersonTypes = personTypes,
Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), Years = years,
MinCommunityRating = minCommunityRating, MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions, DtoOptions = dtoOptions,
SearchTerm = searchTerm, SearchTerm = searchTerm,
@ -174,9 +170,9 @@ namespace Jellyfin.Api.Controllers
} }
// Studios // Studios
if (!string.IsNullOrEmpty(studios)) if (studios.Length != 0)
{ {
query.StudioIds = studios.Split('|').Select(i => query.StudioIds = studios.Select(i =>
{ {
try try
{ {
@ -230,7 +226,7 @@ namespace Jellyfin.Api.Controllers
var (baseItem, itemCounts) = i; var (baseItem, itemCounts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes)) if (includeItemTypes.Length != 0)
{ {
dto.ChildCount = itemCounts.ItemCount; dto.ChildCount = itemCounts.ItemCount;
dto.ProgramCount = itemCounts.ProgramCount; dto.ProgramCount = itemCounts.ProgramCount;
@ -297,24 +293,24 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? genres, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] string? officialRatings, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
[FromQuery] string? tags, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
[FromQuery] string? years, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person, [FromQuery] string? person,
[FromQuery] string? personIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
[FromQuery] string? personTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? studios, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
[FromQuery] string? studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith, [FromQuery] string? nameStartsWith,
@ -339,30 +335,26 @@ namespace Jellyfin.Api.Controllers
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
} }
var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = excludeItemTypesArr, ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = includeItemTypesArr, IncludeItemTypes = includeItemTypes,
MediaTypes = mediaTypesArr, MediaTypes = mediaTypes,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
IsFavorite = isFavorite, IsFavorite = isFavorite,
NameLessThan = nameLessThan, NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith, NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater, NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, '|', true), Tags = tags,
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), OfficialRatings = officialRatings,
Genres = RequestHelpers.Split(genres, '|', true), Genres = genres,
GenreIds = RequestHelpers.GetGuids(genreIds), GenreIds = genreIds,
StudioIds = RequestHelpers.GetGuids(studioIds), StudioIds = studioIds,
Person = person, Person = person,
PersonIds = RequestHelpers.GetGuids(personIds), PersonIds = personIds,
PersonTypes = RequestHelpers.Split(personTypes, ',', true), PersonTypes = personTypes,
Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), Years = years,
MinCommunityRating = minCommunityRating, MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions, DtoOptions = dtoOptions,
SearchTerm = searchTerm, SearchTerm = searchTerm,
@ -382,9 +374,9 @@ namespace Jellyfin.Api.Controllers
} }
// Studios // Studios
if (!string.IsNullOrEmpty(studios)) if (studios.Length != 0)
{ {
query.StudioIds = studios.Split('|').Select(i => query.StudioIds = studios.Select(i =>
{ {
try try
{ {
@ -438,7 +430,7 @@ namespace Jellyfin.Api.Controllers
var (baseItem, itemCounts) = i; var (baseItem, itemCounts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes)) if (includeItemTypes.Length != 0)
{ {
dto.ChildCount = itemCounts.ItemCount; dto.ChildCount = itemCounts.ItemCount;
dto.ProgramCount = itemCounts.ProgramCount; dto.ProgramCount = itemCounts.ProgramCount;

View File

@ -85,15 +85,178 @@ namespace Jellyfin.Api.Controllers
/// <param name="streamOptions">Optional. The streaming options.</param> /// <param name="streamOptions">Optional. The streaming options.</param>
/// <response code="200">Audio stream returned.</response> /// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")]
[HttpGet("{itemId}/stream", Name = "GetAudioStream")] [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
[HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
[HttpHead("{itemId}/stream", Name = "HeadAudioStream")] [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile] [ProducesAudioFile]
public async Task<ActionResult> GetAudioStream( public async Task<ActionResult> GetAudioStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute] string? container, [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? transcodingReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
[FromQuery] Dictionary<string, string>? streamOptions)
{
StreamingRequestDto streamingRequest = new StreamingRequestDto
{
Id = itemId,
Container = container,
Static = @static ?? true,
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 ?? true,
StartTimeTicks = startTimeTicks,
Width = width,
Height = height,
VideoBitRate = videoBitRate,
SubtitleStreamIndex = subtitleStreamIndex,
SubtitleMethod = subtitleMethod,
MaxRefFrames = maxRefFrames,
MaxVideoBitDepth = maxVideoBitDepth,
RequireAvc = requireAvc ?? true,
DeInterlace = deInterlace ?? true,
RequireNonAnamorphic = requireNonAnamorphic ?? true,
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
CpuCoreLimit = cpuCoreLimit,
LiveStreamId = liveStreamId,
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
TranscodeReasons = transcodingReasons,
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 lenght.</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, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
/// <param name="transcodingReasons">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 = "GetAudioStreamByContainer")]
[HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
public async Task<ActionResult> GetAudioStreamByContainer(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,

View File

@ -1,4 +1,4 @@
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Branding; using MediaBrowser.Model.Branding;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;

View File

@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? channelIds) [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
{ {
var user = userId.HasValue && !userId.Equals(Guid.Empty) var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value) ? _userManager.GetUserById(userId.Value)
@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers
{ {
Limit = limit, Limit = limit,
StartIndex = startIndex, StartIndex = startIndex,
ChannelIds = (channelIds ?? string.Empty) ChannelIds = channelIds,
.Split(',')
.Where(i => !string.IsNullOrWhiteSpace(i))
.Select(i => new Guid(i))
.ToArray(),
DtoOptions = new DtoOptions { Fields = fields } DtoOptions = new DtoOptions { Fields = fields }
}; };

View File

@ -1,9 +1,10 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<CollectionCreationResult>> CreateCollection( public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
[FromQuery] string? name, [FromQuery] string? name,
[FromQuery] string? ids, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
[FromQuery] Guid? parentId, [FromQuery] Guid? parentId,
[FromQuery] bool isLocked = false) [FromQuery] bool isLocked = false)
{ {
@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers
IsLocked = isLocked, IsLocked = isLocked,
Name = name, Name = name,
ParentId = parentId, ParentId = parentId,
ItemIdList = RequestHelpers.Split(ids, ',', true), ItemIdList = ids,
UserIds = new[] { userId } UserIds = new[] { userId }
}).ConfigureAwait(false); }).ConfigureAwait(false);
@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("{collectionId}/Items")] [HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) public async Task<ActionResult> AddToCollection(
[FromRoute, Required] Guid collectionId,
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{ {
await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true); await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
return NoContent(); return NoContent();
} }
@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpDelete("{collectionId}/Items")] [HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) public async Task<ActionResult> RemoveFromCollection(
[FromRoute, Required] Guid collectionId,
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{ {
await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false); await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent(); return NoContent();
} }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;

View File

@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -41,6 +42,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
public class DynamicHlsController : BaseJellyfinApiController public class DynamicHlsController : BaseJellyfinApiController
{ {
private const string DefaultEncoderPreset = "veryfast";
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IDlnaManager _dlnaManager; private readonly IDlnaManager _dlnaManager;
@ -56,8 +60,7 @@ namespace Jellyfin.Api.Controllers
private readonly ILogger<DynamicHlsController> _logger; private readonly ILogger<DynamicHlsController> _logger;
private readonly EncodingHelper _encodingHelper; private readonly EncodingHelper _encodingHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper; private readonly DynamicHlsHelper _dynamicHlsHelper;
private readonly EncodingOptions _encodingOptions;
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class. /// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@ -92,6 +95,8 @@ namespace Jellyfin.Api.Controllers
ILogger<DynamicHlsController> logger, ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper) DynamicHlsHelper dynamicHlsHelper)
{ {
_encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
_libraryManager = libraryManager; _libraryManager = libraryManager;
_userManager = userManager; _userManager = userManager;
_dlnaManager = dlnaManager; _dlnaManager = dlnaManager;
@ -106,8 +111,7 @@ namespace Jellyfin.Api.Controllers
_transcodingJobHelper = transcodingJobHelper; _transcodingJobHelper = transcodingJobHelper;
_logger = logger; _logger = logger;
_dynamicHlsHelper = dynamicHlsHelper; _dynamicHlsHelper = dynamicHlsHelper;
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
_encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
} }
/// <summary> /// <summary>
@ -272,7 +276,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
}; };
return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -439,7 +443,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
}; };
return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -834,7 +838,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId, [FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId, [FromRoute, Required] int segmentId,
[FromRoute] string container, [FromRoute, Required] string container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
@ -1005,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId, [FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId, [FromRoute, Required] int segmentId,
[FromRoute] string container, [FromRoute, Required] string container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
@ -1129,7 +1133,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager, _dlnaManager,
_deviceManager, _deviceManager,
_transcodingJobHelper, _transcodingJobHelper,
_transcodingJobType, TranscodingJobType,
cancellationTokenSource.Token) cancellationTokenSource.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
@ -1137,11 +1141,19 @@ namespace Jellyfin.Api.Controllers
var segmentLengths = GetSegmentLengths(state); var segmentLengths = GetSegmentLengths(state);
var segmentContainer = state.Request.SegmentContainer ?? "ts";
// http://ffmpeg.org/ffmpeg-all.html#toc-hls-2
var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase);
var hlsVersion = isHlsInFmp4 ? "7" : "3";
var builder = new StringBuilder(); var builder = new StringBuilder();
builder.AppendLine("#EXTM3U") builder.AppendLine("#EXTM3U")
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
.AppendLine("#EXT-X-VERSION:3") .Append("#EXT-X-VERSION:")
.Append(hlsVersion)
.AppendLine()
.Append("#EXT-X-TARGETDURATION:") .Append("#EXT-X-TARGETDURATION:")
.Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength)) .Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength))
.AppendLine() .AppendLine()
@ -1151,6 +1163,18 @@ namespace Jellyfin.Api.Controllers
var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer);
var queryString = Request.QueryString; var queryString = Request.QueryString;
if (isHlsInFmp4)
{
builder.Append("#EXT-X-MAP:URI=\"")
.Append("hls1/")
.Append(name)
.Append("/-1")
.Append(segmentExtension)
.Append(queryString)
.Append('"')
.AppendLine();
}
foreach (var length in segmentLengths) foreach (var length in segmentLengths)
{ {
builder.Append("#EXTINF:") builder.Append("#EXTINF:")
@ -1194,7 +1218,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager, _dlnaManager,
_deviceManager, _deviceManager,
_transcodingJobHelper, _transcodingJobHelper,
_transcodingJobType, TranscodingJobType,
cancellationTokenSource.Token) cancellationTokenSource.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
@ -1208,7 +1232,7 @@ namespace Jellyfin.Api.Controllers
if (System.IO.File.Exists(segmentPath)) if (System.IO.File.Exists(segmentPath))
{ {
job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
_logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
} }
@ -1222,7 +1246,7 @@ namespace Jellyfin.Api.Controllers
{ {
if (System.IO.File.Exists(segmentPath)) if (System.IO.File.Exists(segmentPath))
{ {
job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
transcodingLock.Release(); transcodingLock.Release();
released = true; released = true;
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
@ -1233,7 +1257,13 @@ namespace Jellyfin.Api.Controllers
var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
if (currentTranscodingIndex == null) if (segmentId == -1)
{
_logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
startTranscoding = true;
segmentId = 0;
}
else if (currentTranscodingIndex == null)
{ {
_logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
startTranscoding = true; startTranscoding = true;
@ -1265,13 +1295,12 @@ namespace Jellyfin.Api.Controllers
streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId); streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
state.WaitForPath = segmentPath; state.WaitForPath = segmentPath;
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
job = await _transcodingJobHelper.StartFfMpeg( job = await _transcodingJobHelper.StartFfMpeg(
state, state,
playlistPath, playlistPath,
GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId), GetCommandLineArguments(playlistPath, state, true, segmentId),
Request, Request,
_transcodingJobType, TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false); cancellationTokenSource).ConfigureAwait(false);
} }
catch catch
@ -1284,7 +1313,7 @@ namespace Jellyfin.Api.Controllers
} }
else else
{ {
job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
if (job?.TranscodingThrottler != null) if (job?.TranscodingThrottler != null)
{ {
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
@ -1301,7 +1330,7 @@ namespace Jellyfin.Api.Controllers
} }
_logger.LogDebug("returning {0} [general case]", segmentPath); _logger.LogDebug("returning {0} [general case]", segmentPath);
job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
} }
@ -1325,11 +1354,10 @@ namespace Jellyfin.Api.Controllers
return result.ToArray(); return result.ToArray();
} }
private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber) private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber)
{ {
var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); // GetNumberOfThreads is static.
if (state.BaseRequest.BreakOnNonKeyFrames) if (state.BaseRequest.BreakOnNonKeyFrames)
{ {
@ -1341,36 +1369,57 @@ namespace Jellyfin.Api.Controllers
state.BaseRequest.BreakOnNonKeyFrames = false; state.BaseRequest.BreakOnNonKeyFrames = false;
} }
var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
// If isEncoding is true we're actually starting ffmpeg // If isEncoding is true we're actually starting ffmpeg
var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
var outputTsArg = outputPrefix + "%d" + outputExtension;
var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer); var segmentFormat = outputExtension.TrimStart('.');
var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
{ {
segmentFormat = "mpegts"; segmentFormat = "mpegts";
} }
else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
{
var outputFmp4HeaderArg = string.Empty;
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (isWindows)
{
// on Windows, the path of fmp4 header file needs to be configured
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
}
else
{
// on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
}
var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128 segmentFormat = "fmp4" + outputFmp4HeaderArg;
? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) }
else
{
_logger.LogError("Invalid HLS segment container: " + segmentFormat);
}
var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
: "128"; : "128";
return string.Format( return string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
inputModifier, inputModifier,
_encodingHelper.GetInputArgument(state, encodingOptions), _encodingHelper.GetInputArgument(state, _encodingOptions),
threads, threads,
mapArgs, mapArgs,
GetVideoArguments(state, encodingOptions, startNumber), GetVideoArguments(state, startNumber),
GetAudioArguments(state, encodingOptions), GetAudioArguments(state),
maxMuxingQueueSize, maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture), state.SegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat, segmentFormat,
@ -1379,50 +1428,63 @@ namespace Jellyfin.Api.Controllers
outputPath).Trim(); outputPath).Trim();
} }
private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) /// <summary>
/// Gets the audio arguments for transcoding.
/// </summary>
/// <param name="state">The <see cref="StreamState"/>.</param>
/// <returns>The command line arguments for audio transcoding.</returns>
private string GetAudioArguments(StreamState state)
{ {
if (state.AudioStream == null)
{
return string.Empty;
}
var audioCodec = _encodingHelper.GetAudioEncoder(state); var audioCodec = _encodingHelper.GetAudioEncoder(state);
if (!state.IsOutputVideo) if (!state.IsOutputVideo)
{ {
if (EncodingHelper.IsCopyCodec(audioCodec)) if (EncodingHelper.IsCopyCodec(audioCodec))
{ {
return "-acodec copy"; var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
return "-acodec copy -strict -2" + bitStreamArgs;
} }
var audioTranscodeParams = new List<string>(); var audioTranscodeParams = string.Empty;
audioTranscodeParams.Add("-acodec " + audioCodec); audioTranscodeParams += "-acodec " + audioCodec;
if (state.OutputAudioBitrate.HasValue) if (state.OutputAudioBitrate.HasValue)
{ {
audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
} }
if (state.OutputAudioChannels.HasValue) if (state.OutputAudioChannels.HasValue)
{ {
audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
} }
if (state.OutputAudioSampleRate.HasValue) if (state.OutputAudioSampleRate.HasValue)
{ {
audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
} }
audioTranscodeParams.Add("-vn"); audioTranscodeParams += " -vn";
return string.Join(' ', audioTranscodeParams); return audioTranscodeParams;
} }
if (EncodingHelper.IsCopyCodec(audioCodec)) if (EncodingHelper.IsCopyCodec(audioCodec))
{ {
var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
{ {
return "-codec:a:0 copy -copypriorss:a:0 0"; return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs;
} }
return "-codec:a:0 copy"; return "-codec:a:0 copy -strict -2" + bitStreamArgs;
} }
var args = "-codec:a:0 " + audioCodec; var args = "-codec:a:0 " + audioCodec;
@ -1446,94 +1508,89 @@ namespace Jellyfin.Api.Controllers
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
} }
args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true); args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
return args; return args;
} }
private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber) /// <summary>
/// Gets the video arguments for transcoding.
/// </summary>
/// <param name="state">The <see cref="StreamState"/>.</param>
/// <param name="startNumber">The first number in the hls sequence.</param>
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state, int startNumber)
{ {
if (state.VideoStream == null)
{
return string.Empty;
}
if (!state.IsOutputVideo) if (!state.IsOutputVideo)
{ {
return string.Empty; return string.Empty;
} }
var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions); var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var args = "-codec:v:0 " + codec; var args = "-codec:v:0 " + codec;
// Prefer hvc1 to hev1.
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
{
args += " -tag:v:0 hvc1";
}
// if (state.EnableMpegtsM2TsMode) // if (state.EnableMpegtsM2TsMode)
// { // {
// args += " -mpegts_m2ts_mode 1"; // args += " -mpegts_m2ts_mode 1";
// } // }
// See if we can save come cpu cycles by avoiding encoding // See if we can save come cpu cycles by avoiding encoding.
if (EncodingHelper.IsCopyCodec(codec)) if (EncodingHelper.IsCopyCodec(codec))
{ {
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
{ {
string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
if (!string.IsNullOrEmpty(bitStreamArgs)) if (!string.IsNullOrEmpty(bitStreamArgs))
{ {
args += " " + bitStreamArgs; args += " " + bitStreamArgs;
} }
} }
args += " -start_at_zero";
// args += " -flags -global_header"; // args += " -flags -global_header";
} }
else else
{ {
var gopArg = string.Empty; args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
var keyFrameArg = string.Format(
CultureInfo.InvariantCulture,
" -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
startNumber * state.SegmentLength,
state.SegmentLength);
var framerate = state.VideoStream?.RealFrameRate; // Set the key frame params for video encoding to match the hls segment time.
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber);
if (framerate.HasValue) // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{ {
// This is to make sure keyframe interval is limited to our segment, args += " -bf 0";
// as forcing keyframes is not enough.
// Example: we encoded half of desired length, then codec detected
// scene cut and inserted a keyframe; next forced keyframe would
// be created outside of segment, which breaks seeking
// -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
gopArg = string.Format(
CultureInfo.InvariantCulture,
" -g {0} -keyint_min {0} -sc_threshold 0",
Math.Ceiling(state.SegmentLength * framerate.Value));
}
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
// Unable to force key frames using these hw encoders, set key frames by GOP
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase))
{
args += " " + gopArg;
}
else
{
args += " " + keyFrameArg + gopArg;
} }
// args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
// This is for graphical subs
if (hasGraphicalSubs) if (hasGraphicalSubs)
{ {
args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); // Graphical subs overlay and resolution params.
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
} }
// Add resolution params, if specified
else else
{ {
args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec); // Resolution params.
args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
} }
// -start_at_zero is necessary to use with -ss when seeking, // -start_at_zero is necessary to use with -ss when seeking,
@ -1693,7 +1750,7 @@ namespace Jellyfin.Api.Controllers
private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
{ {
var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType); var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
if (job == null || job.HasExited) if (job == null || job.HasExited)
{ {

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Audio;
@ -50,8 +51,8 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] string? mediaTypes) [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
{ {
var parentItem = string.IsNullOrEmpty(parentId) var parentItem = string.IsNullOrEmpty(parentId)
? null ? null
@ -61,10 +62,11 @@ namespace Jellyfin.Api.Controllers
? _userManager.GetUserById(userId.Value) ? _userManager.GetUserById(userId.Value)
: null; : null;
if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) if (includeItemTypes.Length == 1
|| string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{ {
parentItem = null; parentItem = null;
} }
@ -78,8 +80,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery var query = new InternalItemsQuery
{ {
User = user, User = user,
MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries), MediaTypes = mediaTypes,
IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries), IncludeItemTypes = includeItemTypes,
Recursive = true, Recursive = true,
EnableTotalRecordCount = false, EnableTotalRecordCount = false,
DtoOptions = new DtoOptions DtoOptions = new DtoOptions
@ -139,7 +141,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryFilters> GetQueryFilters( public ActionResult<QueryFilters> GetQueryFilters(
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isAiring, [FromQuery] bool? isAiring,
[FromQuery] bool? isMovie, [FromQuery] bool? isMovie,
[FromQuery] bool? isSports, [FromQuery] bool? isSports,
@ -156,10 +158,11 @@ namespace Jellyfin.Api.Controllers
? _userManager.GetUserById(userId.Value) ? _userManager.GetUserById(userId.Value)
: null; : null;
if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) if (includeItemTypes.Length == 1
|| string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{ {
parentItem = null; parentItem = null;
} }
@ -167,8 +170,7 @@ namespace Jellyfin.Api.Controllers
var filters = new QueryFilters(); var filters = new QueryFilters();
var genreQuery = new InternalItemsQuery(user) var genreQuery = new InternalItemsQuery(user)
{ {
IncludeItemTypes = IncludeItemTypes = includeItemTypes,
(includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
DtoOptions = new DtoOptions DtoOptions = new DtoOptions
{ {
Fields = Array.Empty<ItemFields>(), Fields = Array.Empty<ItemFields>(),
@ -192,10 +194,11 @@ namespace Jellyfin.Api.Controllers
genreQuery.Parent = parentItem; genreQuery.Parent = parentItem;
} }
if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) if (includeItemTypes.Length == 1
|| string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase)))
{ {
filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
{ {

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
IsFavorite = isFavorite, IsFavorite = isFavorite,
@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
result = _libraryManager.GetGenres(query); result = _libraryManager.GetGenres(query);
} }
var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="segmentId">The segment id.</param> /// <param name="segmentId">The segment id.</param>
/// <param name="segmentContainer">The segment container.</param> /// <param name="segmentContainer">The segment container.</param>
/// <response code="200">Hls video segment returned.</response> /// <response code="200">Hls video segment returned.</response>
/// <response code="404">Hls segment not found.</response>
/// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
// Can't require authentication just yet due to seeing some requests come from Chrome without full query string // Can't require authentication just yet due to seeing some requests come from Chrome without full query string
// [Authenticated] // [Authenticated]
[HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesVideoFile] [ProducesVideoFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsVideoSegmentLegacy( public ActionResult GetHlsVideoSegmentLegacy(
@ -132,13 +134,25 @@ namespace Jellyfin.Api.Controllers
var normalizedPlaylistId = playlistId; var normalizedPlaylistId = playlistId;
var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
.FirstOrDefault(i => // Add . to start of segment container for future use.
string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) segmentContainer = segmentContainer.Insert(0, ".");
&& i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) string? playlistPath = null;
?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid."); foreach (var path in filePaths)
{
var pathExtension = Path.GetExtension(path);
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
&& path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
{
playlistPath = path;
break;
}
}
return GetFileResult(file, playlistPath); return playlistPath == null
? NotFound("Hls segment not found.")
: GetFileResult(file, playlistPath);
} }
private ActionResult GetFileResult(string path, string playlistPath) private ActionResult GetFileResult(string path, string playlistPath)

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
@ -86,7 +86,6 @@ namespace Jellyfin.Api.Controllers
/// <response code="403">User does not have permission to delete the image.</response> /// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Users/{userId}/Images/{imageType}")] [HttpPost("Users/{userId}/Images/{imageType}")]
[HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> PostUserImage( public async Task<ActionResult> PostUserImage(
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType, [FromRoute, Required] ImageType imageType,
[FromRoute] int? index = null) [FromQuery] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
return Forbid("User is not allowed to update the image.");
}
var user = _userManager.GetUserById(userId);
await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage != null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
await _providerManager
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Sets the user image.
/// </summary>
/// <param name="userId">User Id.</param>
/// <param name="imageType">(Unused) Image type.</param>
/// <param name="index">(Unused) Image index.</param>
/// <response code="204">Image updated.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Users/{userId}/Images/{imageType}/{index}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> PostUserImageByIndex(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int index)
{ {
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{ {
@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="204">Image deleted.</response> /// <response code="204">Image deleted.</response>
/// <response code="403">User does not have permission to delete the image.</response> /// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("Users/{userId}/Images/{itemType}")] [HttpDelete("Users/{userId}/Images/{imageType}")]
[HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> DeleteUserImage( public async Task<ActionResult> DeleteUserImage(
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType, [FromRoute, Required] ImageType imageType,
[FromRoute] int? index = null) [FromQuery] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
return Forbid("User is not allowed to delete the image.");
}
var user = _userManager.GetUserById(userId);
try
{
System.IO.File.Delete(user.ProfileImage.Path);
}
catch (IOException e)
{
_logger.LogError(e, "Error deleting user profile image:");
}
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Delete the user's image.
/// </summary>
/// <param name="userId">User Id.</param>
/// <param name="imageType">(Unused) Image type.</param>
/// <param name="index">(Unused) Image index.</param>
/// <response code="204">Image deleted.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> DeleteUserImageByIndex(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int index)
{ {
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{ {
@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Item not found.</response> /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpDelete("Items/{itemId}/Images/{imageType}")] [HttpDelete("Items/{itemId}/Images/{imageType}")]
[HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteItemImage( public async Task<ActionResult> DeleteItemImage(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType, [FromRoute, Required] ImageType imageType,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{ {
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
if (item == null) if (item == null)
@ -192,6 +274,65 @@ namespace Jellyfin.Api.Controllers
return NoContent(); return NoContent();
} }
/// <summary>
/// Delete an item's image.
/// </summary>
/// <param name="itemId">Item id.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">The image index.</param>
/// <response code="204">Image deleted.</response>
/// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteItemImageByIndex(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
return NotFound();
}
await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Set item image.
/// </summary>
/// <param name="itemId">Item id.</param>
/// <param name="imageType">Image type.</param>
/// <response code="204">Image saved.</response>
/// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpPost("Items/{itemId}/Images/{imageType}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> SetItemImage(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
return NotFound();
}
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType.Split(';').FirstOrDefault();
await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
/// <summary> /// <summary>
/// Set item image. /// Set item image.
/// </summary> /// </summary>
@ -201,16 +342,15 @@ namespace Jellyfin.Api.Controllers
/// <response code="204">Image saved.</response> /// <response code="204">Image saved.</response>
/// <response code="404">Item not found.</response> /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpPost("Items/{itemId}/Images/{imageType}")] [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
[HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> SetItemImage( public async Task<ActionResult> SetItemImageByIndex(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType, [FromRoute, Required] ImageType imageType,
[FromRoute] int? imageIndex = null) [FromRoute] int imageIndex)
{ {
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
if (item == null) if (item == null)
@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers
/// </returns> /// </returns>
[HttpGet("Items/{itemId}/Images/{imageType}")] [HttpGet("Items/{itemId}/Images/{imageType}")]
[HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
[HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
[HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
return NotFound();
}
return await GetImageInternal(
itemId,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
item,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Gets the item's image.
/// </summary>
/// <param name="itemId">Item id.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
[HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetItemImageByIndex(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int imageIndex,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] string? tag,
[FromQuery] bool? cropWhitespace,
[FromQuery] ImageFormat? format,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{ {
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
if (item == null) if (item == null)
@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")] [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
[HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")] [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")] [HttpGet("Genres/{name}/Images/{imageType}")]
[HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")] [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -609,7 +826,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetGenre(name);
if (item == null)
{
return NotFound();
}
return await GetImageInternal(
item.Id,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
item,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Get genre image by name.
/// </summary>
/// <param name="name">Genre name.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
[HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetGenreImageByIndex(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] bool? cropWhitespace,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{ {
var item = _libraryManager.GetGenre(name); var item = _libraryManager.GetGenre(name);
if (item == null) if (item == null)
@ -666,8 +962,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")] [HttpGet("MusicGenres/{name}/Images/{imageType}")]
[HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")] [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -688,7 +984,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetMusicGenre(name);
if (item == null)
{
return NotFound();
}
return await GetImageInternal(
item.Id,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
item,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Get music genre image by name.
/// </summary>
/// <param name="name">Music genre name.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
[HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetMusicGenreImageByIndex(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] bool? cropWhitespace,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{ {
var item = _libraryManager.GetMusicGenre(name); var item = _libraryManager.GetMusicGenre(name);
if (item == null) if (item == null)
@ -745,8 +1120,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")] [HttpGet("Persons/{name}/Images/{imageType}")]
[HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")] [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -767,7 +1142,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetPerson(name);
if (item == null)
{
return NotFound();
}
return await GetImageInternal(
item.Id,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
item,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Get person image by name.
/// </summary>
/// <param name="name">Person name.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
[HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetPersonImageByIndex(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] bool? cropWhitespace,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{ {
var item = _libraryManager.GetPerson(name); var item = _libraryManager.GetPerson(name);
if (item == null) if (item == null)
@ -824,16 +1278,16 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")] [HttpGet("Studios/{name}/Images/{imageType}")]
[HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")] [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
public async Task<ActionResult> GetStudioImage( public async Task<ActionResult> GetStudioImage(
[FromRoute, Required] string name, [FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType, [FromRoute, Required] ImageType imageType,
[FromRoute, Required] string tag, [FromQuery] string? tag,
[FromRoute, Required] ImageFormat format, [FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth, [FromQuery] int? maxWidth,
[FromQuery] int? maxHeight, [FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed, [FromQuery] double? percentPlayed,
@ -846,7 +1300,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetStudio(name);
if (item == null)
{
return NotFound();
}
return await GetImageInternal(
item.Id,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
item,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
.ConfigureAwait(false);
}
/// <summary>
/// Get studio image by name.
/// </summary>
/// <param name="name">Studio name.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
[HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetStudioImageByIndex(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] string? tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] bool? cropWhitespace,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{ {
var item = _libraryManager.GetStudio(name); var item = _libraryManager.GetStudio(name);
if (item == null) if (item == null)
@ -903,8 +1436,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success, /// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found. /// or a <see cref="NotFoundResult"/> if item not found.
/// </returns> /// </returns>
[HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")] [HttpGet("Users/{userId}/Images/{imageType}")]
[HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")] [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile] [ProducesImageFile]
@ -925,7 +1458,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur, [FromQuery] int? blur,
[FromQuery] string? backgroundColor, [FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer, [FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null) [FromQuery] int? imageIndex)
{ {
var user = _userManager.GetUserById(userId); var user = _userManager.GetUserById(userId);
if (user == null) if (user == null)
@ -974,6 +1507,103 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>
/// Get user profile image.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="imageType">Image type.</param>
/// <param name="imageIndex">Image index.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="maxWidth">The maximum image width to return.</param>
/// <param name="maxHeight">The maximum image height to return.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
/// <param name="width">The fixed image width to return.</param>
/// <param name="height">The fixed image height to return.</param>
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="blur">Optional. Blur image.</param>
/// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
/// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
[HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
[HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetUserImageByIndex(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] string? tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] bool? cropWhitespace,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{
var user = _userManager.GetUserById(userId);
if (user?.ProfileImage == null)
{
return NotFound();
}
var info = new ItemImageInfo
{
Path = user.ProfileImage.Path,
Type = ImageType.Profile,
DateModified = user.ProfileImage.LastModified
};
if (width.HasValue)
{
info.Width = width.Value;
}
if (height.HasValue)
{
info.Height = height.Value;
}
return await GetImageInternal(
user.Id,
imageType,
imageIndex,
tag,
format,
maxWidth,
maxHeight,
percentPlayed,
unplayedCount,
width,
height,
quality,
cropWhitespace,
addPlayedIndicator,
blur,
backgroundColor,
foregroundLayer,
null,
Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
info)
.ConfigureAwait(false);
}
private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
{ {
using var reader = new StreamReader(inputStream); using var reader = new StreamReader(inputStream);

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response> /// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Artists/InstantMix")] [HttpGet("Artists/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
[FromRoute, Required] Guid id, [FromRoute, Required] Guid id,
@ -242,7 +242,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response> /// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("MusicGenres/InstantMix")] [HttpGet("MusicGenres/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres( public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
[FromRoute, Required] Guid id, [FromRoute, Required] Guid id,

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.IO; using System.IO;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;

View File

@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers
/// <summary> /// <summary>
/// Gets items based on a query. /// Gets items based on a query.
/// </summary> /// </summary>
/// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
/// <param name="userId">The user id supplied as query parameter.</param> /// <param name="userId">The user id supplied as query parameter.</param>
/// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="hasThemeSong">Optional filter by items with theme songs.</param> /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
@ -143,10 +142,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="enableImages">Optional, include image information in output.</param> /// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
[HttpGet("Items")] [HttpGet("Items")]
[HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetItems( public ActionResult<QueryResult<BaseItemDto>> GetItems(
[FromRoute] Guid? uId,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating, [FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong, [FromQuery] bool? hasThemeSong,
@ -159,7 +156,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating, [FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd, [FromQuery] bool? isHd,
[FromQuery] bool? is4K, [FromQuery] bool? is4K,
[FromQuery] string? locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing, [FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired, [FromQuery] bool? isUnaired,
@ -173,7 +170,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasImdbId, [FromQuery] bool? hasImdbId,
[FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTmdbId,
[FromQuery] bool? hasTvdbId, [FromQuery] bool? hasTvdbId,
[FromQuery] string? excludeItemIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
[FromQuery] int? startIndex, [FromQuery] int? startIndex,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] bool? recursive, [FromQuery] bool? recursive,
@ -181,34 +178,34 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder, [FromQuery] string? sortOrder,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
[FromQuery] string? sortBy, [FromQuery] string? sortBy,
[FromQuery] bool? isPlayed, [FromQuery] bool? isPlayed,
[FromQuery] string? genres, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery] string? officialRatings, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
[FromQuery] string? tags, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
[FromQuery] string? years, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person, [FromQuery] string? person,
[FromQuery] string? personIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
[FromQuery] string? personTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? studios, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
[FromQuery] string? artists, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
[FromQuery] string? excludeArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery] string? artistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
[FromQuery] string? albumArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
[FromQuery] string? contributingArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
[FromQuery] string? albums, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
[FromQuery] string? albumIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
[FromQuery] string? ids, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] string? videoTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating, [FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked, [FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder, [FromQuery] bool? isPlaceHolder,
@ -219,18 +216,15 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth, [FromQuery] int? maxWidth,
[FromQuery] int? maxHeight, [FromQuery] int? maxHeight,
[FromQuery] bool? is3D, [FromQuery] bool? is3D,
[FromQuery] string? seriesStatus, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith, [FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan, [FromQuery] string? nameLessThan,
[FromQuery] string? studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true) [FromQuery] bool? enableImages = true)
{ {
// use user id route parameter over query parameter
userId = uId ?? userId;
var user = userId.HasValue && !userId.Equals(Guid.Empty) var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value) ? _userManager.GetUserById(userId.Value)
: null; : null;
@ -238,8 +232,9 @@ namespace Jellyfin.Api.Controllers
.AddClientFields(Request) .AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) if (includeItemTypes.Length == 1
|| string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase)
|| includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase)))
{ {
parentId = null; parentId = null;
} }
@ -262,7 +257,7 @@ namespace Jellyfin.Api.Controllers
&& string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
{ {
recursive = true; recursive = true;
includeItemTypes = "Playlist"; includeItemTypes = new[] { "Playlist" };
} }
bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
@ -291,14 +286,14 @@ namespace Jellyfin.Api.Controllers
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
} }
if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder)) if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder))
{ {
var query = new InternalItemsQuery(user!) var query = new InternalItemsQuery(user!)
{ {
IsPlayed = isPlayed, IsPlayed = isPlayed,
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), MediaTypes = mediaTypes,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false, Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite, IsFavorite = isFavorite,
@ -330,28 +325,28 @@ namespace Jellyfin.Api.Controllers
HasTrailer = hasTrailer, HasTrailer = hasTrailer,
IsHD = isHd, IsHD = isHd,
Is4K = is4K, Is4K = is4K,
Tags = RequestHelpers.Split(tags, '|', true), Tags = tags,
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), OfficialRatings = officialRatings,
Genres = RequestHelpers.Split(genres, '|', true), Genres = genres,
ArtistIds = RequestHelpers.GetGuids(artistIds), ArtistIds = artistIds,
AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds), AlbumArtistIds = albumArtistIds,
ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds), ContributingArtistIds = contributingArtistIds,
GenreIds = RequestHelpers.GetGuids(genreIds), GenreIds = genreIds,
StudioIds = RequestHelpers.GetGuids(studioIds), StudioIds = studioIds,
Person = person, Person = person,
PersonIds = RequestHelpers.GetGuids(personIds), PersonIds = personIds,
PersonTypes = RequestHelpers.Split(personTypes, ',', true), PersonTypes = personTypes,
Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), Years = years,
ImageTypes = imageTypes, ImageTypes = imageTypes,
VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(), VideoTypes = videoTypes,
AdjacentTo = adjacentTo, AdjacentTo = adjacentTo,
ItemIds = RequestHelpers.GetGuids(ids), ItemIds = ids,
MinCommunityRating = minCommunityRating, MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating, MinCriticRating = minCriticRating,
ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId), ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
ParentIndexNumber = parentIndexNumber, ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds), ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions, DtoOptions = dtoOptions,
SearchTerm = searchTerm, SearchTerm = searchTerm,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
@ -360,7 +355,7 @@ namespace Jellyfin.Api.Controllers
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
}; };
if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm)) if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
{ {
query.CollapseBoxSetItems = false; query.CollapseBoxSetItems = false;
} }
@ -400,9 +395,9 @@ namespace Jellyfin.Api.Controllers
} }
// Filter by Series Status // Filter by Series Status
if (!string.IsNullOrEmpty(seriesStatus)) if (seriesStatus.Length != 0)
{ {
query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); query.SeriesStatuses = seriesStatus;
} }
// ExcludeLocationTypes // ExcludeLocationTypes
@ -411,13 +406,9 @@ namespace Jellyfin.Api.Controllers
query.IsVirtualItem = false; query.IsVirtualItem = false;
} }
if (!string.IsNullOrEmpty(locationTypes)) if (locationTypes.Length > 0 && locationTypes.Length < 4)
{ {
var requestedLocationTypes = locationTypes.Split(','); query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
{
query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
}
} }
// Min official rating // Min official rating
@ -433,9 +424,9 @@ namespace Jellyfin.Api.Controllers
} }
// Artists // Artists
if (!string.IsNullOrEmpty(artists)) if (artists.Length != 0)
{ {
query.ArtistIds = artists.Split('|').Select(i => query.ArtistIds = artists.Select(i =>
{ {
try try
{ {
@ -449,29 +440,29 @@ namespace Jellyfin.Api.Controllers
} }
// ExcludeArtistIds // ExcludeArtistIds
if (!string.IsNullOrWhiteSpace(excludeArtistIds)) if (excludeArtistIds.Length != 0)
{ {
query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); query.ExcludeArtistIds = excludeArtistIds;
} }
if (!string.IsNullOrWhiteSpace(albumIds)) if (albumIds.Length != 0)
{ {
query.AlbumIds = RequestHelpers.GetGuids(albumIds); query.AlbumIds = albumIds;
} }
// Albums // Albums
if (!string.IsNullOrEmpty(albums)) if (albums.Length != 0)
{ {
query.AlbumIds = albums.Split('|').SelectMany(i => query.AlbumIds = albums.SelectMany(i =>
{ {
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
}).ToArray(); }).ToArray();
} }
// Studios // Studios
if (!string.IsNullOrEmpty(studios)) if (studios.Length != 0)
{ {
query.StudioIds = studios.Split('|').Select(i => query.StudioIds = studios.Select(i =>
{ {
try try
{ {
@ -505,6 +496,257 @@ namespace Jellyfin.Api.Controllers
return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) }; return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
} }
/// <summary>
/// Gets items based on a query.
/// </summary>
/// <param name="userId">The user id supplied as query parameter.</param>
/// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
/// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
/// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
/// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
/// <param name="hasTrailer">Optional filter by items with trailers.</param>
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
/// <param name="parentIndexNumber">Optional filter by parent index number.</param>
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
/// <param name="isHd">Optional filter by items that are HD or not.</param>
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
/// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
/// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
/// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
/// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
/// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
/// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
/// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
/// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
/// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
/// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
/// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
/// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
/// <param name="isPlayed">Optional filter by items that are played, or not.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
/// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
/// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
/// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
/// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
/// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="isLocked">Optional filter by items that are locked.</param>
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
/// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
/// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
/// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
/// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
/// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
[HttpGet("Users/{userId}/Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
[FromRoute] Guid userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
[FromQuery] bool? hasThemeVideo,
[FromQuery] bool? hasSubtitles,
[FromQuery] bool? hasSpecialFeature,
[FromQuery] bool? hasTrailer,
[FromQuery] string? adjacentTo,
[FromQuery] int? parentIndexNumber,
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
[FromQuery] double? minCommunityRating,
[FromQuery] double? minCriticRating,
[FromQuery] DateTime? minPremiereDate,
[FromQuery] DateTime? minDateLastSaved,
[FromQuery] DateTime? minDateLastSavedForUser,
[FromQuery] DateTime? maxPremiereDate,
[FromQuery] bool? hasOverview,
[FromQuery] bool? hasImdbId,
[FromQuery] bool? hasTmdbId,
[FromQuery] bool? hasTvdbId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool? recursive,
[FromQuery] string? searchTerm,
[FromQuery] string? sortOrder,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? isPlayed,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
[FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder,
[FromQuery] bool? hasOfficialRating,
[FromQuery] bool? collapseBoxSetItems,
[FromQuery] int? minWidth,
[FromQuery] int? minHeight,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] bool? is3D,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
return GetItems(
userId,
maxOfficialRating,
hasThemeSong,
hasThemeVideo,
hasSubtitles,
hasSpecialFeature,
hasTrailer,
adjacentTo,
parentIndexNumber,
hasParentalRating,
isHd,
is4K,
locationTypes,
excludeLocationTypes,
isMissing,
isUnaired,
minCommunityRating,
minCriticRating,
minPremiereDate,
minDateLastSaved,
minDateLastSavedForUser,
maxPremiereDate,
hasOverview,
hasImdbId,
hasTmdbId,
hasTvdbId,
excludeItemIds,
startIndex,
limit,
recursive,
searchTerm,
sortOrder,
parentId,
fields,
excludeItemTypes,
includeItemTypes,
filters,
isFavorite,
mediaTypes,
imageTypes,
sortBy,
isPlayed,
genres,
officialRatings,
tags,
years,
enableUserData,
imageTypeLimit,
enableImageTypes,
person,
personIds,
personTypes,
studios,
artists,
excludeArtistIds,
artistIds,
albumArtistIds,
contributingArtistIds,
albums,
albumIds,
ids,
videoTypes,
minOfficialRating,
isLocked,
isPlaceHolder,
hasOfficialRating,
collapseBoxSetItems,
minWidth,
minHeight,
maxWidth,
maxHeight,
is3D,
seriesStatus,
nameStartsWithOrGreater,
nameStartsWith,
nameLessThan,
studioIds,
genreIds,
enableTotalRecordCount,
enableImages);
}
/// <summary> /// <summary>
/// Gets items based on a query. /// Gets items based on a query.
/// </summary> /// </summary>
@ -533,12 +775,12 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true) [FromQuery] bool? enableImages = true)
{ {
@ -569,13 +811,13 @@ namespace Jellyfin.Api.Controllers
ParentId = parentIdGuid, ParentId = parentIdGuid,
Recursive = true, Recursive = true,
DtoOptions = dtoOptions, DtoOptions = dtoOptions,
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), MediaTypes = mediaTypes,
IsVirtualItem = false, IsVirtualItem = false,
CollapseBoxSetItems = false, CollapseBoxSetItems = false,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds, AncestorIds = ancestorIds,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), ExcludeItemTypes = excludeItemTypes,
SearchTerm = searchTerm SearchTerm = searchTerm
}); });

View File

@ -362,15 +362,14 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult DeleteItems([FromQuery] string? ids) public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids)
{ {
if (string.IsNullOrEmpty(ids)) if (ids.Length == 0)
{ {
return NoContent(); return NoContent();
} }
var itemIds = RequestHelpers.Split(ids, ',', true); foreach (var i in ids)
foreach (var i in itemIds)
{ {
var item = _libraryManager.GetItemById(i); var item = _libraryManager.GetItemById(i);
var auth = _authContext.GetAuthorizationInfo(Request); var auth = _authContext.GetAuthorizationInfo(Request);
@ -691,7 +690,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] string? excludeArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
@ -753,9 +752,9 @@ namespace Jellyfin.Api.Controllers
}; };
// ExcludeArtistIds // ExcludeArtistIds
if (!string.IsNullOrEmpty(excludeArtistIds)) if (excludeArtistIds.Length != 0)
{ {
query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); query.ExcludeArtistIds = excludeArtistIds;
} }
List<BaseItem> itemsResult = _libraryManager.GetItemList(query); List<BaseItem> itemsResult = _libraryManager.GetItemList(query);

View File

@ -150,7 +150,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] string? sortBy, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
[FromQuery] SortOrder? sortOrder, [FromQuery] SortOrder? sortOrder,
[FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool enableFavoriteSorting = false,
[FromQuery] bool addCurrentProgram = true) [FromQuery] bool addCurrentProgram = true)
@ -175,7 +175,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews, IsNews = isNews,
IsKids = isKids, IsKids = isKids,
IsSports = isSports, IsSports = isSports,
SortBy = RequestHelpers.Split(sortBy, ',', true), SortBy = sortBy,
SortOrder = sortOrder ?? SortOrder.Ascending, SortOrder = sortOrder ?? SortOrder.Ascending,
AddCurrentProgram = addCurrentProgram AddCurrentProgram = addCurrentProgram
}, },
@ -539,7 +539,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
[FromQuery] string? channelIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] DateTime? minStartDate, [FromQuery] DateTime? minStartDate,
[FromQuery] bool? hasAired, [FromQuery] bool? hasAired,
@ -556,8 +556,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] string? sortBy, [FromQuery] string? sortBy,
[FromQuery] string? sortOrder, [FromQuery] string? sortOrder,
[FromQuery] string? genres, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool? enableImages, [FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@ -573,8 +573,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ChannelIds = RequestHelpers.Split(channelIds, ',', true) ChannelIds = channelIds,
.Select(i => new Guid(i)).ToArray(),
HasAired = hasAired, HasAired = hasAired,
IsAiring = isAiring, IsAiring = isAiring,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
@ -591,8 +590,8 @@ namespace Jellyfin.Api.Controllers
IsKids = isKids, IsKids = isKids,
IsSports = isSports, IsSports = isSports,
SeriesTimerId = seriesTimerId, SeriesTimerId = seriesTimerId,
Genres = RequestHelpers.Split(genres, '|', true), Genres = genres,
GenreIds = RequestHelpers.GetGuids(genreIds) GenreIds = genreIds
}; };
if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty)) if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
@ -628,8 +627,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true) ChannelIds = body.ChannelIds,
.Select(i => new Guid(i)).ToArray(),
HasAired = body.HasAired, HasAired = body.HasAired,
IsAiring = body.IsAiring, IsAiring = body.IsAiring,
EnableTotalRecordCount = body.EnableTotalRecordCount, EnableTotalRecordCount = body.EnableTotalRecordCount,
@ -646,8 +644,8 @@ namespace Jellyfin.Api.Controllers
IsKids = body.IsKids, IsKids = body.IsKids,
IsSports = body.IsSports, IsSports = body.IsSports,
SeriesTimerId = body.SeriesTimerId, SeriesTimerId = body.SeriesTimerId,
Genres = RequestHelpers.Split(body.Genres, '|', true), Genres = body.Genres,
GenreIds = RequestHelpers.GetGuids(body.GenreIds) GenreIds = body.GenreIds
}; };
if (!body.LibrarySeriesId.Equals(Guid.Empty)) if (!body.LibrarySeriesId.Equals(Guid.Empty))
@ -703,7 +701,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableImages, [FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true) [FromQuery] bool enableTotalRecordCount = true)
@ -723,7 +721,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews, IsNews = isNews,
IsSports = isSports, IsSports = isSports,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
GenreIds = RequestHelpers.GetGuids(genreIds) GenreIds = genreIds
}; };
var dtoOptions = new DtoOptions { Fields = fields } var dtoOptions = new DtoOptions { Fields = fields }

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Buffers; using System.Buffers;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
IsFavorite = isFavorite, IsFavorite = isFavorite,
@ -123,7 +123,7 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetMusicGenres(query); var result = _libraryManager.GetMusicGenres(query);
var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes); var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
} }

View File

@ -99,7 +99,7 @@ namespace Jellyfin.Api.Controllers
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl)) if (!string.IsNullOrEmpty(repositoryUrl))
{ {
packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)) packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
.ToList(); .ToList();
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
@ -77,8 +77,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? excludePersonTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
[FromQuery] string? personTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? appearsInItemId, [FromQuery] string? appearsInItemId,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] bool? enableImages = true) [FromQuery] bool? enableImages = true)
@ -97,8 +97,8 @@ namespace Jellyfin.Api.Controllers
var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
{ {
PersonTypes = RequestHelpers.Split(personTypes, ',', true), PersonTypes = personTypes,
ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true), ExcludePersonTypes = excludePersonTypes,
NameContains = searchTerm, NameContains = searchTerm,
User = user, User = user,
IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -63,11 +63,10 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
[FromBody, Required] CreatePlaylistDto createPlaylistRequest) [FromBody, Required] CreatePlaylistDto createPlaylistRequest)
{ {
Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
{ {
Name = createPlaylistRequest.Name, Name = createPlaylistRequest.Name,
ItemIdList = idGuidArray, ItemIdList = createPlaylistRequest.Ids,
UserId = createPlaylistRequest.UserId, UserId = createPlaylistRequest.UserId,
MediaType = createPlaylistRequest.MediaType MediaType = createPlaylistRequest.MediaType
}).ConfigureAwait(false); }).ConfigureAwait(false);
@ -87,10 +86,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddToPlaylist( public async Task<ActionResult> AddToPlaylist(
[FromRoute, Required] Guid playlistId, [FromRoute, Required] Guid playlistId,
[FromQuery] string? ids, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] Guid? userId) [FromQuery] Guid? userId)
{ {
await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false); await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
return NoContent(); return NoContent();
} }
@ -122,9 +121,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns> /// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")] [HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds) public async Task<ActionResult> RemoveFromPlaylist(
[FromRoute, Required] string playlistId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
{ {
await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false); await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
return NoContent(); return NoContent();
} }

View File

@ -1,9 +1,10 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<UserItemDataDto> MarkPlayedItem( public ActionResult<UserItemDataDto> MarkPlayedItem(
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] DateTime? datePlayed) [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
{ {
var user = _userManager.GetUserById(userId); var user = _userManager.GetUserById(userId);
var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;

View File

@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@ -82,9 +83,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery, Required] string searchTerm, [FromQuery, Required] string searchTerm,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery] bool? isMovie, [FromQuery] bool? isMovie,
[FromQuery] bool? isSeries, [FromQuery] bool? isSeries,
@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers
IncludeStudios = includeStudios, IncludeStudios = includeStudios,
StartIndex = startIndex, StartIndex = startIndex,
UserId = userId ?? Guid.Empty, UserId = userId ?? Guid.Empty,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), ExcludeItemTypes = excludeItemTypes,
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), MediaTypes = mediaTypes,
ParentId = parentId, ParentId = parentId,
IsKids = isKids, IsKids = isKids,

View File

@ -6,6 +6,7 @@ using System.Threading;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders; using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.SessionDtos;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -160,12 +161,12 @@ namespace Jellyfin.Api.Controllers
public ActionResult Play( public ActionResult Play(
[FromRoute, Required] string sessionId, [FromRoute, Required] string sessionId,
[FromQuery, Required] PlayCommand playCommand, [FromQuery, Required] PlayCommand playCommand,
[FromQuery, Required] string itemIds, [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
[FromQuery] long? startPositionTicks) [FromQuery] long? startPositionTicks)
{ {
var playRequest = new PlayRequest var playRequest = new PlayRequest
{ {
ItemIds = RequestHelpers.GetGuids(itemIds), ItemIds = itemIds,
StartPositionTicks = startPositionTicks, StartPositionTicks = startPositionTicks,
PlayCommand = playCommand PlayCommand = playCommand
}; };
@ -378,7 +379,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities( public ActionResult PostCapabilities(
[FromQuery] string? id, [FromQuery] string? id,
[FromQuery] string? playableMediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
[FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsSync = false, [FromQuery] bool supportsSync = false,
@ -391,7 +392,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.ReportCapabilities(id, new ClientCapabilities _sessionManager.ReportCapabilities(id, new ClientCapabilities
{ {
PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands, SupportedCommands = supportedCommands,
SupportsMediaControl = supportsMediaControl, SupportsMediaControl = supportsMediaControl,
SupportsSync = supportsSync, SupportsSync = supportsSync,
@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostFullCapabilities( public ActionResult PostFullCapabilities(
[FromQuery] string? id, [FromQuery] string? id,
[FromBody, Required] ClientCapabilities capabilities) [FromBody, Required] ClientCapabilitiesDto capabilities)
{ {
if (string.IsNullOrWhiteSpace(id)) if (string.IsNullOrWhiteSpace(id))
{ {
id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
} }
_sessionManager.ReportCapabilities(id, capabilities); _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
return NoContent(); return NoContent();
} }

View File

@ -72,9 +72,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
{ {
_config.Configuration.UICulture = startupConfiguration.UICulture; _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
_config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode; _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
_config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage; _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
_config.SaveConfiguration(); _config.SaveConfiguration();
return NoContent(); return NoContent();
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
@ -73,8 +73,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm, [FromQuery] string? searchTerm,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
@ -94,13 +94,10 @@ namespace Jellyfin.Api.Controllers
var parentItem = _libraryManager.GetParentItem(parentId, userId); var parentItem = _libraryManager.GetParentItem(parentId, userId);
var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = excludeItemTypesArr, ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = includeItemTypesArr, IncludeItemTypes = includeItemTypes,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
IsFavorite = isFavorite, IsFavorite = isFavorite,
@ -125,7 +122,7 @@ namespace Jellyfin.Api.Controllers
} }
var result = _libraryManager.GetStudios(query); var result = _libraryManager.GetStudios(query);
var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
} }

View File

@ -193,7 +193,6 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">File returned.</response> /// <response code="200">File returned.</response>
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("text/*")] [ProducesFile("text/*")]
public async Task<ActionResult> GetSubtitle( public async Task<ActionResult> GetSubtitle(
@ -204,7 +203,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] long? endPositionTicks, [FromQuery] long? endPositionTicks,
[FromQuery] bool copyTimestamps = false, [FromQuery] bool copyTimestamps = false,
[FromQuery] bool addVttTimeMap = false, [FromQuery] bool addVttTimeMap = false,
[FromRoute] long startPositionTicks = 0) [FromQuery] long startPositionTicks = 0)
{ {
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
{ {
@ -249,6 +248,43 @@ namespace Jellyfin.Api.Controllers
MimeTypes.GetMimeType("file." + format)); MimeTypes.GetMimeType("file." + format));
} }
/// <summary>
/// Gets subtitles in a specified format.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="index">The subtitle stream index.</param>
/// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
/// <param name="format">The format of the returned subtitle.</param>
/// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
/// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
/// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("text/*")]
public Task<ActionResult> GetSubtitleWithTicks(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string mediaSourceId,
[FromRoute, Required] int index,
[FromRoute, Required] long startPositionTicks,
[FromRoute, Required] string format,
[FromQuery] long? endPositionTicks,
[FromQuery] bool copyTimestamps = false,
[FromQuery] bool addVttTimeMap = false)
{
return GetSubtitle(
itemId,
mediaSourceId,
index,
format,
endPositionTicks,
copyTimestamps,
addVttTimeMap,
startPositionTicks);
}
/// <summary> /// <summary>
/// Gets an HLS subtitle playlist. /// Gets an HLS subtitle playlist.
/// </summary> /// </summary>
@ -335,6 +371,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="204">Subtitle uploaded.</response> /// <response code="204">Subtitle uploaded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Videos/{itemId}/Subtitles")] [HttpPost("Videos/{itemId}/Subtitles")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UploadSubtitle( public async Task<ActionResult> UploadSubtitle(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromBody, Required] UploadSubtitleDto body) [FromBody, Required] UploadSubtitleDto body)
@ -446,6 +483,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("FallbackFont/Fonts/{name}")] [HttpGet("FallbackFont/Fonts/{name}")]
[Authorize(Policy = Policies.DefaultAuthorization)] [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("font/*")]
public ActionResult GetFallbackFont([FromRoute, Required] string name) public ActionResult GetFallbackFont([FromRoute, Required] string name)
{ {
var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); var encodingOptions = _serverConfigurationManager.GetEncodingOptions();

View File

@ -1,9 +1,10 @@
using System; using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromQuery] string? mediaType, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
[FromQuery] string? type, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type,
[FromQuery] int? startIndex, [FromQuery] int? startIndex,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] bool enableTotalRecordCount = false) [FromQuery] bool enableTotalRecordCount = false)
@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{ {
OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
MediaTypes = RequestHelpers.Split(mediaType!, ',', true), MediaTypes = mediaType,
IncludeItemTypes = RequestHelpers.Split(type!, ',', true), IncludeItemTypes = type,
IsVirtualItem = false, IsVirtualItem = false,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Threading; using System.Threading;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.IO; using System.IO;

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Globalization; using System.Globalization;
using MediaBrowser.Model.SyncPlay; using MediaBrowser.Model.SyncPlay;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;

View File

@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating, [FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd, [FromQuery] bool? isHd,
[FromQuery] bool? is4K, [FromQuery] bool? is4K,
[FromQuery] string? locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing, [FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired, [FromQuery] bool? isUnaired,
@ -139,7 +139,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasImdbId, [FromQuery] bool? hasImdbId,
[FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTmdbId,
[FromQuery] bool? hasTvdbId, [FromQuery] bool? hasTvdbId,
[FromQuery] string? excludeItemIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
[FromQuery] int? startIndex, [FromQuery] int? startIndex,
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] bool? recursive, [FromQuery] bool? recursive,
@ -147,33 +147,33 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder, [FromQuery] string? sortOrder,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite, [FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
[FromQuery] string? sortBy, [FromQuery] string? sortBy,
[FromQuery] bool? isPlayed, [FromQuery] bool? isPlayed,
[FromQuery] string? genres, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
[FromQuery] string? officialRatings, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
[FromQuery] string? tags, [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
[FromQuery] string? years, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person, [FromQuery] string? person,
[FromQuery] string? personIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
[FromQuery] string? personTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? studios, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
[FromQuery] string? artists, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
[FromQuery] string? excludeArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery] string? artistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
[FromQuery] string? albumArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
[FromQuery] string? contributingArtistIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
[FromQuery] string? albums, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
[FromQuery] string? albumIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
[FromQuery] string? ids, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] string? videoTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating, [FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked, [FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder, [FromQuery] bool? isPlaceHolder,
@ -184,20 +184,19 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth, [FromQuery] int? maxWidth,
[FromQuery] int? maxHeight, [FromQuery] int? maxHeight,
[FromQuery] bool? is3D, [FromQuery] bool? is3D,
[FromQuery] string? seriesStatus, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith, [FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan, [FromQuery] string? nameLessThan,
[FromQuery] string? studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] string? genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true) [FromQuery] bool? enableImages = true)
{ {
var includeItemTypes = "Trailer"; var includeItemTypes = new[] { "Trailer" };
return _itemsController return _itemsController
.GetItems( .GetItems(
userId,
userId, userId,
maxOfficialRating, maxOfficialRating,
hasThemeSong, hasThemeSong,

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Jellyfin.Api.Attributes; using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Devices;
@ -96,7 +97,7 @@ namespace Jellyfin.Api.Controllers
[ProducesAudioFile] [ProducesAudioFile]
public async Task<ActionResult> GetUniversalAudioStream( public async Task<ActionResult> GetUniversalAudioStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] string? container, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
@ -191,8 +192,11 @@ namespace Jellyfin.Api.Controllers
if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
{ {
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation // hls segment container can only be mpegts or fmp4 per ffmpeg documentation
// ffmpeg option -> file extension
// mpegts -> ts
// fmp4 -> mp4
// TODO: remove this when we switch back to the segment muxer // TODO: remove this when we switch back to the segment muxer
var supportedHlsContainers = new[] { "mpegts", "fmp4" }; var supportedHlsContainers = new[] { "ts", "mp4" };
var dynamicHlsRequestDto = new HlsAudioRequestDto var dynamicHlsRequestDto = new HlsAudioRequestDto
{ {
@ -201,7 +205,7 @@ namespace Jellyfin.Api.Controllers
Static = isStatic, Static = isStatic,
PlaySessionId = info.PlaySessionId, PlaySessionId = info.PlaySessionId,
// fallback to mpegts if device reports some weird value unsupported by hls // fallback to mpegts if device reports some weird value unsupported by hls
SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts", SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
MediaSourceId = mediaSourceId, MediaSourceId = mediaSourceId,
DeviceId = deviceId, DeviceId = deviceId,
AudioCodec = audioCodec, AudioCodec = audioCodec,
@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers
} }
private DeviceProfile GetDeviceProfile( private DeviceProfile GetDeviceProfile(
string? container, string[] containers,
string? transcodingContainer, string? transcodingContainer,
string? audioCodec, string? audioCodec,
string? transcodingProtocol, string? transcodingProtocol,
@ -270,7 +274,6 @@ namespace Jellyfin.Api.Controllers
{ {
var deviceProfile = new DeviceProfile(); var deviceProfile = new DeviceProfile();
var containers = RequestHelpers.Split(container, ',', true);
int len = containers.Length; int len = containers.Length;
var directPlayProfiles = new DirectPlayProfile[len]; var directPlayProfiles = new DirectPlayProfile[len];
for (int i = 0; i < len; i++) for (int i = 0; i < len; i++)
@ -327,7 +330,7 @@ namespace Jellyfin.Api.Controllers
if (conditions.Count > 0) if (conditions.Count > 0)
{ {
// codec profile // codec profile
codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() }); codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = string.Join(',', containers), Conditions = conditions.ToArray() });
} }
deviceProfile.CodecProfiles = codecProfiles.ToArray(); deviceProfile.CodecProfiles = codecProfiles.ToArray();

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;

View File

@ -269,7 +269,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromQuery] Guid? parentId, [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isPlayed, [FromQuery] bool? isPlayed,
[FromQuery] bool? enableImages, [FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers
new LatestItemsQuery new LatestItemsQuery
{ {
GroupItems = groupItems, GroupItems = groupItems,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), IncludeItemTypes = includeItemTypes,
IsPlayed = isPlayed, IsPlayed = isPlayed,
Limit = limit, Limit = limit,
ParentId = parentId ?? Guid.Empty, ParentId = parentId ?? Guid.Empty,

View File

@ -1,10 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.UserViewDtos; using Jellyfin.Api.Models.UserViewDtos;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryResult<BaseItemDto>> GetUserViews( public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
[FromRoute, Required] Guid userId, [FromRoute, Required] Guid userId,
[FromQuery] bool? includeExternalContent, [FromQuery] bool? includeExternalContent,
[FromQuery] string? presetViews, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
[FromQuery] bool includeHidden = false) [FromQuery] bool includeHidden = false)
{ {
var query = new UserViewQuery var query = new UserViewQuery
@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers
query.IncludeExternalContent = includeExternalContent.Value; query.IncludeExternalContent = includeExternalContent.Value;
} }
if (!string.IsNullOrWhiteSpace(presetViews)) if (presetViews.Length != 0)
{ {
query.PresetViews = RequestHelpers.Split(presetViews, ',', true); query.PresetViews = presetViews;
} }
var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;

View File

@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Net.Mime; using System.Net.Mime;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Video or attachment not found.</response> /// <response code="404">Video or attachment not found.</response>
/// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
[HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
[Produces(MediaTypeNames.Application.Octet)] [ProducesFile(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetAttachment( public async Task<ActionResult> GetAttachment(

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Attributes; using Jellyfin.Api.Attributes;
@ -296,23 +297,23 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false); .ConfigureAwait(false);
TranscodingJobDto? job = null; TranscodingJobDto? job = null;
var playlist = state.OutputFilePath; var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
if (!System.IO.File.Exists(playlist)) if (!System.IO.File.Exists(playlistPath))
{ {
var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist); var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
try try
{ {
if (!System.IO.File.Exists(playlist)) if (!System.IO.File.Exists(playlistPath))
{ {
// If the playlist doesn't already exist, startup ffmpeg // If the playlist doesn't already exist, startup ffmpeg
try try
{ {
job = await _transcodingJobHelper.StartFfMpeg( job = await _transcodingJobHelper.StartFfMpeg(
state, state,
playlist, playlistPath,
GetCommandLineArguments(playlist, state), GetCommandLineArguments(playlistPath, state),
Request, Request,
TranscodingJobType, TranscodingJobType,
cancellationTokenSource) cancellationTokenSource)
@ -328,7 +329,7 @@ namespace Jellyfin.Api.Controllers
minSegments = state.MinSegments; minSegments = state.MinSegments;
if (minSegments > 0) if (minSegments > 0)
{ {
await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
} }
} }
} }
@ -338,14 +339,14 @@ namespace Jellyfin.Api.Controllers
} }
} }
job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType); job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
if (job != null) if (job != null)
{ {
_transcodingJobHelper.OnTranscodeEndRequest(job); _transcodingJobHelper.OnTranscodeEndRequest(job);
} }
var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength); var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
} }
@ -361,15 +362,44 @@ namespace Jellyfin.Api.Controllers
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static. var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static.
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts"; var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
var segmentFormat = format.TrimStart('.'); var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
var outputTsArg = outputPrefix + "%d" + outputExtension;
var segmentFormat = outputExtension.TrimStart('.');
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
{ {
segmentFormat = "mpegts"; segmentFormat = "mpegts";
} }
else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
{
var outputFmp4HeaderArg = string.Empty;
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (isWindows)
{
// on Windows, the path of fmp4 header file needs to be configured
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
}
else
{
// on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
}
segmentFormat = "fmp4" + outputFmp4HeaderArg;
}
else
{
_logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat);
}
var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
: "128";
var baseUrlParam = string.Format( var baseUrlParam = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
@ -378,20 +408,19 @@ namespace Jellyfin.Api.Controllers
return string.Format( return string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"", "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
inputModifier, inputModifier,
_encodingHelper.GetInputArgument(state, _encodingOptions), _encodingHelper.GetInputArgument(state, _encodingOptions),
threads, threads,
_encodingHelper.GetMapArgs(state), mapArgs,
GetVideoArguments(state), GetVideoArguments(state),
GetAudioArguments(state), GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture), state.SegmentLength.ToString(CultureInfo.InvariantCulture),
string.Empty,
segmentFormat, segmentFormat,
baseUrlParam, baseUrlParam,
outputPath, outputTsArg,
outputTsArg) outputPath).Trim();
.Trim();
} }
/// <summary> /// <summary>
@ -401,14 +430,53 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for audio transcoding.</returns> /// <returns>The command line arguments for audio transcoding.</returns>
private string GetAudioArguments(StreamState state) private string GetAudioArguments(StreamState state)
{ {
var codec = _encodingHelper.GetAudioEncoder(state); if (state.AudioStream == null)
if (EncodingHelper.IsCopyCodec(codec))
{ {
return "-codec:a:0 copy"; return string.Empty;
} }
var args = "-codec:a:0 " + codec; var audioCodec = _encodingHelper.GetAudioEncoder(state);
if (!state.IsOutputVideo)
{
if (EncodingHelper.IsCopyCodec(audioCodec))
{
var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
return "-acodec copy -strict -2" + bitStreamArgs;
}
var audioTranscodeParams = string.Empty;
audioTranscodeParams += "-acodec " + audioCodec;
if (state.OutputAudioBitrate.HasValue)
{
audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioChannels.HasValue)
{
audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioSampleRate.HasValue)
{
audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
audioTranscodeParams += " -vn";
return audioTranscodeParams;
}
if (EncodingHelper.IsCopyCodec(audioCodec))
{
var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
return "-acodec copy -strict -2" + bitStreamArgs;
}
var args = "-codec:a:0 " + audioCodec;
var channels = state.OutputAudioChannels; var channels = state.OutputAudioChannels;
@ -429,7 +497,7 @@ namespace Jellyfin.Api.Controllers
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
} }
args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
return args; return args;
} }
@ -441,6 +509,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for video transcoding.</returns> /// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state) private string GetVideoArguments(StreamState state)
{ {
if (state.VideoStream == null)
{
return string.Empty;
}
if (!state.IsOutputVideo) if (!state.IsOutputVideo)
{ {
return string.Empty; return string.Empty;
@ -450,46 +523,64 @@ namespace Jellyfin.Api.Controllers
var args = "-codec:v:0 " + codec; var args = "-codec:v:0 " + codec;
// Prefer hvc1 to hev1.
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
{
args += " -tag:v:0 hvc1";
}
// if (state.EnableMpegtsM2TsMode) // if (state.EnableMpegtsM2TsMode)
// { // {
// args += " -mpegts_m2ts_mode 1"; // args += " -mpegts_m2ts_mode 1";
// } // }
// See if we can save come cpu cycles by avoiding encoding // See if we can save come cpu cycles by avoiding encoding.
if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) if (EncodingHelper.IsCopyCodec(codec))
{ {
// if h264_mp4toannexb is ever added, do not use it for live tv // If h264_mp4toannexb is ever added, do not use it for live tv.
if (state.VideoStream != null && if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
!string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
{ {
string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
if (!string.IsNullOrEmpty(bitStreamArgs)) if (!string.IsNullOrEmpty(bitStreamArgs))
{ {
args += " " + bitStreamArgs; args += " " + bitStreamArgs;
} }
} }
args += " -start_at_zero";
} }
else else
{ {
var keyFrameArg = string.Format( args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
CultureInfo.InvariantCulture,
" -force_key_frames \"expr:gte(t,n_forced*{0})\"", // Set the key frame params for video encoding to match the hls segment time.
state.SegmentLength.ToString(CultureInfo.InvariantCulture)); args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null);
// Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{
args += " -bf 0";
}
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg; if (hasGraphicalSubs)
// Add resolution params, if specified
if (!hasGraphicalSubs)
{ {
// Graphical subs overlay and resolution params.
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
}
else
{
// Resolution params.
args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
} }
// This is for internal graphical subs if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream)
if (hasGraphicalSubs)
{ {
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); args += " -start_at_zero";
} }
} }

View File

@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds) public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds)
{ {
var items = RequestHelpers.Split(itemIds, ',', true) var items = itemIds
.Select(i => _libraryManager.GetItemById(i)) .Select(i => _libraryManager.GetItemById(i))
.OfType<Video>() .OfType<Video>()
.OrderBy(i => i.Id) .OrderBy(i => i.Id)
@ -326,15 +327,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="streamOptions">Optional. The streaming options.</param> /// <param name="streamOptions">Optional. The streaming options.</param>
/// <response code="200">Video stream returned.</response> /// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")]
[HttpGet("{itemId}/stream")] [HttpGet("{itemId}/stream")]
[HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")]
[HttpHead("{itemId}/stream", Name = "HeadVideoStream")] [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile] [ProducesVideoFile]
public async Task<ActionResult> GetVideoStream( public async Task<ActionResult> GetVideoStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute] string? container, [FromQuery] string? container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
@ -529,5 +528,166 @@ namespace Jellyfin.Api.Controllers
_transcodingJobType, _transcodingJobType,
cancellationTokenSource).ConfigureAwait(false); 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 lenght.</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, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
/// <param name="transcodingReasons">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=stream}.{container}")]
[HttpHead("{itemId}/{stream=stream}.{container}", Name = "HeadVideoStreamByContainer")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile]
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? 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? transcodingReasons,
[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,
videoBitRate,
subtitleStreamIndex,
subtitleMethod,
maxRefFrames,
maxVideoBitDepth,
requireAvc,
deInterlace,
requireNonAnamorphic,
transcodingMaxAudioChannels,
cpuCoreLimit,
liveStreamId,
enableMpegtsM2TsMode,
videoCodec,
subtitleCodec,
transcodingReasons,
audioStreamIndex,
videoStreamIndex,
context,
streamOptions);
}
} }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
@ -73,9 +73,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder, [FromQuery] string? sortOrder,
[FromQuery] string? parentId, [FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery] string? includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] string? mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? sortBy, [FromQuery] string? sortBy,
[FromQuery] bool? enableUserData, [FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit, [FromQuery] int? imageTypeLimit,
@ -103,19 +103,15 @@ namespace Jellyfin.Api.Controllers
IList<BaseItem> items; IList<BaseItem> items;
var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
ExcludeItemTypes = excludeItemTypesArr, ExcludeItemTypes = excludeItemTypes,
IncludeItemTypes = includeItemTypesArr, IncludeItemTypes = includeItemTypes,
MediaTypes = mediaTypesArr, MediaTypes = mediaTypes,
DtoOptions = dtoOptions DtoOptions = dtoOptions
}; };
bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr); bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
if (parentItem.IsFolder) if (parentItem.IsFolder)
{ {

View File

@ -207,7 +207,61 @@ namespace Jellyfin.Api.Helpers
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
} }
AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (state.VideoStream != null && state.VideoRequest != null)
{
// Provide SDR HEVC entrance for backward compatibility.
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrEmpty(state.VideoStream.VideoRange)
&& string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
if (requestedVideoProfiles != 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";
EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0;
var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 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 (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue
&& state.VideoStream.Level > 150
&& !string.IsNullOrEmpty(state.VideoStream.VideoRange)
&& string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase)
&& 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())) if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
{ {
@ -217,40 +271,77 @@ namespace Jellyfin.Api.Helpers
var variation = GetBitrateVariation(totalBitrate); var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation; var newBitrate = totalBitrate - variation;
var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2; variation *= 2;
newBitrate = totalBitrate - variation; newBitrate = totalBitrate - variation;
variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
} }
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
} }
private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
{ {
builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") var playlistBuilder = new StringBuilder();
playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture)) .Append(bitrate.ToString(CultureInfo.InvariantCulture))
.Append(",AVERAGE-BANDWIDTH=") .Append(",AVERAGE-BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture)); .Append(bitrate.ToString(CultureInfo.InvariantCulture));
AppendPlaylistCodecsField(builder, state); AppendPlaylistVideoRangeField(playlistBuilder, state);
AppendPlaylistResolutionField(builder, state); AppendPlaylistCodecsField(playlistBuilder, state);
AppendPlaylistFramerateField(builder, state); AppendPlaylistResolutionField(playlistBuilder, state);
AppendPlaylistFramerateField(playlistBuilder, state);
if (!string.IsNullOrWhiteSpace(subtitleGroup)) if (!string.IsNullOrWhiteSpace(subtitleGroup))
{ {
builder.Append(",SUBTITLES=\"") playlistBuilder.Append(",SUBTITLES=\"")
.Append(subtitleGroup) .Append(subtitleGroup)
.Append('"'); .Append('"');
} }
builder.Append(Environment.NewLine); playlistBuilder.Append(Environment.NewLine);
builder.AppendLine(url); 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 != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
{
var videoRange = state.VideoStream.VideoRange;
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
{
if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
{
builder.Append(",VIDEO-RANGE=SDR");
}
if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
{
builder.Append(",VIDEO-RANGE=PQ");
}
}
else
{
// Currently we only encode to SDR.
builder.Append(",VIDEO-RANGE=SDR");
}
}
} }
/// <summary> /// <summary>
@ -419,15 +510,27 @@ namespace Jellyfin.Api.Helpers
/// <returns>H.26X level of the output video stream.</returns> /// <returns>H.26X level of the output video stream.</returns>
private int? GetOutputVideoCodecLevel(StreamState state) private int? GetOutputVideoCodecLevel(StreamState state)
{ {
string? levelString; string levelString = string.Empty;
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream != null
&& state.VideoStream.Level.HasValue) && state.VideoStream.Level.HasValue)
{ {
levelString = state.VideoStream?.Level.ToString(); levelString = state.VideoStream.Level.ToString() ?? string.Empty;
} }
else else
{ {
levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); 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 (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
@ -438,6 +541,38 @@ namespace Jellyfin.Api.Helpers
return null; return null;
} }
/// <summary>
/// Get the H.26X profile of the output video stream.
/// </summary>
/// <param name="state">StreamState of the current stream.</param>
/// <param name="codec">Video codec.</param>
/// <returns>H.26X 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 = profileString ?? "high";
}
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{
profileString = profileString ?? "main";
}
}
return profileString;
}
/// <summary> /// <summary>
/// Gets a formatted string of the output audio codec, for use in the CODECS field. /// Gets a formatted string of the output audio codec, for use in the CODECS field.
/// </summary> /// </summary>
@ -468,6 +603,16 @@ namespace Jellyfin.Api.Helpers
return HlsCodecStringHelpers.GetEAC3String(); 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();
}
return string.Empty; return string.Empty;
} }
@ -492,15 +637,14 @@ namespace Jellyfin.Api.Helpers
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
{ {
string? profile = state.GetRequestedProfiles("h264").FirstOrDefault(); string profile = GetOutputVideoCodecProfile(state, "h264");
return HlsCodecStringHelpers.GetH264String(profile, level); return HlsCodecStringHelpers.GetH264String(profile, level);
} }
if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
{ {
string? profile = state.GetRequestedProfiles("h265").FirstOrDefault(); string profile = GetOutputVideoCodecProfile(state, "hevc");
return HlsCodecStringHelpers.GetH265String(profile, level); return HlsCodecStringHelpers.GetH265String(profile, level);
} }
@ -544,12 +688,30 @@ namespace Jellyfin.Api.Helpers
return variation; return variation;
} }
private string ReplaceBitrate(string url, int oldValue, int newValue) private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
{ {
return url.Replace( return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase); 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.OrdinalIgnoreCase);
}
} }
} }

View File

@ -9,13 +9,38 @@ namespace Jellyfin.Api.Helpers
/// </summary> /// </summary>
public static class HlsCodecStringHelpers 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> /// <summary>
/// Gets a MP3 codec string. /// Gets a MP3 codec string.
/// </summary> /// </summary>
/// <returns>MP3 codec string.</returns> /// <returns>MP3 codec string.</returns>
public static string GetMP3String() public static string GetMP3String()
{ {
return "mp4a.40.34"; return MP3;
} }
/// <summary> /// <summary>
@ -40,6 +65,42 @@ namespace Jellyfin.Api.Helpers
return result.ToString(); 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> /// <summary>
/// Gets a H.264 codec string. /// Gets a H.264 codec string.
/// </summary> /// </summary>
@ -85,41 +146,24 @@ namespace Jellyfin.Api.Helpers
// The h265 syntax is a bit of a mystery at the time this comment was written. // 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: // This is what I've found through various sources:
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
StringBuilder result = new StringBuilder("hev1", 16); StringBuilder result = new StringBuilder("hvc1", 16);
if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)) if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
{ {
result.Append(".2.6"); result.Append(".2.4");
} }
else else
{ {
// Default to main if profile is invalid // Default to main if profile is invalid
result.Append(".1.6"); result.Append(".1.4");
} }
result.Append(".L") result.Append(".L")
.Append(level * 3) .Append(level)
.Append(".B0"); .Append(".B0");
return result.ToString(); return result.ToString();
} }
/// <summary>
/// Gets an AC-3 codec string.
/// </summary>
/// <returns>AC-3 codec string.</returns>
public static string GetAC3String()
{
return "mp4a.a5";
}
/// <summary>
/// Gets an E-AC-3 codec string.
/// </summary>
/// <returns>E-AC-3 codec string.</returns>
public static string GetEAC3String()
{
return "mp4a.a6";
}
} }
} }

View File

@ -1,8 +1,11 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -74,25 +77,65 @@ namespace Jellyfin.Api.Helpers
} }
} }
/// <summary>
/// Gets the #EXT-X-MAP string.
/// </summary>
/// <param name="outputPath">The output path of the file.</param>
/// <param name="state">The <see cref="StreamState"/>.</param>
/// <param name="isOsDepends">Get a normal string or depends on OS.</param>
/// <returns>The string text of #EXT-X-MAP.</returns>
public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
{
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
// on Linux/Unix
// #EXT-X-MAP:URI="prefix-1.mp4"
var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
if (!isOsDepends)
{
return fmp4InitFileName;
}
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (isWindows)
{
// on Windows
// #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
fmp4InitFileName = outputPrefix + "-1" + outputExtension;
}
return fmp4InitFileName;
}
/// <summary> /// <summary>
/// Gets the hls playlist text. /// Gets the hls playlist text.
/// </summary> /// </summary>
/// <param name="path">The path to the playlist file.</param> /// <param name="path">The path to the playlist file.</param>
/// <param name="segmentLength">The segment length.</param> /// <param name="state">The <see cref="StreamState"/>.</param>
/// <returns>The playlist text as a string.</returns> /// <returns>The playlist text as a string.</returns>
public static string GetLivePlaylistText(string path, int segmentLength) public static string GetLivePlaylistText(string path, StreamState state)
{ {
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var text = reader.ReadToEnd(); var text = reader.ReadToEnd();
text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture); var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
{
var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
var baseUrlParam = string.Format(
CultureInfo.InvariantCulture,
"hls/{0}/",
Path.GetFileNameWithoutExtension(path));
var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); // Replace fMP4 init file URI.
text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); }
// text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
return text; return text;
} }

View File

@ -122,49 +122,6 @@ namespace Jellyfin.Api.Helpers
return session; return session;
} }
/// <summary>
/// Get Guid array from string.
/// </summary>
/// <param name="value">String value.</param>
/// <returns>Guid array.</returns>
internal static Guid[] GetGuids(string? value)
{
if (value == null)
{
return Array.Empty<Guid>();
}
return Split(value, ',', true)
.Select(i => new Guid(i))
.ToArray();
}
/// <summary>
/// Gets the item fields.
/// </summary>
/// <param name="fields">The fields string.</param>
/// <returns>IEnumerable{ItemFields}.</returns>
internal static ItemFields[] GetItemFields(string? fields)
{
if (string.IsNullOrEmpty(fields))
{
return Array.Empty<ItemFields>();
}
return Split(fields, ',', true)
.Select(v =>
{
if (Enum.TryParse(v, true, out ItemFields value))
{
return (ItemFields?)value;
}
return null;
}).Where(i => i.HasValue)
.Select(i => i!.Value)
.ToArray();
}
internal static QueryResult<BaseItemDto> CreateQueryResult( internal static QueryResult<BaseItemDto> CreateQueryResult(
QueryResult<(BaseItem, ItemCounts)> result, QueryResult<(BaseItem, ItemCounts)> result,
DtoOptions dtoOptions, DtoOptions dtoOptions,

View File

@ -169,7 +169,9 @@ namespace Jellyfin.Api.Helpers
state.DirectStreamProvider = liveStreamInfo.Item2; state.DirectStreamProvider = liveStreamInfo.Item2;
} }
encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); var encodingOptions = serverConfigurationManager.GetEncodingOptions();
encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
string? containerInternal = Path.GetExtension(state.RequestedUrl); string? containerInternal = Path.GetExtension(state.RequestedUrl);
@ -187,7 +189,7 @@ namespace Jellyfin.Api.Helpers
state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream);
state.OutputAudioCodec = streamingRequest.AudioCodec; state.OutputAudioCodec = streamingRequest.AudioCodec;
@ -200,20 +202,41 @@ namespace Jellyfin.Api.Helpers
encodingHelper.TryStreamCopy(state); encodingHelper.TryStreamCopy(state);
if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
{ {
var resolution = ResolutionNormalizer.Normalize( var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
state.VideoStream?.BitRate, && !state.VideoRequest.Height.HasValue
state.VideoStream?.Width, && !state.VideoRequest.MaxWidth.HasValue
state.VideoStream?.Height, && !state.VideoRequest.MaxHeight.HasValue;
state.OutputVideoBitrate.Value,
state.VideoStream?.Codec,
state.OutputVideoCodec,
state.VideoRequest.MaxWidth,
state.VideoRequest.MaxHeight);
state.VideoRequest.MaxWidth = resolution.MaxWidth; if (isVideoResolutionNotRequested
state.VideoRequest.MaxHeight = resolution.MaxHeight; && 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.VideoStream?.Width,
state.VideoStream?.Height,
state.OutputVideoBitrate.Value,
state.VideoStream?.Codec,
state.OutputVideoCodec,
state.VideoRequest.MaxWidth,
state.VideoRequest.MaxHeight);
state.VideoRequest.MaxWidth = resolution.MaxWidth;
state.VideoRequest.MaxHeight = resolution.MaxHeight;
}
} }
} }

View File

@ -145,7 +145,7 @@ namespace Jellyfin.Api.Helpers
lock (_activeTranscodingJobs) lock (_activeTranscodingJobs)
{ {
// This is really only needed for HLS. // This is really only needed for HLS.
// Progressive streams can stop on their own reliably // Progressive streams can stop on their own reliably.
jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
} }
@ -241,7 +241,7 @@ namespace Jellyfin.Api.Helpers
lock (_activeTranscodingJobs) lock (_activeTranscodingJobs)
{ {
// This is really only needed for HLS. // This is really only needed for HLS.
// Progressive streams can stop on their own reliably // Progressive streams can stop on their own reliably.
jobs.AddRange(_activeTranscodingJobs.Where(killJob)); jobs.AddRange(_activeTranscodingJobs.Where(killJob));
} }
@ -304,10 +304,10 @@ namespace Jellyfin.Api.Helpers
process!.StandardInput.WriteLine("q"); process!.StandardInput.WriteLine("q");
// Need to wait because killing is asynchronous // Need to wait because killing is asynchronous.
if (!process.WaitForExit(5000)) if (!process.WaitForExit(5000))
{ {
_logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
process.Kill(); process.Kill();
} }
} }
@ -470,11 +470,11 @@ namespace Jellyfin.Api.Helpers
} }
/// <summary> /// <summary>
/// Starts the FFMPEG. /// Starts FFmpeg.
/// </summary> /// </summary>
/// <param name="state">The state.</param> /// <param name="state">The state.</param>
/// <param name="outputPath">The output path.</param> /// <param name="outputPath">The output path.</param>
/// <param name="commandLineArguments">The command line arguments for ffmpeg.</param> /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
/// <param name="request">The <see cref="HttpRequest"/>.</param> /// <param name="request">The <see cref="HttpRequest"/>.</param>
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
/// <param name="cancellationTokenSource">The cancellation token source.</param> /// <param name="cancellationTokenSource">The cancellation token source.</param>
@ -501,13 +501,13 @@ namespace Jellyfin.Api.Helpers
{ {
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw new ArgumentException("User does not have access to video transcoding"); throw new ArgumentException("User does not have access to video transcoding.");
} }
} }
if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath)) if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath))
{ {
throw new ArgumentException("FFMPEG path not set."); throw new ArgumentException("FFmpeg path not set.");
} }
var process = new Process var process = new Process
@ -544,18 +544,20 @@ namespace Jellyfin.Api.Helpers
var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
_logger.LogInformation(commandLineLogMessage); _logger.LogInformation(commandLineLogMessage);
var logFilePrefix = "ffmpeg-transcode"; var logFilePrefix = "FFmpeg.Transcode-";
if (state.VideoRequest != null if (state.VideoRequest != null
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) && EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
{ {
logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
? "ffmpeg-remux" ? "FFmpeg.Remux-"
: "ffmpeg-directstream"; : "FFmpeg.DirectStream-";
} }
var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); 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. // 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, true); Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
@ -569,17 +571,17 @@ namespace Jellyfin.Api.Helpers
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error starting ffmpeg"); _logger.LogError(ex, "Error starting FFmpeg");
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw; throw;
} }
_logger.LogDebug("Launched ffmpeg process"); _logger.LogDebug("Launched FFmpeg process");
state.TranscodingJob = transcodingJob; state.TranscodingJob = transcodingJob;
// Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback // 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.BaseStream, logStream); _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
// Wait for the file to exist before proceeding // Wait for the file to exist before proceeding
@ -748,11 +750,11 @@ namespace Jellyfin.Api.Helpers
if (process.ExitCode == 0) if (process.ExitCode == 0)
{ {
_logger.LogInformation("FFMpeg exited with code 0"); _logger.LogInformation("FFmpeg exited with code 0");
} }
else else
{ {
_logger.LogError("FFMpeg exited with code {0}", process.ExitCode); _logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
} }
process.Dispose(); process.Dispose();
@ -771,8 +773,9 @@ namespace Jellyfin.Api.Helpers
new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
cancellationTokenSource.Token) cancellationTokenSource.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
_encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
if (state.VideoRequest != null) if (state.VideoRequest != null)
{ {

View File

@ -0,0 +1,49 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.ModelBinders
{
/// <summary>
/// DateTime model binder.
/// </summary>
public class LegacyDateTimeModelBinder : IModelBinder
{
// Borrowed from the DateTimeModelBinderProvider
private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
private readonly DateTimeModelBinder _defaultModelBinder;
/// <summary>
/// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
{
_defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult.Values.Count == 1)
{
var dateTimeString = valueProviderResult.FirstValue;
// Mark Played Item.
if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
{
bindingContext.Result = ModelBindingResult.Success(dateTime);
}
else
{
return _defaultModelBinder.BindModelAsync(bindingContext);
}
}
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,47 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.ModelBinders
{
/// <summary>
/// Nullable enum model binder.
/// </summary>
public class NullableEnumModelBinder : IModelBinder
{
private readonly ILogger<NullableEnumModelBinder> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param>
public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger)
{
_logger = logger;
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
var converter = TypeDescriptor.GetConverter(elementType);
if (valueProviderResult.Length != 0)
{
try
{
var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue);
bindingContext.Result = ModelBindingResult.Success(convertedValue);
}
catch (FormatException e)
{
_logger.LogWarning(e, "Error converting value.");
}
}
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,27 @@
using System;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.ModelBinders
{
/// <summary>
/// Nullable enum model binder provider.
/// </summary>
public class NullableEnumModelBinderProvider : IModelBinderProvider
{
/// <inheritdoc />
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType);
if (nullableType == null || !nullableType.IsEnum)
{
// Type isn't nullable or isn't an enum.
return null;
}
var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>();
return new NullableEnumModelBinder(logger);
}
}
}

View File

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.ModelBinders
{
/// <summary>
/// Comma delimited array model binder.
/// Returns an empty array of specified type if there is no query parameter.
/// </summary>
public class PipeDelimitedArrayModelBinder : IModelBinder
{
private readonly ILogger<PipeDelimitedArrayModelBinder> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param>
public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
var converter = TypeDescriptor.GetConverter(elementType);
if (valueProviderResult.Length > 1)
{
var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
bindingContext.Result = ModelBindingResult.Success(typedValues);
}
else
{
var value = valueProviderResult.FirstValue;
if (value != null)
{
var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
var typedValues = GetParsedResult(splitValues, elementType, converter);
bindingContext.Result = ModelBindingResult.Success(typedValues);
}
else
{
var emptyResult = Array.CreateInstance(elementType, 0);
bindingContext.Result = ModelBindingResult.Success(emptyResult);
}
}
return Task.CompletedTask;
}
private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
{
var parsedValues = new object?[values.Count];
var convertedCount = 0;
for (var i = 0; i < values.Count; i++)
{
try
{
parsedValues[i] = converter.ConvertFromString(values[i].Trim());
convertedCount++;
}
catch (FormatException e)
{
_logger.LogWarning(e, "Error converting value.");
}
}
var typedValues = Array.CreateInstance(elementType, convertedCount);
var typedValueIndex = 0;
for (var i = 0; i < parsedValues.Length; i++)
{
if (parsedValues[i] != null)
{
typedValues.SetValue(parsedValues[i], typedValueIndex);
typedValueIndex++;
}
}
return typedValues;
}
}
}

View File

@ -16,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary> /// <summary>
/// Gets or sets the channels to return guide information for. /// Gets or sets the channels to return guide information for.
/// </summary> /// </summary>
public string? ChannelIds { get; set; } [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>();
/// <summary> /// <summary>
/// Gets or sets optional. Filter by user id. /// Gets or sets optional. Filter by user id.
@ -115,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary> /// <summary>
/// Gets or sets the genres to return guide information for. /// Gets or sets the genres to return guide information for.
/// </summary> /// </summary>
public string? Genres { get; set; } [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
/// <summary> /// <summary>
/// Gets or sets the genre ids to return guide information for. /// Gets or sets the genre ids to return guide information for.
/// </summary> /// </summary>
public string? GenreIds { get; set; } [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>();
/// <summary> /// <summary>
/// Gets or sets include image information in output. /// Gets or sets include image information in output.

View File

@ -1,4 +1,7 @@
using System; using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using MediaBrowser.Common.Json.Converters;
namespace Jellyfin.Api.Models.PlaylistDtos namespace Jellyfin.Api.Models.PlaylistDtos
{ {
@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos
/// <summary> /// <summary>
/// Gets or sets item ids to add to the playlist. /// Gets or sets item ids to add to the playlist.
/// </summary> /// </summary>
public string? Ids { get; set; } [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
/// <summary> /// <summary>
/// Gets or sets the user id. /// Gets or sets the user id.

View File

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Json.Converters;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Session;
using Newtonsoft.Json;
namespace Jellyfin.Api.Models.SessionDtos
{
/// <summary>
/// Client capabilities dto.
/// </summary>
public class ClientCapabilitiesDto
{
/// <summary>
/// Gets or sets the list of playable media types.
/// </summary>
public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the list of supported commands.
/// </summary>
[JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>();
/// <summary>
/// Gets or sets a value indicating whether session supports media control.
/// </summary>
public bool SupportsMediaControl { get; set; }
/// <summary>
/// Gets or sets a value indicating whether session supports content uploading.
/// </summary>
public bool SupportsContentUploading { get; set; }
/// <summary>
/// Gets or sets the message callback url.
/// </summary>
public string? MessageCallbackUrl { get; set; }
/// <summary>
/// Gets or sets a value indicating whether session supports a persistent identifier.
/// </summary>
public bool SupportsPersistentIdentifier { get; set; }
/// <summary>
/// Gets or sets a value indicating whether session supports sync.
/// </summary>
public bool SupportsSync { get; set; }
/// <summary>
/// Gets or sets the device profile.
/// </summary>
public DeviceProfile? DeviceProfile { get; set; }
/// <summary>
/// Gets or sets the app store url.
/// </summary>
public string? AppStoreUrl { get; set; }
/// <summary>
/// Gets or sets the icon url.
/// </summary>
public string? IconUrl { get; set; }
/// <summary>
/// Convert the dto to the full <see cref="ClientCapabilities"/> model.
/// </summary>
/// <returns>The converted <see cref="ClientCapabilities"/> model.</returns>
public ClientCapabilities ToClientCapabilities()
{
return new ClientCapabilities
{
PlayableMediaTypes = PlayableMediaTypes,
SupportedCommands = SupportedCommands,
SupportsMediaControl = SupportsMediaControl,
SupportsContentUploading = SupportsContentUploading,
MessageCallbackUrl = MessageCallbackUrl,
SupportsPersistentIdentifier = SupportsPersistentIdentifier,
SupportsSync = SupportsSync,
DeviceProfile = DeviceProfile,
AppStoreUrl = AppStoreUrl,
IconUrl = IconUrl
};
}
}
}

View File

@ -1,44 +0,0 @@
using System;
using System.ComponentModel;
using System.Globalization;
namespace Jellyfin.Api.TypeConverters
{
/// <summary>
/// Custom datetime parser.
/// </summary>
public class DateTimeTypeConverter : TypeConverter
{
/// <inheritdoc />
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
if (sourceType == typeof(string))
{
return true;
}
return base.CanConvertFrom(context, sourceType);
}
/// <inheritdoc />
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is string dateString)
{
// Mark Played Item.
if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
{
return dateTime;
}
// Get Activity Logs.
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime))
{
return dateTime;
}
}
return base.ConvertFrom(context, culture, value);
}
}
}

View File

@ -0,0 +1,221 @@
#pragma warning disable CA1819 // Properties should not return arrays
using System;
using MediaBrowser.Model.Configuration;
namespace Jellyfin.Networking.Configuration
{
/// <summary>
/// Defines the <see cref="NetworkConfiguration" />.
/// </summary>
public class NetworkConfiguration
{
/// <summary>
/// The default value for <see cref="HttpServerPortNumber"/>.
/// </summary>
public const int DefaultHttpPort = 8096;
/// <summary>
/// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
/// </summary>
public const int DefaultHttpsPort = 8920;
private string _baseUrl = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the server should force connections over HTTPS.
/// </summary>
public bool RequireHttps { get; set; }
/// <summary>
/// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
/// </summary>
public string BaseUrl
{
get => _baseUrl;
set
{
// Normalize the start of the string
if (string.IsNullOrWhiteSpace(value))
{
// If baseUrl is empty, set an empty prefix string
_baseUrl = string.Empty;
return;
}
if (value[0] != '/')
{
// If baseUrl was not configured with a leading slash, append one for consistency
value = "/" + value;
}
// Normalize the end of the string
if (value[^1] == '/')
{
// If baseUrl was configured with a trailing slash, remove it for consistency
value = value.Remove(value.Length - 1);
}
_baseUrl = value;
}
}
/// <summary>
/// Gets or sets the public HTTPS port.
/// </summary>
/// <value>The public HTTPS port.</value>
public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets the HTTP server port number.
/// </summary>
/// <value>The HTTP server port number.</value>
public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
/// <summary>
/// Gets or sets the HTTPS server port number.
/// </summary>
/// <value>The HTTPS server port number.</value>
public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets a value indicating whether to use HTTPS.
/// </summary>
/// <remarks>
/// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
/// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>.
/// </remarks>
public bool EnableHttps { get; set; }
/// <summary>
/// Gets or sets the public mapped port.
/// </summary>
/// <value>The public mapped port.</value>
public int PublicPort { get; set; } = DefaultHttpPort;
/// <summary>
/// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
/// </summary>
public bool UPnPCreateHttpPortMap { get; set; }
/// <summary>
/// Gets or sets the UDPPortRange.
/// </summary>
public string UDPPortRange { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether gets or sets IPV6 capability.
/// </summary>
public bool EnableIPV6 { get; set; }
/// <summary>
/// Gets or sets a value indicating whether gets or sets IPV4 capability.
/// </summary>
public bool EnableIPV4 { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log.
/// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect.
/// </summary>
public bool EnableSSDPTracing { get; set; }
/// <summary>
/// Gets or sets the SSDPTracingFilter
/// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being 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 string SSDPTracingFilter { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of times SSDP UDP messages are sent.
/// </summary>
public int UDPSendCount { get; set; } = 2;
/// <summary>
/// Gets or sets the delay between each groups of SSDP messages (in ms).
/// </summary>
public int UDPSendDelay { get; set; } = 100;
/// <summary>
/// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
/// </summary>
public bool IgnoreVirtualInterfaces { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
/// </summary>
public string VirtualInterfaceNames { get; set; } = "vEthernet*";
/// <summary>
/// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
/// </summary>
public int GatewayMonitorPeriod { get; set; } = 60;
/// <summary>
/// Gets a value indicating whether multi-socket binding is available.
/// </summary>
public bool EnableMultiSocketBinding { get; } = true;
/// <summary>
/// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
/// Depending on the address range implemented ULA ranges might not be used.
/// </summary>
public bool TrustAllIP6Interfaces { get; set; }
/// <summary>
/// Gets or sets the ports that HDHomerun uses.
/// </summary>
public string HDHomerunPortRange { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the PublishedServerUriBySubnet
/// Gets or sets PublishedServerUri to advertise for specific subnets.
/// </summary>
public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
/// </summary>
public bool AutoDiscoveryTracing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether Autodiscovery is enabled.
/// </summary>
public bool AutoDiscovery { get; set; } = true;
/// <summary>
/// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
/// </summary>
public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
/// </summary>
public bool IsRemoteIPFilterBlacklist { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable automatic port forwarding.
/// </summary>
public bool EnableUPnP { get; set; }
/// <summary>
/// Gets or sets a value indicating whether access outside of the LAN is permitted.
/// </summary>
public bool EnableRemoteAccess { get; set; } = true;
/// <summary>
/// Gets or sets the subnets that are deemed to make up the LAN.
/// </summary>
public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
/// </summary>
public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the known proxies.
/// </summary>
public string[] KnownProxies { get; set; } = Array.Empty<string>();
}
}

View File

@ -0,0 +1,21 @@
using Jellyfin.Networking.Configuration;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Networking.Configuration
{
/// <summary>
/// Defines the <see cref="NetworkConfigurationExtensions" />.
/// </summary>
public static class NetworkConfigurationExtensions
{
/// <summary>
/// Retrieves the network configuration.
/// </summary>
/// <param name="config">The <see cref="IConfigurationManager"/>.</param>
/// <returns>The <see cref="NetworkConfiguration"/>.</returns>
public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config)
{
return config.GetConfiguration<NetworkConfiguration>("network");
}
}
}

View File

@ -0,0 +1,27 @@
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Networking.Configuration
{
/// <summary>
/// Defines the <see cref="NetworkConfigurationFactory" />.
/// </summary>
public class NetworkConfigurationFactory : IConfigurationFactory
{
/// <summary>
/// The GetConfigurations.
/// </summary>
/// <returns>The <see cref="IEnumerable{ConfigurationStore}"/>.</returns>
public IEnumerable<ConfigurationStore> GetConfigurations()
{
return new[]
{
new ConfigurationStore
{
Key = "network",
ConfigurationType = typeof(NetworkConfiguration)
}
};
}
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
<!-- Code Analyzers-->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,234 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Net;
using System.Net.NetworkInformation;
using Jellyfin.Networking.Configuration;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Networking.Manager
{
/// <summary>
/// Interface for the NetworkManager class.
/// </summary>
public interface INetworkManager
{
/// <summary>
/// Event triggered on network changes.
/// </summary>
event EventHandler NetworkChanged;
/// <summary>
/// Gets the published server urls list.
/// </summary>
Dictionary<IPNetAddress, string> PublishedServerUrls { get; }
/// <summary>
/// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
/// </summary>
bool TrustAllIP6Interfaces { get; }
/// <summary>
/// Gets the remote address filter.
/// </summary>
Collection<IPObject> RemoteAddressFilter { get; }
/// <summary>
/// Gets or sets a value indicating whether iP6 is enabled.
/// </summary>
bool IsIP6Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether iP4 is enabled.
/// </summary>
bool IsIP4Enabled { get; set; }
/// <summary>
/// Calculates the list of interfaces to use for Kestrel.
/// </summary>
/// <returns>A Collection{IPObject} object containing all the interfaces to bind.
/// If all the interfaces are specified, and none are excluded, it returns zero items
/// to represent any address.</returns>
/// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param>
Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false);
/// <summary>
/// Returns a collection containing the loopback interfaces.
/// </summary>
/// <returns>Collection{IPObject}.</returns>
Collection<IPObject> GetLoopbacks();
/// <summary>
/// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
/// If no bind addresses are specified, an internal interface address is selected.
/// The priority of selection is as follows:-
///
/// The value contained in the startup parameter --published-server-url.
///
/// If the user specified custom subnet overrides, the correct subnet for the source address.
///
/// If the user specified bind interfaces to use:-
/// The bind interface that contains the source subnet.
/// The first bind interface specified that suits best first the source's endpoint. eg. external or internal.
///
/// If the source is from a public subnet address range and the user hasn't specified any bind addresses:-
/// The first public interface that isn't a loopback and contains the source subnet.
/// The first public interface that isn't a loopback. Priority is given to interfaces with gateways.
/// An internal interface if there are no public ip addresses.
///
/// If the source is from a private subnet address range and the user hasn't specified any bind addresses:-
/// The first private interface that contains the source subnet.
/// The first private interface that isn't a loopback. Priority is given to interfaces with gateways.
///
/// If no interfaces meet any of these criteria, then a loopback address is returned.
///
/// Interface that have been specifically excluded from binding are not used in any of the calculations.
/// </summary>
/// <param name="source">Source of the request.</param>
/// <param name="port">Optional port returned, if it's part of an override.</param>
/// <returns>IP Address to use, or loopback address if all else fails.</returns>
string GetBindInterface(IPObject source, out int? port);
/// <summary>
/// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
/// If no bind addresses are specified, an internal interface address is selected.
/// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
/// </summary>
/// <param name="source">Source of the request.</param>
/// <param name="port">Optional port returned, if it's part of an override.</param>
/// <returns>IP Address to use, or loopback address if all else fails.</returns>
string GetBindInterface(HttpRequest source, out int? port);
/// <summary>
/// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
/// If no bind addresses are specified, an internal interface address is selected.
/// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
/// </summary>
/// <param name="source">IP address of the request.</param>
/// <param name="port">Optional port returned, if it's part of an override.</param>
/// <returns>IP Address to use, or loopback address if all else fails.</returns>
string GetBindInterface(IPAddress source, out int? port);
/// <summary>
/// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
/// If no bind addresses are specified, an internal interface address is selected.
/// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
/// </summary>
/// <param name="source">Source of the request.</param>
/// <param name="port">Optional port returned, if it's part of an override.</param>
/// <returns>IP Address to use, or loopback address if all else fails.</returns>
string GetBindInterface(string source, out int? port);
/// <summary>
/// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses.
/// </summary>
/// <param name="address">IP address to check.</param>
/// <returns>True if it is.</returns>
bool IsExcludedInterface(IPAddress address);
/// <summary>
/// Get a list of all the MAC addresses associated with active interfaces.
/// </summary>
/// <returns>List of MAC addresses.</returns>
IReadOnlyCollection<PhysicalAddress> GetMacAddresses();
/// <summary>
/// Checks to see if the IP Address provided matches an interface that has a gateway.
/// </summary>
/// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
/// <returns>Result of the check.</returns>
bool IsGatewayInterface(IPObject? addressObj);
/// <summary>
/// Checks to see if the IP Address provided matches an interface that has a gateway.
/// </summary>
/// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
/// <returns>Result of the check.</returns>
bool IsGatewayInterface(IPAddress? addressObj);
/// <summary>
/// Returns true if the address is a private address.
/// The config option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
/// <param name="address">Address to check.</param>
/// <returns>True or False.</returns>
bool IsPrivateAddressRange(IPObject address);
/// <summary>
/// Returns true if the address is part of the user defined LAN.
/// The config option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
/// <param name="address">IP to check.</param>
/// <returns>True if endpoint is within the LAN range.</returns>
bool IsInLocalNetwork(string address);
/// <summary>
/// Returns true if the address is part of the user defined LAN.
/// The config option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
/// <param name="address">IP to check.</param>
/// <returns>True if endpoint is within the LAN range.</returns>
bool IsInLocalNetwork(IPObject address);
/// <summary>
/// Returns true if the address is part of the user defined LAN.
/// The config option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
/// <param name="address">IP to check.</param>
/// <returns>True if endpoint is within the LAN range.</returns>
bool IsInLocalNetwork(IPAddress address);
/// <summary>
/// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes.
/// eg. "eth1", or "TP-LINK Wireless USB Adapter".
/// </summary>
/// <param name="token">Token to parse.</param>
/// <param name="result">Resultant object's ip addresses, if successful.</param>
/// <returns>Success of the operation.</returns>
bool TryParseInterface(string token, out Collection<IPObject>? result);
/// <summary>
/// Parses an array of strings into a Collection{IPObject}.
/// </summary>
/// <param name="values">Values to parse.</param>
/// <param name="bracketed">When true, only include values in []. When false, ignore bracketed values.</param>
/// <returns>IPCollection object containing the value strings.</returns>
Collection<IPObject> CreateIPCollection(string[] values, bool bracketed = false);
/// <summary>
/// Returns all the internal Bind interface addresses.
/// </summary>
/// <returns>An internal list of interfaces addresses.</returns>
Collection<IPObject> GetInternalBindAddresses();
/// <summary>
/// Checks to see if an IP address is still a valid interface address.
/// </summary>
/// <param name="address">IP address to check.</param>
/// <returns>True if it is.</returns>
bool IsValidInterfaceAddress(IPAddress address);
/// <summary>
/// Returns true if the IP address is in the excluded list.
/// </summary>
/// <param name="ip">IP to check.</param>
/// <returns>True if excluded.</returns>
bool IsExcluded(IPAddress ip);
/// <summary>
/// Returns true if the IP address is in the excluded list.
/// </summary>
/// <param name="ip">IP to check.</param>
/// <returns>True if excluded.</returns>
bool IsExcluded(EndPoint ip);
/// <summary>
/// Gets the filtered LAN ip addresses.
/// </summary>
/// <param name="filter">Optional filter for the list.</param>
/// <returns>Returns a filtered list of LAN addresses.</returns>
Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants; using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers; using Jellyfin.Api.Controllers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Server.Configuration; using Jellyfin.Server.Configuration;
using Jellyfin.Server.Filters; using Jellyfin.Server.Filters;
using Jellyfin.Server.Formatters; using Jellyfin.Server.Formatters;
@ -169,6 +170,8 @@ namespace Jellyfin.Server.Extensions
opts.OutputFormatters.Add(new CssOutputFormatter()); opts.OutputFormatters.Add(new CssOutputFormatter());
opts.OutputFormatters.Add(new XmlOutputFormatter()); opts.OutputFormatters.Add(new XmlOutputFormatter());
opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
}) })
// Clear app parts to avoid other assemblies being picked up // Clear app parts to avoid other assemblies being picked up

View File

@ -1,4 +1,4 @@
using System; using System;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates; using MediaBrowser.Model.Updates;
@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines
} }
} }
} }
} }

View File

@ -1,8 +1,5 @@
using System;
using System.ComponentModel;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Mime; using System.Net.Mime;
using Jellyfin.Api.TypeConverters;
using Jellyfin.Server.Extensions; using Jellyfin.Server.Extensions;
using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations;
using Jellyfin.Server.Middleware; using Jellyfin.Server.Middleware;
@ -66,10 +63,16 @@ namespace Jellyfin.Server
var productHeader = new ProductInfoHeaderValue( var productHeader = new ProductInfoHeaderValue(
_serverApplicationHost.Name.Replace(' ', '-'), _serverApplicationHost.Name.Replace(' ', '-'),
_serverApplicationHost.ApplicationVersionString); _serverApplicationHost.ApplicationVersionString);
var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
services services
.AddHttpClient(NamedClient.Default, c => .AddHttpClient(NamedClient.Default, c =>
{ {
c.DefaultRequestHeaders.UserAgent.Add(productHeader); c.DefaultRequestHeaders.UserAgent.Add(productHeader);
c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
}) })
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler()); .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@ -77,6 +80,8 @@ namespace Jellyfin.Server
{ {
c.DefaultRequestHeaders.UserAgent.Add(productHeader); c.DefaultRequestHeaders.UserAgent.Add(productHeader);
c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})")); c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
}) })
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler()); .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@ -164,9 +169,6 @@ namespace Jellyfin.Server
endpoints.MapHealthChecks("/health"); endpoints.MapHealthChecks("/health");
}); });
}); });
// Add type descriptor for legacy datetime parsing.
TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
} }
} }
} }

View File

@ -43,7 +43,8 @@ namespace MediaBrowser.Common.Json.Converters
} }
catch (FormatException) catch (FormatException)
{ {
// TODO log when upgraded to .Net5 // TODO log when upgraded to .Net6
// https://github.com/dotnet/runtime/issues/42975
// _logger.LogWarning(e, "Error converting value."); // _logger.LogWarning(e, "Error converting value.");
} }
} }

View File

@ -0,0 +1,24 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MediaBrowser.Common.Json.Converters
{
/// <summary>
/// Returns an ISO8601 formatted datetime.
/// </summary>
/// <remarks>
/// Used for legacy compatibility.
/// </remarks>
public class JsonDateTimeIso8601Converter : JsonConverter<DateTime>
{
/// <inheritdoc />
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetDateTime();
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture));
}
}

View File

@ -0,0 +1,75 @@
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MediaBrowser.Common.Json.Converters
{
/// <summary>
/// Convert Pipe delimited string to array of type.
/// </summary>
/// <typeparam name="T">Type to convert to.</typeparam>
public class JsonPipeDelimitedArrayConverter<T> : JsonConverter<T[]>
{
private readonly TypeConverter _typeConverter;
/// <summary>
/// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
/// </summary>
public JsonPipeDelimitedArrayConverter()
{
_typeConverter = TypeDescriptor.GetConverter(typeof(T));
}
/// <inheritdoc />
public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries);
if (stringEntries == null || stringEntries.Length == 0)
{
return Array.Empty<T>();
}
var parsedValues = new object[stringEntries.Length];
var convertedCount = 0;
for (var i = 0; i < stringEntries.Length; i++)
{
try
{
parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim());
convertedCount++;
}
catch (FormatException)
{
// TODO log when upgraded to .Net6
// https://github.com/dotnet/runtime/issues/42975
// _logger.LogWarning(e, "Error converting value.");
}
}
var typedValues = new T[convertedCount];
var typedValueIndex = 0;
for (var i = 0; i < stringEntries.Length; i++)
{
if (parsedValues[i] != null)
{
typedValues.SetValue(parsedValues[i], typedValueIndex);
typedValueIndex++;
}
}
return typedValues;
}
return JsonSerializer.Deserialize<T[]>(ref reader, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}
}

View File

@ -0,0 +1,28 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MediaBrowser.Common.Json.Converters
{
/// <summary>
/// Json Pipe delimited array converter factory.
/// </summary>
/// <remarks>
/// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
/// </remarks>
public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
{
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert)
{
return true;
}
/// <inheritdoc />
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType));
}
}
}

View File

@ -42,6 +42,7 @@ namespace MediaBrowser.Common.Json
options.Converters.Add(new JsonGuidConverter()); options.Converters.Add(new JsonGuidConverter());
options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new JsonStringEnumConverter());
options.Converters.Add(new JsonNullableStructConverterFactory()); options.Converters.Add(new JsonNullableStructConverterFactory());
options.Converters.Add(new JsonDateTimeIso8601Converter());
return options; return options;
} }

View File

@ -20,7 +20,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" /> <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,445 @@
#nullable enable
using System;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace MediaBrowser.Common.Net
{
/// <summary>
/// Object that holds a host name.
/// </summary>
public class IPHost : IPObject
{
/// <summary>
/// Gets or sets timeout value before resolve required, in minutes.
/// </summary>
public const int Timeout = 30;
/// <summary>
/// Represents an IPHost that has no value.
/// </summary>
public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None);
/// <summary>
/// Time when last resolved in ticks.
/// </summary>
private DateTime? _lastResolved = null;
/// <summary>
/// Gets the IP Addresses, attempting to resolve the name, if there are none.
/// </summary>
private IPAddress[] _addresses;
/// <summary>
/// Initializes a new instance of the <see cref="IPHost"/> class.
/// </summary>
/// <param name="name">Host name to assign.</param>
public IPHost(string name)
{
HostName = name ?? throw new ArgumentNullException(nameof(name));
_addresses = Array.Empty<IPAddress>();
Resolved = false;
}
/// <summary>
/// Initializes a new instance of the <see cref="IPHost"/> class.
/// </summary>
/// <param name="name">Host name to assign.</param>
/// <param name="address">Address to assign.</param>
private IPHost(string name, IPAddress address)
{
HostName = name ?? throw new ArgumentNullException(nameof(name));
_addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) };
Resolved = !address.Equals(IPAddress.None);
}
/// <summary>
/// Gets or sets the object's first IP address.
/// </summary>
public override IPAddress Address
{
get
{
return ResolveHost() ? this[0] : IPAddress.None;
}
set
{
// Not implemented, as a host's address is determined by DNS.
throw new NotImplementedException("The address of a host is determined by DNS.");
}
}
/// <summary>
/// Gets or sets the object's first IP's subnet prefix.
/// The setter does nothing, but shouldn't raise an exception.
/// </summary>
public override byte PrefixLength
{
get
{
return (byte)(ResolveHost() ? 128 : 32);
}
set
{
// Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length,
// which is automatically determined by it's IP type. Anything else is meaningless.
}
}
/// <summary>
/// Gets a value indicating whether the address has a value.
/// </summary>
public bool HasAddress => _addresses.Length != 0;
/// <summary>
/// Gets the host name of this object.
/// </summary>
public string HostName { get; }
/// <summary>
/// Gets a value indicating whether this host has attempted to be resolved.
/// </summary>
public bool Resolved { get; private set; }
/// <summary>
/// Gets or sets the IP Addresses associated with this object.
/// </summary>
/// <param name="index">Index of address.</param>
public IPAddress this[int index]
{
get
{
ResolveHost();
return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None;
}
}
/// <summary>
/// Attempts to parse the host string.
/// </summary>
/// <param name="host">Host name to parse.</param>
/// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param>
/// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns>
public static bool TryParse(string host, out IPHost hostObj)
{
if (!string.IsNullOrEmpty(host))
{
// See if it's an IPv6 with port address e.g. [::1]:120.
int i = host.IndexOf("]:", StringComparison.OrdinalIgnoreCase);
if (i != -1)
{
return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
}
else
{
// See if it's an IPv6 in [] with no port.
i = host.IndexOf(']', StringComparison.OrdinalIgnoreCase);
if (i != -1)
{
return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
}
// Is it a host or IPv4 with port?
string[] hosts = host.Split(':');
if (hosts.Length > 2)
{
hostObj = new IPHost(string.Empty, IPAddress.None);
return false;
}
// Remove port from IPv4 if it exists.
host = hosts[0];
if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase))
{
hostObj = new IPHost(host, new IPAddress(Ipv4Loopback));
return true;
}
if (IPNetAddress.TryParse(host, out IPNetAddress netIP))
{
// Host name is an ip address, so fake resolve.
hostObj = new IPHost(host, netIP.Address);
return true;
}
}
// Only thing left is to see if it's a host string.
if (!string.IsNullOrEmpty(host))
{
// Use regular expression as CheckHostName isn't RFC5892 compliant.
// Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
Regex re = new Regex(@"^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
if (re.Match(host).Success)
{
hostObj = new IPHost(host);
return true;
}
}
}
hostObj = IPHost.None;
return false;
}
/// <summary>
/// Attempts to parse the host string.
/// </summary>
/// <param name="host">Host name to parse.</param>
/// <returns>Object representing the string, if it has successfully been parsed.</returns>
public static IPHost Parse(string host)
{
if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
{
return res;
}
throw new InvalidCastException("Host does not contain a valid value. {host}");
}
/// <summary>
/// Attempts to parse the host string, ensuring that it resolves only to a specific IP type.
/// </summary>
/// <param name="host">Host name to parse.</param>
/// <param name="family">Addressfamily filter.</param>
/// <returns>Object representing the string, if it has successfully been parsed.</returns>
public static IPHost Parse(string host, AddressFamily family)
{
if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
{
if (family == AddressFamily.InterNetwork)
{
res.Remove(AddressFamily.InterNetworkV6);
}
else
{
res.Remove(AddressFamily.InterNetwork);
}
return res;
}
throw new InvalidCastException("Host does not contain a valid value. {host}");
}
/// <summary>
/// Returns the Addresses that this item resolved to.
/// </summary>
/// <returns>IPAddress Array.</returns>
public IPAddress[] GetAddresses()
{
ResolveHost();
return _addresses;
}
/// <inheritdoc/>
public override bool Contains(IPAddress address)
{
if (address != null && !Address.Equals(IPAddress.None))
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
foreach (var addr in GetAddresses())
{
if (address.Equals(addr))
{
return true;
}
}
}
return false;
}
/// <inheritdoc/>
public override bool Equals(IPObject? other)
{
if (other is IPHost otherObj)
{
// Do we have the name Hostname?
if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!ResolveHost() || !otherObj.ResolveHost())
{
return false;
}
// Do any of our IP addresses match?
foreach (IPAddress addr in _addresses)
{
foreach (IPAddress otherAddress in otherObj._addresses)
{
if (addr.Equals(otherAddress))
{
return true;
}
}
}
}
return false;
}
/// <inheritdoc/>
public override bool IsIP6()
{
// Returns true if interfaces are only IP6.
if (ResolveHost())
{
foreach (IPAddress i in _addresses)
{
if (i.AddressFamily != AddressFamily.InterNetworkV6)
{
return false;
}
}
return true;
}
return false;
}
/// <inheritdoc/>
public override string ToString()
{
// StringBuilder not optimum here.
string output = string.Empty;
if (_addresses.Length > 0)
{
bool moreThanOne = _addresses.Length > 1;
if (moreThanOne)
{
output = "[";
}
foreach (var i in _addresses)
{
if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified)
{
output += HostName + ",";
}
else if (i.Equals(IPAddress.Any))
{
output += "Any IP4 Address,";
}
else if (Address.Equals(IPAddress.IPv6Any))
{
output += "Any IP6 Address,";
}
else if (i.Equals(IPAddress.Broadcast))
{
output += "Any Address,";
}
else
{
output += $"{i}/32,";
}
}
output = output[0..^1];
if (moreThanOne)
{
output += "]";
}
}
else
{
output = HostName;
}
return output;
}
/// <inheritdoc/>
public override void Remove(AddressFamily family)
{
if (ResolveHost())
{
_addresses = _addresses.Where(p => p.AddressFamily != family).ToArray();
}
}
/// <inheritdoc/>
public override bool Contains(IPObject address)
{
// An IPHost cannot contain another IPObject, it can only be equal.
return Equals(address);
}
/// <inheritdoc/>
protected override IPObject CalculateNetworkAddress()
{
var netAddr = NetworkAddressOf(this[0], PrefixLength);
return new IPNetAddress(netAddr.Address, netAddr.PrefixLength);
}
/// <summary>
/// Attempt to resolve the ip address of a host.
/// </summary>
/// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns>
private bool ResolveHost()
{
// When was the last time we resolved?
if (_lastResolved == null)
{
_lastResolved = DateTime.UtcNow;
}
// If we haven't resolved before, or our timer has run out...
if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved?.AddMinutes(Timeout)))
{
_lastResolved = DateTime.UtcNow;
ResolveHostInternal().GetAwaiter().GetResult();
Resolved = true;
}
return _addresses.Length > 0;
}
/// <summary>
/// Task that looks up a Host name and returns its IP addresses.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task ResolveHostInternal()
{
if (!string.IsNullOrEmpty(HostName))
{
// Resolves the host name - so save a DNS lookup.
if (string.Equals(HostName, "localhost", StringComparison.OrdinalIgnoreCase))
{
_addresses = new IPAddress[] { new IPAddress(Ipv4Loopback), new IPAddress(Ipv6Loopback) };
return;
}
if (Uri.CheckHostName(HostName).Equals(UriHostNameType.Dns))
{
try
{
IPHostEntry ip = await Dns.GetHostEntryAsync(HostName).ConfigureAwait(false);
_addresses = ip.AddressList;
}
catch (SocketException ex)
{
// Log and then ignore socket errors, as the result value will just be an empty array.
Debug.WriteLine("GetHostEntryAsync failed with {Message}.", ex.Message);
}
}
}
}
}
}

View File

@ -0,0 +1,277 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
namespace MediaBrowser.Common.Net
{
/// <summary>
/// An object that holds and IP address and subnet mask.
/// </summary>
public class IPNetAddress : IPObject
{
/// <summary>
/// Represents an IPNetAddress that has no value.
/// </summary>
public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None);
/// <summary>
/// IPv4 multicast address.
/// </summary>
public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250");
/// <summary>
/// IPv6 local link multicast address.
/// </summary>
public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C");
/// <summary>
/// IPv6 site local multicast address.
/// </summary>
public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C");
/// <summary>
/// IP4Loopback address host.
/// </summary>
public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/32");
/// <summary>
/// IP6Loopback address host.
/// </summary>
public static readonly IPNetAddress IP6Loopback = IPNetAddress.Parse("::1");
/// <summary>
/// Object's IP address.
/// </summary>
private IPAddress _address;
/// <summary>
/// Initializes a new instance of the <see cref="IPNetAddress"/> class.
/// </summary>
/// <param name="address">Address to assign.</param>
public IPNetAddress(IPAddress address)
{
_address = address ?? throw new ArgumentNullException(nameof(address));
PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128);
}
/// <summary>
/// Initializes a new instance of the <see cref="IPNetAddress"/> class.
/// </summary>
/// <param name="address">IP Address.</param>
/// <param name="prefixLength">Mask as a CIDR.</param>
public IPNetAddress(IPAddress address, byte prefixLength)
{
if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address)))
{
_address = address.MapToIPv4();
}
else
{
_address = address;
}
PrefixLength = prefixLength;
}
/// <summary>
/// Gets or sets the object's IP address.
/// </summary>
public override IPAddress Address
{
get
{
return _address;
}
set
{
_address = value ?? IPAddress.None;
}
}
/// <inheritdoc/>
public override byte PrefixLength { get; set; }
/// <summary>
/// Try to parse the address and subnet strings into an IPNetAddress object.
/// </summary>
/// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param>
/// <param name="ip">Resultant object.</param>
/// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns>
public static bool TryParse(string addr, out IPNetAddress ip)
{
if (!string.IsNullOrEmpty(addr))
{
addr = addr.Trim();
// Try to parse it as is.
if (IPAddress.TryParse(addr, out IPAddress? res))
{
ip = new IPNetAddress(res);
return true;
}
// Is it a network?
string[] tokens = addr.Split("/");
if (tokens.Length == 2)
{
tokens[0] = tokens[0].TrimEnd();
tokens[1] = tokens[1].TrimStart();
if (IPAddress.TryParse(tokens[0], out res))
{
// Is the subnet part a cidr?
if (byte.TryParse(tokens[1], out byte cidr))
{
ip = new IPNetAddress(res, cidr);
return true;
}
// Is the subnet in x.y.a.b form?
if (IPAddress.TryParse(tokens[1], out IPAddress? mask))
{
ip = new IPNetAddress(res, MaskToCidr(mask));
return true;
}
}
}
}
ip = None;
return false;
}
/// <summary>
/// Parses the string provided, throwing an exception if it is badly formed.
/// </summary>
/// <param name="addr">String to parse.</param>
/// <returns>IPNetAddress object.</returns>
public static IPNetAddress Parse(string addr)
{
if (TryParse(addr, out IPNetAddress o))
{
return o;
}
throw new ArgumentException("Unable to recognise object :" + addr);
}
/// <inheritdoc/>
public override bool Contains(IPAddress address)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
var altAddress = NetworkAddressOf(address, PrefixLength);
return NetworkAddress.Address.Equals(altAddress.Address) && NetworkAddress.PrefixLength >= altAddress.PrefixLength;
}
/// <inheritdoc/>
public override bool Contains(IPObject address)
{
if (address is IPHost addressObj && addressObj.HasAddress)
{
foreach (IPAddress addr in addressObj.GetAddresses())
{
if (Contains(addr))
{
return true;
}
}
}
else if (address is IPNetAddress netaddrObj)
{
// Have the same network address, but different subnets?
if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address))
{
return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength;
}
var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength);
return NetworkAddress.Address.Equals(altAddress.Address);
}
return false;
}
/// <inheritdoc/>
public override bool Equals(IPObject? other)
{
if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None))
{
return Address.Equals(otherObj.Address) &&
PrefixLength == otherObj.PrefixLength;
}
return false;
}
/// <inheritdoc/>
public override bool Equals(IPAddress address)
{
if (address != null && !address.Equals(IPAddress.None) && !Address.Equals(IPAddress.None))
{
return address.Equals(Address);
}
return false;
}
/// <inheritdoc/>
public override string ToString()
{
return ToString(false);
}
/// <summary>
/// Returns a textual representation of this object.
/// </summary>
/// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param>
/// <returns>String representation of this object.</returns>
public string ToString(bool shortVersion)
{
if (!Address.Equals(IPAddress.None))
{
if (Address.Equals(IPAddress.Any))
{
return "Any IP4 Address";
}
if (Address.Equals(IPAddress.IPv6Any))
{
return "Any IP6 Address";
}
if (Address.Equals(IPAddress.Broadcast))
{
return "Any Address";
}
if (shortVersion)
{
return Address.ToString();
}
return $"{Address}/{PrefixLength}";
}
return string.Empty;
}
/// <inheritdoc/>
protected override IPObject CalculateNetworkAddress()
{
var value = NetworkAddressOf(_address, PrefixLength);
return new IPNetAddress(value.Address, value.PrefixLength);
}
}
}

View File

@ -0,0 +1,406 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
namespace MediaBrowser.Common.Net
{
/// <summary>
/// Base network object class.
/// </summary>
public abstract class IPObject : IEquatable<IPObject>
{
/// <summary>
/// IPv6 Loopback address.
/// </summary>
protected static readonly byte[] Ipv6Loopback = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
/// <summary>
/// IPv4 Loopback address.
/// </summary>
protected static readonly byte[] Ipv4Loopback = { 127, 0, 0, 1 };
/// <summary>
/// The network address of this object.
/// </summary>
private IPObject? _networkAddress;
/// <summary>
/// Gets or sets a user defined value that is associated with this object.
/// </summary>
public int Tag { get; set; }
/// <summary>
/// Gets or sets the object's IP address.
/// </summary>
public abstract IPAddress Address { get; set; }
/// <summary>
/// Gets the object's network address.
/// </summary>
public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress();
/// <summary>
/// Gets or sets the object's IP address.
/// </summary>
public abstract byte PrefixLength { get; set; }
/// <summary>
/// Gets the AddressFamily of this object.
/// </summary>
public AddressFamily AddressFamily
{
get
{
// Keep terms separate as Address performs other functions in inherited objects.
IPAddress address = Address;
return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily;
}
}
/// <summary>
/// Returns the network address of an object.
/// </summary>
/// <param name="address">IP Address to convert.</param>
/// <param name="prefixLength">Subnet prefix.</param>
/// <returns>IPAddress.</returns>
public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IsLoopback(address))
{
return (Address: address, PrefixLength: prefixLength);
}
// An ip address is just a list of bytes, each one representing a segment on the network.
// This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the
// prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out.
// Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept.
// GetAddressBytes
Span<byte> addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
address.TryWriteBytes(addressBytes, out _);
int div = prefixLength / 8;
int mod = prefixLength % 8;
if (mod != 0)
{
// Prefix length is counted right to left, so subtract 8 so we know how many bits to clear.
mod = 8 - mod;
// Shift out the bits from the octet that we don't want, by moving right then back left.
addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod);
// Move on the next byte.
div++;
}
// Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0)
for (int octet = div; octet < addressBytes.Length; octet++)
{
addressBytes[octet] = 0;
}
// Return the network address for the prefix.
return (Address: new IPAddress(addressBytes), PrefixLength: prefixLength);
}
/// <summary>
/// Tests to see if the ip address is a Loopback address.
/// </summary>
/// <param name="address">Value to test.</param>
/// <returns>True if it is.</returns>
public static bool IsLoopback(IPAddress address)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (!address.Equals(IPAddress.None))
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
return address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback);
}
return false;
}
/// <summary>
/// Tests to see if the ip address is an IP6 address.
/// </summary>
/// <param name="address">Value to test.</param>
/// <returns>True if it is.</returns>
public static bool IsIP6(IPAddress address)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6);
}
/// <summary>
/// Tests to see if the address in the private address range.
/// </summary>
/// <param name="address">Object to test.</param>
/// <returns>True if it contains a private address.</returns>
public static bool IsPrivateAddressRange(IPAddress address)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (!address.Equals(IPAddress.None))
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
// GetAddressBytes
Span<byte> octet = stackalloc byte[4];
address.TryWriteBytes(octet, out _);
return (octet[0] == 10)
|| (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918
|| (octet[0] == 192 && octet[1] == 168) // RFC1918
|| (octet[0] == 127); // RFC1122
}
else
{
// GetAddressBytes
Span<byte> octet = stackalloc byte[16];
address.TryWriteBytes(octet, out _);
uint word = (uint)(octet[0] << 8) + octet[1];
return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link.
|| (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address.
}
}
return false;
}
/// <summary>
/// Returns true if the IPAddress contains an IP6 Local link address.
/// </summary>
/// <param name="address">IPAddress object to check.</param>
/// <returns>True if it is a local link address.</returns>
/// <remarks>
/// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress
/// it appears that the IPAddress.IsIPv6LinkLocal is out of date.
/// </remarks>
public static bool IsIPv6LinkLocal(IPAddress address)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address));
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily != AddressFamily.InterNetworkV6)
{
return false;
}
// GetAddressBytes
Span<byte> octet = stackalloc byte[16];
address.TryWriteBytes(octet, out _);
uint word = (uint)(octet[0] << 8) + octet[1];
return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link.
}
/// <summary>
/// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only.
/// </summary>
/// <param name="cidr">Subnet mask in CIDR notation.</param>
/// <param name="family">IPv4 or IPv6 family.</param>
/// <returns>String value of the subnet mask in dotted decimal notation.</returns>
public static IPAddress CidrToMask(byte cidr, AddressFamily family)
{
uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr);
addr = ((addr & 0xff000000) >> 24)
| ((addr & 0x00ff0000) >> 8)
| ((addr & 0x0000ff00) << 8)
| ((addr & 0x000000ff) << 24);
return new IPAddress(addr);
}
/// <summary>
/// Convert a mask to a CIDR. IPv4 only.
/// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask.
/// </summary>
/// <param name="mask">Subnet mask.</param>
/// <returns>Byte CIDR representing the mask.</returns>
public static byte MaskToCidr(IPAddress mask)
{
if (mask == null)
{
throw new ArgumentNullException(nameof(mask));
}
byte cidrnet = 0;
if (!mask.Equals(IPAddress.Any))
{
// GetAddressBytes
Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
mask.TryWriteBytes(bytes, out _);
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;
}
/// <summary>
/// Tests to see if this object is a Loopback address.
/// </summary>
/// <returns>True if it is.</returns>
public virtual bool IsLoopback()
{
return IsLoopback(Address);
}
/// <summary>
/// Removes all addresses of a specific type from this object.
/// </summary>
/// <param name="family">Type of address to remove.</param>
public virtual void Remove(AddressFamily family)
{
// This method only performs a function in the IPHost implementation of IPObject.
}
/// <summary>
/// Tests to see if this object is an IPv6 address.
/// </summary>
/// <returns>True if it is.</returns>
public virtual bool IsIP6()
{
return IsIP6(Address);
}
/// <summary>
/// Returns true if this IP address is in the RFC private address range.
/// </summary>
/// <returns>True this object has a private address.</returns>
public virtual bool IsPrivateAddressRange()
{
return IsPrivateAddressRange(Address);
}
/// <summary>
/// Compares this to the object passed as a parameter.
/// </summary>
/// <param name="ip">Object to compare to.</param>
/// <returns>Equality result.</returns>
public virtual bool Equals(IPAddress ip)
{
if (ip != null)
{
if (ip.IsIPv4MappedToIPv6)
{
ip = ip.MapToIPv4();
}
return !Address.Equals(IPAddress.None) && Address.Equals(ip);
}
return false;
}
/// <summary>
/// Compares this to the object passed as a parameter.
/// </summary>
/// <param name="other">Object to compare to.</param>
/// <returns>Equality result.</returns>
public virtual bool Equals(IPObject? other)
{
if (other != null)
{
return !Address.Equals(IPAddress.None) && Address.Equals(other.Address);
}
return false;
}
/// <summary>
/// Compares the address in this object and the address in the object passed as a parameter.
/// </summary>
/// <param name="address">Object's IP address to compare to.</param>
/// <returns>Comparison result.</returns>
public abstract bool Contains(IPObject address);
/// <summary>
/// Compares the address in this object and the address in the object passed as a parameter.
/// </summary>
/// <param name="address">Object's IP address to compare to.</param>
/// <returns>Comparison result.</returns>
public abstract bool Contains(IPAddress address);
/// <inheritdoc/>
public override int GetHashCode()
{
return Address.GetHashCode();
}
/// <inheritdoc/>
public override bool Equals(object? obj)
{
return Equals(obj as IPObject);
}
/// <summary>
/// Calculates the network address of this object.
/// </summary>
/// <returns>Returns the network address of this object.</returns>
protected abstract IPObject CalculateNetworkAddress();
}
}

View File

@ -0,0 +1,262 @@
#pragma warning disable CA1062 // Validate arguments of public methods
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Net;
using System.Runtime.CompilerServices;
using System.Text;
namespace MediaBrowser.Common.Net
{
/// <summary>
/// Defines the <see cref="NetworkExtensions" />.
/// </summary>
public static class NetworkExtensions
{
/// <summary>
/// Add an address to the collection.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="ip">Item to add.</param>
public static void AddItem(this Collection<IPObject> source, IPAddress ip)
{
if (!source.ContainsAddress(ip))
{
source.Add(new IPNetAddress(ip, 32));
}
}
/// <summary>
/// Adds a network to the collection.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="item">Item to add.</param>
public static void AddItem(this Collection<IPObject> source, IPObject item)
{
if (!source.ContainsAddress(item))
{
source.Add(item);
}
}
/// <summary>
/// Converts this object to a string.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <returns>Returns a string representation of this object.</returns>
public static string AsString(this Collection<IPObject> source)
{
return $"[{string.Join(',', source)}]";
}
/// <summary>
/// Returns true if the collection contains an item with the ip address,
/// or the ip address falls within any of the collection's network ranges.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="item">The item to look for.</param>
/// <returns>True if the collection contains the item.</returns>
public static bool ContainsAddress(this Collection<IPObject> source, IPAddress item)
{
if (source.Count == 0)
{
return false;
}
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}
if (item.IsIPv4MappedToIPv6)
{
item = item.MapToIPv4();
}
foreach (var i in source)
{
if (i.Contains(item))
{
return true;
}
}
return false;
}
/// <summary>
/// Returns true if the collection contains an item with the ip address,
/// or the ip address falls within any of the collection's network ranges.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="item">The item to look for.</param>
/// <returns>True if the collection contains the item.</returns>
public static bool ContainsAddress(this Collection<IPObject> source, IPObject item)
{
if (source.Count == 0)
{
return false;
}
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}
foreach (var i in source)
{
if (i.Contains(item))
{
return true;
}
}
return false;
}
/// <summary>
/// Compares two Collection{IPObject} objects. The order is ignored.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="dest">Item to compare to.</param>
/// <returns>True if both are equal.</returns>
public static bool Compare(this Collection<IPObject> source, Collection<IPObject> dest)
{
if (dest == null || source.Count != dest.Count)
{
return false;
}
foreach (var sourceItem in source)
{
bool found = false;
foreach (var destItem in dest)
{
if (sourceItem.Equals(destItem))
{
found = true;
break;
}
}
if (!found)
{
return false;
}
}
return true;
}
/// <summary>
/// Returns a collection containing the subnets of this collection given.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <returns>Collection{IPObject} object containing the subnets.</returns>
public static Collection<IPObject> AsNetworks(this Collection<IPObject> source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
Collection<IPObject> res = new Collection<IPObject>();
foreach (IPObject i in source)
{
if (i is IPNetAddress nw)
{
// Add the subnet calculated from the interface address/mask.
var na = nw.NetworkAddress;
na.Tag = i.Tag;
res.AddItem(na);
}
else if (i is IPHost ipHost)
{
// Flatten out IPHost and add all its ip addresses.
foreach (var addr in ipHost.GetAddresses())
{
IPNetAddress host = new IPNetAddress(addr)
{
Tag = i.Tag
};
res.AddItem(host);
}
}
}
return res;
}
/// <summary>
/// Excludes all the items from this list that are found in excludeList.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="excludeList">Items to exclude.</param>
/// <returns>A new collection, with the items excluded.</returns>
public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList)
{
if (source.Count == 0 || excludeList == null)
{
return new Collection<IPObject>(source);
}
Collection<IPObject> results = new Collection<IPObject>();
bool found;
foreach (var outer in source)
{
found = false;
foreach (var inner in excludeList)
{
if (outer.Equals(inner))
{
found = true;
break;
}
}
if (!found)
{
results.AddItem(outer);
}
}
return results;
}
/// <summary>
/// Returns all items that co-exist in this object and target.
/// </summary>
/// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
/// <param name="target">Collection to compare with.</param>
/// <returns>A collection containing all the matches.</returns>
public static Collection<IPObject> Union(this Collection<IPObject> source, Collection<IPObject> target)
{
if (source.Count == 0)
{
return new Collection<IPObject>();
}
if (target == null)
{
throw new ArgumentNullException(nameof(target));
}
Collection<IPObject> nc = new Collection<IPObject>();
foreach (IPObject i in source)
{
if (target.ContainsAddress(i))
{
nc.AddItem(i);
}
}
return nc;
}
}
}

View File

@ -247,7 +247,23 @@ namespace MediaBrowser.Common.Plugins
} }
catch catch
{ {
return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
SaveConfiguration(config);
return config;
}
}
/// <summary>
/// Saves the current configuration to the file system.
/// </summary>
/// <param name="config">Configuration to save.</param>
public virtual void SaveConfiguration(TConfigurationType config)
{
lock (_configurationSaveLock)
{
_directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
} }
} }
@ -256,12 +272,7 @@ namespace MediaBrowser.Common.Plugins
/// </summary> /// </summary>
public virtual void SaveConfiguration() public virtual void SaveConfiguration()
{ {
lock (_configurationSaveLock) SaveConfiguration(Configuration);
{
_directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath);
}
} }
/// <inheritdoc /> /// <inheritdoc />
@ -274,7 +285,7 @@ namespace MediaBrowser.Common.Plugins
Configuration = (TConfigurationType)configuration; Configuration = (TConfigurationType)configuration;
SaveConfiguration(); SaveConfiguration(Configuration);
ConfigurationChanged?.Invoke(this, configuration); ConfigurationChanged?.Invoke(this, configuration);
} }

View File

@ -19,10 +19,11 @@ namespace MediaBrowser.Common.Updates
/// <summary> /// <summary>
/// Parses a plugin manifest at the supplied URL. /// Parses a plugin manifest at the supplied URL.
/// </summary> /// </summary>
/// <param name="manifestName">Name of the repository.</param>
/// <param name="manifest">The URL to query.</param> /// <param name="manifest">The URL to query.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task{IReadOnlyList{PackageInfo}}.</returns> /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default); Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all available packages. /// Gets all available packages.
@ -37,11 +38,13 @@ namespace MediaBrowser.Common.Updates
/// <param name="availablePackages">The available packages.</param> /// <param name="availablePackages">The available packages.</param>
/// <param name="name">The name of the plugin.</param> /// <param name="name">The name of the plugin.</param>
/// <param name="guid">The id of the plugin.</param> /// <param name="guid">The id of the plugin.</param>
/// <param name="specificVersion">The version of the plugin.</param>
/// <returns>All plugins matching the requirements.</returns> /// <returns>All plugins matching the requirements.</returns>
IEnumerable<PackageInfo> FilterPackages( IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages, IEnumerable<PackageInfo> availablePackages,
string name = null, string name = null,
Guid guid = default); Guid guid = default,
Version specificVersion = null);
/// <summary> /// <summary>
/// Returns all compatible versions ordered from newest to oldest. /// Returns all compatible versions ordered from newest to oldest.

View File

@ -1099,12 +1099,12 @@ namespace MediaBrowser.Controller.Entities
return false; return false;
} }
if (request.Genres.Length > 0) if (request.Genres.Count > 0)
{ {
return false; return false;
} }
if (request.GenreIds.Length > 0) if (request.GenreIds.Count > 0)
{ {
return false; return false;
} }
@ -1209,7 +1209,7 @@ namespace MediaBrowser.Controller.Entities
return false; return false;
} }
if (request.GenreIds.Length > 0) if (request.GenreIds.Count > 0)
{ {
return false; return false;
} }

View File

@ -46,7 +46,7 @@ namespace MediaBrowser.Controller.Entities
public string[] ExcludeInheritedTags { get; set; } public string[] ExcludeInheritedTags { get; set; }
public string[] Genres { get; set; } public IReadOnlyList<string> Genres { get; set; }
public bool? IsSpecialSeason { get; set; } public bool? IsSpecialSeason { get; set; }
@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.Entities
public Guid[] StudioIds { get; set; } public Guid[] StudioIds { get; set; }
public Guid[] GenreIds { get; set; } public IReadOnlyList<Guid> GenreIds { get; set; }
public ImageType[] ImageTypes { get; set; } public ImageType[] ImageTypes { get; set; }
@ -162,7 +162,7 @@ namespace MediaBrowser.Controller.Entities
public double? MinCommunityRating { get; set; } public double? MinCommunityRating { get; set; }
public Guid[] ChannelIds { get; set; } public IReadOnlyList<Guid> ChannelIds { get; set; }
public int? ParentIndexNumber { get; set; } public int? ParentIndexNumber { get; set; }

View File

@ -791,7 +791,7 @@ namespace MediaBrowser.Controller.Entities
} }
// Apply genre filter // Apply genre filter
if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
{ {
return false; return false;
} }
@ -822,7 +822,7 @@ namespace MediaBrowser.Controller.Entities
} }
// Apply genre filter // Apply genre filter
if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id => if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id =>
{ {
var genreItem = libraryManager.GetItemById(id); var genreItem = libraryManager.GetItemById(id);
return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase); return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase);

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
public class EncodingHelper public class EncodingHelper
{ {
private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly IMediaEncoder _mediaEncoder; private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem; private readonly IFileSystem _fileSystem;
@ -440,6 +441,12 @@ namespace MediaBrowser.Controller.MediaEncoding
return "libopus"; return "libopus";
} }
if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase))
{
// flac is experimental in mp4 muxer
return "flac -strict -2";
}
return codec.ToLowerInvariant(); return codec.ToLowerInvariant();
} }
@ -573,7 +580,7 @@ namespace MediaBrowser.Controller.MediaEncoding
/// </summary> /// </summary>
/// <param name="stream">The stream.</param> /// <param name="stream">The stream.</param>
/// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns> /// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns>
public bool IsH264(MediaStream stream) public static bool IsH264(MediaStream stream)
{ {
var codec = stream.Codec ?? string.Empty; var codec = stream.Codec ?? string.Empty;
@ -581,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1;
} }
public bool IsH265(MediaStream stream) public static bool IsH265(MediaStream stream)
{ {
var codec = stream.Codec ?? string.Empty; var codec = stream.Codec ?? string.Empty;
@ -589,10 +596,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1; || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1;
} }
// TODO This is auto inserted into the mpegts mux so it might not be needed public static bool IsAAC(MediaStream stream)
// https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
public string GetBitStreamArgs(MediaStream stream)
{ {
var codec = stream.Codec ?? string.Empty;
return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1;
}
public static string GetBitStreamArgs(MediaStream stream)
{
// TODO This is auto inserted into the mpegts mux so it might not be needed.
// https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
if (IsH264(stream)) if (IsH264(stream))
{ {
return "-bsf:v h264_mp4toannexb"; return "-bsf:v h264_mp4toannexb";
@ -601,12 +615,44 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
return "-bsf:v hevc_mp4toannexb"; return "-bsf:v hevc_mp4toannexb";
} }
else if (IsAAC(stream))
{
// Convert adts header(mpegts) to asc header(mp4).
return "-bsf:a aac_adtstoasc";
}
else else
{ {
return null; return null;
} }
} }
public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
{
var bitStreamArgs = string.Empty;
var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
// Apply aac_adtstoasc bitstream filter when media source is in mpegts.
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase)
|| string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
{
bitStreamArgs = GetBitStreamArgs(state.AudioStream);
bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
}
return bitStreamArgs;
}
public static string GetSegmentFileExtension(string segmentContainer)
{
if (!string.IsNullOrWhiteSpace(segmentContainer))
{
return "." + segmentContainer;
}
return ".ts";
}
public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
{ {
var bitrate = state.OutputVideoBitrate; var bitrate = state.OutputVideoBitrate;
@ -654,16 +700,30 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Empty; return string.Empty;
} }
public string NormalizeTranscodingLevel(string videoCodec, string level) public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
{ {
// Clients may direct play higher than level 41, but there's no reason to transcode higher if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel))
if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)
&& requestLevel > 41
&& (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)))
{ {
return "41"; if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
{
// Transcode to level 5.0 and lower for maximum compatibility.
// Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
// https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
// MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
if (requestLevel >= 150)
{
return "150";
}
}
else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
{
// Clients may direct play higher than level 41, but there's no reason to transcode higher.
if (requestLevel >= 41)
{
return "41";
}
}
} }
return level; return level;
@ -766,6 +826,72 @@ namespace MediaBrowser.Controller.MediaEncoding
return null; return null;
} }
public string GetHlsVideoKeyFrameArguments(
EncodingJobInfo state,
string codec,
int segmentLength,
bool isEventPlaylist,
int? startNumber)
{
var args = string.Empty;
var gopArg = string.Empty;
var keyFrameArg = string.Empty;
if (isEventPlaylist)
{
keyFrameArg = string.Format(
CultureInfo.InvariantCulture,
" -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"",
segmentLength);
}
else if (startNumber.HasValue)
{
keyFrameArg = string.Format(
CultureInfo.InvariantCulture,
" -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
startNumber.Value * segmentLength,
segmentLength);
}
var framerate = state.VideoStream?.RealFrameRate;
if (framerate.HasValue)
{
// This is to make sure keyframe interval is limited to our segment,
// as forcing keyframes is not enough.
// Example: we encoded half of desired length, then codec detected
// scene cut and inserted a keyframe; next forced keyframe would
// be created outside of segment, which breaks seeking.
// -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
gopArg = string.Format(
CultureInfo.InvariantCulture,
" -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0",
Math.Ceiling(segmentLength * framerate.Value));
}
// Unable to force key frames using these encoders, set key frames by GOP.
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
args += gopArg;
}
else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
{
args += " " + keyFrameArg;
}
else
{
args += " " + keyFrameArg + gopArg;
}
return args;
}
/// <summary> /// <summary>
/// Gets the video bitrate to specify on the command line. /// Gets the video bitrate to specify on the command line.
/// </summary> /// </summary>
@ -773,6 +899,47 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
var param = string.Empty; var param = string.Empty;
if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
param += " -pix_fmt yuv420p";
}
if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
var videoStream = state.VideoStream;
var isColorDepth10 = IsColorDepth10(state);
if (isColorDepth10
&& _mediaEncoder.SupportsHwaccel("opencl")
&& encodingOptions.EnableTonemapping
&& !string.IsNullOrEmpty(videoStream.VideoRange)
&& videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
{
param += " -pix_fmt nv12";
}
else
{
param += " -pix_fmt yuv420p";
}
}
if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{
param += " -pix_fmt nv21";
}
var isVc1 = state.VideoStream != null && var isVc1 = state.VideoStream != null &&
string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase); string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase);
var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase);
@ -781,11 +948,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset)) if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset))
{ {
param += "-preset " + encodingOptions.EncoderPreset; param += " -preset " + encodingOptions.EncoderPreset;
} }
else else
{ {
param += "-preset " + defaultPreset; param += " -preset " + defaultPreset;
} }
int encodeCrf = encodingOptions.H264Crf; int encodeCrf = encodingOptions.H264Crf;
@ -809,38 +976,40 @@ namespace MediaBrowser.Controller.MediaEncoding
param += " -crf " + defaultCrf; param += " -crf " + defaultCrf;
} }
} }
else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv) else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv)
|| string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv)
{ {
string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" };
if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase)) if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase))
{ {
param += "-preset " + encodingOptions.EncoderPreset; param += " -preset " + encodingOptions.EncoderPreset;
} }
else else
{ {
param += "-preset 7"; param += " -preset 7";
} }
param += " -look_ahead 0"; param += " -look_ahead 0";
} }
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc)
|| string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc)
{ {
// following preset will be deprecated in ffmpeg 4.4, use p1~p7 instead.
switch (encodingOptions.EncoderPreset) switch (encodingOptions.EncoderPreset)
{ {
case "veryslow": case "veryslow":
param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+) param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+)
break; break;
case "slow": case "slow":
case "slower": case "slower":
param += "-preset slow"; param += " -preset slow";
break; break;
case "medium": case "medium":
param += "-preset medium"; param += " -preset medium";
break; break;
case "fast": case "fast":
@ -848,27 +1017,27 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast": case "veryfast":
case "superfast": case "superfast":
case "ultrafast": case "ultrafast":
param += "-preset fast"; param += " -preset fast";
break; break;
default: default:
param += "-preset default"; param += " -preset default";
break; break;
} }
} }
else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf)
|| string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf)
{ {
switch (encodingOptions.EncoderPreset) switch (encodingOptions.EncoderPreset)
{ {
case "veryslow": case "veryslow":
case "slow": case "slow":
case "slower": case "slower":
param += "-quality quality"; param += " -quality quality";
break; break;
case "medium": case "medium":
param += "-quality balanced"; param += " -quality balanced";
break; break;
case "fast": case "fast":
@ -876,11 +1045,11 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast": case "veryfast":
case "superfast": case "superfast":
case "ultrafast": case "ultrafast":
param += "-quality speed"; param += " -quality speed";
break; break;
default: default:
param += "-quality speed"; param += " -quality speed";
break; break;
} }
@ -896,6 +1065,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// Enhance workload when tone mapping with AMF on some APUs // Enhance workload when tone mapping with AMF on some APUs
param += " -preanalysis true"; param += " -preanalysis true";
} }
if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
param += " -header_insertion_mode gop -gops_per_idr 1";
}
} }
else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm
{ {
@ -917,7 +1091,7 @@ namespace MediaBrowser.Controller.MediaEncoding
profileScore = Math.Min(profileScore, 2); profileScore = Math.Min(profileScore, 2);
// http://www.webmproject.org/docs/encoder-parameters/ // http://www.webmproject.org/docs/encoder-parameters/
param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", param += string.Format(CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
profileScore.ToString(_usCulture), profileScore.ToString(_usCulture),
crf, crf,
qmin, qmin,
@ -925,15 +1099,15 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase))
{ {
param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
} }
else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv
{ {
param += "-qmin 2"; param += " -qmin 2";
} }
else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase))
{ {
param += "-mbd 2"; param += " -mbd 2";
} }
param += GetVideoBitrateParam(state, videoEncoder); param += GetVideoBitrateParam(state, videoEncoder);
@ -945,11 +1119,25 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
var targetVideoCodec = state.ActualOutputVideoCodec; var targetVideoCodec = state.ActualOutputVideoCodec;
if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{
targetVideoCodec = "hevc";
}
var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault(); var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault();
profile = Regex.Replace(profile, @"\s+", String.Empty);
// vaapi does not support Baseline profile, force Constrained Baseline in this case, // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile.
// which is compatible (and ugly) if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
&& profile != null
&& profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1)
{
profile = "high";
}
// h264_vaapi does not support Baseline profile, force Constrained Baseline in this case,
// which is compatible (and ugly).
if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
&& profile != null && profile != null
&& profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
@ -957,13 +1145,31 @@ namespace MediaBrowser.Controller.MediaEncoding
profile = "constrained_baseline"; profile = "constrained_baseline";
} }
// libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case.
if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
&& profile != null
&& profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
{
profile = "baseline";
}
// Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile.
if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
&& profile != null
&& profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1)
{
profile = "main";
}
if (!string.IsNullOrEmpty(profile)) if (!string.IsNullOrEmpty(profile))
{ {
if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{ {
// not supported by h264_omx // not supported by h264_omx
param += " -profile:v " + profile; param += " -profile:v:0 " + profile;
} }
} }
@ -971,55 +1177,35 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(level)) if (!string.IsNullOrEmpty(level))
{ {
level = NormalizeTranscodingLevel(state.OutputVideoCodec, level); level = NormalizeTranscodingLevel(state, level);
// h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format // libx264, QSV, AMF, VAAPI can adjust the given level to match the output.
// also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|| string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{ {
switch (level) param += " -level " + level;
}
else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{
// hevc_qsv use -level 51 instead of -level 153.
if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel))
{ {
case "30": param += " -level " + hevcLevel / 3;
param += " -level 3.0";
break;
case "31":
param += " -level 3.1";
break;
case "32":
param += " -level 3.2";
break;
case "40":
param += " -level 4.0";
break;
case "41":
param += " -level 4.1";
break;
case "42":
param += " -level 4.2";
break;
case "50":
param += " -level 5.0";
break;
case "51":
param += " -level 5.1";
break;
case "52":
param += " -level 5.2";
break;
default:
param += " -level " + level;
break;
} }
} }
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{ {
// nvenc doesn't decode with param -level set ?! param += " -level " + level;
// TODO:
} }
else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
{
// level option may cause NVENC to fail.
// NVENC cannot adjust the given level, just throw an error.
}
else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{ {
param += " -level " + level; param += " -level " + level;
} }
@ -1032,42 +1218,11 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{ {
// todo // libx265 only accept level option in -x265-params.
} // level option may cause libx265 to fail.
// libx265 cannot adjust the given level, just throw an error.
if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) // TODO: set fine tuned params.
&& !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) param += " -x265-params:0 no-info=1";
&& !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{
param = "-pix_fmt yuv420p " + param;
}
if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase))
{
var videoStream = state.VideoStream;
var isColorDepth10 = IsColorDepth10(state);
if (isColorDepth10
&& _mediaEncoder.SupportsHwaccel("opencl")
&& encodingOptions.EnableTonemapping
&& !string.IsNullOrEmpty(videoStream.VideoRange)
&& videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
{
param = "-pix_fmt nv12 " + param;
}
else
{
param = "-pix_fmt yuv420p " + param;
}
}
if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{
param = "-pix_fmt nv21 " + param;
} }
return param; return param;
@ -1346,7 +1501,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
{ {
return .5; return .6;
} }
return 1; return 1;
@ -1380,36 +1535,48 @@ namespace MediaBrowser.Controller.MediaEncoding
public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream) public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
{ {
if (audioStream == null) return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream);
{
return null;
}
if (request.AudioBitRate.HasValue)
{
// Don't encode any higher than this
return Math.Min(384000, request.AudioBitRate.Value);
}
// Empty bitrate area is not allow on iOS
// Default audio bitrate to 128K if it is not being requested
// https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
return 128000;
} }
public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream)
{ {
if (audioStream == null) if (audioStream == null)
{ {
return null; return null;
} }
if (audioBitRate.HasValue) if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec))
{ {
// Don't encode any higher than this
return Math.Min(384000, audioBitRate.Value); return Math.Min(384000, audioBitRate.Value);
} }
if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec))
{
if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
|| string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
|| string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
{
if ((audioStream.Channels ?? 0) >= 6)
{
return Math.Min(640000, audioBitRate.Value);
}
return Math.Min(384000, audioBitRate.Value);
}
if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
{
if ((audioStream.Channels ?? 0) >= 6)
{
return Math.Min(3584000, audioBitRate.Value);
}
return Math.Min(1536000, audioBitRate.Value);
}
}
// Empty bitrate area is not allow on iOS // Empty bitrate area is not allow on iOS
// Default audio bitrate to 128K if it is not being requested // Default audio bitrate to 128K if it is not being requested
// https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
@ -1447,7 +1614,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (filters.Count > 0) if (filters.Count > 0)
{ {
return "-af \"" + string.Join(",", filters) + "\""; return " -af \"" + string.Join(",", filters) + "\"";
} }
return string.Empty; return string.Empty;
@ -1462,6 +1629,11 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <returns>System.Nullable{System.Int32}.</returns> /// <returns>System.Nullable{System.Int32}.</returns>
public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec) public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec)
{ {
if (audioStream == null)
{
return null;
}
var request = state.BaseRequest; var request = state.BaseRequest;
var inputChannels = audioStream?.Channels; var inputChannels = audioStream?.Channels;
@ -1484,6 +1656,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// libmp3lame currently only supports two channel output // libmp3lame currently only supports two channel output
transcoderChannelLimit = 2; transcoderChannelLimit = 2;
} }
else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1)
{
// aac is able to handle 8ch(7.1 layout)
transcoderChannelLimit = 8;
}
else else
{ {
// If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels
@ -1708,7 +1885,8 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
// For QSV, feed it into hardware encoder now // For QSV, feed it into hardware encoder now
if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)))
{ {
videoSizeParam += ",hwupload=extra_hw_frames=64"; videoSizeParam += ",hwupload=extra_hw_frames=64";
} }
@ -1729,7 +1907,8 @@ namespace MediaBrowser.Controller.MediaEncoding
: " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"";
// When the input may or may not be hardware VAAPI decodable // When the input may or may not be hardware VAAPI decodable
if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
|| string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
{ {
/* /*
[base]: HW scaling video to OutputSize [base]: HW scaling video to OutputSize
@ -1741,7 +1920,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// If we're hardware VAAPI decoding and software encoding, download frames from the decoder first // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1 else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1
&& string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)) && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)
|| string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase)))
{ {
/* /*
[base]: SW scaling video to OutputSize [base]: SW scaling video to OutputSize
@ -1750,7 +1930,8 @@ namespace MediaBrowser.Controller.MediaEncoding
*/ */
retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\""; retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"";
} }
else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{ {
/* /*
QSV in FFMpeg can now setup hardware overlay for transcodes. QSV in FFMpeg can now setup hardware overlay for transcodes.
@ -1776,7 +1957,7 @@ namespace MediaBrowser.Controller.MediaEncoding
videoSizeParam); videoSizeParam);
} }
private (int? width, int? height) GetFixedOutputSize( public static (int? width, int? height) GetFixedOutputSize(
int? videoWidth, int? videoWidth,
int? videoHeight, int? videoHeight,
int? requestedWidth, int? requestedWidth,
@ -1836,7 +2017,9 @@ namespace MediaBrowser.Controller.MediaEncoding
requestedMaxHeight); requestedMaxHeight);
if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
&& width.HasValue && width.HasValue
&& height.HasValue) && height.HasValue)
{ {
@ -1845,7 +2028,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// output dimensions. Output dimensions are guaranteed to be even. // output dimensions. Output dimensions are guaranteed to be even.
var outputWidth = width.Value; var outputWidth = width.Value;
var outputHeight = height.Value; var outputHeight = height.Value;
var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase); var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase);
var isDeintEnabled = state.DeInterlace("h264", true) var isDeintEnabled = state.DeInterlace("h264", true)
|| state.DeInterlace("avc", true) || state.DeInterlace("avc", true)
|| state.DeInterlace("h265", true) || state.DeInterlace("h265", true)
@ -2107,10 +2291,13 @@ namespace MediaBrowser.Controller.MediaEncoding
var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1; var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1; var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1; var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1; var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1; var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1;
var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1;
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var isColorDepth10 = IsColorDepth10(state); var isColorDepth10 = IsColorDepth10(state);
@ -2185,6 +2372,7 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add("hwdownload"); filters.Add("hwdownload");
if (isLibX264Encoder if (isLibX264Encoder
|| isLibX265Encoder
|| hasGraphicalSubs || hasGraphicalSubs
|| (isNvdecHevcDecoder && isDeinterlaceHevc) || (isNvdecHevcDecoder && isDeinterlaceHevc)
|| (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc)) || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
@ -2195,20 +2383,20 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
// When the input may or may not be hardware VAAPI decodable // When the input may or may not be hardware VAAPI decodable
if (isVaapiH264Encoder) if (isVaapiH264Encoder || isVaapiHevcEncoder)
{ {
filters.Add("format=nv12|vaapi"); filters.Add("format=nv12|vaapi");
filters.Add("hwupload"); filters.Add("hwupload");
} }
// When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context
else if (isLinux && hasGraphicalSubs && isQsvH264Encoder) else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder))
{ {
filters.Add("hwupload=extra_hw_frames=64"); filters.Add("hwupload=extra_hw_frames=64");
} }
// If we're hardware VAAPI decoding and software encoding, download frames from the decoder first // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder) else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder))
{ {
var codec = videoStream.Codec.ToLowerInvariant(); var codec = videoStream.Codec.ToLowerInvariant();
@ -2250,7 +2438,9 @@ namespace MediaBrowser.Controller.MediaEncoding
// Add software deinterlace filter before scaling filter // Add software deinterlace filter before scaling filter
if ((isDeinterlaceH264 || isDeinterlaceHevc) if ((isDeinterlaceH264 || isDeinterlaceHevc)
&& !isVaapiH264Encoder && !isVaapiH264Encoder
&& !isVaapiHevcEncoder
&& !isQsvH264Encoder && !isQsvH264Encoder
&& !isQsvHevcEncoder
&& !isNvdecH264Decoder) && !isNvdecH264Decoder)
{ {
if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase)) if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase))
@ -2289,7 +2479,7 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
// Add parameters to use VAAPI with burn-in text subtitles (GH issue #642) // Add parameters to use VAAPI with burn-in text subtitles (GH issue #642)
if (isVaapiH264Encoder) if (isVaapiH264Encoder || isVaapiHevcEncoder)
{ {
if (hasTextSubs) if (hasTextSubs)
{ {
@ -2562,6 +2752,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public void AttachMediaSourceInfo( public void AttachMediaSourceInfo(
EncodingJobInfo state, EncodingJobInfo state,
EncodingOptions encodingOptions,
MediaSourceInfo mediaSource, MediaSourceInfo mediaSource,
string requestedUrl) string requestedUrl)
{ {
@ -2692,11 +2883,23 @@ namespace MediaBrowser.Controller.MediaEncoding
request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i)) request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i))
?? state.SupportedAudioCodecs.FirstOrDefault(); ?? state.SupportedAudioCodecs.FirstOrDefault();
} }
var supportedVideoCodecs = state.SupportedVideoCodecs;
if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0)
{
var supportedVideoCodecsList = supportedVideoCodecs.ToList();
ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions);
state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray();
request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
}
} }
private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream) private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream)
{ {
// Nothing to do here // No need to shift if there is only one supported audio codec.
if (audioCodecs.Count < 2) if (audioCodecs.Count < 2)
{ {
return; return;
@ -2724,6 +2927,34 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
} }
private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions)
{
// Shift hevc/h265 to the end of list if hevc encoding is not allowed.
if (encodingOptions.AllowHevcEncoding)
{
return;
}
// No need to shift if there is only one supported video codec.
if (videoCodecs.Count < 2)
{
return;
}
var shiftVideoCodecs = new[] { "hevc", "h265" };
if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase)))
{
return;
}
while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase))
{
var removed = shiftVideoCodecs[0];
videoCodecs.RemoveAt(0);
videoCodecs.Add(removed);
}
}
private void NormalizeSubtitleEmbed(EncodingJobInfo state) private void NormalizeSubtitleEmbed(EncodingJobInfo state)
{ {
if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed) if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed)
@ -3357,7 +3588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture); args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture);
} }
args += " " + GetAudioFilterParam(state, encodingOptions, false); args += GetAudioFilterParam(state, encodingOptions, false);
return args; return args;
} }

View File

@ -593,6 +593,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
get get
{ {
if (VideoStream == null)
{
return null;
}
if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
{ {
return VideoStream?.Codec; return VideoStream?.Codec;
@ -606,6 +611,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
get get
{ {
if (AudioStream == null)
{
return null;
}
if (EncodingHelper.IsCopyCodec(OutputAudioCodec)) if (EncodingHelper.IsCopyCodec(OutputAudioCodec))
{ {
return AudioStream?.Codec; return AudioStream?.Codec;

View File

@ -31,7 +31,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="itemIds">The item ids.</param> /// <param name="itemIds">The item ids.</param>
/// <param name="userId">The user identifier.</param> /// <param name="userId">The user identifier.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId); Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
/// <summary> /// <summary>
/// Removes from playlist. /// Removes from playlist.

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