mirror of https://github.com/jellyfin/jellyfin.git
Merge branch 'master' into userdb-efcore
# Conflicts: # Emby.Server.Implementations/Library/UserManager.cs # Jellyfin.Data/Jellyfin.Data.csproj # MediaBrowser.Api/UserService.cs # MediaBrowser.Controller/Library/IUserManager.cs
This commit is contained in:
commit
01ce56016a
|
@ -2,7 +2,7 @@ ARG DOTNET_VERSION=3.1
|
|||
|
||||
FROM node:alpine as web-builder
|
||||
ARG JELLYFIN_WEB_VERSION=master
|
||||
RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm \
|
||||
RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \
|
||||
&& curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
|
||||
&& cd jellyfin-web-* \
|
||||
&& yarn install \
|
||||
|
|
|
@ -38,7 +38,7 @@ COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin
|
|||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \
|
||||
curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \
|
||||
curl -s https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0x6587ffd6536b8826e88a62547876ae518cbcf2f2 | apt-key add - && \
|
||||
curl -ks https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0x6587ffd6536b8826e88a62547876ae518cbcf2f2 | apt-key add - && \
|
||||
echo 'deb [arch=armhf] https://repo.jellyfin.org/debian buster main' > /etc/apt/sources.list.d/jellyfin.list && \
|
||||
echo "deb http://ppa.launchpad.net/ubuntu-raspi2/ppa/ubuntu bionic main">> /etc/apt/sources.list.d/raspbins.list && \
|
||||
apt-get update && \
|
||||
|
|
|
@ -45,6 +45,7 @@ using Emby.Server.Implementations.Services;
|
|||
using Emby.Server.Implementations.Session;
|
||||
using Emby.Server.Implementations.TV;
|
||||
using Emby.Server.Implementations.Updates;
|
||||
using Emby.Server.Implementations.SyncPlay;
|
||||
using MediaBrowser.Api;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
|
@ -78,6 +79,7 @@ using MediaBrowser.Controller.Session;
|
|||
using MediaBrowser.Controller.Sorting;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Controller.TV;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using MediaBrowser.LocalMetadata.Savers;
|
||||
using MediaBrowser.MediaEncoding.BdInfo;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
@ -610,6 +612,8 @@ namespace Emby.Server.Implementations
|
|||
|
||||
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
|
||||
|
||||
serviceCollection.AddSingleton<LiveTvDtoService>();
|
||||
serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
|
||||
|
||||
|
|
|
@ -34,15 +34,16 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Mono.Nat" Version="2.0.1" />
|
||||
<PackageReference Include="prometheus-net.DotNetRuntime" Version="3.3.1" />
|
||||
<PackageReference Include="ServiceStack.Text.Core" Version="5.8.0" />
|
||||
<PackageReference Include="sharpcompress" Version="0.25.0" />
|
||||
<PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
|
||||
<PackageReference Include="DotNet.Glob" Version="3.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||
using Emby.Server.Implementations.Udp;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.EntryPoints
|
||||
|
@ -22,6 +23,7 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||
/// </summary>
|
||||
private readonly ILogger _logger;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
/// <summary>
|
||||
/// The UDP server.
|
||||
|
@ -35,18 +37,19 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||
/// </summary>
|
||||
public UdpServerEntryPoint(
|
||||
ILogger<UdpServerEntryPoint> logger,
|
||||
IServerApplicationHost appHost)
|
||||
IServerApplicationHost appHost,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_appHost = appHost;
|
||||
|
||||
_config = configuration;
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync()
|
||||
{
|
||||
_udpServer = new UdpServer(_logger, _appHost);
|
||||
_udpServer = new UdpServer(_logger, _appHost, _config);
|
||||
_udpServer.Start(PortNumber, _cancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
|
|
|
@ -210,16 +210,8 @@ namespace Emby.Server.Implementations.HttpServer
|
|||
}
|
||||
}
|
||||
|
||||
private async Task ErrorHandler(Exception ex, IRequest httpReq, int statusCode, string urlToLog)
|
||||
private async Task ErrorHandler(Exception ex, IRequest httpReq, int statusCode, string urlToLog, bool ignoreStackTrace)
|
||||
{
|
||||
bool ignoreStackTrace =
|
||||
ex is SocketException
|
||||
|| ex is IOException
|
||||
|| ex is OperationCanceledException
|
||||
|| ex is SecurityException
|
||||
|| ex is AuthenticationException
|
||||
|| ex is FileNotFoundException;
|
||||
|
||||
if (ignoreStackTrace)
|
||||
{
|
||||
_logger.LogError("Error processing request: {Message}. URL: {Url}", ex.Message.TrimEnd('.'), urlToLog);
|
||||
|
@ -505,14 +497,32 @@ namespace Emby.Server.Implementations.HttpServer
|
|||
var requestInnerEx = GetActualException(requestEx);
|
||||
var statusCode = GetStatusCode(requestInnerEx);
|
||||
|
||||
// Do not handle 500 server exceptions manually when in development mode
|
||||
// The framework-defined development exception page will be returned instead
|
||||
if (statusCode == 500 && _hostEnvironment.IsDevelopment())
|
||||
foreach (var (key, value) in GetDefaultCorsHeaders(httpReq))
|
||||
{
|
||||
if (!httpRes.Headers.ContainsKey(key))
|
||||
{
|
||||
httpRes.Headers.Add(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
bool ignoreStackTrace =
|
||||
requestInnerEx is SocketException
|
||||
|| requestInnerEx is IOException
|
||||
|| requestInnerEx is OperationCanceledException
|
||||
|| requestInnerEx is SecurityException
|
||||
|| requestInnerEx is AuthenticationException
|
||||
|| requestInnerEx is FileNotFoundException;
|
||||
|
||||
// Do not handle 500 server exceptions manually when in development mode.
|
||||
// Instead, re-throw the exception so it can be handled by the DeveloperExceptionPageMiddleware.
|
||||
// However, do not use the DeveloperExceptionPageMiddleware when the stack trace should be ignored,
|
||||
// because it will log the stack trace when it handles the exception.
|
||||
if (statusCode == 500 && !ignoreStackTrace && _hostEnvironment.IsDevelopment())
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
await ErrorHandler(requestInnerEx, httpReq, statusCode, urlToLog).ConfigureAwait(false);
|
||||
await ErrorHandler(requestInnerEx, httpReq, statusCode, urlToLog, ignoreStackTrace).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception handlerException)
|
||||
{
|
||||
|
|
|
@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.HttpServer
|
|||
/// <value>The last activity date.</value>
|
||||
public DateTime LastActivityDate { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastKeepAliveDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the query string.
|
||||
/// </summary>
|
||||
|
@ -218,7 +221,42 @@ namespace Emby.Server.Implementations.HttpServer
|
|||
Connection = this
|
||||
};
|
||||
|
||||
if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal))
|
||||
{
|
||||
await SendKeepAliveResponse();
|
||||
}
|
||||
else
|
||||
{
|
||||
await OnReceive(info).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task SendKeepAliveResponse()
|
||||
{
|
||||
LastKeepAliveDate = DateTime.UtcNow;
|
||||
return SendAsync(new WebSocketMessage<string>
|
||||
{
|
||||
MessageType = "KeepAlive"
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_socket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ using MediaBrowser.Controller.Entities;
|
|||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.IO
|
||||
|
@ -37,38 +38,6 @@ namespace Emby.Server.Implementations.IO
|
|||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Any file name ending in any of these will be ignored by the watchers.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> _alwaysIgnoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"small.jpg",
|
||||
"albumart.jpg",
|
||||
|
||||
// WMC temp recording directories that will constantly be written to
|
||||
"TempRec",
|
||||
"TempSBE"
|
||||
};
|
||||
|
||||
private static readonly string[] _alwaysIgnoreSubstrings = new string[]
|
||||
{
|
||||
// Synology
|
||||
"eaDir",
|
||||
"#recycle",
|
||||
".wd_tv",
|
||||
".actors"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _alwaysIgnoreExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// thumbs.db
|
||||
".db",
|
||||
|
||||
// bts sync files
|
||||
".bts",
|
||||
".sync"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Add the path to our temporary ignore list. Use when writing to a path within our listening scope.
|
||||
/// </summary>
|
||||
|
@ -395,12 +364,7 @@ namespace Emby.Server.Implementations.IO
|
|||
throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
|
||||
var filename = Path.GetFileName(path);
|
||||
|
||||
var monitorPath = !string.IsNullOrEmpty(filename) &&
|
||||
!_alwaysIgnoreFiles.Contains(filename) &&
|
||||
!_alwaysIgnoreExtensions.Contains(Path.GetExtension(path)) &&
|
||||
_alwaysIgnoreSubstrings.All(i => path.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1);
|
||||
var monitorPath = !IgnorePatterns.ShouldIgnore(path);
|
||||
|
||||
// Ignore certain files
|
||||
var tempIgnorePaths = _tempIgnoredPaths.Keys.ToList();
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
using System;
|
||||
|
||||
namespace Emby.Server.Implementations
|
||||
{
|
||||
public interface IStartupOptions
|
||||
|
@ -36,5 +38,10 @@ namespace Emby.Server.Implementations
|
|||
/// Gets the value of the --plugin-manifest-url command line option.
|
||||
/// </summary>
|
||||
string PluginManifestUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of the --published-server-url command line option.
|
||||
/// </summary>
|
||||
Uri PublishedServerUrl { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
|
@ -16,32 +14,6 @@ namespace Emby.Server.Implementations.Library
|
|||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Any folder named in this list will be ignored
|
||||
/// </summary>
|
||||
private static readonly string[] _ignoreFolders =
|
||||
{
|
||||
"metadata",
|
||||
"ps3_update",
|
||||
"ps3_vprm",
|
||||
"extrafanart",
|
||||
"extrathumbs",
|
||||
".actors",
|
||||
".wd_tv",
|
||||
|
||||
// Synology
|
||||
"@eaDir",
|
||||
"eaDir",
|
||||
"#recycle",
|
||||
|
||||
// Qnap
|
||||
"@Recycle",
|
||||
".@__thumb",
|
||||
"$RECYCLE.BIN",
|
||||
"System Volume Information",
|
||||
".grab",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CoreResolutionIgnoreRule"/> class.
|
||||
/// </summary>
|
||||
|
@ -60,23 +32,15 @@ namespace Emby.Server.Implementations.Library
|
|||
return false;
|
||||
}
|
||||
|
||||
var filename = fileInfo.Name;
|
||||
|
||||
// Ignore hidden files on UNIX
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT
|
||||
&& filename[0] == '.')
|
||||
if (IgnorePatterns.ShouldIgnore(fileInfo.FullName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var filename = fileInfo.Name;
|
||||
|
||||
if (fileInfo.IsDirectory)
|
||||
{
|
||||
// Ignore any folders in our list
|
||||
if (_ignoreFolders.Contains(filename, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent != null)
|
||||
{
|
||||
// Ignore trailer folders but allow it at the collection level
|
||||
|
@ -109,11 +73,6 @@ namespace Emby.Server.Implementations.Library
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore samples
|
||||
Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase);
|
||||
|
||||
return m.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
using System.Linq;
|
||||
using DotNet.Globbing;
|
||||
|
||||
namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
/// <summary>
|
||||
/// Glob patterns for files to ignore
|
||||
/// </summary>
|
||||
public static class IgnorePatterns
|
||||
{
|
||||
/// <summary>
|
||||
/// Files matching these glob patterns will be ignored
|
||||
/// </summary>
|
||||
public static readonly string[] Patterns = new string[]
|
||||
{
|
||||
"**/small.jpg",
|
||||
"**/albumart.jpg",
|
||||
"**/*sample*",
|
||||
|
||||
// Directories
|
||||
"**/metadata/**",
|
||||
"**/ps3_update/**",
|
||||
"**/ps3_vprm/**",
|
||||
"**/extrafanart/**",
|
||||
"**/extrathumbs/**",
|
||||
"**/.actors/**",
|
||||
"**/.wd_tv/**",
|
||||
"**/lost+found/**",
|
||||
|
||||
// WMC temp recording directories that will constantly be written to
|
||||
"**/TempRec/**",
|
||||
"**/TempSBE/**",
|
||||
|
||||
// Synology
|
||||
"**/eaDir/**",
|
||||
"**/@eaDir/**",
|
||||
"**/#recycle/**",
|
||||
|
||||
// Qnap
|
||||
"**/@Recycle/**",
|
||||
"**/.@__thumb/**",
|
||||
"**/$RECYCLE.BIN/**",
|
||||
"**/System Volume Information/**",
|
||||
"**/.grab/**",
|
||||
|
||||
// Unix hidden files and directories
|
||||
"**/.*/**",
|
||||
|
||||
// thumbs.db
|
||||
"**/thumbs.db",
|
||||
|
||||
// bts sync files
|
||||
"**/*.bts",
|
||||
"**/*.sync",
|
||||
};
|
||||
|
||||
private static readonly GlobOptions _globOptions = new GlobOptions
|
||||
{
|
||||
Evaluation = {
|
||||
CaseInsensitive = true
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly Glob[] _globs = Patterns.Select(p => Glob.Parse(p, _globOptions)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the supplied path should be ignored
|
||||
/// </summary>
|
||||
public static bool ShouldIgnore(string path)
|
||||
{
|
||||
return _globs.Any(g => g.IsMatch(path));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||
{
|
||||
public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book>
|
||||
{
|
||||
private readonly string[] _validExtensions = { ".pdf", ".epub", ".mobi", ".cbr", ".cbz", ".azw3" };
|
||||
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".opf", ".pdf" };
|
||||
|
||||
protected override Book Resolve(ItemResolveArgs args)
|
||||
{
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
|
@ -118,6 +119,17 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
|
|||
//OpenedMediaSource.SupportsDirectStream = true;
|
||||
//OpenedMediaSource.SupportsTranscoding = true;
|
||||
await taskCompletionSource.Task.ConfigureAwait(false);
|
||||
if (taskCompletionSource.Task.Exception != null)
|
||||
{
|
||||
// Error happened while opening the stream so raise the exception again to inform the caller
|
||||
throw taskCompletionSource.Task.Exception;
|
||||
}
|
||||
|
||||
if (!taskCompletionSource.Task.Result)
|
||||
{
|
||||
Logger.LogWarning("Zero bytes copied from stream {0} to {1} but no exception raised", GetType().Name, TempFilePath);
|
||||
throw new EndOfStreamException(String.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name));
|
||||
}
|
||||
}
|
||||
|
||||
private Task StartStreaming(HttpResponseInfo response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
|
||||
|
@ -139,14 +151,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
|
|||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Logger.LogInformation("Copying of {0} to {1} was canceled", GetType().Name, TempFilePath);
|
||||
openTaskCompletionSource.TrySetException(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error copying live stream.");
|
||||
Logger.LogError(ex, "Error copying live stream {0} to {1}.", GetType().Name, TempFilePath);
|
||||
openTaskCompletionSource.TrySetException(ex);
|
||||
}
|
||||
|
||||
openTaskCompletionSource.TrySetResult(false);
|
||||
|
||||
EnableStreamSharing = false;
|
||||
await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"Channels": "القنوات",
|
||||
"ChapterNameValue": "الفصل {0}",
|
||||
"Collections": "مجموعات",
|
||||
"DeviceOfflineWithName": "قُطِع الاتصال بـ{0}",
|
||||
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
|
||||
"DeviceOnlineWithName": "{0} متصل",
|
||||
"FailedLoginAttemptWithUserName": "عملية تسجيل الدخول فشلت من {0}",
|
||||
"Favorites": "المفضلة",
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"HeaderFavoriteEpisodes": "Oblíbené epizody",
|
||||
"HeaderFavoriteShows": "Oblíbené seriály",
|
||||
"HeaderFavoriteSongs": "Oblíbená hudba",
|
||||
"HeaderLiveTV": "Živá TV",
|
||||
"HeaderLiveTV": "Televize",
|
||||
"HeaderNextUp": "Nadcházející",
|
||||
"HeaderRecordingGroups": "Skupiny nahrávek",
|
||||
"HomeVideos": "Domáci videa",
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
"HeaderFavoriteShows": "Programas favoritos",
|
||||
"HeaderFavoriteSongs": "Canciones favoritas",
|
||||
"HeaderLiveTV": "TV en vivo",
|
||||
"HeaderNextUp": "A Continuación",
|
||||
"HeaderNextUp": "Siguiente",
|
||||
"HeaderRecordingGroups": "Grupos de grabación",
|
||||
"HomeVideos": "Videos caseros",
|
||||
"Inherit": "Heredar",
|
||||
|
@ -44,7 +44,7 @@
|
|||
"NameInstallFailed": "{0} instalación fallida",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada desconocida",
|
||||
"NewVersionIsAvailable": "Una nueva versión del Servidor Jellyfin está disponible para descargar.",
|
||||
"NewVersionIsAvailable": "Una nueva versión del servidor Jellyfin está disponible para descargar.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada",
|
||||
"NotificationOptionAudioPlayback": "Se inició la reproducción de audio",
|
||||
|
@ -56,7 +56,7 @@
|
|||
"NotificationOptionPluginInstalled": "Complemento instalado",
|
||||
"NotificationOptionPluginUninstalled": "Complemento desinstalado",
|
||||
"NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada",
|
||||
"NotificationOptionServerRestartRequired": "Se necesita reiniciar el Servidor",
|
||||
"NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor",
|
||||
"NotificationOptionTaskFailed": "Falla de tarea programada",
|
||||
"NotificationOptionUserLockedOut": "Usuario bloqueado",
|
||||
"NotificationOptionVideoPlayback": "Se inició la reproducción de video",
|
||||
|
@ -71,7 +71,7 @@
|
|||
"ScheduledTaskFailedWithName": "{0} falló",
|
||||
"ScheduledTaskStartedWithName": "{0} iniciado",
|
||||
"ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
|
||||
"Shows": "Series",
|
||||
"Shows": "Programas",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
|
@ -94,25 +94,25 @@
|
|||
"ValueSpecialEpisodeName": "Especial - {0}",
|
||||
"VersionNumber": "Versión {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten basándose en la configuración de los metadatos.",
|
||||
"TaskDownloadMissingSubtitles": "Descargar subtítulos extraviados",
|
||||
"TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes",
|
||||
"TaskRefreshChannelsDescription": "Actualizar información de canales de internet.",
|
||||
"TaskRefreshChannels": "Actualizar canales",
|
||||
"TaskCleanTranscodeDescription": "Eliminar archivos transcodificados con mas de un día de antigüedad.",
|
||||
"TaskCleanTranscode": "Limpiar directorio de Transcodificado",
|
||||
"TaskCleanTranscode": "Limpiar directorio de transcodificación",
|
||||
"TaskUpdatePluginsDescription": "Descargar e instalar actualizaciones para complementos que estén configurados en actualizar automáticamente.",
|
||||
"TaskUpdatePlugins": "Actualizar complementos",
|
||||
"TaskRefreshPeopleDescription": "Actualizar metadatos de actores y directores en su librería multimedia.",
|
||||
"TaskRefreshPeopleDescription": "Actualizar metadatos de actores y directores en su biblioteca multimedia.",
|
||||
"TaskRefreshPeople": "Actualizar personas",
|
||||
"TaskCleanLogsDescription": "Eliminar archivos de registro que tengan mas de {0} días de antigüedad.",
|
||||
"TaskCleanLogs": "Limpiar directorio de registros",
|
||||
"TaskRefreshLibraryDescription": "Escanear su librería multimedia por nuevos archivos y refrescar metadatos.",
|
||||
"TaskRefreshLibrary": "Escanear librería multimedia",
|
||||
"TaskRefreshLibraryDescription": "Escanear su biblioteca multimedia por nuevos archivos y refrescar metadatos.",
|
||||
"TaskRefreshLibrary": "Escanear biblioteca multimedia",
|
||||
"TaskRefreshChapterImagesDescription": "Crear miniaturas de videos que tengan capítulos.",
|
||||
"TaskRefreshChapterImages": "Extraer imágenes de capitulo",
|
||||
"TaskCleanCacheDescription": "Eliminar archivos de cache que no se necesiten en el sistema.",
|
||||
"TaskCleanCache": "Limpiar directorio Cache",
|
||||
"TasksChannelsCategory": "Canales de Internet",
|
||||
"TasksApplicationCategory": "Solicitud",
|
||||
"TaskRefreshChapterImages": "Extraer imágenes de capítulo",
|
||||
"TaskCleanCacheDescription": "Eliminar archivos de caché que no se necesiten en el sistema.",
|
||||
"TaskCleanCache": "Limpiar directorio caché",
|
||||
"TasksChannelsCategory": "Canales de internet",
|
||||
"TasksApplicationCategory": "Aplicación",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksMaintenanceCategory": "Mantenimiento"
|
||||
}
|
||||
|
|
|
@ -16,16 +16,16 @@
|
|||
"Folders": "Carpetas",
|
||||
"Genres": "Géneros",
|
||||
"HeaderAlbumArtists": "Artistas del álbum",
|
||||
"HeaderCameraUploads": "Subidos desde Camara",
|
||||
"HeaderContinueWatching": "Continuar Viendo",
|
||||
"HeaderCameraUploads": "Subidas desde la cámara",
|
||||
"HeaderContinueWatching": "Continuar viendo",
|
||||
"HeaderFavoriteAlbums": "Álbumes favoritos",
|
||||
"HeaderFavoriteArtists": "Artistas favoritos",
|
||||
"HeaderFavoriteEpisodes": "Episodios favoritos",
|
||||
"HeaderFavoriteShows": "Programas favoritos",
|
||||
"HeaderFavoriteSongs": "Canciones favoritas",
|
||||
"HeaderLiveTV": "TV en Vivo",
|
||||
"HeaderNextUp": "A Continuación",
|
||||
"HeaderRecordingGroups": "Grupos de Grabaciones",
|
||||
"HeaderLiveTV": "TV en vivo",
|
||||
"HeaderNextUp": "A continuación",
|
||||
"HeaderRecordingGroups": "Grupos de grabación",
|
||||
"HomeVideos": "Videos caseros",
|
||||
"Inherit": "Heredar",
|
||||
"ItemAddedWithName": "{0} fue agregado a la biblioteca",
|
||||
|
@ -41,12 +41,12 @@
|
|||
"Movies": "Películas",
|
||||
"Music": "Música",
|
||||
"MusicVideos": "Videos musicales",
|
||||
"NameInstallFailed": "{0} instalación fallida",
|
||||
"NameInstallFailed": "Falló la instalación de {0}",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada Desconocida",
|
||||
"NameSeasonUnknown": "Temporada desconocida",
|
||||
"NewVersionIsAvailable": "Una nueva versión del Servidor Jellyfin está disponible para descargar.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Actualización de aplicación disponible",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Actualización de aplicación instalada",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada",
|
||||
"NotificationOptionAudioPlayback": "Reproducción de audio iniciada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida",
|
||||
"NotificationOptionCameraImageUploaded": "Imagen de la cámara subida",
|
||||
|
@ -56,7 +56,7 @@
|
|||
"NotificationOptionPluginInstalled": "Complemento instalado",
|
||||
"NotificationOptionPluginUninstalled": "Complemento desinstalado",
|
||||
"NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada",
|
||||
"NotificationOptionServerRestartRequired": "Se necesita reiniciar el Servidor",
|
||||
"NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor",
|
||||
"NotificationOptionTaskFailed": "Falla de tarea programada",
|
||||
"NotificationOptionUserLockedOut": "Usuario bloqueado",
|
||||
"NotificationOptionVideoPlayback": "Reproducción de video iniciada",
|
||||
|
@ -69,48 +69,48 @@
|
|||
"PluginUpdatedWithName": "{0} fue actualizado",
|
||||
"ProviderValue": "Proveedor: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} falló",
|
||||
"ScheduledTaskStartedWithName": "{0} Iniciado",
|
||||
"ScheduledTaskStartedWithName": "{0} iniciado",
|
||||
"ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado",
|
||||
"Shows": "Programas",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin esta cargando. Por favor intente de nuevo dentro de poco.",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.",
|
||||
"SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}",
|
||||
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
|
||||
"Sync": "Sincronizar",
|
||||
"System": "Sistema",
|
||||
"TvShows": "Programas de TV",
|
||||
"User": "Usuario",
|
||||
"UserCreatedWithName": "Se ha creado el usuario {0}",
|
||||
"UserDeletedWithName": "Se ha eliminado el usuario {0}",
|
||||
"UserDownloadingItemWithValues": "{0} esta descargando {1}",
|
||||
"UserCreatedWithName": "El usuario {0} ha sido creado",
|
||||
"UserDeletedWithName": "El usuario {0} ha sido eliminado",
|
||||
"UserDownloadingItemWithValues": "{0} está descargando {1}",
|
||||
"UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
|
||||
"UserOfflineFromDevice": "{0} se ha desconectado desde {1}",
|
||||
"UserOnlineFromDevice": "{0} está en línea desde {1}",
|
||||
"UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}",
|
||||
"UserPolicyUpdatedWithName": "Las política de usuario ha sido actualizada por {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está reproduciéndose {1} en {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducirse {1} en {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} se han añadido a su biblioteca de medios",
|
||||
"UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios",
|
||||
"ValueSpecialEpisodeName": "Especial - {0}",
|
||||
"VersionNumber": "Versión {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Buscar subtítulos de internet basado en configuración de metadatos.",
|
||||
"TaskDownloadMissingSubtitles": "Descargar subtítulos perdidos",
|
||||
"TaskRefreshChannelsDescription": "Refrescar información de canales de internet.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.",
|
||||
"TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes",
|
||||
"TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.",
|
||||
"TaskRefreshChannels": "Actualizar canales",
|
||||
"TaskCleanTranscodeDescription": "Eliminar archivos transcodificados que tengan mas de un día.",
|
||||
"TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.",
|
||||
"TaskCleanTranscode": "Limpiar directorio de transcodificado",
|
||||
"TaskUpdatePluginsDescription": "Descargar y actualizar complementos que están configurados para actualizarse automáticamente.",
|
||||
"TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.",
|
||||
"TaskUpdatePlugins": "Actualizar complementos",
|
||||
"TaskRefreshPeopleDescription": "Actualizar datos de actores y directores en su librería multimedia.",
|
||||
"TaskRefreshPeople": "Refrescar persona",
|
||||
"TaskCleanLogsDescription": "Eliminar archivos de registro con mas de {0} días.",
|
||||
"TaskCleanLogs": "Directorio de logo limpio",
|
||||
"TaskRefreshLibraryDescription": "Escanear su librería multimedia para nuevos archivos y refrescar metadatos.",
|
||||
"TaskRefreshLibrary": "Escanear librería multimerdia",
|
||||
"TaskRefreshChapterImagesDescription": "Crear miniaturas para videos con capítulos.",
|
||||
"TaskRefreshChapterImages": "Extraer imágenes de capítulos",
|
||||
"TaskCleanCacheDescription": "Eliminar archivos cache que ya no se necesiten por el sistema.",
|
||||
"TaskCleanCache": "Limpiar directorio cache",
|
||||
"TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.",
|
||||
"TaskRefreshPeople": "Actualizar personas",
|
||||
"TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.",
|
||||
"TaskCleanLogs": "Limpiar directorio de registros",
|
||||
"TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios por archivos nuevos y actualiza los metadatos.",
|
||||
"TaskRefreshLibrary": "Escanear biblioteca de medios",
|
||||
"TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.",
|
||||
"TaskRefreshChapterImages": "Extraer imágenes de los capítulos",
|
||||
"TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.",
|
||||
"TaskCleanCache": "Limpiar directorio caché",
|
||||
"TasksChannelsCategory": "Canales de Internet",
|
||||
"TasksApplicationCategory": "Aplicación",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
|
|
|
@ -96,21 +96,22 @@
|
|||
"TasksLibraryCategory": "Bibliothèque",
|
||||
"TasksMaintenanceCategory": "Entretien",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Recherche l'internet pour des sous-titres manquants à base de métadonnées configurées.",
|
||||
"TaskDownloadMissingSubtitles": "Télécharger des sous-titres manquants",
|
||||
"TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines d'internet.",
|
||||
"TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants",
|
||||
"TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines internet.",
|
||||
"TaskRefreshChannels": "Rafraîchir des chaines",
|
||||
"TaskCleanTranscodeDescription": "Retirer des fichiers de transcodage de plus qu'un jour.",
|
||||
"TaskCleanTranscode": "Nettoyer le directoire de transcodage",
|
||||
"TaskUpdatePluginsDescription": "Télécharger et installer des mises à jours des plugins qui sont configurés m.à.j. automisés.",
|
||||
"TaskUpdatePlugins": "Mise à jour des plugins",
|
||||
"TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque.",
|
||||
"TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage de plus d'un jour.",
|
||||
"TaskCleanTranscode": "Nettoyer le répertoire de transcodage",
|
||||
"TaskUpdatePluginsDescription": "Télécharger et installer les mises à jours des extensions qui sont configurés pour les m.à.j. automisés.",
|
||||
"TaskUpdatePlugins": "Mise à jour des extensions",
|
||||
"TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque de médias.",
|
||||
"TaskRefreshPeople": "Rafraîchir les acteurs",
|
||||
"TaskCleanLogsDescription": "Retire les données qui ont plus que {0} jours.",
|
||||
"TaskCleanLogs": "Nettoyer les données de directoire",
|
||||
"TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour des nouveaux fichiers et rafraîchit les métadonnées.",
|
||||
"TaskRefreshChapterImages": "Extraire des images du chapitre",
|
||||
"TaskRefreshChapterImagesDescription": "Créer des vignettes pour des vidéos qui ont des chapitres",
|
||||
"TaskRefreshLibrary": "Analyser la bibliothèque de média",
|
||||
"TaskCleanCache": "Nettoyer le cache de directoire",
|
||||
"TasksApplicationCategory": "Application"
|
||||
"TaskCleanLogsDescription": "Supprime les journaux qui ont plus que {0} jours.",
|
||||
"TaskCleanLogs": "Nettoyer le répertoire des journaux",
|
||||
"TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour trouver de nouveaux fichiers et rafraîchit les métadonnées.",
|
||||
"TaskRefreshChapterImages": "Extraire les images de chapitre",
|
||||
"TaskRefreshChapterImagesDescription": "Créer des vignettes pour les vidéos qui ont des chapitres",
|
||||
"TaskRefreshLibrary": "Analyser la bibliothèque de médias",
|
||||
"TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
|
||||
"TasksApplicationCategory": "Application",
|
||||
"TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système."
|
||||
}
|
||||
|
|
|
@ -107,5 +107,12 @@
|
|||
"TaskCleanLogs": "נקה תיקיית יומן",
|
||||
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.",
|
||||
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.",
|
||||
"TasksChannelsCategory": "ערוצי אינטרנט"
|
||||
"TasksChannelsCategory": "ערוצי אינטרנט",
|
||||
"TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.",
|
||||
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות.",
|
||||
"TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.",
|
||||
"TaskRefreshChannels": "רענן ערוץ",
|
||||
"TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.",
|
||||
"TaskCleanTranscode": "נקה תקיית Transcode",
|
||||
"TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי."
|
||||
}
|
||||
|
|
|
@ -80,16 +80,32 @@
|
|||
"ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt",
|
||||
"UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} er að spila {1} á {2}",
|
||||
"UserPolicyUpdatedWithName": "Notandaregla hefur verið uppfærð fyrir notanda {0}",
|
||||
"UserPolicyUpdatedWithName": "Notandaregla hefur verið uppfærð fyrir {0}",
|
||||
"UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt",
|
||||
"UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}",
|
||||
"UserOfflineFromDevice": "{0} hefur aftengst frá {1}",
|
||||
"UserLockedOutWithName": "Notanda {0} hefur verið hindraður aðgangur",
|
||||
"UserLockedOutWithName": "Notanda {0} hefur verið heflaður aðgangur",
|
||||
"UserDownloadingItemWithValues": "{0} Hleður niður {1}",
|
||||
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
|
||||
"ProviderValue": "Veitandi: {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón",
|
||||
"ValueSpecialEpisodeName": "Sérstakt - {0}",
|
||||
"Shows": "Þættir",
|
||||
"Playlists": "Spilunarlisti"
|
||||
"Shows": "Sýningar",
|
||||
"Playlists": "Spilunarlisti",
|
||||
"TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
|
||||
"TaskRefreshChannels": "Endurhlaða Rásir",
|
||||
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
|
||||
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
|
||||
"TaskUpdatePluginsDescription": "Sækja og setja upp uppfærslur fyrir viðbætur sem eru stilltar til að uppfæra sjálfkrafa.",
|
||||
"TaskUpdatePlugins": "Uppfæra viðbætur",
|
||||
"TaskRefreshPeopleDescription": "Uppfærir lýsigögn fyrir leikara og leikstjóra í miðlasafninu þínu.",
|
||||
"TaskRefreshLibraryDescription": "Skannar miðlasafnið þitt fyrir nýjum skrám og uppfærir lýsigögn.",
|
||||
"TaskRefreshLibrary": "Skanna miðlasafn",
|
||||
"TaskRefreshChapterImagesDescription": "Býr til smámyndir fyrir myndbönd sem hafa kaflaskil.",
|
||||
"TaskCleanCacheDescription": "Eyðir skrám í skyndiminni sem ekki er lengur þörf fyrir í kerfinu.",
|
||||
"TaskCleanCache": "Hreinsa skráasafn skyndiminnis",
|
||||
"TasksChannelsCategory": "Netrásir",
|
||||
"TasksApplicationCategory": "Forrit",
|
||||
"TasksLibraryCategory": "Miðlasafn",
|
||||
"TasksMaintenanceCategory": "Viðhald"
|
||||
}
|
||||
|
|
|
@ -92,5 +92,27 @@
|
|||
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką",
|
||||
"ValueSpecialEpisodeName": "Ypatinga - {0}",
|
||||
"VersionNumber": "Version {0}"
|
||||
"VersionNumber": "Version {0}",
|
||||
"TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.",
|
||||
"TaskUpdatePlugins": "Atnaujinti Priedus",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Ieško internete trūkstamų subtitrų remiantis metaduomenų konfigūracija.",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija",
|
||||
"TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.",
|
||||
"TaskRefreshLibrary": "Skenuoti Mediateka",
|
||||
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
||||
"TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informacija.",
|
||||
"TaskRefreshChannels": "Atnaujinti Kanalus",
|
||||
"TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.",
|
||||
"TaskRefreshPeople": "Atnaujinti Žmones",
|
||||
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
||||
"TaskCleanLogs": "Išvalyti Žurnalą",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašam, kurie turi scenas.",
|
||||
"TaskRefreshChapterImages": "Ištraukti Scenų Paveikslus",
|
||||
"TaskCleanCache": "Išvalyti Talpyklą",
|
||||
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
||||
"TasksChannelsCategory": "Internetiniai Kanalai",
|
||||
"TasksApplicationCategory": "Programa",
|
||||
"TasksLibraryCategory": "Mediateka",
|
||||
"TasksMaintenanceCategory": "Priežiūra"
|
||||
}
|
||||
|
|
|
@ -19,10 +19,10 @@
|
|||
"HeaderCameraUploads": "Envios da Câmera",
|
||||
"HeaderContinueWatching": "Continuar Assistindo",
|
||||
"HeaderFavoriteAlbums": "Álbuns Favoritos",
|
||||
"HeaderFavoriteArtists": "Artistas Favoritos",
|
||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
||||
"HeaderFavoriteShows": "Séries Favoritas",
|
||||
"HeaderFavoriteSongs": "Músicas Favoritas",
|
||||
"HeaderFavoriteArtists": "Artistas favoritos",
|
||||
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||
"HeaderFavoriteShows": "Séries favoritas",
|
||||
"HeaderFavoriteSongs": "Músicas favoritas",
|
||||
"HeaderLiveTV": "TV ao Vivo",
|
||||
"HeaderNextUp": "A Seguir",
|
||||
"HeaderRecordingGroups": "Grupos de Gravação",
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"ProviderValue": "ผู้ให้บริการ: {0}",
|
||||
"PluginUpdatedWithName": "{0} ได้รับการ update แล้ว",
|
||||
"PluginUninstalledWithName": "ถอนการติดตั้ง {0}",
|
||||
"PluginInstalledWithName": "{0} ได้รับการติดตั้ง",
|
||||
"Plugin": "Plugin",
|
||||
"Playlists": "รายการ",
|
||||
"Photos": "รูปภาพ",
|
||||
"NotificationOptionVideoPlaybackStopped": "หยุดการเล่น Video",
|
||||
"NotificationOptionVideoPlayback": "เริ่มแสดง Video",
|
||||
"NotificationOptionUserLockedOut": "ผู้ใช้ Locked Out",
|
||||
"NotificationOptionTaskFailed": "ตารางการทำงานล้มเหลว",
|
||||
"NotificationOptionServerRestartRequired": "ควร Restart Server",
|
||||
"NotificationOptionPluginUpdateInstalled": "Update Plugin แล้ว",
|
||||
"NotificationOptionPluginUninstalled": "ถอด Plugin",
|
||||
"NotificationOptionPluginInstalled": "ติดตั้ง Plugin แล้ว",
|
||||
"NotificationOptionPluginError": "Plugin ล้มเหลว",
|
||||
"NotificationOptionNewLibraryContent": "เพิ่มข้อมูลใหม่แล้ว",
|
||||
"NotificationOptionInstallationFailed": "ติดตั้งล้มเหลว",
|
||||
"NotificationOptionCameraImageUploaded": "รูปภาพถูก upload",
|
||||
"NotificationOptionAudioPlaybackStopped": "หยุดการเล่นเสียง",
|
||||
"NotificationOptionAudioPlayback": "เริ่มเล่นเสียง",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Update ระบบแล้ว",
|
||||
"NotificationOptionApplicationUpdateAvailable": "ระบบ update สามารถใช้ได้แล้ว",
|
||||
"NewVersionIsAvailable": "ตรวจพบ Jellyfin เวอร์ชั่นใหม่",
|
||||
"NameSeasonUnknown": "ไม่ทราบปี",
|
||||
"NameSeasonNumber": "ปี {0}",
|
||||
"NameInstallFailed": "{0} ติดตั้งไม่สำเร็จ",
|
||||
"MusicVideos": "MV",
|
||||
"Music": "เพลง",
|
||||
"Movies": "ภาพยนต์",
|
||||
"MixedContent": "รายการแบบผสม",
|
||||
"MessageServerConfigurationUpdated": "การตั้งค่า update แล้ว",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "รายการตั้งค่า {0} ได้รับการ update แล้ว",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Server จะ update ไปที่ {0}",
|
||||
"MessageApplicationUpdated": "Jellyfin Server update แล้ว",
|
||||
"Latest": "ล่าสุด",
|
||||
"LabelRunningTimeValue": "เวลาที่เล่น : {0}",
|
||||
"LabelIpAddressValue": "IP address: {0}",
|
||||
"ItemRemovedWithName": "{0} ถูกลบจากรายการ",
|
||||
"ItemAddedWithName": "{0} ถูกเพิ่มในรายการ",
|
||||
"Inherit": "การสืบทอด",
|
||||
"HomeVideos": "วีดีโอส่วนตัว",
|
||||
"HeaderRecordingGroups": "ค่ายบันทึก",
|
||||
"HeaderNextUp": "ถัดไป",
|
||||
"HeaderLiveTV": "รายการสด",
|
||||
"HeaderFavoriteSongs": "เพลงโปรด",
|
||||
"HeaderFavoriteShows": "รายการโชว์โปรด",
|
||||
"HeaderFavoriteEpisodes": "ฉากโปรด",
|
||||
"HeaderFavoriteArtists": "นักแสดงโปรด",
|
||||
"HeaderFavoriteAlbums": "อัมบั้มโปรด",
|
||||
"HeaderContinueWatching": "ชมต่อจากเดิม",
|
||||
"HeaderCameraUploads": "Upload รูปภาพ",
|
||||
"HeaderAlbumArtists": "อัลบั้มนักแสดง",
|
||||
"Genres": "ประเภท",
|
||||
"Folders": "โฟลเดอร์",
|
||||
"Favorites": "รายการโปรด",
|
||||
"FailedLoginAttemptWithUserName": "การเชื่อมต่อล้มเหลวจาก {0}",
|
||||
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จ",
|
||||
"DeviceOfflineWithName": "{0} ตัดการเชื่อมต่อ",
|
||||
"Collections": "ชุด",
|
||||
"ChapterNameValue": "บทที่ {0}",
|
||||
"Channels": "ชาแนล",
|
||||
"CameraImageUploadedFrom": "รูปภาพถูก upload จาก {0}",
|
||||
"Books": "หนังสือ",
|
||||
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จ",
|
||||
"Artists": "นักแสดง",
|
||||
"Application": "แอปพลิเคชั่น",
|
||||
"AppDeviceValues": "App: {0}, อุปกรณ์: {1}",
|
||||
"Albums": "อัลบั้ม"
|
||||
}
|
|
@ -11,15 +11,15 @@
|
|||
"Collections": "合輯",
|
||||
"DeviceOfflineWithName": "{0} 已經斷開連結",
|
||||
"DeviceOnlineWithName": "{0} 已經連接",
|
||||
"FailedLoginAttemptWithUserName": "來自 {0} 的失敗登入嘗試",
|
||||
"FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗",
|
||||
"Favorites": "我的最愛",
|
||||
"Folders": "檔案夾",
|
||||
"Genres": "風格",
|
||||
"HeaderAlbumArtists": "專輯藝術家",
|
||||
"HeaderAlbumArtists": "專輯藝人",
|
||||
"HeaderCameraUploads": "相機上載",
|
||||
"HeaderContinueWatching": "繼續觀看",
|
||||
"HeaderFavoriteAlbums": "最愛專輯",
|
||||
"HeaderFavoriteArtists": "最愛藝術家",
|
||||
"HeaderFavoriteArtists": "最愛的藝人",
|
||||
"HeaderFavoriteEpisodes": "最愛的劇集",
|
||||
"HeaderFavoriteShows": "最愛的節目",
|
||||
"HeaderFavoriteSongs": "最愛的歌曲",
|
||||
|
@ -33,14 +33,14 @@
|
|||
"LabelIpAddressValue": "IP 地址: {0}",
|
||||
"LabelRunningTimeValue": "運行時間: {0}",
|
||||
"Latest": "最新",
|
||||
"MessageApplicationUpdated": "Jellyfin Server 已更新",
|
||||
"MessageApplicationUpdated": "Jellyfin 伺服器已更新",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin 伺服器已更新至 {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 部分已更新",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已更新",
|
||||
"MessageServerConfigurationUpdated": "伺服器設定已經更新",
|
||||
"MixedContent": "Mixed content",
|
||||
"MixedContent": "混合內容",
|
||||
"Movies": "電影",
|
||||
"Music": "音樂",
|
||||
"MusicVideos": "音樂MV",
|
||||
"MusicVideos": "音樂視頻",
|
||||
"NameInstallFailed": "{0} 安裝失敗",
|
||||
"NameSeasonNumber": "第 {0} 季",
|
||||
"NameSeasonUnknown": "未知季數",
|
||||
|
@ -49,7 +49,7 @@
|
|||
"NotificationOptionApplicationUpdateInstalled": "應用程式已更新",
|
||||
"NotificationOptionAudioPlayback": "開始播放音頻",
|
||||
"NotificationOptionAudioPlaybackStopped": "已停止播放音頻",
|
||||
"NotificationOptionCameraImageUploaded": "相機相片已上傳",
|
||||
"NotificationOptionCameraImageUploaded": "相片已上傳",
|
||||
"NotificationOptionInstallationFailed": "安裝失敗",
|
||||
"NotificationOptionNewLibraryContent": "已添加新内容",
|
||||
"NotificationOptionPluginError": "擴充元件錯誤",
|
||||
|
@ -63,11 +63,11 @@
|
|||
"NotificationOptionVideoPlaybackStopped": "已停止播放視頻",
|
||||
"Photos": "相片",
|
||||
"Playlists": "播放清單",
|
||||
"Plugin": "Plugin",
|
||||
"Plugin": "插件",
|
||||
"PluginInstalledWithName": "已安裝 {0}",
|
||||
"PluginUninstalledWithName": "已移除 {0}",
|
||||
"PluginUpdatedWithName": "已更新 {0}",
|
||||
"ProviderValue": "Provider: {0}",
|
||||
"ProviderValue": "提供者: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} 任務失敗",
|
||||
"ScheduledTaskStartedWithName": "{0} 任務開始",
|
||||
"ServerNameNeedsToBeRestarted": "{0} 需要重啓",
|
||||
|
@ -77,17 +77,17 @@
|
|||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
|
||||
"Sync": "同步",
|
||||
"System": "System",
|
||||
"System": "系統",
|
||||
"TvShows": "電視節目",
|
||||
"User": "User",
|
||||
"UserCreatedWithName": "用家 {0} 已創建",
|
||||
"UserDeletedWithName": "用家 {0} 已移除",
|
||||
"User": "使用者",
|
||||
"UserCreatedWithName": "使用者 {0} 已創建",
|
||||
"UserDeletedWithName": "使用者 {0} 已移除",
|
||||
"UserDownloadingItemWithValues": "{0} 正在下載 {1}",
|
||||
"UserLockedOutWithName": "用家 {0} 已被鎖定",
|
||||
"UserLockedOutWithName": "使用者 {0} 已被鎖定",
|
||||
"UserOfflineFromDevice": "{0} 已從 {1} 斷開",
|
||||
"UserOnlineFromDevice": "{0} 已連綫,來自 {1}",
|
||||
"UserPasswordChangedWithName": "用家 {0} 的密碼已變更",
|
||||
"UserPolicyUpdatedWithName": "用戶協議已被更新為 {0}",
|
||||
"UserPasswordChangedWithName": "使用者 {0} 的密碼已變更",
|
||||
"UserPolicyUpdatedWithName": "使用者協議已更新為 {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} 已添加到你的媒體庫",
|
||||
|
@ -95,5 +95,23 @@
|
|||
"VersionNumber": "版本{0}",
|
||||
"TaskDownloadMissingSubtitles": "下載遺失的字幕",
|
||||
"TaskUpdatePlugins": "更新插件",
|
||||
"TasksApplicationCategory": "應用程式"
|
||||
"TasksApplicationCategory": "應用程式",
|
||||
"TaskRefreshLibraryDescription": "掃描媒體庫以查找新文件並刷新metadata。",
|
||||
"TasksMaintenanceCategory": "維護",
|
||||
"TaskDownloadMissingSubtitlesDescription": "根據metadata配置在互聯網上搜索缺少的字幕。",
|
||||
"TaskRefreshChannelsDescription": "刷新互聯網頻道信息。",
|
||||
"TaskRefreshChannels": "刷新頻道",
|
||||
"TaskCleanTranscodeDescription": "刪除超過一天的轉碼文件。",
|
||||
"TaskCleanTranscode": "清理轉碼目錄",
|
||||
"TaskUpdatePluginsDescription": "下載並安裝配置為自動更新的插件的更新。",
|
||||
"TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的metadata。",
|
||||
"TaskCleanLogsDescription": "刪除超過{0}天的日誌文件。",
|
||||
"TaskCleanLogs": "清理日誌目錄",
|
||||
"TaskRefreshLibrary": "掃描媒體庫",
|
||||
"TaskRefreshChapterImagesDescription": "為帶有章節的視頻創建縮略圖。",
|
||||
"TaskRefreshChapterImages": "提取章節圖像",
|
||||
"TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。",
|
||||
"TaskCleanCache": "清理緩存目錄",
|
||||
"TasksChannelsCategory": "互聯網頻道",
|
||||
"TasksLibraryCategory": "庫"
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ using MediaBrowser.Model.Events;
|
|||
using MediaBrowser.Model.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
||||
|
||||
|
@ -1164,6 +1165,22 @@ namespace Emby.Server.Implementations.Session
|
|||
await SendMessageToSession(session, "Play", command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckDisposed();
|
||||
var session = GetSessionToRemoteControl(sessionId);
|
||||
await SendMessageToSession(session, "SyncPlayCommand", command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckDisposed();
|
||||
var session = GetSessionToRemoteControl(sessionId);
|
||||
await SendMessageToSession(session, "SyncPlayGroupUpdate", command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IEnumerable<BaseItem> TranslateItemForPlayback(Guid id, User user)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Events;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
@ -13,6 +18,21 @@ namespace Emby.Server.Implementations.Session
|
|||
/// </summary>
|
||||
public sealed class SessionWebSocketListener : IWebSocketListener, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The timeout in seconds after which a WebSocket is considered to be lost.
|
||||
/// </summary>
|
||||
public const int WebSocketLostTimeout = 60;
|
||||
|
||||
/// <summary>
|
||||
/// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets.
|
||||
/// </summary>
|
||||
public const float IntervalFactor = 0.2f;
|
||||
|
||||
/// <summary>
|
||||
/// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent.
|
||||
/// </summary>
|
||||
public const float ForceKeepAliveFactor = 0.75f;
|
||||
|
||||
/// <summary>
|
||||
/// The _session manager
|
||||
/// </summary>
|
||||
|
@ -26,6 +46,26 @@ namespace Emby.Server.Implementations.Session
|
|||
|
||||
private readonly IHttpServer _httpServer;
|
||||
|
||||
/// <summary>
|
||||
/// The KeepAlive cancellation token.
|
||||
/// </summary>
|
||||
private CancellationTokenSource _keepAliveCancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// Lock used for accesing the KeepAlive cancellation token.
|
||||
/// </summary>
|
||||
private readonly object _keepAliveLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// The WebSocket watchlist.
|
||||
/// </summary>
|
||||
private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
|
||||
|
||||
/// <summary>
|
||||
/// Lock used for accesing the WebSockets watchlist.
|
||||
/// </summary>
|
||||
private readonly object _webSocketsLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
|
||||
/// </summary>
|
||||
|
@ -47,12 +87,13 @@ namespace Emby.Server.Implementations.Session
|
|||
httpServer.WebSocketConnected += OnServerManagerWebSocketConnected;
|
||||
}
|
||||
|
||||
private void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
|
||||
private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
|
||||
{
|
||||
var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
|
||||
if (session != null)
|
||||
{
|
||||
EnsureController(session, e.Argument);
|
||||
await KeepAliveWebSocket(e.Argument);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -81,6 +122,7 @@ namespace Emby.Server.Implementations.Session
|
|||
public void Dispose()
|
||||
{
|
||||
_httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected;
|
||||
StopKeepAlive();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -99,5 +141,206 @@ namespace Emby.Server.Implementations.Session
|
|||
var controller = (WebSocketController)controllerInfo.Item1;
|
||||
controller.AddWebSocket(connection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a WebSocket is closed.
|
||||
/// </summary>
|
||||
/// <param name="sender">The WebSocket.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void OnWebSocketClosed(object sender, EventArgs e)
|
||||
{
|
||||
var webSocket = (IWebSocketConnection)sender;
|
||||
_logger.LogDebug("WebSocket {0} is closed.", webSocket);
|
||||
RemoveWebSocket(webSocket);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a WebSocket to the KeepAlive watchlist.
|
||||
/// </summary>
|
||||
/// <param name="webSocket">The WebSocket to monitor.</param>
|
||||
private async Task KeepAliveWebSocket(IWebSocketConnection webSocket)
|
||||
{
|
||||
lock (_webSocketsLock)
|
||||
{
|
||||
if (!_webSockets.Add(webSocket))
|
||||
{
|
||||
_logger.LogWarning("Multiple attempts to keep alive single WebSocket {0}", webSocket);
|
||||
return;
|
||||
}
|
||||
webSocket.Closed += OnWebSocketClosed;
|
||||
webSocket.LastKeepAliveDate = DateTime.UtcNow;
|
||||
|
||||
StartKeepAlive();
|
||||
}
|
||||
|
||||
// Notify WebSocket about timeout
|
||||
try
|
||||
{
|
||||
await SendForceKeepAlive(webSocket);
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a WebSocket from the KeepAlive watchlist.
|
||||
/// </summary>
|
||||
/// <param name="webSocket">The WebSocket to remove.</param>
|
||||
private void RemoveWebSocket(IWebSocketConnection webSocket)
|
||||
{
|
||||
lock (_webSocketsLock)
|
||||
{
|
||||
if (!_webSockets.Remove(webSocket))
|
||||
{
|
||||
_logger.LogWarning("WebSocket {0} not on watchlist.", webSocket);
|
||||
}
|
||||
else
|
||||
{
|
||||
webSocket.Closed -= OnWebSocketClosed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the KeepAlive watcher.
|
||||
/// </summary>
|
||||
private void StartKeepAlive()
|
||||
{
|
||||
lock (_keepAliveLock)
|
||||
{
|
||||
if (_keepAliveCancellationToken == null)
|
||||
{
|
||||
_keepAliveCancellationToken = new CancellationTokenSource();
|
||||
// Start KeepAlive watcher
|
||||
_ = RepeatAsyncCallbackEvery(
|
||||
KeepAliveSockets,
|
||||
TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor),
|
||||
_keepAliveCancellationToken.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the KeepAlive watcher.
|
||||
/// </summary>
|
||||
private void StopKeepAlive()
|
||||
{
|
||||
lock (_keepAliveLock)
|
||||
{
|
||||
if (_keepAliveCancellationToken != null)
|
||||
{
|
||||
_keepAliveCancellationToken.Cancel();
|
||||
_keepAliveCancellationToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_webSocketsLock)
|
||||
{
|
||||
foreach (var webSocket in _webSockets)
|
||||
{
|
||||
webSocket.Closed -= OnWebSocketClosed;
|
||||
}
|
||||
|
||||
_webSockets.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks status of KeepAlive of WebSockets.
|
||||
/// </summary>
|
||||
private async Task KeepAliveSockets()
|
||||
{
|
||||
List<IWebSocketConnection> inactive;
|
||||
List<IWebSocketConnection> lost;
|
||||
|
||||
lock (_webSocketsLock)
|
||||
{
|
||||
_logger.LogDebug("Watching {0} WebSockets.", _webSockets.Count);
|
||||
|
||||
inactive = _webSockets.Where(i =>
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds;
|
||||
return (elapsed > WebSocketLostTimeout * ForceKeepAliveFactor) && (elapsed < WebSocketLostTimeout);
|
||||
}).ToList();
|
||||
lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList();
|
||||
}
|
||||
|
||||
if (inactive.Any())
|
||||
{
|
||||
_logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
}
|
||||
|
||||
foreach (var webSocket in inactive)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendForceKeepAlive(webSocket);
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
_logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket.");
|
||||
lost.Add(webSocket);
|
||||
}
|
||||
}
|
||||
|
||||
lock (_webSocketsLock)
|
||||
{
|
||||
if (lost.Any())
|
||||
{
|
||||
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
|
||||
foreach (var webSocket in lost)
|
||||
{
|
||||
// TODO: handle session relative to the lost webSocket
|
||||
RemoveWebSocket(webSocket);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_webSockets.Any())
|
||||
{
|
||||
StopKeepAlive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a ForceKeepAlive message to a WebSocket.
|
||||
/// </summary>
|
||||
/// <param name="webSocket">The WebSocket.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private Task SendForceKeepAlive(IWebSocketConnection webSocket)
|
||||
{
|
||||
return webSocket.SendAsync(new WebSocketMessage<int>
|
||||
{
|
||||
MessageType = "ForceKeepAlive",
|
||||
Data = WebSocketLostTimeout
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a given async callback once every specified interval time, until cancelled.
|
||||
/// </summary>
|
||||
/// <param name="callback">The async callback.</param>
|
||||
/// <param name="interval">The interval time.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task RepeatAsyncCallbackEvery(Func<Task> callback, TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await callback();
|
||||
Task task = Task.Delay(interval, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,517 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using MediaBrowser.Model.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
|
||||
namespace Emby.Server.Implementations.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class SyncPlayController.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Class is not thread-safe, external locking is required when accessing methods.
|
||||
/// </remarks>
|
||||
public class SyncPlayController : ISyncPlayController
|
||||
{
|
||||
/// <summary>
|
||||
/// Used to filter the sessions of a group.
|
||||
/// </summary>
|
||||
private enum BroadcastType
|
||||
{
|
||||
/// <summary>
|
||||
/// All sessions will receive the message.
|
||||
/// </summary>
|
||||
AllGroup = 0,
|
||||
/// <summary>
|
||||
/// Only the specified session will receive the message.
|
||||
/// </summary>
|
||||
CurrentSession = 1,
|
||||
/// <summary>
|
||||
/// All sessions, except the current one, will receive the message.
|
||||
/// </summary>
|
||||
AllExceptCurrentSession = 2,
|
||||
/// <summary>
|
||||
/// Only sessions that are not buffering will receive the message.
|
||||
/// </summary>
|
||||
AllReady = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The session manager.
|
||||
/// </summary>
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// The SyncPlay manager.
|
||||
/// </summary>
|
||||
private readonly ISyncPlayManager _syncPlayManager;
|
||||
|
||||
/// <summary>
|
||||
/// The group to manage.
|
||||
/// </summary>
|
||||
private readonly GroupInfo _group = new GroupInfo();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid GetGroupId() => _group.GroupId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid GetPlayingItemId() => _group.PlayingItem.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsGroupEmpty() => _group.IsEmpty();
|
||||
|
||||
public SyncPlayController(
|
||||
ISessionManager sessionManager,
|
||||
ISyncPlayManager syncPlayManager)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_syncPlayManager = syncPlayManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts DateTime to UTC string.
|
||||
/// </summary>
|
||||
/// <param name="date">The date to convert.</param>
|
||||
/// <value>The UTC string.</value>
|
||||
private string DateToUTCString(DateTime date)
|
||||
{
|
||||
return date.ToUniversalTime().ToString("o");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters sessions of this group.
|
||||
/// </summary>
|
||||
/// <param name="from">The current session.</param>
|
||||
/// <param name="type">The filtering type.</param>
|
||||
/// <value>The array of sessions matching the filter.</value>
|
||||
private SessionInfo[] FilterSessions(SessionInfo from, BroadcastType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case BroadcastType.CurrentSession:
|
||||
return new SessionInfo[] { from };
|
||||
case BroadcastType.AllGroup:
|
||||
return _group.Participants.Values.Select(
|
||||
session => session.Session
|
||||
).ToArray();
|
||||
case BroadcastType.AllExceptCurrentSession:
|
||||
return _group.Participants.Values.Select(
|
||||
session => session.Session
|
||||
).Where(
|
||||
session => !session.Id.Equals(from.Id)
|
||||
).ToArray();
|
||||
case BroadcastType.AllReady:
|
||||
return _group.Participants.Values.Where(
|
||||
session => !session.IsBuffering
|
||||
).Select(
|
||||
session => session.Session
|
||||
).ToArray();
|
||||
default:
|
||||
return Array.Empty<SessionInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GroupUpdate message to the interested sessions.
|
||||
/// </summary>
|
||||
/// <param name="from">The current session.</param>
|
||||
/// <param name="type">The filtering type.</param>
|
||||
/// <param name="message">The message to send.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <value>The task.</value>
|
||||
private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Task> GetTasks()
|
||||
{
|
||||
SessionInfo[] sessions = FilterSessions(from, type);
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), message, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.WhenAll(GetTasks());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a playback command to the interested sessions.
|
||||
/// </summary>
|
||||
/// <param name="from">The current session.</param>
|
||||
/// <param name="type">The filtering type.</param>
|
||||
/// <param name="message">The message to send.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <value>The task.</value>
|
||||
private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Task> GetTasks()
|
||||
{
|
||||
SessionInfo[] sessions = FilterSessions(from, type);
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
yield return _sessionManager.SendSyncPlayCommand(session.Id.ToString(), message, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.WhenAll(GetTasks());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a new playback command with some default values.
|
||||
/// </summary>
|
||||
/// <param name="type">The command type.</param>
|
||||
/// <value>The SendCommand.</value>
|
||||
private SendCommand NewSyncPlayCommand(SendCommandType type)
|
||||
{
|
||||
return new SendCommand()
|
||||
{
|
||||
GroupId = _group.GroupId.ToString(),
|
||||
Command = type,
|
||||
PositionTicks = _group.PositionTicks,
|
||||
When = DateToUTCString(_group.LastActivity),
|
||||
EmittedAt = DateToUTCString(DateTime.UtcNow)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a new group update message.
|
||||
/// </summary>
|
||||
/// <param name="type">The update type.</param>
|
||||
/// <param name="data">The data to send.</param>
|
||||
/// <value>The GroupUpdate.</value>
|
||||
private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
|
||||
{
|
||||
return new GroupUpdate<T>()
|
||||
{
|
||||
GroupId = _group.GroupId.ToString(),
|
||||
Type = type,
|
||||
Data = data
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InitGroup(SessionInfo session, CancellationToken cancellationToken)
|
||||
{
|
||||
_group.AddSession(session);
|
||||
_syncPlayManager.AddSessionToGroup(session, this);
|
||||
|
||||
_group.PlayingItem = session.FullNowPlayingItem;
|
||||
_group.IsPaused = true;
|
||||
_group.PositionTicks = session.PlayState.PositionTicks ?? 0;
|
||||
_group.LastActivity = DateTime.UtcNow;
|
||||
|
||||
var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
|
||||
SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
|
||||
var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (session.NowPlayingItem?.Id == _group.PlayingItem.Id && request.PlayingItemId == _group.PlayingItem.Id)
|
||||
{
|
||||
_group.AddSession(session);
|
||||
_syncPlayManager.AddSessionToGroup(session, this);
|
||||
|
||||
var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
|
||||
SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
|
||||
|
||||
var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
|
||||
SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
|
||||
|
||||
// Client join and play, syncing will happen client side
|
||||
if (!_group.IsPaused)
|
||||
{
|
||||
var playCommand = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var playRequest = new PlayRequest();
|
||||
playRequest.ItemIds = new Guid[] { _group.PlayingItem.Id };
|
||||
playRequest.StartPositionTicks = _group.PositionTicks;
|
||||
var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
|
||||
SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SessionLeave(SessionInfo session, CancellationToken cancellationToken)
|
||||
{
|
||||
_group.RemoveSession(session);
|
||||
_syncPlayManager.RemoveSessionFromGroup(session, this);
|
||||
|
||||
var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks);
|
||||
SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
|
||||
|
||||
var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
|
||||
SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// The server's job is to mantain a consistent state to which clients refer to,
|
||||
// as also to notify clients of state changes.
|
||||
// The actual syncing of media playback happens client side.
|
||||
// Clients are aware of the server's time and use it to sync.
|
||||
switch (request.Type)
|
||||
{
|
||||
case PlaybackRequestType.Play:
|
||||
HandlePlayRequest(session, request, cancellationToken);
|
||||
break;
|
||||
case PlaybackRequestType.Pause:
|
||||
HandlePauseRequest(session, request, cancellationToken);
|
||||
break;
|
||||
case PlaybackRequestType.Seek:
|
||||
HandleSeekRequest(session, request, cancellationToken);
|
||||
break;
|
||||
case PlaybackRequestType.Buffering:
|
||||
HandleBufferingRequest(session, request, cancellationToken);
|
||||
break;
|
||||
case PlaybackRequestType.BufferingDone:
|
||||
HandleBufferingDoneRequest(session, request, cancellationToken);
|
||||
break;
|
||||
case PlaybackRequestType.UpdatePing:
|
||||
HandlePingUpdateRequest(session, request);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a play action requested by a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The play action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_group.IsPaused)
|
||||
{
|
||||
// Pick a suitable time that accounts for latency
|
||||
var delay = _group.GetHighestPing() * 2;
|
||||
delay = delay < _group.DefaulPing ? _group.DefaulPing : delay;
|
||||
|
||||
// Unpause group and set starting point in future
|
||||
// Clients will start playback at LastActivity (datetime) from PositionTicks (playback position)
|
||||
// The added delay does not guarantee, of course, that the command will be received in time
|
||||
// Playback synchronization will mainly happen client side
|
||||
_group.IsPaused = false;
|
||||
_group.LastActivity = DateTime.UtcNow.AddMilliseconds(
|
||||
delay
|
||||
);
|
||||
|
||||
var command = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Client got lost, sending current state
|
||||
var command = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a pause action requested by a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The pause action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_group.IsPaused)
|
||||
{
|
||||
// Pause group and compute the media playback position
|
||||
_group.IsPaused = true;
|
||||
var currentTime = DateTime.UtcNow;
|
||||
var elapsedTime = currentTime - _group.LastActivity;
|
||||
_group.LastActivity = currentTime;
|
||||
// Seek only if playback actually started
|
||||
// (a pause request may be issued during the delay added to account for latency)
|
||||
_group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
|
||||
|
||||
var command = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Client got lost, sending current state
|
||||
var command = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a seek action requested by a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The seek action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Sanitize PositionTicks
|
||||
var ticks = SanitizePositionTicks(request.PositionTicks);
|
||||
|
||||
// Pause and seek
|
||||
_group.IsPaused = true;
|
||||
_group.PositionTicks = ticks;
|
||||
_group.LastActivity = DateTime.UtcNow;
|
||||
|
||||
var command = NewSyncPlayCommand(SendCommandType.Seek);
|
||||
SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a buffering action requested by a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The buffering action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_group.IsPaused)
|
||||
{
|
||||
// Pause group and compute the media playback position
|
||||
_group.IsPaused = true;
|
||||
var currentTime = DateTime.UtcNow;
|
||||
var elapsedTime = currentTime - _group.LastActivity;
|
||||
_group.LastActivity = currentTime;
|
||||
_group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
|
||||
|
||||
_group.SetBuffering(session, true);
|
||||
|
||||
// Send pause command to all non-buffering sessions
|
||||
var command = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.AllReady, command, cancellationToken);
|
||||
|
||||
var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
|
||||
SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Client got lost, sending current state
|
||||
var command = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a buffering-done action requested by a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The buffering-done action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_group.IsPaused)
|
||||
{
|
||||
_group.SetBuffering(session, false);
|
||||
|
||||
var requestTicks = SanitizePositionTicks(request.PositionTicks);
|
||||
|
||||
var when = request.When ?? DateTime.UtcNow;
|
||||
var currentTime = DateTime.UtcNow;
|
||||
var elapsedTime = currentTime - when;
|
||||
var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
|
||||
var delay = _group.PositionTicks - clientPosition.Ticks;
|
||||
|
||||
if (_group.IsBuffering())
|
||||
{
|
||||
// Others are still buffering, tell this client to pause when ready
|
||||
var command = NewSyncPlayCommand(SendCommandType.Pause);
|
||||
var pauseAtTime = currentTime.AddMilliseconds(delay);
|
||||
command.When = DateToUTCString(pauseAtTime);
|
||||
SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Let other clients resume as soon as the buffering client catches up
|
||||
_group.IsPaused = false;
|
||||
|
||||
if (delay > _group.GetHighestPing() * 2)
|
||||
{
|
||||
// Client that was buffering is recovering, notifying others to resume
|
||||
_group.LastActivity = currentTime.AddMilliseconds(
|
||||
delay
|
||||
);
|
||||
var command = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Client, that was buffering, resumed playback but did not update others in time
|
||||
delay = _group.GetHighestPing() * 2;
|
||||
delay = delay < _group.DefaulPing ? _group.DefaulPing : delay;
|
||||
|
||||
_group.LastActivity = currentTime.AddMilliseconds(
|
||||
delay
|
||||
);
|
||||
|
||||
var command = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Group was not waiting, make sure client has latest state
|
||||
var command = NewSyncPlayCommand(SendCommandType.Play);
|
||||
SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes the PositionTicks, considers the current playing item when available.
|
||||
/// </summary>
|
||||
/// <param name="positionTicks">The PositionTicks.</param>
|
||||
/// <value>The sanitized PositionTicks.</value>
|
||||
private long SanitizePositionTicks(long? positionTicks)
|
||||
{
|
||||
var ticks = positionTicks ?? 0;
|
||||
ticks = ticks >= 0 ? ticks : 0;
|
||||
if (_group.PlayingItem != null)
|
||||
{
|
||||
var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0;
|
||||
ticks = ticks > runTimeTicks ? runTimeTicks : ticks;
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ping of a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The update.</param>
|
||||
private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request)
|
||||
{
|
||||
// Collected pings are used to account for network latency when unpausing playback
|
||||
_group.UpdatePing(session, request.Ping ?? _group.DefaulPing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GroupInfoView GetInfo()
|
||||
{
|
||||
return new GroupInfoView()
|
||||
{
|
||||
GroupId = GetGroupId().ToString(),
|
||||
PlayingItemName = _group.PlayingItem.Name,
|
||||
PlayingItemId = _group.PlayingItem.Id.ToString(),
|
||||
PositionTicks = _group.PositionTicks,
|
||||
Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,398 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
|
||||
namespace Emby.Server.Implementations.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class SyncPlayManager.
|
||||
/// </summary>
|
||||
public class SyncPlayManager : ISyncPlayManager, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// The user manager.
|
||||
/// </summary>
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// The session manager.
|
||||
/// </summary>
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// The library manager.
|
||||
/// </summary>
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// The map between sessions and groups.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap =
|
||||
new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The groups.
|
||||
/// </summary>
|
||||
private readonly Dictionary<Guid, ISyncPlayController> _groups =
|
||||
new Dictionary<Guid, ISyncPlayController>();
|
||||
|
||||
/// <summary>
|
||||
/// Lock used for accesing any group.
|
||||
/// </summary>
|
||||
private readonly object _groupsLock = new object();
|
||||
|
||||
private bool _disposed = false;
|
||||
|
||||
public SyncPlayManager(
|
||||
ILogger<SyncPlayManager> logger,
|
||||
IUserManager userManager,
|
||||
ISessionManager sessionManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_userManager = userManager;
|
||||
_sessionManager = sessionManager;
|
||||
_libraryManager = libraryManager;
|
||||
|
||||
_sessionManager.SessionEnded += OnSessionManagerSessionEnded;
|
||||
_sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all groups.
|
||||
/// </summary>
|
||||
/// <value>All groups.</value>
|
||||
public IEnumerable<ISyncPlayController> Groups => _groups.Values;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and optionally managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
|
||||
_sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void CheckDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
|
||||
{
|
||||
var session = e.SessionInfo;
|
||||
if (!IsSessionInGroup(session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LeaveGroup(session, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
|
||||
{
|
||||
var session = e.Session;
|
||||
if (!IsSessionInGroup(session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LeaveGroup(session, CancellationToken.None);
|
||||
}
|
||||
|
||||
private bool IsSessionInGroup(SessionInfo session)
|
||||
{
|
||||
return _sessionToGroupMap.ContainsKey(session.Id);
|
||||
}
|
||||
|
||||
private bool HasAccessToItem(User user, Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
|
||||
// Check ParentalRating access
|
||||
var hasParentalRatingAccess = true;
|
||||
if (user.Policy.MaxParentalRating.HasValue)
|
||||
{
|
||||
hasParentalRatingAccess = item.InheritedParentalRatingValue <= user.Policy.MaxParentalRating;
|
||||
}
|
||||
|
||||
if (!user.Policy.EnableAllFolders && hasParentalRatingAccess)
|
||||
{
|
||||
var collections = _libraryManager.GetCollectionFolders(item).Select(
|
||||
folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)
|
||||
);
|
||||
var intersect = collections.Intersect(user.Policy.EnabledFolders);
|
||||
return intersect.Any();
|
||||
}
|
||||
else
|
||||
{
|
||||
return hasParentalRatingAccess;
|
||||
}
|
||||
}
|
||||
|
||||
private Guid? GetSessionGroup(SessionInfo session)
|
||||
{
|
||||
ISyncPlayController group;
|
||||
_sessionToGroupMap.TryGetValue(session.Id, out group);
|
||||
if (group != null)
|
||||
{
|
||||
return group.GetGroupId();
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = _userManager.GetUserById(session.UserId);
|
||||
|
||||
if (user.Policy.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
|
||||
{
|
||||
_logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.CreateGroupDenied
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_groupsLock)
|
||||
{
|
||||
if (IsSessionInGroup(session))
|
||||
{
|
||||
LeaveGroup(session, cancellationToken);
|
||||
}
|
||||
|
||||
var group = new SyncPlayController(_sessionManager, this);
|
||||
_groups[group.GetGroupId()] = group;
|
||||
|
||||
group.InitGroup(session, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = _userManager.GetUserById(session.UserId);
|
||||
|
||||
if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
|
||||
{
|
||||
_logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.JoinGroupDenied
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_groupsLock)
|
||||
{
|
||||
ISyncPlayController group;
|
||||
_groups.TryGetValue(groupId, out group);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
_logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.GroupDoesNotExist
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HasAccessToItem(user, group.GetPlayingItemId()))
|
||||
{
|
||||
_logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
GroupId = group.GetGroupId().ToString(),
|
||||
Type = GroupUpdateType.LibraryAccessDenied
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsSessionInGroup(session))
|
||||
{
|
||||
if (GetSessionGroup(session).Equals(groupId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LeaveGroup(session, cancellationToken);
|
||||
}
|
||||
|
||||
group.SessionJoin(session, request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: determine what happens to users that are in a group and get their permissions revoked
|
||||
lock (_groupsLock)
|
||||
{
|
||||
ISyncPlayController group;
|
||||
_sessionToGroupMap.TryGetValue(session.Id, out group);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
_logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.NotInGroup
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
group.SessionLeave(session, cancellationToken);
|
||||
|
||||
if (group.IsGroupEmpty())
|
||||
{
|
||||
_logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
|
||||
_groups.Remove(group.GetGroupId(), out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
|
||||
{
|
||||
var user = _userManager.GetUserById(session.UserId);
|
||||
|
||||
if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
|
||||
{
|
||||
return new List<GroupInfoView>();
|
||||
}
|
||||
|
||||
// Filter by item if requested
|
||||
if (!filterItemId.Equals(Guid.Empty))
|
||||
{
|
||||
return _groups.Values.Where(
|
||||
group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())
|
||||
).Select(
|
||||
group => group.GetInfo()
|
||||
).ToList();
|
||||
}
|
||||
// Otherwise show all available groups
|
||||
else
|
||||
{
|
||||
return _groups.Values.Where(
|
||||
group => HasAccessToItem(user, group.GetPlayingItemId())
|
||||
).Select(
|
||||
group => group.GetInfo()
|
||||
).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = _userManager.GetUserById(session.UserId);
|
||||
|
||||
if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
|
||||
{
|
||||
_logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.JoinGroupDenied
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_groupsLock)
|
||||
{
|
||||
ISyncPlayController group;
|
||||
_sessionToGroupMap.TryGetValue(session.Id, out group);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
_logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
|
||||
|
||||
var error = new GroupUpdate<string>()
|
||||
{
|
||||
Type = GroupUpdateType.NotInGroup
|
||||
};
|
||||
_sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
group.HandleRequest(session, request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
|
||||
{
|
||||
if (IsSessionInGroup(session))
|
||||
{
|
||||
throw new InvalidOperationException("Session in other group already!");
|
||||
}
|
||||
|
||||
_sessionToGroupMap[session.Id] = group;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
|
||||
{
|
||||
if (!IsSessionInGroup(session))
|
||||
{
|
||||
throw new InvalidOperationException("Session not in any group!");
|
||||
}
|
||||
|
||||
ISyncPlayController tempGroup;
|
||||
_sessionToGroupMap.Remove(session.Id, out tempGroup);
|
||||
|
||||
if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
|
||||
{
|
||||
throw new InvalidOperationException("Session was in wrong group!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.ApiClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Udp
|
||||
|
@ -21,6 +22,12 @@ namespace Emby.Server.Implementations.Udp
|
|||
/// </summary>
|
||||
private readonly ILogger _logger;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
/// <summary>
|
||||
/// Address Override Configuration Key.
|
||||
/// </summary>
|
||||
public const string AddressOverrideConfigKey = "PublishedServerUrl";
|
||||
|
||||
private Socket _udpSocket;
|
||||
private IPEndPoint _endpoint;
|
||||
|
@ -31,15 +38,18 @@ namespace Emby.Server.Implementations.Udp
|
|||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UdpServer" /> class.
|
||||
/// </summary>
|
||||
public UdpServer(ILogger logger, IServerApplicationHost appHost)
|
||||
public UdpServer(ILogger logger, IServerApplicationHost appHost, IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_appHost = appHost;
|
||||
_config = configuration;
|
||||
}
|
||||
|
||||
private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
var localUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
|
||||
string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey])
|
||||
? _config[AddressOverrideConfigKey]
|
||||
: await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(localUrl))
|
||||
{
|
||||
|
@ -105,7 +115,7 @@ namespace Emby.Server.Implementations.Udp
|
|||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to receive data drom socket");
|
||||
_logger.LogError(ex, "Failed to receive data from socket");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Authentication;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -60,6 +61,10 @@ namespace Jellyfin.Api.Auth
|
|||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
catch (AuthenticationException ex)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail(ex));
|
||||
}
|
||||
catch (SecurityException ex)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail(ex));
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -19,9 +19,9 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -28,8 +28,11 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.3">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.7.82" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.4" />
|
||||
<PackageReference Include="prometheus-net" Version="3.5.0" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="3.5.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CommandLine;
|
||||
using Emby.Server.Implementations;
|
||||
using Emby.Server.Implementations.EntryPoints;
|
||||
using Emby.Server.Implementations.Udp;
|
||||
using Emby.Server.Implementations.Updates;
|
||||
using MediaBrowser.Controller.Extensions;
|
||||
|
||||
|
@ -80,6 +83,10 @@ namespace Jellyfin.Server
|
|||
[Option("plugin-manifest-url", Required = false, HelpText = "A custom URL for the plugin repository JSON manifest")]
|
||||
public string? PluginManifestUrl { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
[Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")]
|
||||
public Uri? PublishedServerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
|
||||
/// </summary>
|
||||
|
@ -98,6 +105,11 @@ namespace Jellyfin.Server
|
|||
config.Add(ConfigurationExtensions.HostWebClientKey, bool.FalseString);
|
||||
}
|
||||
|
||||
if (PublishedServerUrl != null)
|
||||
{
|
||||
config.Add(UdpServer.AddressOverrideConfigKey, PublishedServerUrl.ToString());
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,302 @@
|
|||
using System.Threading;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using MediaBrowser.Model.Services;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Api.SyncPlay
|
||||
{
|
||||
[Route("/SyncPlay/{SessionId}/NewGroup", "POST", Summary = "Create a new SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlayNewGroup : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/JoinGroup", "POST", Summary = "Join an existing SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlayJoinGroup : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Group id.
|
||||
/// </summary>
|
||||
/// <value>The Group id to join.</value>
|
||||
[ApiMember(Name = "GroupId", Description = "Group Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
|
||||
public string GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playing item id.
|
||||
/// </summary>
|
||||
/// <value>The client's currently playing item id.</value>
|
||||
[ApiMember(Name = "PlayingItemId", Description = "Client's playing item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
|
||||
public string PlayingItemId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/LeaveGroup", "POST", Summary = "Leave joined SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlayLeaveGroup : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/ListGroups", "POST", Summary = "List SyncPlay groups")]
|
||||
[Authenticated]
|
||||
public class SyncPlayListGroups : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filter item id.
|
||||
/// </summary>
|
||||
/// <value>The filter item id.</value>
|
||||
[ApiMember(Name = "FilterItemId", Description = "Filter by item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
|
||||
public string FilterItemId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/PlayRequest", "POST", Summary = "Request play in SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlayPlayRequest : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/PauseRequest", "POST", Summary = "Request pause in SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlayPauseRequest : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/SeekRequest", "POST", Summary = "Request seek in SyncPlay group")]
|
||||
[Authenticated]
|
||||
public class SyncPlaySeekRequest : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
|
||||
[ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")]
|
||||
public long PositionTicks { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/BufferingRequest", "POST", Summary = "Request group wait in SyncPlay group while buffering")]
|
||||
[Authenticated]
|
||||
public class SyncPlayBufferingRequest : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date used to pin PositionTicks in time.
|
||||
/// </summary>
|
||||
/// <value>The date related to PositionTicks.</value>
|
||||
[ApiMember(Name = "When", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
|
||||
public string When { get; set; }
|
||||
|
||||
[ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")]
|
||||
public long PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this is a buffering or a buffering-done request.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if buffering is complete; <c>false</c> otherwise.</value>
|
||||
[ApiMember(Name = "BufferingDone", IsRequired = true, DataType = "bool", ParameterType = "query", Verb = "POST")]
|
||||
public bool BufferingDone { get; set; }
|
||||
}
|
||||
|
||||
[Route("/SyncPlay/{SessionId}/UpdatePing", "POST", Summary = "Update session ping")]
|
||||
[Authenticated]
|
||||
public class SyncPlayUpdatePing : IReturnVoid
|
||||
{
|
||||
[ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
|
||||
public string SessionId { get; set; }
|
||||
|
||||
[ApiMember(Name = "Ping", IsRequired = true, DataType = "double", ParameterType = "query", Verb = "POST")]
|
||||
public double Ping { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class SyncPlayService.
|
||||
/// </summary>
|
||||
public class SyncPlayService : BaseApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// The session context.
|
||||
/// </summary>
|
||||
private readonly ISessionContext _sessionContext;
|
||||
|
||||
/// <summary>
|
||||
/// The SyncPlay manager.
|
||||
/// </summary>
|
||||
private readonly ISyncPlayManager _syncPlayManager;
|
||||
|
||||
public SyncPlayService(
|
||||
ILogger<SyncPlayService> logger,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IHttpResultFactory httpResultFactory,
|
||||
ISessionContext sessionContext,
|
||||
ISyncPlayManager syncPlayManager)
|
||||
: base(logger, serverConfigurationManager, httpResultFactory)
|
||||
{
|
||||
_sessionContext = sessionContext;
|
||||
_syncPlayManager = syncPlayManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayNewGroup request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
_syncPlayManager.NewGroup(currentSession, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayJoinGroup request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
|
||||
Guid groupId;
|
||||
Guid playingItemId = Guid.Empty;
|
||||
|
||||
if (!Guid.TryParse(request.GroupId, out groupId))
|
||||
{
|
||||
Logger.LogError("JoinGroup: {0} is not a valid format for GroupId. Ignoring request.", request.GroupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both null and empty strings mean that client isn't playing anything
|
||||
if (!string.IsNullOrEmpty(request.PlayingItemId) && !Guid.TryParse(request.PlayingItemId, out playingItemId))
|
||||
{
|
||||
Logger.LogError("JoinGroup: {0} is not a valid format for PlayingItemId. Ignoring request.", request.PlayingItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
var joinRequest = new JoinGroupRequest()
|
||||
{
|
||||
GroupId = groupId,
|
||||
PlayingItemId = playingItemId
|
||||
};
|
||||
|
||||
_syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayLeaveGroup request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
_syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <value>The requested list of groups.</value>
|
||||
public List<GroupInfoView> Post(SyncPlayListGroups request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var filterItemId = Guid.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.FilterItemId) && !Guid.TryParse(request.FilterItemId, out filterItemId))
|
||||
{
|
||||
Logger.LogWarning("ListGroups: {0} is not a valid format for FilterItemId. Ignoring filter.", request.FilterItemId);
|
||||
}
|
||||
|
||||
return _syncPlayManager.ListGroups(currentSession, filterItemId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayPlayRequest request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var syncPlayRequest = new PlaybackRequest()
|
||||
{
|
||||
Type = PlaybackRequestType.Play
|
||||
};
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayPauseRequest request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var syncPlayRequest = new PlaybackRequest()
|
||||
{
|
||||
Type = PlaybackRequestType.Pause
|
||||
};
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlaySeekRequest request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var syncPlayRequest = new PlaybackRequest()
|
||||
{
|
||||
Type = PlaybackRequestType.Seek,
|
||||
PositionTicks = request.PositionTicks
|
||||
};
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayBufferingRequest request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var syncPlayRequest = new PlaybackRequest()
|
||||
{
|
||||
Type = request.BufferingDone ? PlaybackRequestType.BufferingDone : PlaybackRequestType.Buffering,
|
||||
When = DateTime.Parse(request.When),
|
||||
PositionTicks = request.PositionTicks
|
||||
};
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
public void Post(SyncPlayUpdatePing request)
|
||||
{
|
||||
var currentSession = GetSession(_sessionContext);
|
||||
var syncPlayRequest = new PlaybackRequest()
|
||||
{
|
||||
Type = PlaybackRequestType.UpdatePing,
|
||||
Ping = Convert.ToInt64(request.Ping)
|
||||
};
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
using System;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Model.Services;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Api.SyncPlay
|
||||
{
|
||||
[Route("/GetUtcTime", "GET", Summary = "Get UtcTime")]
|
||||
public class GetUtcTime : IReturnVoid
|
||||
{
|
||||
// Nothing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class TimeSyncService.
|
||||
/// </summary>
|
||||
public class TimeSyncService : BaseApiService
|
||||
{
|
||||
public TimeSyncService(
|
||||
ILogger<TimeSyncService> logger,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IHttpResultFactory httpResultFactory)
|
||||
: base(logger, serverConfigurationManager, httpResultFactory)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the specified request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <value>The current UTC time response.</value>
|
||||
public UtcTimeResponse Get(GetUtcTime request)
|
||||
{
|
||||
// Important to keep the following line at the beginning
|
||||
var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o");
|
||||
|
||||
var response = new UtcTimeResponse();
|
||||
response.RequestReceptionTime = requestReceptionTime;
|
||||
|
||||
// Important to keep the following two lines at the end
|
||||
var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o");
|
||||
response.ResponseTransmissionTime = responseTransmissionTime;
|
||||
|
||||
// Implementing NTP on such a high level results in this useless
|
||||
// information being sent. On the other hand it enables future additions.
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -36,7 +36,7 @@ namespace MediaBrowser.Api
|
|||
}
|
||||
|
||||
[Route("/Users/Public", "GET", Summary = "Gets a list of publicly visible users for display on a login screen.")]
|
||||
public class GetPublicUsers : IReturn<PublicUserDto[]>
|
||||
public class GetPublicUsers : IReturn<UserDto[]>
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -267,39 +267,22 @@ namespace MediaBrowser.Api
|
|||
_authContext = authContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public available Users information
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <returns>System.Object.</returns>
|
||||
public object Get(GetPublicUsers request)
|
||||
{
|
||||
var result = _userManager
|
||||
.Users
|
||||
.Where(user => !user.HasPermission(PermissionKind.IsDisabled))
|
||||
.AsQueryable();
|
||||
|
||||
if (ServerConfigurationManager.Configuration.IsStartupWizardCompleted)
|
||||
// If the startup wizard hasn't been completed then just return all users
|
||||
if (!ServerConfigurationManager.Configuration.IsStartupWizardCompleted)
|
||||
{
|
||||
var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId;
|
||||
result = result.Where(item => !item.HasPermission(PermissionKind.IsHidden));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
return Get(new GetUsers
|
||||
{
|
||||
result = result.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
|
||||
IsDisabled = false
|
||||
});
|
||||
}
|
||||
|
||||
if (!_networkManager.IsInLocalNetwork(Request.RemoteIp))
|
||||
return Get(new GetUsers
|
||||
{
|
||||
result = result.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
|
||||
}
|
||||
}
|
||||
|
||||
return ToOptimizedResult(result
|
||||
.OrderBy(u => u.Username)
|
||||
.Select(i => _userManager.GetPublicUserDto(i, Request.RemoteIp))
|
||||
.ToArray()
|
||||
);
|
||||
IsHidden = false,
|
||||
IsDisabled = false
|
||||
}, true, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
@ -141,14 +141,6 @@ namespace MediaBrowser.Controller.Library
|
|||
/// <returns>UserDto.</returns>
|
||||
UserDto GetUserDto(User user, string remoteEndPoint = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user public dto.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>\
|
||||
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||
/// <returns>A public UserDto, aka a UserDto stripped of personal data.</returns>
|
||||
PublicUserDto GetPublicUserDto(User user, string remoteEndPoint = null);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user.
|
||||
/// </summary>
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -23,6 +23,12 @@ namespace MediaBrowser.Controller.Net
|
|||
/// <value>The last activity date.</value>
|
||||
DateTime LastActivityDate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date of last Keeplive received.
|
||||
/// </summary>
|
||||
/// <value>The date of last Keeplive received.</value>
|
||||
DateTime LastKeepAliveDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the query string.
|
||||
/// </summary>
|
||||
|
|
|
@ -9,6 +9,7 @@ using MediaBrowser.Controller.Security;
|
|||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Events;
|
||||
using MediaBrowser.Model.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
|
||||
namespace MediaBrowser.Controller.Session
|
||||
{
|
||||
|
@ -140,6 +141,24 @@ namespace MediaBrowser.Controller.Session
|
|||
/// <returns>Task.</returns>
|
||||
Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the SyncPlayCommand.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The command.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the SyncPlayGroupUpdate.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The group update.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the browse command.
|
||||
/// </summary>
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Session;
|
||||
|
||||
namespace MediaBrowser.Controller.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class GroupInfo.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Class is not thread-safe, external locking is required when accessing methods.
|
||||
/// </remarks>
|
||||
public class GroupInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Default ping value used for sessions.
|
||||
/// </summary>
|
||||
public long DefaulPing { get; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the group identifier.
|
||||
/// </summary>
|
||||
/// <value>The group identifier.</value>
|
||||
public Guid GroupId { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playing item.
|
||||
/// </summary>
|
||||
/// <value>The playing item.</value>
|
||||
public BaseItem PlayingItem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether playback is paused.
|
||||
/// </summary>
|
||||
/// <value>Playback is paused.</value>
|
||||
public bool IsPaused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position ticks.
|
||||
/// </summary>
|
||||
/// <value>The position ticks.</value>
|
||||
public long PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last activity.
|
||||
/// </summary>
|
||||
/// <value>The last activity.</value>
|
||||
public DateTime LastActivity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the participants.
|
||||
/// </summary>
|
||||
/// <value>The participants, or members of the group.</value>
|
||||
public Dictionary<string, GroupMember> Participants { get; } =
|
||||
new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a session is in this group.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if the session is in this group; <c>false</c> otherwise.</value>
|
||||
public bool ContainsSession(string sessionId)
|
||||
{
|
||||
return Participants.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the session to the group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
public void AddSession(SessionInfo session)
|
||||
{
|
||||
if (ContainsSession(session.Id.ToString()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var member = new GroupMember();
|
||||
member.Session = session;
|
||||
member.Ping = DefaulPing;
|
||||
member.IsBuffering = false;
|
||||
Participants[session.Id.ToString()] = member;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the session from the group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
public void RemoveSession(SessionInfo session)
|
||||
{
|
||||
if (!ContainsSession(session.Id.ToString()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Participants.Remove(session.Id.ToString(), out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the ping of a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="ping">The ping.</param>
|
||||
public void UpdatePing(SessionInfo session, long ping)
|
||||
{
|
||||
if (!ContainsSession(session.Id.ToString()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Participants[session.Id.ToString()].Ping = ping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest ping in the group.
|
||||
/// </summary>
|
||||
/// <value name="session">The highest ping in the group.</value>
|
||||
public long GetHighestPing()
|
||||
{
|
||||
long max = Int64.MinValue;
|
||||
foreach (var session in Participants.Values)
|
||||
{
|
||||
max = Math.Max(max, session.Ping);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the session's buffering state.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="isBuffering">The state.</param>
|
||||
public void SetBuffering(SessionInfo session, bool isBuffering)
|
||||
{
|
||||
if (!ContainsSession(session.Id.ToString()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Participants[session.Id.ToString()].IsBuffering = isBuffering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the group buffering state.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</value>
|
||||
public bool IsBuffering()
|
||||
{
|
||||
foreach (var session in Participants.Values)
|
||||
{
|
||||
if (session.IsBuffering)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the group is empty.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if the group is empty; <c>false</c> otherwise.</value>
|
||||
public bool IsEmpty()
|
||||
{
|
||||
return Participants.Count == 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
using MediaBrowser.Controller.Session;
|
||||
|
||||
namespace MediaBrowser.Controller.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class GroupMember.
|
||||
/// </summary>
|
||||
public class GroupMember
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether this member is buffering.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
|
||||
public bool IsBuffering { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the session.
|
||||
/// </summary>
|
||||
/// <value>The session.</value>
|
||||
public SessionInfo Session { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ping.
|
||||
/// </summary>
|
||||
/// <value>The ping.</value>
|
||||
public long Ping { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
|
||||
namespace MediaBrowser.Controller.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface ISyncPlayController.
|
||||
/// </summary>
|
||||
public interface ISyncPlayController
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the group id.
|
||||
/// </summary>
|
||||
/// <value>The group id.</value>
|
||||
Guid GetGroupId();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playing item id.
|
||||
/// </summary>
|
||||
/// <value>The playing item id.</value>
|
||||
Guid GetPlayingItemId();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the group is empty.
|
||||
/// </summary>
|
||||
/// <value>If the group is empty.</value>
|
||||
bool IsGroupEmpty();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the group with the session's info.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void InitGroup(SessionInfo session, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the session to the group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the session from the group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void SessionLeave(SessionInfo session, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the requested action by the session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The requested action.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the info about the group for the clients.
|
||||
/// </summary>
|
||||
/// <value>The group info for the clients.</value>
|
||||
GroupInfoView GetInfo();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
|
||||
namespace MediaBrowser.Controller.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface ISyncPlayManager.
|
||||
/// </summary>
|
||||
public interface ISyncPlayManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session that's creating the group.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void NewGroup(SessionInfo session, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the session to a group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="groupId">The group id.</param>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the session from a group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void LeaveGroup(SessionInfo session, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets list of available groups for a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="filterItemId">The item id to filter by.</param>
|
||||
/// <value>The list of available groups.</value>
|
||||
List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId);
|
||||
|
||||
/// <summary>
|
||||
/// Handle a request by a session in a group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a session to a group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="group">The group.</param>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
|
||||
|
||||
/// <summary>
|
||||
/// Unmaps a session from a group.
|
||||
/// </summary>
|
||||
/// <param name="session">The session.</param>
|
||||
/// <param name="group">The group.</param>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
|
||||
}
|
||||
}
|
|
@ -278,5 +278,19 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
/// <value>The disposition.</value>
|
||||
[JsonPropertyName("disposition")]
|
||||
public IReadOnlyDictionary<string, int> Disposition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color transfer.
|
||||
/// </summary>
|
||||
/// <value>The color transfer.</value>
|
||||
[JsonPropertyName("color_transfer")]
|
||||
public string ColorTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color primaries.
|
||||
/// </summary>
|
||||
/// <value>The color primaries.</value>
|
||||
[JsonPropertyName("color_primaries")]
|
||||
public string ColorPrimaries { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -695,6 +695,16 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
{
|
||||
stream.RefFrames = streamInfo.Refs;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(streamInfo.ColorTransfer))
|
||||
{
|
||||
stream.ColorTransfer = streamInfo.ColorTransfer;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(streamInfo.ColorPrimaries))
|
||||
{
|
||||
stream.ColorPrimaries = streamInfo.ColorPrimaries;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
namespace MediaBrowser.Model.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum SyncPlayAccess.
|
||||
/// </summary>
|
||||
public enum SyncPlayAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// User can create groups and join them.
|
||||
/// </summary>
|
||||
CreateAndJoinGroups,
|
||||
|
||||
/// <summary>
|
||||
/// User can only join already existing groups.
|
||||
/// </summary>
|
||||
JoinGroups,
|
||||
|
||||
/// <summary>
|
||||
/// SyncPlay is disabled for the user.
|
||||
/// </summary>
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace MediaBrowser.Model.Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// Class PublicUserDto. Its goal is to show only public information about a user
|
||||
/// </summary>
|
||||
public class PublicUserDto : IItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name.
|
||||
/// </summary>
|
||||
/// <value>The name.</value>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primary image tag.
|
||||
/// </summary>
|
||||
/// <value>The primary image tag.</value>
|
||||
public string PrimaryImageTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this instance has password.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if this instance has password; otherwise, <c>false</c>.</value>
|
||||
public bool HasPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this instance has configured password.
|
||||
/// Note that in this case this method should not be here, but it is necessary when changing password at the
|
||||
/// first login.
|
||||
/// </summary>
|
||||
/// <value><c>true</c> if this instance has configured password; otherwise, <c>false</c>.</value>
|
||||
public bool HasConfiguredPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primary image aspect ratio.
|
||||
/// </summary>
|
||||
/// <value>The primary image aspect ratio.</value>
|
||||
public double? PrimaryImageAspectRatio { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return Name ?? base.ToString();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -34,8 +34,22 @@ namespace MediaBrowser.Model.Entities
|
|||
/// <value>The language.</value>
|
||||
public string Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color transfer.
|
||||
/// </summary>
|
||||
/// <value>The color transfer.</value>
|
||||
public string ColorTransfer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color primaries.
|
||||
/// </summary>
|
||||
/// <value>The color primaries.</value>
|
||||
public string ColorPrimaries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color space.
|
||||
/// </summary>
|
||||
/// <value>The color space.</value>
|
||||
public string ColorSpace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
@ -44,11 +58,28 @@ namespace MediaBrowser.Model.Entities
|
|||
/// <value>The comment.</value>
|
||||
public string Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time base.
|
||||
/// </summary>
|
||||
/// <value>The time base.</value>
|
||||
public string TimeBase { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the codec time base.
|
||||
/// </summary>
|
||||
/// <value>The codec time base.</value>
|
||||
public string CodecTimeBase { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
/// <value>The title.</value>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video range.
|
||||
/// </summary>
|
||||
/// <value>The video range.</value>
|
||||
public string VideoRange
|
||||
{
|
||||
get
|
||||
|
@ -60,7 +91,8 @@ namespace MediaBrowser.Model.Entities
|
|||
|
||||
var colorTransfer = ColorTransfer;
|
||||
|
||||
if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "HDR";
|
||||
}
|
||||
|
@ -70,7 +102,9 @@ namespace MediaBrowser.Model.Entities
|
|||
}
|
||||
|
||||
public string localizedUndefined { get; set; }
|
||||
|
||||
public string localizedDefault { get; set; }
|
||||
|
||||
public string localizedForced { get; set; }
|
||||
|
||||
public string DisplayTitle
|
||||
|
@ -197,34 +231,34 @@ namespace MediaBrowser.Model.Entities
|
|||
{
|
||||
if (i.IsInterlaced)
|
||||
{
|
||||
return "1440I";
|
||||
return "1440i";
|
||||
}
|
||||
return "1440P";
|
||||
return "1440p";
|
||||
}
|
||||
if (width >= 1900 || height >= 1000)
|
||||
{
|
||||
if (i.IsInterlaced)
|
||||
{
|
||||
return "1080I";
|
||||
return "1080i";
|
||||
}
|
||||
return "1080P";
|
||||
return "1080p";
|
||||
}
|
||||
if (width >= 1260 || height >= 700)
|
||||
{
|
||||
if (i.IsInterlaced)
|
||||
{
|
||||
return "720I";
|
||||
return "720i";
|
||||
}
|
||||
return "720P";
|
||||
return "720p";
|
||||
}
|
||||
if (width >= 700 || height >= 440)
|
||||
{
|
||||
|
||||
if (i.IsInterlaced)
|
||||
{
|
||||
return "480I";
|
||||
return "480i";
|
||||
}
|
||||
return "480P";
|
||||
return "480p";
|
||||
}
|
||||
|
||||
return "SD";
|
||||
|
|
|
@ -21,9 +21,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="4.7.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="4.7.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -67,6 +67,7 @@ namespace MediaBrowser.Model.Net
|
|||
{ ".m3u8", "application/x-mpegURL" },
|
||||
{ ".map", "application/x-javascript" },
|
||||
{ ".mobi", "application/x-mobipocket-ebook" },
|
||||
{ ".opf", "application/oebps-package+xml" },
|
||||
{ ".pdf", "application/pdf" },
|
||||
{ ".rar", "application/vnd.rar" },
|
||||
{ ".srt", "application/x-subrip" },
|
||||
|
@ -99,6 +100,7 @@ namespace MediaBrowser.Model.Net
|
|||
{ ".ssa", "text/x-ssa" },
|
||||
{ ".css", "text/css" },
|
||||
{ ".csv", "text/csv" },
|
||||
{ ".edl", "text/plain" },
|
||||
{ ".rtf", "text/rtf" },
|
||||
{ ".txt", "text/plain" },
|
||||
{ ".vtt", "text/vtt" },
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class GroupInfoView.
|
||||
/// </summary>
|
||||
public class GroupInfoView
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the group identifier.
|
||||
/// </summary>
|
||||
/// <value>The group identifier.</value>
|
||||
public string GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playing item id.
|
||||
/// </summary>
|
||||
/// <value>The playing item id.</value>
|
||||
public string PlayingItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playing item name.
|
||||
/// </summary>
|
||||
/// <value>The playing item name.</value>
|
||||
public string PlayingItemName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position ticks.
|
||||
/// </summary>
|
||||
/// <value>The position ticks.</value>
|
||||
public long PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the participants.
|
||||
/// </summary>
|
||||
/// <value>The participants.</value>
|
||||
public IReadOnlyList<string> Participants { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class GroupUpdate.
|
||||
/// </summary>
|
||||
public class GroupUpdate<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the group identifier.
|
||||
/// </summary>
|
||||
/// <value>The group identifier.</value>
|
||||
public string GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the update type.
|
||||
/// </summary>
|
||||
/// <value>The update type.</value>
|
||||
public GroupUpdateType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data.
|
||||
/// </summary>
|
||||
/// <value>The data.</value>
|
||||
public T Data { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum GroupUpdateType.
|
||||
/// </summary>
|
||||
public enum GroupUpdateType
|
||||
{
|
||||
/// <summary>
|
||||
/// The user-joined update. Tells members of a group about a new user.
|
||||
/// </summary>
|
||||
UserJoined,
|
||||
/// <summary>
|
||||
/// The user-left update. Tells members of a group that a user left.
|
||||
/// </summary>
|
||||
UserLeft,
|
||||
/// <summary>
|
||||
/// The group-joined update. Tells a user that the group has been joined.
|
||||
/// </summary>
|
||||
GroupJoined,
|
||||
/// <summary>
|
||||
/// The group-left update. Tells a user that the group has been left.
|
||||
/// </summary>
|
||||
GroupLeft,
|
||||
/// <summary>
|
||||
/// The group-wait update. Tells members of the group that a user is buffering.
|
||||
/// </summary>
|
||||
GroupWait,
|
||||
/// <summary>
|
||||
/// The prepare-session update. Tells a user to load some content.
|
||||
/// </summary>
|
||||
PrepareSession,
|
||||
/// <summary>
|
||||
/// The not-in-group error. Tells a user that they don't belong to a group.
|
||||
/// </summary>
|
||||
NotInGroup,
|
||||
/// <summary>
|
||||
/// The group-does-not-exist error. Sent when trying to join a non-existing group.
|
||||
/// </summary>
|
||||
GroupDoesNotExist,
|
||||
/// <summary>
|
||||
/// The create-group-denied error. Sent when a user tries to create a group without required permissions.
|
||||
/// </summary>
|
||||
CreateGroupDenied,
|
||||
/// <summary>
|
||||
/// The join-group-denied error. Sent when a user tries to join a group without required permissions.
|
||||
/// </summary>
|
||||
JoinGroupDenied,
|
||||
/// <summary>
|
||||
/// The library-access-denied error. Sent when a user tries to join a group without required access to the library.
|
||||
/// </summary>
|
||||
LibraryAccessDenied
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
|
||||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class JoinGroupRequest.
|
||||
/// </summary>
|
||||
public class JoinGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Group id.
|
||||
/// </summary>
|
||||
/// <value>The Group id to join.</value>
|
||||
public Guid GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playing item id.
|
||||
/// </summary>
|
||||
/// <value>The client's currently playing item id.</value>
|
||||
public Guid PlayingItemId { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
|
||||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class PlaybackRequest.
|
||||
/// </summary>
|
||||
public class PlaybackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the request type.
|
||||
/// </summary>
|
||||
/// <value>The request type.</value>
|
||||
public PlaybackRequestType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the request has been made by the client.
|
||||
/// </summary>
|
||||
/// <value>The date of the request.</value>
|
||||
public DateTime? When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position ticks.
|
||||
/// </summary>
|
||||
/// <value>The position ticks.</value>
|
||||
public long? PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ping time.
|
||||
/// </summary>
|
||||
/// <value>The ping time.</value>
|
||||
public long? Ping { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum PlaybackRequestType
|
||||
/// </summary>
|
||||
public enum PlaybackRequestType
|
||||
{
|
||||
/// <summary>
|
||||
/// A user is requesting a play command for the group.
|
||||
/// </summary>
|
||||
Play = 0,
|
||||
/// <summary>
|
||||
/// A user is requesting a pause command for the group.
|
||||
/// </summary>
|
||||
Pause = 1,
|
||||
/// <summary>
|
||||
/// A user is requesting a seek command for the group.
|
||||
/// </summary>
|
||||
Seek = 2,
|
||||
/// <summary>
|
||||
/// A user is signaling that playback is buffering.
|
||||
/// </summary>
|
||||
Buffering = 3,
|
||||
/// <summary>
|
||||
/// A user is signaling that playback resumed.
|
||||
/// </summary>
|
||||
BufferingDone = 4,
|
||||
/// <summary>
|
||||
/// A user is reporting its ping.
|
||||
/// </summary>
|
||||
UpdatePing = 5
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class SendCommand.
|
||||
/// </summary>
|
||||
public class SendCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the group identifier.
|
||||
/// </summary>
|
||||
/// <value>The group identifier.</value>
|
||||
public string GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when to execute the command.
|
||||
/// </summary>
|
||||
/// <value>The UTC time when to execute the command.</value>
|
||||
public string When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position ticks.
|
||||
/// </summary>
|
||||
/// <value>The position ticks.</value>
|
||||
public long? PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command.
|
||||
/// </summary>
|
||||
/// <value>The command.</value>
|
||||
public SendCommandType Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when this command has been emitted.
|
||||
/// </summary>
|
||||
/// <value>The UTC time when this command has been emitted.</value>
|
||||
public string EmittedAt { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum SendCommandType.
|
||||
/// </summary>
|
||||
public enum SendCommandType
|
||||
{
|
||||
/// <summary>
|
||||
/// The play command. Instructs users to start playback.
|
||||
/// </summary>
|
||||
Play = 0,
|
||||
/// <summary>
|
||||
/// The pause command. Instructs users to pause playback.
|
||||
/// </summary>
|
||||
Pause = 1,
|
||||
/// <summary>
|
||||
/// The seek command. Instructs users to seek to a specified time.
|
||||
/// </summary>
|
||||
Seek = 2
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
namespace MediaBrowser.Model.SyncPlay
|
||||
{
|
||||
/// <summary>
|
||||
/// Class UtcTimeResponse.
|
||||
/// </summary>
|
||||
public class UtcTimeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when request has been received.
|
||||
/// </summary>
|
||||
/// <value>The UTC time when request has been received.</value>
|
||||
public string RequestReceptionTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when response has been sent.
|
||||
/// </summary>
|
||||
/// <value>The UTC time when response has been sent.</value>
|
||||
public string ResponseTransmissionTime { get; set; }
|
||||
}
|
||||
}
|
|
@ -85,6 +85,12 @@ namespace MediaBrowser.Model.Users
|
|||
public string AuthenticationProviderId { get; set; }
|
||||
public string PasswordResetProviderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating what SyncPlay features the user can access.
|
||||
/// </summary>
|
||||
/// <value>Access level to SyncPlay features.</value>
|
||||
public SyncPlayAccess SyncPlayAccess { get; set; }
|
||||
|
||||
public UserPolicy()
|
||||
{
|
||||
IsHidden = true;
|
||||
|
@ -130,6 +136,7 @@ namespace MediaBrowser.Model.Users
|
|||
EnableContentDownloading = true;
|
||||
EnablePublicSharing = true;
|
||||
EnableRemoteAccess = true;
|
||||
SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.4" />
|
||||
<PackageReference Include="OptimizedPriorityQueue" Version="4.2.0" />
|
||||
<PackageReference Include="PlaylistsNET" Version="1.0.4" />
|
||||
<PackageReference Include="TvDbSharper" Version="3.0.1" />
|
||||
|
|
|
@ -31,8 +31,8 @@
|
|||
$('.configPage').on('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
|
||||
$('#enable').checked(config.Enable);
|
||||
$('#replaceAlbumName').checked(config.ReplaceAlbumName);
|
||||
$('#enable').checked = config.Enable;
|
||||
$('#replaceAlbumName').checked = config.ReplaceAlbumName;
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
@ -43,8 +43,8 @@
|
|||
|
||||
var form = this;
|
||||
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
|
||||
config.Enable = $('#enable', form).checked();
|
||||
config.ReplaceAlbumName = $('#replaceAlbumName', form).checked();
|
||||
config.Enable = $('#enable', form).checked;
|
||||
config.ReplaceAlbumName = $('#replaceAlbumName', form).checked;
|
||||
|
||||
ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||
});
|
||||
|
|
|
@ -41,8 +41,8 @@
|
|||
ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
|
||||
$('#server').val(config.Server).change();
|
||||
$('#rateLimit').val(config.RateLimit).change();
|
||||
$('#enable').checked(config.Enable);
|
||||
$('#replaceArtistName').checked(config.ReplaceArtistName);
|
||||
$('#enable').checked = config.Enable;
|
||||
$('#replaceArtistName').checked = config.ReplaceArtistName;
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
@ -55,8 +55,8 @@
|
|||
ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
|
||||
config.Server = $('#server', form).val();
|
||||
config.RateLimit = $('#rateLimit', form).val();
|
||||
config.Enable = $('#enable', form).checked();
|
||||
config.ReplaceArtistName = $('#replaceArtistName', form).checked();
|
||||
config.Enable = $('#enable', form).checked;
|
||||
config.ReplaceArtistName = $('#replaceArtistName', form).checked;
|
||||
|
||||
ApiClient.updatePluginConfiguration(MusicBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
|
@ -18,8 +19,22 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
{
|
||||
public class TmdbSearch
|
||||
{
|
||||
private static readonly CultureInfo EnUs = new CultureInfo("en-US");
|
||||
private const string Search3 = TmdbUtils.BaseTmdbApiUrl + @"3/search/{3}?api_key={1}&query={0}&language={2}";
|
||||
private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
|
||||
|
||||
private static readonly Regex _cleanEnclosed = new Regex(@"\p{Ps}.*\p{Pe}", RegexOptions.Compiled);
|
||||
private static readonly Regex _cleanNonWord = new Regex(@"[\W_]+", RegexOptions.Compiled);
|
||||
private static readonly Regex _cleanStopWords = new Regex(@"\b( # Start at word boundary
|
||||
19[0-9]{2}|20[0-9]{2}| # 1900-2099
|
||||
S[0-9]{2}| # Season
|
||||
E[0-9]{2}| # Episode
|
||||
(2160|1080|720|576|480)[ip]?| # Resolution
|
||||
[xh]?264| # Encoding
|
||||
(web|dvd|bd|hdtv|hd)rip| # *Rip
|
||||
web|hdtv|mp4|bluray|ktr|dl|single|imageset|internal|doku|dubbed|retail|xxx|flac
|
||||
).* # Match rest of string",
|
||||
RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace | RegexOptions.IgnoreCase);
|
||||
|
||||
private const string _searchURL = TmdbUtils.BaseTmdbApiUrl + @"3/search/{3}?api_key={1}&query={0}&language={2}";
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly IJsonSerializer _json;
|
||||
|
@ -61,19 +76,18 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
|
||||
var tmdbImageUrl = tmdbSettings.images.GetImageUrl("original");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// TODO: Investigate: Does this mean we are reparsing already parsed ItemLookupInfo?
|
||||
var parsedName = _libraryManager.ParseName(name);
|
||||
var yearInName = parsedName.Year;
|
||||
name = parsedName.Name;
|
||||
year = year ?? yearInName;
|
||||
}
|
||||
year ??= yearInName;
|
||||
|
||||
_logger.LogInformation("MovieDbProvider: Finding id for item: " + name);
|
||||
_logger.LogInformation("TmdbSearch: Finding id for item: {0} ({1})", name, year);
|
||||
var language = idInfo.MetadataLanguage.ToLowerInvariant();
|
||||
|
||||
//nope - search for it
|
||||
//var searchType = item is BoxSet ? "collection" : "movie";
|
||||
// Replace sequences of non-word characters with space
|
||||
// TMDB expects a space separated list of words make sure that is the case
|
||||
name = _cleanNonWord.Replace(name, " ").Trim();
|
||||
|
||||
var results = await GetSearchResults(name, searchType, year, language, tmdbImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
@ -86,36 +100,35 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: retrying alternatives should be done outside the search
|
||||
// provider so that the retry logic can be common for all search
|
||||
// providers
|
||||
if (results.Count == 0)
|
||||
{
|
||||
// try with dot and _ turned to space
|
||||
var originalName = name;
|
||||
var name2 = parsedName.Name;
|
||||
|
||||
name = name.Replace(",", " ");
|
||||
name = name.Replace(".", " ");
|
||||
name = name.Replace("_", " ");
|
||||
name = name.Replace("-", " ");
|
||||
name = name.Replace("!", " ");
|
||||
name = name.Replace("?", " ");
|
||||
// Remove things enclosed in []{}() etc
|
||||
name2 = _cleanEnclosed.Replace(name2, string.Empty);
|
||||
|
||||
var parenthIndex = name.IndexOf('(');
|
||||
if (parenthIndex != -1)
|
||||
{
|
||||
name = name.Substring(0, parenthIndex);
|
||||
}
|
||||
// Replace sequences of non-word characters with space
|
||||
name2 = _cleanNonWord.Replace(name2, " ");
|
||||
|
||||
name = name.Trim();
|
||||
// Clean based on common stop words / tokens
|
||||
name2 = _cleanStopWords.Replace(name2, string.Empty);
|
||||
|
||||
// Trim whitespace
|
||||
name2 = name2.Trim();
|
||||
|
||||
// Search again if the new name is different
|
||||
if (!string.Equals(name, originalName))
|
||||
if (!string.Equals(name2, name) && !string.IsNullOrWhiteSpace(name2))
|
||||
{
|
||||
results = await GetSearchResults(name, searchType, year, language, tmdbImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("TmdbSearch: Finding id for item: {0} ({1})", name2, year);
|
||||
results = await GetSearchResults(name2, searchType, year, language, tmdbImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (results.Count == 0 && !string.Equals(language, "en", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
//one more time, in english
|
||||
results = await GetSearchResults(name, searchType, year, "en", tmdbImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results = await GetSearchResults(name2, searchType, year, "en", tmdbImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -150,7 +163,7 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
throw new ArgumentException("name");
|
||||
}
|
||||
|
||||
var url3 = string.Format(Search3, WebUtility.UrlEncode(name), TmdbUtils.ApiKey, language, type);
|
||||
var url3 = string.Format(_searchURL, WebUtility.UrlEncode(name), TmdbUtils.ApiKey, language, type);
|
||||
|
||||
using (var response = await TmdbMovieProvider.Current.GetMovieDbResponse(new HttpRequestOptions
|
||||
{
|
||||
|
@ -179,14 +192,14 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
if (!string.IsNullOrWhiteSpace(i.Release_Date))
|
||||
{
|
||||
// These dates are always in this exact format
|
||||
if (DateTime.TryParseExact(i.Release_Date, "yyyy-MM-dd", EnUs, DateTimeStyles.None, out var r))
|
||||
if (DateTime.TryParseExact(i.Release_Date, "yyyy-MM-dd", _usCulture, DateTimeStyles.None, out var r))
|
||||
{
|
||||
remoteResult.PremiereDate = r.ToUniversalTime();
|
||||
remoteResult.ProductionYear = remoteResult.PremiereDate.Value.Year;
|
||||
}
|
||||
}
|
||||
|
||||
remoteResult.SetProviderId(MetadataProviders.Tmdb, i.Id.ToString(EnUs));
|
||||
remoteResult.SetProviderId(MetadataProviders.Tmdb, i.Id.ToString(_usCulture));
|
||||
|
||||
return remoteResult;
|
||||
|
||||
|
@ -203,7 +216,7 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
throw new ArgumentException("name");
|
||||
}
|
||||
|
||||
var url3 = string.Format(Search3, WebUtility.UrlEncode(name), TmdbUtils.ApiKey, language, "tv");
|
||||
var url3 = string.Format(_searchURL, WebUtility.UrlEncode(name), TmdbUtils.ApiKey, language, "tv");
|
||||
|
||||
using (var response = await TmdbMovieProvider.Current.GetMovieDbResponse(new HttpRequestOptions
|
||||
{
|
||||
|
@ -232,14 +245,14 @@ namespace MediaBrowser.Providers.Tmdb.Movies
|
|||
if (!string.IsNullOrWhiteSpace(i.First_Air_Date))
|
||||
{
|
||||
// These dates are always in this exact format
|
||||
if (DateTime.TryParseExact(i.First_Air_Date, "yyyy-MM-dd", EnUs, DateTimeStyles.None, out var r))
|
||||
if (DateTime.TryParseExact(i.First_Air_Date, "yyyy-MM-dd", _usCulture, DateTimeStyles.None, out var r))
|
||||
{
|
||||
remoteResult.PremiereDate = r.ToUniversalTime();
|
||||
remoteResult.ProductionYear = remoteResult.PremiereDate.Value.Year;
|
||||
}
|
||||
}
|
||||
|
||||
remoteResult.SetProviderId(MetadataProviders.Tmdb, i.Id.ToString(EnUs));
|
||||
remoteResult.SetProviderId(MetadataProviders.Tmdb, i.Id.ToString(_usCulture));
|
||||
|
||||
return remoteResult;
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<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.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||
<PropertyGroup>
|
||||
|
@ -22,4 +22,14 @@
|
|||
<ProjectReference Include="..\..\Emby.Naming\Emby.Naming.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Code Analyzers-->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -23,9 +23,9 @@ namespace Jellyfin.Naming.Tests.Subtitles
|
|||
|
||||
var result = parser.ParseFile(input);
|
||||
|
||||
Assert.Equal(language, result.Language, true);
|
||||
Assert.Equal(isDefault, result.IsDefault);
|
||||
Assert.Equal(isForced, result.IsForced);
|
||||
Assert.Equal(language, result?.Language, true);
|
||||
Assert.Equal(isDefault, result?.IsDefault);
|
||||
Assert.Equal(isForced, result?.IsForced);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
|
@ -21,7 +21,7 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(options)
|
||||
.Resolve(path, false, null, null, true);
|
||||
|
||||
Assert.Equal(episodeNumber, result.EpisodeNumber);
|
||||
Assert.Equal(episodeNumber, result?.EpisodeNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,6 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
{
|
||||
public class DailyEpisodeTests
|
||||
{
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)]
|
||||
[InlineData(@"/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)]
|
||||
|
@ -23,12 +21,12 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(options)
|
||||
.Resolve(path, false);
|
||||
|
||||
Assert.Null(result.SeasonNumber);
|
||||
Assert.Null(result.EpisodeNumber);
|
||||
Assert.Equal(year, result.Year);
|
||||
Assert.Equal(month, result.Month);
|
||||
Assert.Equal(day, result.Day);
|
||||
Assert.Equal(seriesName, result.SeriesName, true);
|
||||
Assert.Null(result?.SeasonNumber);
|
||||
Assert.Null(result?.EpisodeNumber);
|
||||
Assert.Equal(year, result?.Year);
|
||||
Assert.Equal(month, result?.Month);
|
||||
Assert.Equal(day, result?.Day);
|
||||
Assert.Equal(seriesName, result?.SeriesName, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
{
|
||||
public class EpisodeNumberWithoutSeasonTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData(8, @"The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")]
|
||||
[InlineData(2, @"The Simpsons/The Simpsons - 02 - Ep Name.avi")]
|
||||
|
@ -30,7 +29,7 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(options)
|
||||
.Resolve(path, false);
|
||||
|
||||
Assert.Equal(episodeNumber, result.EpisodeNumber);
|
||||
Assert.Equal(episodeNumber, result?.EpisodeNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using Emby.Naming.Common;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.TV;
|
||||
using Xunit;
|
||||
|
||||
|
@ -35,7 +35,6 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
// TODO: [InlineData("Watchmen (2019)/Watchmen 1x03 [WEBDL-720p][EAC3 5.1][h264][-TBS] - She Was Killed by Space Junk.mkv", "Watchmen (2019)", 1, 3)]
|
||||
// TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", "The Legend of Condor Heroes 2017", 1, 7)]
|
||||
public void ParseEpisodesCorrectly(string path, string name, int season, int episode)
|
||||
|
||||
{
|
||||
NamingOptions o = new NamingOptions();
|
||||
EpisodePathParser p = new EpisodePathParser(o);
|
||||
|
|
|
@ -19,9 +19,9 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(options)
|
||||
.Resolve(path, false);
|
||||
|
||||
Assert.Equal(seasonNumber, result.SeasonNumber);
|
||||
Assert.Equal(episodeNumber, result.EpisodeNumber);
|
||||
Assert.Equal(seriesName, result.SeriesName, true);
|
||||
Assert.Equal(seasonNumber, result?.SeasonNumber);
|
||||
Assert.Equal(episodeNumber, result?.EpisodeNumber);
|
||||
Assert.Equal(seriesName, result?.SeriesName, ignoreCase: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(_namingOptions)
|
||||
.Resolve(path, false);
|
||||
|
||||
Assert.Equal(expected, result.SeasonNumber);
|
||||
Assert.Equal(expected, result?.SeasonNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,9 +31,9 @@ namespace Jellyfin.Naming.Tests.TV
|
|||
var result = new EpisodeResolver(options)
|
||||
.Resolve(path, false);
|
||||
|
||||
Assert.Equal(seasonNumber, result.SeasonNumber);
|
||||
Assert.Equal(episodeNumber, result.EpisodeNumber);
|
||||
Assert.Equal(seriesName, result.SeriesName, true);
|
||||
Assert.Equal(seasonNumber, result?.SeasonNumber);
|
||||
Assert.Equal(episodeNumber, result?.EpisodeNumber);
|
||||
Assert.Equal(seriesName, result?.SeriesName, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
var result =
|
||||
new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv");
|
||||
|
||||
Assert.Equal("hsbs", result.Format3D);
|
||||
Assert.Equal("Oblivion", result.Name);
|
||||
Assert.Equal("hsbs", result?.Format3D);
|
||||
Assert.Equal("Oblivion", result?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
@ -12,7 +12,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiEdition1()
|
||||
private void TestMultiEdition1()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -28,7 +28,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -37,7 +36,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiEdition2()
|
||||
private void TestMultiEdition2()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -53,7 +52,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -76,7 +74,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -85,7 +82,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestLetterFolders()
|
||||
private void TestLetterFolders()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -104,7 +101,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(7, result.Count);
|
||||
|
@ -114,7 +110,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersionLimit()
|
||||
private void TestMultiVersionLimit()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -134,7 +130,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -144,7 +139,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersionLimit2()
|
||||
private void TestMultiVersionLimit2()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -165,7 +160,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(9, result.Count);
|
||||
|
@ -175,7 +169,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion3()
|
||||
private void TestMultiVersion3()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -192,7 +186,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
@ -202,7 +195,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion4()
|
||||
private void TestMultiVersion4()
|
||||
{
|
||||
// Test for false positive
|
||||
|
||||
|
@ -221,7 +214,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
@ -231,7 +223,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion5()
|
||||
private void TestMultiVersion5()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -251,7 +243,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -264,7 +255,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion6()
|
||||
private void TestMultiVersion6()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -284,7 +275,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -297,7 +287,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion7()
|
||||
private void TestMultiVersion7()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -311,7 +301,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
|
@ -319,7 +308,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion8()
|
||||
private void TestMultiVersion8()
|
||||
{
|
||||
// This is not actually supported yet
|
||||
|
||||
|
@ -340,7 +329,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -353,7 +341,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion9()
|
||||
private void TestMultiVersion9()
|
||||
{
|
||||
// Test for false positive
|
||||
|
||||
|
@ -372,7 +360,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
@ -382,7 +369,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion10()
|
||||
private void TestMultiVersion10()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
|
@ -396,7 +383,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -406,7 +392,7 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestMultiVersion11()
|
||||
private void TestMultiVersion11()
|
||||
{
|
||||
// Currently not supported but we should probably handle this.
|
||||
|
||||
|
@ -422,7 +408,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
|
|
@ -368,11 +368,11 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
var files = new[]
|
||||
{
|
||||
new FileSystemMetadata{FullName = "Bad Boys (2006) part1.mkv", IsDirectory = false},
|
||||
new FileSystemMetadata{FullName = "Bad Boys (2006) part2.mkv", IsDirectory = false},
|
||||
new FileSystemMetadata{FullName = "300 (2006) part2", IsDirectory = true},
|
||||
new FileSystemMetadata{FullName = "300 (2006) part3", IsDirectory = true},
|
||||
new FileSystemMetadata{FullName = "300 (2006) part1", IsDirectory = true}
|
||||
new FileSystemMetadata { FullName = "Bad Boys (2006) part1.mkv", IsDirectory = false },
|
||||
new FileSystemMetadata { FullName = "Bad Boys (2006) part2.mkv", IsDirectory = false },
|
||||
new FileSystemMetadata { FullName = "300 (2006) part2", IsDirectory = true },
|
||||
new FileSystemMetadata { FullName = "300 (2006) part3", IsDirectory = true },
|
||||
new FileSystemMetadata { FullName = "300 (2006) part1", IsDirectory = true }
|
||||
};
|
||||
|
||||
var resolver = GetResolver();
|
||||
|
|
|
@ -31,10 +31,10 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
var result =
|
||||
new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc");
|
||||
|
||||
Assert.Equal("Oblivion", result.Name);
|
||||
Assert.Equal("Oblivion", result?.Name);
|
||||
}
|
||||
|
||||
private void Test(string path, bool isStub, string stubType)
|
||||
private void Test(string path, bool isStub, string? stubType)
|
||||
{
|
||||
var isStubResult = StubResolver.TryResolveFile(path, _namingOptions, out var stubTypeResult);
|
||||
|
||||
|
|
|
@ -9,9 +9,10 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
public class VideoListResolverTests
|
||||
{
|
||||
private readonly NamingOptions _namingOptions = new NamingOptions();
|
||||
|
||||
// FIXME
|
||||
// [Fact]
|
||||
public void TestStackAndExtras()
|
||||
private void TestStackAndExtras()
|
||||
{
|
||||
// No stacking here because there is no part/disc/etc
|
||||
var files = new[]
|
||||
|
@ -45,7 +46,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
@ -74,7 +74,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -95,7 +94,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -116,7 +114,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -138,7 +135,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -159,7 +155,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -184,7 +179,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
@ -205,7 +199,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = true,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -227,7 +220,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = true,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
|
@ -249,7 +241,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -271,7 +262,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -294,7 +284,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -317,7 +306,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
|
@ -337,7 +325,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -357,7 +344,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -378,7 +364,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -399,7 +384,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
@ -422,7 +406,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
|
@ -443,7 +426,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
{
|
||||
IsDirectory = false,
|
||||
FullName = i
|
||||
|
||||
}).ToList()).ToList();
|
||||
|
||||
Assert.Single(result);
|
||||
|
|
|
@ -176,7 +176,6 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetResolveFileTestData))]
|
||||
public void ResolveFile_ValidFileName_Success(VideoFileInfo expectedResult)
|
||||
|
@ -184,17 +183,17 @@ namespace Jellyfin.Naming.Tests.Video
|
|||
var result = new VideoResolver(_namingOptions).ResolveFile(expectedResult.Path);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(result.Path, expectedResult.Path);
|
||||
Assert.Equal(result.Container, expectedResult.Container);
|
||||
Assert.Equal(result.Name, expectedResult.Name);
|
||||
Assert.Equal(result.Year, expectedResult.Year);
|
||||
Assert.Equal(result.ExtraType, expectedResult.ExtraType);
|
||||
Assert.Equal(result.Format3D, expectedResult.Format3D);
|
||||
Assert.Equal(result.Is3D, expectedResult.Is3D);
|
||||
Assert.Equal(result.IsStub, expectedResult.IsStub);
|
||||
Assert.Equal(result.StubType, expectedResult.StubType);
|
||||
Assert.Equal(result.IsDirectory, expectedResult.IsDirectory);
|
||||
Assert.Equal(result.FileNameWithoutExtension, expectedResult.FileNameWithoutExtension);
|
||||
Assert.Equal(result?.Path, expectedResult.Path);
|
||||
Assert.Equal(result?.Container, expectedResult.Container);
|
||||
Assert.Equal(result?.Name, expectedResult.Name);
|
||||
Assert.Equal(result?.Year, expectedResult.Year);
|
||||
Assert.Equal(result?.ExtraType, expectedResult.ExtraType);
|
||||
Assert.Equal(result?.Format3D, expectedResult.Format3D);
|
||||
Assert.Equal(result?.Is3D, expectedResult.Is3D);
|
||||
Assert.Equal(result?.IsStub, expectedResult.IsStub);
|
||||
Assert.Equal(result?.StubType, expectedResult.StubType);
|
||||
Assert.Equal(result?.IsDirectory, expectedResult.IsDirectory);
|
||||
Assert.Equal(result?.FileNameWithoutExtension, expectedResult.FileNameWithoutExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
using Emby.Server.Implementations.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
{
|
||||
public class IgnorePatternsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/media/small.jpg", true)]
|
||||
[InlineData("/media/movies/#Recycle/test.txt", true)]
|
||||
[InlineData("/media/movies/#recycle/", true)]
|
||||
[InlineData("thumbs.db", true)]
|
||||
[InlineData(@"C:\media\movies\movie.avi", false)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", true)]
|
||||
[InlineData("/media/dir/.hiddenfile.mp4", true)]
|
||||
public void PathIgnored(string path, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
|
||||
|
|
Loading…
Reference in New Issue