diff options
36 files changed, 688 insertions, 407 deletions
diff --git a/.github/ISSUE_TEMPLATE/media_playback.md b/.github/ISSUE_TEMPLATE/media_playback.md index 93af33fbf..b51500f87 100644 --- a/.github/ISSUE_TEMPLATE/media_playback.md +++ b/.github/ISSUE_TEMPLATE/media_playback.md @@ -11,7 +11,10 @@ assignees: '' <!-- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo) or copy the info from the web ui for the file with the playback issue. --> **Logs** -<!-- Please paste any log message from during the playback issue, for example the ffmpeg command line can be very useful. --> +<!-- Please paste any log messages from during the playback issue. --> + +**FFmpeg Logs** +<!-- Please paste any FFmpeg logs if remuxing or transcoding appears to be part of the issue. --> **Stats for Nerds Screenshots** <!-- If available, add screenshots of the stats for nerds screen to help show the issue problem. --> @@ -29,4 +32,3 @@ assignees: '' - Client: [e.g. Web/Browser, webOS, Android, Android TV, Electron] - Browser (if Web client): [e.g. Firefox, Chrome, Safari] - Client and Browser Version: [e.g. 10.3.4 and 68.0] - diff --git a/Dockerfile b/Dockerfile index 53a425262..1cd864e0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,9 @@ FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:GenerateDocumentationFile=false;DebugSymbols=false;DebugType=none" +# because of changes in docker and systemd we need to not build in parallel at the moment +# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting +RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:GenerateDocumentationFile=false;DebugSymbols=false;DebugType=none" FROM jellyfin/ffmpeg:${FFMPEG_VERSION} as ffmpeg FROM debian:buster-slim diff --git a/Dockerfile.arm b/Dockerfile.arm index 4d8fbb77d..0e6236628 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -24,10 +24,11 @@ RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" FROM multiarch/qemu-user-static:x86_64-arm as qemu -FROM debian:stretch-slim-arm32v7 +FROM debian:stretch-slim COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ffmpeg \ + libssl-dev \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /cache /config /media \ && chmod 777 /cache /config /media diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index d41268f13..796d12f98 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -24,10 +24,11 @@ RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM debian:stretch-slim-arm64v8 +FROM debian:stretch-slim COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ffmpeg \ + libssl-dev \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /cache /config /media \ && chmod 777 /cache /config /media diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 03cbe00b7..77333a03d 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -35,7 +35,7 @@ <PackageReference Include="Mono.Nat" Version="2.0.0" /> <PackageReference Include="ServiceStack.Text.Core" Version="5.7.0" /> <PackageReference Include="sharpcompress" Version="0.24.0" /> - <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.0.1" /> + <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" /> <PackageReference Include="System.Interactive.Async" Version="4.0.0" /> </ItemGroup> diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 9ee219854..a83817cb9 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Udp; using MediaBrowser.Controller; @@ -12,7 +13,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// Class UdpServerEntryPoint. /// </summary> - public class UdpServerEntryPoint : IServerEntryPoint + public sealed class UdpServerEntryPoint : IServerEntryPoint { /// <summary> /// The port of the UDP server. @@ -31,61 +32,44 @@ namespace Emby.Server.Implementations.EntryPoints /// The UDP server. /// </summary> private UdpServer _udpServer; + private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private bool _disposed = false; /// <summary> /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class. /// </summary> public UdpServerEntryPoint( - ILogger logger, - IServerApplicationHost appHost, - IJsonSerializer json, - ISocketFactory socketFactory) + ILogger<UdpServerEntryPoint> logger, + IServerApplicationHost appHost) { _logger = logger; _appHost = appHost; - _json = json; - _socketFactory = socketFactory; - } - /// <inheritdoc /> - public Task RunAsync() - { - var udpServer = new UdpServer(_logger, _appHost, _json, _socketFactory); - - try - { - udpServer.Start(PortNumber); - - _udpServer = udpServer; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start UDP Server"); - } - return Task.CompletedTask; } /// <inheritdoc /> - public void Dispose() + public async Task RunAsync() { - Dispose(true); - GC.SuppressFinalize(this); + _udpServer = new UdpServer(_logger, _appHost); + _udpServer.Start(PortNumber, _cancellationTokenSource.Token); } - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) + /// <inheritdoc /> + public void Dispose() { - if (dispose) + if (_disposed) { - if (_udpServer != null) - { - _udpServer.Dispose(); - } + return; } + + _cancellationTokenSource.Cancel(); + _udpServer.Dispose(); + + _cancellationTokenSource = null; + _udpServer = null; + + _disposed = true; } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index e27081c45..68417876c 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -17,7 +17,7 @@ using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations.IO { /// <summary> - /// Class ManagedFileSystem + /// Class ManagedFileSystem. /// </summary> public class ManagedFileSystem : IFileSystem { @@ -80,20 +80,20 @@ namespace Emby.Server.Implementations.IO public virtual string MakeAbsolutePath(string folderPath, string filePath) { - if (string.IsNullOrWhiteSpace(filePath) - // stream - || filePath.Contains("://")) + // path is actually a stream + if (string.IsNullOrWhiteSpace(filePath) || filePath.Contains("://", StringComparison.Ordinal)) { return filePath; } if (filePath.Length > 3 && filePath[1] == ':' && filePath[2] == '/') { - return filePath; // absolute local path + // absolute local path + return filePath; } // unc path - if (filePath.StartsWith("\\\\")) + if (filePath.StartsWith("\\\\", StringComparison.Ordinal)) { return filePath; } @@ -101,13 +101,16 @@ namespace Emby.Server.Implementations.IO var firstChar = filePath[0]; if (firstChar == '/') { - // For this we don't really know. + // for this we don't really know return filePath; } - if (firstChar == '\\') //relative path + + // relative path + if (firstChar == '\\') { filePath = filePath.Substring(1); } + try { return Path.GetFullPath(Path.Combine(folderPath, filePath)); @@ -131,11 +134,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="shortcutPath">The shortcut path.</param> /// <param name="target">The target.</param> - /// <exception cref="ArgumentNullException"> - /// shortcutPath - /// or - /// target - /// </exception> + /// <exception cref="ArgumentNullException">The shortcutPath or target is null.</exception> public virtual void CreateShortcut(string shortcutPath, string target) { if (string.IsNullOrEmpty(shortcutPath)) @@ -281,11 +280,11 @@ namespace Emby.Server.Implementations.IO } /// <summary> - /// Takes a filename and removes invalid characters + /// Takes a filename and removes invalid characters. /// </summary> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">filename</exception> + /// <exception cref="ArgumentNullException">The filename is null.</exception> public virtual string GetValidFilename(string filename) { var builder = new StringBuilder(filename); @@ -449,7 +448,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetHidden(string path, bool isHidden) { - if (OperatingSystem.Id != MediaBrowser.Model.System.OperatingSystemId.Windows) + if (OperatingSystem.Id != OperatingSystemId.Windows) { return; } @@ -473,7 +472,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetReadOnly(string path, bool isReadOnly) { - if (OperatingSystem.Id != MediaBrowser.Model.System.OperatingSystemId.Windows) + if (OperatingSystem.Id != OperatingSystemId.Windows) { return; } @@ -497,7 +496,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetAttributes(string path, bool isHidden, bool isReadOnly) { - if (OperatingSystem.Id != MediaBrowser.Model.System.OperatingSystemId.Windows) + if (OperatingSystem.Id != OperatingSystemId.Windows) { return; } @@ -780,7 +779,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetExecutable(string path) { - if (OperatingSystem.Id == MediaBrowser.Model.System.OperatingSystemId.Darwin) + if (OperatingSystem.Id == OperatingSystemId.Darwin) { RunProcess("chmod", "+x \"" + path + "\"", Path.GetDirectoryName(path)); } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index b871626f0..71bffffc6 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -65,8 +65,8 @@ "LabelIpAddressValue": "Adresa IP: {0}", "ItemRemovedWithName": "{0} a fost eliminat din bibliotecă", "ItemAddedWithName": "{0} a fost adăugat în bibliotecă", - "Inherit": "moștenit", - "HomeVideos": "Videoclipuri personale", + "Inherit": "Moștenit", + "HomeVideos": "Filme personale", "HeaderRecordingGroups": "Grupuri de înregistrare", "HeaderLiveTV": "TV în Direct", "HeaderFavoriteSongs": "Melodii Favorite", diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 9bac305a2..1988bda52 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -15,7 +15,7 @@ "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Albumy umelcov", + "HeaderAlbumArtists": "Umelci albumu", "HeaderCameraUploads": "Nahrané fotografie", "HeaderContinueWatching": "Pokračovať v pozeraní", "HeaderFavoriteAlbums": "Obľúbené albumy", @@ -45,25 +45,25 @@ "NameSeasonNumber": "Séria {0}", "NameSeasonUnknown": "Neznáma séria", "NewVersionIsAvailable": "Nová verzia Jellyfin Serveru je dostupná na stiahnutie.", - "NotificationOptionApplicationUpdateAvailable": "Aktualizácia aplikácie je dostupná", - "NotificationOptionApplicationUpdateInstalled": "Aktualizácia aplikácie nainštalovaná", - "NotificationOptionAudioPlayback": "Prehrávanie audia bolo spustené", - "NotificationOptionAudioPlaybackStopped": "Prehrávanie audia bolo zastavené", + "NotificationOptionApplicationUpdateAvailable": "Je dostupná aktualizácia aplikácie", + "NotificationOptionApplicationUpdateInstalled": "Aktualizácia aplikácie bola nainštalovaná", + "NotificationOptionAudioPlayback": "Prehrávanie zvuku bolo spustené", + "NotificationOptionAudioPlaybackStopped": "Prehrávanie zvuku bolo zastavené", "NotificationOptionCameraImageUploaded": "Obrázok z fotoaparátu bol nahraný", "NotificationOptionInstallationFailed": "Chyba inštalácie", - "NotificationOptionNewLibraryContent": "Nový obsah bol pridaný", - "NotificationOptionPluginError": "Chyba rozšírenia", - "NotificationOptionPluginInstalled": "Rozšírenie nainštalované", - "NotificationOptionPluginUninstalled": "Rozšírenie odinštalované", - "NotificationOptionPluginUpdateInstalled": "Aktualizácia rozšírenia nainštalovaná", + "NotificationOptionNewLibraryContent": "Bol pridaný nový obsah", + "NotificationOptionPluginError": "Chyba zásuvného modulu", + "NotificationOptionPluginInstalled": "Bol nainštalovaný zásuvný modul", + "NotificationOptionPluginUninstalled": "Zásuvný modul bol odinštalovaný", + "NotificationOptionPluginUpdateInstalled": "Bola nainštalovaná aktualizácia zásuvného modulu", "NotificationOptionServerRestartRequired": "Vyžaduje sa reštart servera", "NotificationOptionTaskFailed": "Naplánovaná úloha zlyhala", "NotificationOptionUserLockedOut": "Používateľ je uzamknutý", "NotificationOptionVideoPlayback": "Spustené prehrávanie videa", "NotificationOptionVideoPlaybackStopped": "Zastavené prehrávanie videa", "Photos": "Fotky", - "Playlists": "Zoznamy skladieb", - "Plugin": "Rozšírenie", + "Playlists": "Playlisty", + "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} bol nainštalovaný", "PluginUninstalledWithName": "{0} bol odinštalovaný", "PluginUpdatedWithName": "{0} bol aktualizovaný", @@ -72,8 +72,8 @@ "ScheduledTaskStartedWithName": "{0} zahájených", "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", "Shows": "Seriály", - "Songs": "Skladby", - "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Skúste to prosím o chvíľu znova.", + "Songs": "Piesne", + "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", "SubtitlesDownloadedForItem": "Titulky pre {0} stiahnuté", @@ -87,11 +87,11 @@ "UserLockedOutWithName": "Používateľ {0} bol vymknutý", "UserOfflineFromDevice": "{0} sa odpojil od {1}", "UserOnlineFromDevice": "{0} je online z {1}", - "UserPasswordChangedWithName": "Heslo používateľa {0} zmenené", + "UserPasswordChangedWithName": "Heslo používateľa {0} bolo zmenené", "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované", - "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1}", - "UserStoppedPlayingItemWithValues": "{0} zastavil prehrávanie {1}", - "ValueHasBeenAddedToLibrary": "{0} bolo pridané do vašej knižnice médií", + "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}", + "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}", + "ValueHasBeenAddedToLibrary": "{0} bol pridané do vašej knižnice médií", "ValueSpecialEpisodeName": "Špeciál - {0}", "VersionNumber": "Verzia {0}" } diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 0870db003..4e04cde78 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -8,32 +8,6 @@ namespace Emby.Server.Implementations.Net { public class SocketFactory : ISocketFactory { - /// <summary> - /// Creates a new UDP acceptSocket and binds it to the specified local port. - /// </summary> - /// <param name="localPort">An integer specifying the local port to bind the acceptSocket to.</param> - public ISocket CreateUdpSocket(int localPort) - { - if (localPort < 0) - { - throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); - } - - var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - - try - { - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - return new UdpSocket(retVal, localPort, IPAddress.Any); - } - catch - { - retVal?.Dispose(); - - throw; - } - } - public ISocket CreateUdpBroadcastSocket(int localPort) { if (localPort < 0) @@ -156,8 +130,5 @@ namespace Emby.Server.Implementations.Net throw; } } - - public Stream CreateNetworkStream(ISocket socket, bool ownsSocket) - => new NetworkStream(((UdpSocket)socket).Socket, ownsSocket); } } diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index dde4a2a34..211ca6784 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -181,15 +181,6 @@ namespace Emby.Server.Implementations.Net return taskCompletion.Task; } - public Task<SocketReceiveResult> ReceiveAsync(CancellationToken cancellationToken) - { - ThrowIfDisposed(); - - var buffer = new byte[8192]; - - return ReceiveAsync(buffer, 0, buffer.Length, cancellationToken); - } - public Task SendToAsync(byte[] buffer, int offset, int size, IPEndPoint endPoint, CancellationToken cancellationToken) { ThrowIfDisposed(); diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 185a282ac..c91d137a7 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -1,112 +1,44 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Net; +using System.Net.Sockets; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller; using MediaBrowser.Model.ApiClient; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Udp { /// <summary> - /// Provides a Udp Server + /// Provides a Udp Server. /// </summary> - public class UdpServer : IDisposable + public sealed class UdpServer : IDisposable { /// <summary> /// The _logger /// </summary> private readonly ILogger _logger; + private readonly IServerApplicationHost _appHost; - private bool _isDisposed; - - private readonly List<Tuple<string, bool, Func<string, IPEndPoint, Encoding, CancellationToken, Task>>> _responders = new List<Tuple<string, bool, Func<string, IPEndPoint, Encoding, CancellationToken, Task>>>(); + private Socket _udpSocket; + private IPEndPoint _endpoint; + private readonly byte[] _receiveBuffer = new byte[8192]; - private readonly IServerApplicationHost _appHost; - private readonly IJsonSerializer _json; + private bool _disposed = false; /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. /// </summary> - public UdpServer(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory) + public UdpServer(ILogger logger, IServerApplicationHost appHost) { _logger = logger; _appHost = appHost; - _json = json; - _socketFactory = socketFactory; - - AddMessageResponder("who is JellyfinServer?", true, RespondToV2Message); - } - - private void AddMessageResponder(string message, bool isSubstring, Func<string, IPEndPoint, Encoding, CancellationToken, Task> responder) - { - _responders.Add(new Tuple<string, bool, Func<string, IPEndPoint, Encoding, CancellationToken, Task>>(message, isSubstring, responder)); - } - - /// <summary> - /// Raises the <see cref="E:MessageReceived" /> event. - /// </summary> - private async void OnMessageReceived(GenericEventArgs<SocketReceiveResult> e) - { - var message = e.Argument; - - var encoding = Encoding.UTF8; - var responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding); - - if (responder == null) - { - encoding = Encoding.Unicode; - responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding); - } - - if (responder != null) - { - var cancellationToken = CancellationToken.None; - - try - { - await responder.Item2.Item3(responder.Item1, message.RemoteEndPoint, encoding, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in OnMessageReceived"); - } - } } - private Tuple<string, Tuple<string, bool, Func<string, IPEndPoint, Encoding, CancellationToken, Task>>> GetResponder(byte[] buffer, int bytesReceived, Encoding encoding) + private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken) { - var text = encoding.GetString(buffer, 0, bytesReceived); - var responder = _responders.FirstOrDefault(i => - { - if (i.Item2) - { - return text.IndexOf(i.Item1, StringComparison.OrdinalIgnoreCase) != -1; - } - return string.Equals(i.Item1, text, StringComparison.OrdinalIgnoreCase); - }); - - if (responder == null) - { - return null; - } - return new Tuple<string, Tuple<string, bool, Func<string, IPEndPoint, Encoding, CancellationToken, Task>>>(text, responder); - } - - private async Task RespondToV2Message(string messageText, IPEndPoint endpoint, Encoding encoding, CancellationToken cancellationToken) - { - var parts = messageText.Split('|'); - var localUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(localUrl)) @@ -118,8 +50,16 @@ namespace Emby.Server.Implementations.Udp Name = _appHost.FriendlyName }; - await SendAsync(encoding.GetBytes(_json.SerializeToString(response)), endpoint, cancellationToken).ConfigureAwait(false); + try + { + await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); + } + catch (SocketException ex) + { + _logger.LogError(ex, "Error sending response message"); + } + var parts = messageText.Split('|'); if (parts.Length > 1) { _appHost.EnableLoopback(parts[1]); @@ -132,161 +72,59 @@ namespace Emby.Server.Implementations.Udp } /// <summary> - /// The _udp client - /// </summary> - private ISocket _udpClient; - private readonly ISocketFactory _socketFactory; - - /// <summary> /// Starts the specified port. /// </summary> /// <param name="port">The port.</param> - public void Start(int port) + /// <param name="cancellationToken"></param> + public void Start(int port, CancellationToken cancellationToken) { - _udpClient = _socketFactory.CreateUdpSocket(port); + _endpoint = new IPEndPoint(IPAddress.Any, port); - Task.Run(() => BeginReceive()); - } - - private readonly byte[] _receiveBuffer = new byte[8192]; - - private void BeginReceive() - { - if (_isDisposed) - { - return; - } + _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _udpSocket.Bind(_endpoint); - try - { - var result = _udpClient.BeginReceive(_receiveBuffer, 0, _receiveBuffer.Length, OnReceiveResult); - - if (result.CompletedSynchronously) - { - OnReceiveResult(result); - } - } - catch (ObjectDisposedException) - { - //TODO Investigate and properly fix. - } - catch (Exception ex) - { - _logger.LogError(ex, "Error receiving udp message"); - } + _ = Task.Run(async () => await BeginReceiveAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); } - private void OnReceiveResult(IAsyncResult result) + private async Task BeginReceiveAsync(CancellationToken cancellationToken) { - if (_isDisposed) - { - return; - } - - try - { - var socketResult = _udpClient.EndReceive(result); - - OnMessageReceived(socketResult); - } - catch (ObjectDisposedException) - { - //TODO Investigate and properly fix. - } - catch (Exception ex) + while (!cancellationToken.IsCancellationRequested) { - _logger.LogError(ex, "Error receiving udp message"); - } - - BeginReceive(); - } - - /// <summary> - /// Called when [message received]. - /// </summary> - /// <param name="message">The message.</param> - private void OnMessageReceived(SocketReceiveResult message) - { - if (_isDisposed) - { - return; - } - - if (message.RemoteEndPoint.Port == 0) - { - return; - } - - try - { - OnMessageReceived(new GenericEventArgs<SocketReceiveResult> + try { - Argument = message - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error handling UDP message"); - } - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - Dispose(true); - } + var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint).ConfigureAwait(false); - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (dispose) - { - _isDisposed = true; + cancellationToken.ThrowIfCancellationRequested(); - if (_udpClient != null) + var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes); + if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) + { + await RespondToV2Message(text, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + } + } + catch (SocketException ex) { - _udpClient.Dispose(); + _logger.LogError(ex, "Failed to receive data drom socket"); + } + catch (OperationCanceledException) + { + // Don't throw } } } - public async Task SendAsync(byte[] bytes, IPEndPoint remoteEndPoint, CancellationToken cancellationToken) + /// <inheritdoc /> + public void Dispose() { - if (_isDisposed) + if (_disposed) { - throw new ObjectDisposedException(GetType().Name); - } - - if (bytes == null) - { - throw new ArgumentNullException(nameof(bytes)); - } - - if (remoteEndPoint == null) - { - throw new ArgumentNullException(nameof(remoteEndPoint)); + return; } - try - { - await _udpClient.SendToAsync(bytes, 0, bytes.Length, remoteEndPoint, cancellationToken).ConfigureAwait(false); + _udpSocket?.Dispose(); - _logger.LogInformation("Udp message sent to {remoteEndPoint}", remoteEndPoint); - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending message to {remoteEndPoint}", remoteEndPoint); - } + GC.SuppressFinalize(this); } } - } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 166e22909..62bf5b0fb 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -42,6 +42,7 @@ <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.0" /> <PackageReference Include="Serilog.AspNetCore" Version="3.2.0" /> + <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" /> diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 712990a1e..fa9b62674 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -475,17 +475,19 @@ namespace Jellyfin.Server Serilog.Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .Enrich.FromLogContext() + .Enrich.WithThreadId() .CreateLogger(); } catch (Exception ex) { Serilog.Log.Logger = new LoggerConfiguration() - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}")) + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}:{Message}{NewLine}{Exception}")) .Enrich.FromLogContext() + .Enrich.WithThreadId() .CreateLogger(); Serilog.Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); diff --git a/Jellyfin.Server/Resources/Configuration/logging.json b/Jellyfin.Server/Resources/Configuration/logging.json index e85ef05af..e9f71afd4 100644 --- a/Jellyfin.Server/Resources/Configuration/logging.json +++ b/Jellyfin.Server/Resources/Configuration/logging.json @@ -5,7 +5,7 @@ { "Name": "Console", "Args": { - "outputTemplate": "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } }, { @@ -20,12 +20,13 @@ "retainedFileCountLimit": 3, "rollOnFileSizeLimit": true, "fileSizeLimitBytes": 100000000, - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}:{Message}{NewLine}{Exception}" } } ] } } - ] + ], + "Enrich": [ "FromLogContext", "WithThreadId" ] } } diff --git a/MediaBrowser.Api/Playback/MediaInfoService.cs b/MediaBrowser.Api/Playback/MediaInfoService.cs index 4b9bb8010..10f10a15e 100644 --- a/MediaBrowser.Api/Playback/MediaInfoService.cs +++ b/MediaBrowser.Api/Playback/MediaInfoService.cs @@ -264,41 +264,26 @@ namespace MediaBrowser.Api.Playback return ToOptimizedResult(result); } - private T Clone<T>(T obj) - { - // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it - // Should we move this directly into MediaSourceManager? - var json = JsonSerializer.SerializeToUtf8Bytes(obj); - return JsonSerializer.Deserialize<T>(json); - } - private async Task<PlaybackInfoResponse> GetPlaybackInfo(Guid id, Guid userId, string[] supportedLiveMediaTypes, string mediaSourceId = null, string liveStreamId = null) { var user = _userManager.GetUserById(userId); var item = _libraryManager.GetItemById(id); var result = new PlaybackInfoResponse(); + MediaSourceInfo[] mediaSources; if (string.IsNullOrWhiteSpace(liveStreamId)) { - IEnumerable<MediaSourceInfo> mediaSources; - try - { - // TODO handle supportedLiveMediaTypes ? - mediaSources = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, false, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - mediaSources = new List<MediaSourceInfo>(); - Logger.LogError(ex, "Could not find media sources for item id {id}", id); - // TODO PlaybackException ?? - //result.ErrorCode = ex.ErrorCode; - } - result.MediaSources = mediaSources.ToArray(); + // TODO handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, false, CancellationToken.None).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(mediaSourceId)) + if (string.IsNullOrWhiteSpace(mediaSourceId)) { - result.MediaSources = result.MediaSources + mediaSources = mediaSourcesList.ToArray(); + } + else + { + mediaSources = mediaSourcesList .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) .ToArray(); } @@ -307,11 +292,13 @@ namespace MediaBrowser.Api.Playback { var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - result.MediaSources = new MediaSourceInfo[] { mediaSource }; + mediaSources = new MediaSourceInfo[] { mediaSource }; } - if (result.MediaSources.Count == 0) + if (mediaSources.Length == 0) { + result.MediaSources = Array.Empty<MediaSourceInfo>(); + if (!result.ErrorCode.HasValue) { result.ErrorCode = PlaybackErrorCode.NoCompatibleStream; @@ -319,7 +306,9 @@ namespace MediaBrowser.Api.Playback } else { - result.MediaSources = Clone(result.MediaSources); + // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it + // Should we move this directly into MediaSourceManager? + result.MediaSources = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 020f0553e..3b8cb93fc 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2642,22 +2642,9 @@ namespace MediaBrowser.Controller.MediaEncoding else return "-hwaccel dxva2"; } - - switch (videoStream.Codec.ToLowerInvariant()) + else { - case "avc": - case "h264": - if (_mediaEncoder.SupportsDecoder("h264_amf") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v h264_amf"; - } - break; - case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("hevc_amf") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_mmal"; - } - break; + return "-hwaccel vaapi"; } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 1feca0ec9..f5decdc0d 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -55,7 +55,9 @@ namespace MediaBrowser.MediaEncoding.Encoder "h264_vaapi", "hevc_vaapi", "h264_v4l2m2m", - "ac3" + "ac3", + "h264_amf", + "hevc_amf" }; // Try and use the individual library versions to determine a FFmpeg version diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index e0f7b992c..4123f0203 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -849,14 +849,92 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + /// <inheritdoc /> public Task ConvertImage(string inputPath, string outputPath) { throw new NotImplementedException(); } + /// <inheritdoc /> public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, IIsoMount isoMount, uint? titleNumber) { - throw new NotImplementedException(); + // min size 300 mb + const long MinPlayableSize = 314572800; + + var root = isoMount != null ? isoMount.MountedPath : path; + + // Try to eliminate menus and intros by skipping all files at the front of the list that are less than the minimum size + // Once we reach a file that is at least the minimum, return all subsequent ones + var allVobs = _fileSystem.GetFiles(root, true) + .Where(file => string.Equals(file.Extension, ".vob", StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.FullName) + .ToList(); + + // If we didn't find any satisfying the min length, just take them all + if (allVobs.Count == 0) + { + _logger.LogWarning("No vobs found in dvd structure."); + return Enumerable.Empty<string>(); + } + + if (titleNumber.HasValue) + { + var prefix = string.Format( + CultureInfo.InvariantCulture, + titleNumber.Value >= 10 ? "VTS_{0}_" : "VTS_0{0}_", + titleNumber.Value); + var vobs = allVobs.Where(i => i.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (vobs.Count > 0) + { + var minSizeVobs = vobs + .SkipWhile(f => f.Length < MinPlayableSize) + .ToList(); + + return minSizeVobs.Count == 0 ? vobs.Select(i => i.FullName) : minSizeVobs.Select(i => i.FullName); + } + + _logger.LogWarning("Could not determine vob file list for {0} using DvdLib. Will scan using file sizes.", path); + } + + var files = allVobs + .SkipWhile(f => f.Length < MinPlayableSize) + .ToList(); + + // If we didn't find any satisfying the min length, just take them all + if (files.Count == 0) + { + _logger.LogWarning("Vob size filter resulted in zero matches. Taking all vobs."); + files = allVobs; + } + + // Assuming they're named "vts_05_01", take all files whose second part matches that of the first file + if (files.Count > 0) + { + var parts = _fileSystem.GetFileNameWithoutExtension(files[0]).Split('_'); + + if (parts.Length == 3) + { + var title = parts[1]; + + files = files.TakeWhile(f => + { + var fileParts = _fileSystem.GetFileNameWithoutExtension(f).Split('_'); + + return fileParts.Length == 3 && string.Equals(title, fileParts[1], StringComparison.OrdinalIgnoreCase); + + }).ToList(); + + // If this resulted in not getting any vobs, just take them all + if (files.Count == 0) + { + _logger.LogWarning("Vob filename filter resulted in zero matches. Taking all vobs."); + files = allVobs; + } + } + } + + return files.Select(i => i.FullName); } public bool CanExtractSubtitles(string codec) diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index 7fa7afa5b..0b2f1d231 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -225,7 +225,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// </summary> /// <value>The start_pts.</value> [JsonPropertyName("start_pts")] - public int StartPts { get; set; } + public long StartPts { get; set; } /// <summary> /// Gets or sets the is_avc. diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index 5cb056566..16a212a81 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -76,6 +76,7 @@ namespace MediaBrowser.Model.Dto { Formats = Array.Empty<string>(); MediaStreams = new List<MediaStream>(); + MediaAttachments = Array.Empty<MediaAttachment>(); RequiredHttpHeaders = new Dictionary<string, string>(); SupportsTranscoding = true; SupportsDirectStream = true; diff --git a/MediaBrowser.Model/Net/ISocket.cs b/MediaBrowser.Model/Net/ISocket.cs index f80de5524..3fdc40bbe 100644 --- a/MediaBrowser.Model/Net/ISocket.cs +++ b/MediaBrowser.Model/Net/ISocket.cs @@ -14,8 +14,6 @@ namespace MediaBrowser.Model.Net Task<SocketReceiveResult> ReceiveAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); - int Receive(byte[] buffer, int offset, int count); - IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback); SocketReceiveResult EndReceive(IAsyncResult result); diff --git a/MediaBrowser.Model/Net/ISocketFactory.cs b/MediaBrowser.Model/Net/ISocketFactory.cs index 2f857f1af..dc69b1fb2 100644 --- a/MediaBrowser.Model/Net/ISocketFactory.cs +++ b/MediaBrowser.Model/Net/ISocketFactory.cs @@ -8,13 +8,6 @@ namespace MediaBrowser.Model.Net /// </summary> public interface ISocketFactory { - /// <summary> - /// Creates a new unicast socket using the specified local port number. - /// </summary> - /// <param name="localPort">The local port to bind to.</param> - /// <returns>A <see cref="ISocket"/> implementation.</returns> - ISocket CreateUdpSocket(int localPort); - ISocket CreateUdpBroadcastSocket(int localPort); /// <summary> @@ -30,7 +23,5 @@ namespace MediaBrowser.Model.Net /// <param name="localPort">The local port to bind to.</param> /// <returns>A <see cref="ISocket"/> implementation.</returns> ISocket CreateUdpMulticastSocket(string ipAddress, int multicastTimeToLive, int localPort); - - Stream CreateNetworkStream(ISocket socket, bool ownsSocket); } } diff --git a/deployment/win-x64/docker-build.sh b/deployment/win-x64/docker-build.sh index 3f1ad78b5..77372a66f 100755 --- a/deployment/win-x64/docker-build.sh +++ b/deployment/win-x64/docker-build.sh @@ -8,7 +8,7 @@ set -o xtrace # Version variables NSSM_VERSION="nssm-2.24-101-g897c7ad" NSSM_URL="http://files.evilt.win/nssm/${NSSM_VERSION}.zip" -FFMPEG_VERSION="ffmpeg-4.0.2-win64-static" +FFMPEG_VERSION="ffmpeg-4.2.1-win64-static" FFMPEG_URL="https://ffmpeg.zeranoe.com/builds/win64/static/${FFMPEG_VERSION}.zip" # Move to source directory diff --git a/deployment/win-x86/docker-build.sh b/deployment/win-x86/docker-build.sh index 7d79ba495..dd411756a 100755 --- a/deployment/win-x86/docker-build.sh +++ b/deployment/win-x86/docker-build.sh @@ -8,7 +8,7 @@ set -o xtrace # Version variables NSSM_VERSION="nssm-2.24-101-g897c7ad" NSSM_URL="http://files.evilt.win/nssm/${NSSM_VERSION}.zip" -FFMPEG_VERSION="ffmpeg-4.0.2-win32-static" +FFMPEG_VERSION="ffmpeg-4.2.1-win32-static" FFMPEG_URL="https://ffmpeg.zeranoe.com/builds/win32/static/${FFMPEG_VERSION}.zip" # Move to source directory diff --git a/deployment/windows/build-jellyfin.ps1 b/deployment/windows/build-jellyfin.ps1 index dde6eb8fc..c762137a7 100644 --- a/deployment/windows/build-jellyfin.ps1 +++ b/deployment/windows/build-jellyfin.ps1 @@ -15,6 +15,8 @@ param( [ValidateSet('x64','x86', 'arm', 'arm64')][string]$Architecture = 'x64' ) +$ProgressPreference = 'SilentlyContinue' # Speedup all downloads by hiding progress bars. + #PowershellCore and *nix check to make determine which temp dir to use. if(($PSVersionTable.PSEdition -eq 'Core') -and (-not $IsWindows)){ $TempDir = mktemp -d @@ -44,7 +46,8 @@ function Build-JellyFin { function Install-FFMPEG { param( [string]$ResolvedInstallLocation, - [string]$Architecture + [string]$Architecture, + [string]$FFMPEGVersionX86 = "ffmpeg-4.2.1-win32-shared" ) Write-Verbose "Checking Architecture" if($Architecture -notin @('x86','x64')){ @@ -55,7 +58,7 @@ function Install-FFMPEG { Invoke-WebRequest -Uri https://repo.jellyfin.org/releases/server/windows/ffmpeg/jellyfin-ffmpeg.zip -UseBasicParsing -OutFile "$tempdir/ffmpeg.zip" | Write-Verbose }else{ Write-Verbose "Downloading 32 bit FFMPEG" - Invoke-WebRequest -Uri https://ffmpeg.zeranoe.com/builds/win32/shared/ffmpeg-4.0.2-win32-shared.zip -UseBasicParsing -OutFile "$tempdir/ffmpeg.zip" | Write-Verbose + Invoke-WebRequest -Uri https://ffmpeg.zeranoe.com/builds/win32/shared/$FFMPEGVersionX86.zip -UseBasicParsing -OutFile "$tempdir/ffmpeg.zip" | Write-Verbose } Expand-Archive "$tempdir/ffmpeg.zip" -DestinationPath "$tempdir/ffmpeg/" -Force | Write-Verbose @@ -66,7 +69,7 @@ function Install-FFMPEG { } }else{ Write-Verbose "Copying Binaries to Jellyfin location" - Get-ChildItem "$tempdir/ffmpeg/ffmpeg-4.0.2-win32-shared/bin" | ForEach-Object { + Get-ChildItem "$tempdir/ffmpeg/$FFMPEGVersionX86/bin" | ForEach-Object { Copy-Item $_.FullName -Destination $installLocation | Write-Verbose } } diff --git a/deployment/windows/jellyfin.nsi b/deployment/windows/jellyfin.nsi index 5666d30f0..86724b8f4 100644 --- a/deployment/windows/jellyfin.nsi +++ b/deployment/windows/jellyfin.nsi @@ -1,9 +1,9 @@ -; Shows a lot of debug information while compiling -; This can be removed once stable. -!verbose 4 -SetCompressor lzma +!verbose 3 +SetCompressor /SOLID bzip2 ShowInstDetails show ShowUninstDetails show +Unicode True + ;-------------------------------- !define SF_USELECTED 0 ; used to check selected options status, rest are inherited from Sections.nsh diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs new file mode 100644 index 000000000..a2f5c2501 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth +{ + public class CustomAuthenticationHandlerTests + { + private readonly IFixture _fixture; + + private readonly Mock<IAuthService> _jellyfinAuthServiceMock; + private readonly Mock<IOptionsMonitor<AuthenticationSchemeOptions>> _optionsMonitorMock; + private readonly Mock<ISystemClock> _clockMock; + private readonly Mock<IServiceProvider> _serviceProviderMock; + private readonly Mock<IAuthenticationService> _authenticationServiceMock; + private readonly UrlEncoder _urlEncoder; + private readonly HttpContext _context; + + private readonly CustomAuthenticationHandler _sut; + private readonly AuthenticationScheme _scheme; + + public CustomAuthenticationHandlerTests() + { + var fixtureCustomizations = new AutoMoqCustomization + { + ConfigureMembers = true + }; + + _fixture = new Fixture().Customize(fixtureCustomizations); + AllowFixtureCircularDependencies(); + + _jellyfinAuthServiceMock = _fixture.Freeze<Mock<IAuthService>>(); + _optionsMonitorMock = _fixture.Freeze<Mock<IOptionsMonitor<AuthenticationSchemeOptions>>>(); + _clockMock = _fixture.Freeze<Mock<ISystemClock>>(); + _serviceProviderMock = _fixture.Freeze<Mock<IServiceProvider>>(); + _authenticationServiceMock = _fixture.Freeze<Mock<IAuthenticationService>>(); + _fixture.Register<ILoggerFactory>(() => new NullLoggerFactory()); + + _urlEncoder = UrlEncoder.Default; + + _serviceProviderMock.Setup(s => s.GetService(typeof(IAuthenticationService))) + .Returns(_authenticationServiceMock.Object); + + _optionsMonitorMock.Setup(o => o.Get(It.IsAny<string>())) + .Returns(new AuthenticationSchemeOptions + { + ForwardAuthenticate = null + }); + + _context = new DefaultHttpContext + { + RequestServices = _serviceProviderMock.Object + }; + + _scheme = new AuthenticationScheme( + _fixture.Create<string>(), + null, + typeof(CustomAuthenticationHandler)); + + _sut = _fixture.Create<CustomAuthenticationHandler>(); + _sut.InitializeAsync(_scheme, _context).Wait(); + } + + [Fact] + public async Task HandleAuthenticateAsyncShouldFailWithNullUser() + { + _jellyfinAuthServiceMock.Setup( + a => a.Authenticate( + It.IsAny<HttpRequest>(), + It.IsAny<AuthenticatedAttribute>())) + .Returns((User)null); + + var authenticateResult = await _sut.AuthenticateAsync(); + + Assert.False(authenticateResult.Succeeded); + Assert.Equal("Invalid user", authenticateResult.Failure.Message); + } + + [Fact] + public async Task HandleAuthenticateAsyncShouldFailOnSecurityException() + { + var errorMessage = _fixture.Create<string>(); + + _jellyfinAuthServiceMock.Setup( + a => a.Authenticate( + It.IsAny<HttpRequest>(), + It.IsAny<AuthenticatedAttribute>())) + .Throws(new SecurityException(errorMessage)); + + var authenticateResult = await _sut.AuthenticateAsync(); + + Assert.False(authenticateResult.Succeeded); + Assert.Equal(errorMessage, authenticateResult.Failure.Message); + } + + [Fact] + public async Task HandleAuthenticateAsyncShouldSucceedWithUser() + { + SetupUser(); + var authenticateResult = await _sut.AuthenticateAsync(); + + Assert.True(authenticateResult.Succeeded); + Assert.Null(authenticateResult.Failure); + } + + [Fact] + public async Task HandleAuthenticateAsyncShouldAssignNameClaim() + { + var user = SetupUser(); + var authenticateResult = await _sut.AuthenticateAsync(); + + Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Name, user.Name)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HandleAuthenticateAsyncShouldAssignRoleClaim(bool isAdmin) + { + var user = SetupUser(isAdmin); + var authenticateResult = await _sut.AuthenticateAsync(); + + var expectedRole = user.Policy.IsAdministrator ? UserRoles.Administrator : UserRoles.User; + Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Role, expectedRole)); + } + + [Fact] + public async Task HandleAuthenticateAsyncShouldAssignTicketCorrectScheme() + { + SetupUser(); + var authenticatedResult = await _sut.AuthenticateAsync(); + + Assert.Equal(_scheme.Name, authenticatedResult.Ticket.AuthenticationScheme); + } + + private User SetupUser(bool isAdmin = false) + { + var user = _fixture.Create<User>(); + user.Policy.IsAdministrator = isAdmin; + + _jellyfinAuthServiceMock.Setup( + a => a.Authenticate( + It.IsAny<HttpRequest>(), + It.IsAny<AuthenticatedAttribute>())) + .Returns(user); + + return user; + } + + private void AllowFixtureCircularDependencies() + { + // A circular dependency exists in the User entity around parent folders, + // this allows Autofixture to generate a User regardless, rather than throw + // an error. + _fixture.Behaviors.OfType<ThrowingRecursionBehavior>().ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs new file mode 100644 index 000000000..84cdbe360 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.AspNetCore.Authorization; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy +{ + public class FirstTimeSetupOrElevatedHandlerTests + { + private readonly Mock<IConfigurationManager> _configurationManagerMock; + private readonly List<IAuthorizationRequirement> _requirements; + private readonly FirstTimeSetupOrElevatedHandler _sut; + + public FirstTimeSetupOrElevatedHandlerTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); + _requirements = new List<IAuthorizationRequirement> {new FirstTimeSetupOrElevatedRequirement()}; + + _sut = fixture.Create<FirstTimeSetupOrElevatedHandler>(); + } + + [Theory] + [InlineData(UserRoles.Administrator)] + [InlineData(UserRoles.Guest)] + [InlineData(UserRoles.User)] + public async Task ShouldSucceedIfStartupWizardIncomplete(string userRole) + { + SetupConfigurationManager(false); + var user = SetupUser(userRole); + var context = new AuthorizationHandlerContext(_requirements, user, null); + + await _sut.HandleAsync(context); + Assert.True(context.HasSucceeded); + } + + [Theory] + [InlineData(UserRoles.Administrator, true)] + [InlineData(UserRoles.Guest, false)] + [InlineData(UserRoles.User, false)] + public async Task ShouldRequireAdministratorIfStartupWizardComplete(string userRole, bool shouldSucceed) + { + SetupConfigurationManager(true); + var user = SetupUser(userRole); + var context = new AuthorizationHandlerContext(_requirements, user, null); + + await _sut.HandleAsync(context); + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + + private static ClaimsPrincipal SetupUser(string role) + { + var claims = new[] {new Claim(ClaimTypes.Role, role)}; + var identity = new ClaimsIdentity(claims); + return new ClaimsPrincipal(identity); + } + + private void SetupConfigurationManager(bool startupWizardCompleted) + { + var commonConfiguration = new BaseApplicationConfiguration + { + IsStartupWizardCompleted = startupWizardCompleted + }; + + _configurationManagerMock.Setup(c => c.CommonConfiguration) + .Returns(commonConfiguration); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs new file mode 100644 index 000000000..e2beea1ad --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Jellyfin.Api.Auth.RequiresElevationPolicy; +using Jellyfin.Api.Constants; +using Microsoft.AspNetCore.Authorization; +using Xunit; + +namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy +{ + public class RequiresElevationHandlerTests + { + private readonly RequiresElevationHandler _sut; + + public RequiresElevationHandlerTests() + { + _sut = new RequiresElevationHandler(); + } + + [Theory] + [InlineData(UserRoles.Administrator, true)] + [InlineData(UserRoles.User, false)] + [InlineData(UserRoles.Guest, false)] + public async Task ShouldHandleRolesCorrectly(string role, bool shouldSucceed) + { + var requirements = new List<IAuthorizationRequirement> {new RequiresElevationRequirement()}; + + var claims = new[] {new Claim(ClaimTypes.Role, role)}; + var identity = new ClaimsIdentity(claims); + var user = new ClaimsPrincipal(identity); + + var context = new AuthorizationHandlerContext(requirements, user, null); + + await _sut.HandleAsync(context); + Assert.Equal(shouldSucceed, context.HasSucceeded); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index e0deeeabb..1f83489bd 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,6 +6,10 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AutoFixture" Version="4.11.0" /> + <PackageReference Include="AutoFixture.AutoMoq" Version="4.11.0" /> + <PackageReference Include="AutoFixture.Xunit2" Version="4.11.0" /> + <PackageReference Include="Microsoft.Extensions.Options" Version="3.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> @@ -15,6 +19,7 @@ <ItemGroup> <ProjectReference Include="../../MediaBrowser.Api/MediaBrowser.Api.csproj" /> + <ProjectReference Include="../../Jellyfin.Api/Jellyfin.Api.csproj" /> </ItemGroup> </Project> diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 12fde0770..c46c9578b 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -26,7 +26,7 @@ libswscale 5. 5.100 / 5. 5.100 libswresample 3. 5.100 / 3. 5.100 libpostproc 55. 5.100 / 55. 5.100"; - public const string FFmpegV414Output = @"ffmpeg version 4.1.4-1~deb10u1 Copyright (c) 2000-2019 the FFmpeg developers + public const string FFmpegV414Output = @"ffmpeg version 4.1.4-1~deb10u1 Copyright (c) 2000-2019 the FFmpeg developers built with gcc 8 (Raspbian 8.3.0-6+rpi1) configuration: --prefix=/usr --extra-version='1~deb10u1' --toolchain=hardened --libdir=/usr/lib/arm-linux-gnueabihf --incdir=/usr/include/arm-linux-gnueabihf --arch=arm --enable-gpl --disable-stripping --enable-avresample --disable-filter=resample --enable-avisynth --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librsvg --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opengl --enable-sdl2 --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared libavutil 56. 22.100 / 56. 22.100 @@ -39,7 +39,7 @@ libswscale 5. 3.100 / 5. 3.100 libswresample 3. 3.100 / 3. 3.100 libpostproc 55. 3.100 / 55. 3.100"; - public const string FFmpegV404Output = @"ffmpeg version 4.0.4 Copyright (c) 2000-2019 the FFmpeg developers + public const string FFmpegV404Output = @"ffmpeg version 4.0.4 Copyright (c) 2000-2019 the FFmpeg developers built with gcc 8 (Debian 8.3.0-6) configuration: --toolchain=hardened --prefix=/usr --target-os=linux --enable-cross-compile --extra-cflags=--static --enable-gpl --enable-static --disable-doc --disable-ffplay --disable-shared --disable-libxcb --disable-sdl2 --disable-xlib --enable-libfontconfig --enable-fontconfig --enable-gmp --enable-gnutls --enable-libass --enable-libbluray --enable-libdrm --enable-libfreetype --enable-libfribidi --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libx264 --enable-libx265 --enable-libzvbi --enable-omx --enable-omx-rpi --enable-version3 --enable-vaapi --enable-vdpau --arch=amd64 --enable-nvenc --enable-nvdec libavutil 56. 14.100 / 56. 14.100 @@ -51,7 +51,7 @@ libswscale 5. 1.100 / 5. 1.100 libswresample 3. 1.100 / 3. 1.100 libpostproc 55. 1.100 / 55. 1.100"; - public const string FFmpegGitUnknownOutput = @"ffmpeg version N-94303-g7cb4f8c962 Copyright (c) 2000-2019 the FFmpeg developers + public const string FFmpegGitUnknownOutput = @"ffmpeg version N-94303-g7cb4f8c962 Copyright (c) 2000-2019 the FFmpeg developers built with gcc 9.1.1 (GCC) 20190716 configuration: --enable-gpl --enable-version3 --enable-sdl2 --enable-fontconfig --enable-gnutls --enable-iconv --enable-libass --enable-libdav1d --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libopus --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libtheora --enable-libtwolame --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libzimg --enable-lzma --enable-zlib --enable-gmp --enable-libvidstab --enable-libvorbis --enable-libvo-amrwbenc --enable-libmysofa --enable-libspeex --enable-libxvid --enable-libaom --enable-libmfx --enable-amf --enable-ffnvcodec --enable-cuvid --enable-d3d11va --enable-nvenc --enable-nvdec --enable-dxva2 --enable-avisynth --enable-libopenmpt libavutil 56. 30.100 / 56. 30.100 diff --git a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs new file mode 100644 index 000000000..2032f6cec --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs @@ -0,0 +1,22 @@ +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.MediaEncoding.Probing; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests +{ + public class FFprobeParserTests + { + [Theory] + [InlineData("ffprobe1.json")] + public async Task Test(string fileName) + { + var path = Path.Join("Test Data", fileName); + using (var stream = File.OpenRead(path)) + { + await JsonSerializer.DeserializeAsync<InternalMediaInfoResult>(stream).ConfigureAwait(false); + } + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 7f6b90533..5d9b32086 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,6 +6,12 @@ </PropertyGroup> <ItemGroup> + <None Include="Test Data\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json new file mode 100644 index 000000000..cdad5df50 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json @@ -0,0 +1,105 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "Main", + "codec_type": "video", + "codec_time_base": "1/50", + "codec_tag_string": "[27][0][0][0]", + "codec_tag": "0x001b", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "has_b_frames": 0, + "sample_aspect_ratio": "0:1", + "display_aspect_ratio": "0:1", + "pix_fmt": "yuvj420p", + "level": 42, + "color_range": "pc", + "color_space": "bt709", + "color_transfer": "bt709", + "color_primaries": "bt709", + "chroma_location": "left", + "field_order": "progressive", + "refs": 1, + "is_avc": "false", + "nal_length_size": "0", + "id": "0x1", + "r_frame_rate": "25/1", + "avg_frame_rate": "25/1", + "time_base": "1/90000", + "start_pts": 8570867078, + "start_time": "95231.856422", + "duration_ts": 31694552, + "duration": "352.161689", + "bits_per_raw_sample": "8", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_time_base": "1/44100", + "codec_tag_string": "[15][0][0][0]", + "codec_tag": "0x000f", + "sample_fmt": "fltp", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "id": "0x2", + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 8570867697, + "start_time": "95231.863300", + "duration_ts": 31695687, + "duration": "352.174300", + "bit_rate": "98191", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + } + ], + "format": { + "filename": "TS Test record.ts", + "nb_streams": 2, + "nb_programs": 1, + "format_name": "mpegts", + "format_long_name": "MPEG-TS (MPEG-2 Transport Stream)", + "start_time": "95231.856422", + "duration": "352.181178", + "size": "179003772", + "bit_rate": "4066174", + "probe_score": 50 + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index f2a8e447f..e324002f0 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -1,6 +1,8 @@ +using System; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Server.Implementations.IO; +using MediaBrowser.Model.System; using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO @@ -26,7 +28,16 @@ namespace Jellyfin.Server.Implementations.Tests.IO string expectedAbsolutePath) { var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath); - Assert.Equal(expectedAbsolutePath, generatedPath); + + if (MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows) + { + var expectedWindowsPath = expectedAbsolutePath.Replace('/', '\\'); + Assert.Equal(expectedWindowsPath, generatedPath.Split(':')[1]); + } + else + { + Assert.Equal(expectedAbsolutePath, generatedPath); + } } } } |
