Browse Source

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
Patrick Barron 5 years ago
parent
commit
01ce56016a
86 changed files with 2896 additions and 457 deletions
  1. 1 1
      Dockerfile
  2. 1 1
      Dockerfile.arm
  3. 4 0
      Emby.Server.Implementations/ApplicationHost.cs
  4. 5 4
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  5. 6 3
      Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
  6. 23 13
      Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
  7. 39 1
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  8. 2 38
      Emby.Server.Implementations/IO/LibraryMonitor.cs
  9. 7 0
      Emby.Server.Implementations/IStartupOptions.cs
  10. 3 44
      Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
  11. 74 0
      Emby.Server.Implementations/Library/IgnorePatterns.cs
  12. 1 1
      Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
  13. 19 2
      Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
  14. 1 1
      Emby.Server.Implementations/Localization/Core/ar.json
  15. 1 1
      Emby.Server.Implementations/Localization/Core/cs.json
  16. 14 14
      Emby.Server.Implementations/Localization/Core/es-AR.json
  17. 35 35
      Emby.Server.Implementations/Localization/Core/es-MX.json
  18. 16 15
      Emby.Server.Implementations/Localization/Core/fr-CA.json
  19. 8 1
      Emby.Server.Implementations/Localization/Core/he.json
  20. 20 4
      Emby.Server.Implementations/Localization/Core/is.json
  21. 23 1
      Emby.Server.Implementations/Localization/Core/lt-LT.json
  22. 4 4
      Emby.Server.Implementations/Localization/Core/pt-BR.json
  23. 71 0
      Emby.Server.Implementations/Localization/Core/th.json
  24. 36 18
      Emby.Server.Implementations/Localization/Core/zh-HK.json
  25. 17 0
      Emby.Server.Implementations/Session/SessionManager.cs
  26. 244 1
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  27. 517 0
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  28. 398 0
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  29. 13 3
      Emby.Server.Implementations/Udp/UdpServer.cs
  30. 5 0
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  31. 1 1
      Jellyfin.Api/Jellyfin.Api.csproj
  32. 3 3
      Jellyfin.Data/Jellyfin.Data.csproj
  33. 5 2
      Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
  34. 2 2
      Jellyfin.Server/Jellyfin.Server.csproj
  35. 12 0
      Jellyfin.Server/StartupOptions.cs
  36. 302 0
      MediaBrowser.Api/SyncPlay/SyncPlayService.cs
  37. 52 0
      MediaBrowser.Api/SyncPlay/TimeSyncService.cs
  38. 11 28
      MediaBrowser.Api/UserService.cs
  39. 2 2
      MediaBrowser.Common/MediaBrowser.Common.csproj
  40. 0 8
      MediaBrowser.Controller/Library/IUserManager.cs
  41. 2 2
      MediaBrowser.Controller/MediaBrowser.Controller.csproj
  42. 6 0
      MediaBrowser.Controller/Net/IWebSocketConnection.cs
  43. 19 0
      MediaBrowser.Controller/Session/ISessionManager.cs
  44. 169 0
      MediaBrowser.Controller/SyncPlay/GroupInfo.cs
  45. 28 0
      MediaBrowser.Controller/SyncPlay/GroupMember.cs
  46. 67 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
  47. 69 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  48. 14 0
      MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs
  49. 10 0
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  50. 23 0
      MediaBrowser.Model/Configuration/SyncplayAccess.cs
  51. 0 48
      MediaBrowser.Model/Dto/PublicUserDto.cs
  52. 43 9
      MediaBrowser.Model/Entities/MediaStream.cs
  53. 2 2
      MediaBrowser.Model/MediaBrowser.Model.csproj
  54. 2 0
      MediaBrowser.Model/Net/MimeTypes.cs
  55. 40 0
      MediaBrowser.Model/SyncPlay/GroupInfoView.cs
  56. 26 0
      MediaBrowser.Model/SyncPlay/GroupUpdate.cs
  57. 53 0
      MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
  58. 22 0
      MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
  59. 34 0
      MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
  60. 33 0
      MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
  61. 38 0
      MediaBrowser.Model/SyncPlay/SendCommand.cs
  62. 21 0
      MediaBrowser.Model/SyncPlay/SendCommandType.cs
  63. 20 0
      MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
  64. 7 0
      MediaBrowser.Model/Users/UserPolicy.cs
  65. 2 2
      MediaBrowser.Providers/MediaBrowser.Providers.csproj
  66. 4 4
      MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
  67. 4 4
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
  68. 51 38
      MediaBrowser.Providers/Tmdb/Movies/TmdbSearch.cs
  69. 1 1
      tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
  70. 11 1
      tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj
  71. 3 3
      tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs
  72. 1 1
      tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs
  73. 6 8
      tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs
  74. 1 2
      tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs
  75. 1 2
      tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs
  76. 3 3
      tests/Jellyfin.Naming.Tests/TV/EpisodeWithoutSeasonTests.cs
  77. 1 1
      tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs
  78. 3 3
      tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs
  79. 2 2
      tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs
  80. 14 29
      tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
  81. 5 5
      tests/Jellyfin.Naming.Tests/Video/StackTests.cs
  82. 2 2
      tests/Jellyfin.Naming.Tests/Video/StubTests.cs
  83. 2 20
      tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
  84. 11 12
      tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs
  85. 21 0
      tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs
  86. 1 1
      tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj

+ 1 - 1
Dockerfile

@@ -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 \

+ 1 - 1
Dockerfile.arm

@@ -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 && \

+ 4 - 0
Emby.Server.Implementations/ApplicationHost.cs

@@ -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>();
 

+ 5 - 4
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -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>

+ 6 - 3
Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs

@@ -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);
         }
 

+ 23 - 13
Emby.Server.Implementations/HttpServer/HttpListenerHost.cs

@@ -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)
                 {

+ 39 - 1
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -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
             };
 
-            await OnReceive(info).ConfigureAwait(false);
+            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();
+            }
         }
     }
 }

+ 2 - 38
Emby.Server.Implementations/IO/LibraryMonitor.cs

@@ -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();

+ 7 - 0
Emby.Server.Implementations/IStartupOptions.cs

@@ -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; }
     }
 }

+ 3 - 44
Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs

@@ -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;

+ 74 - 0
Emby.Server.Implementations/Library/IgnorePatterns.cs

@@ -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));
+        }
+    }
+}

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs

@@ -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)
         {

+ 19 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs

@@ -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);
             });

+ 1 - 1
Emby.Server.Implementations/Localization/Core/ar.json

@@ -9,7 +9,7 @@
     "Channels": "القنوات",
     "ChapterNameValue": "الفصل {0}",
     "Collections": "مجموعات",
-    "DeviceOfflineWithName": "قُطِع الاتصال بـ{0}",
+    "DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
     "DeviceOnlineWithName": "{0} متصل",
     "FailedLoginAttemptWithUserName": "عملية تسجيل الدخول فشلت من {0}",
     "Favorites": "المفضلة",

+ 1 - 1
Emby.Server.Implementations/Localization/Core/cs.json

@@ -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",

+ 14 - 14
Emby.Server.Implementations/Localization/Core/es-AR.json

@@ -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"
 }

+ 35 - 35
Emby.Server.Implementations/Localization/Core/es-MX.json

@@ -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",

+ 16 - 15
Emby.Server.Implementations/Localization/Core/fr-CA.json

@@ -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."
 }

+ 8 - 1
Emby.Server.Implementations/Localization/Core/he.json

@@ -107,5 +107,12 @@
     "TaskCleanLogs": "נקה תיקיית יומן",
     "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.",
     "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.",
-    "TasksChannelsCategory": "ערוצי אינטרנט"
+    "TasksChannelsCategory": "ערוצי אינטרנט",
+    "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.",
+    "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות.",
+    "TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.",
+    "TaskRefreshChannels": "רענן ערוץ",
+    "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.",
+    "TaskCleanTranscode": "נקה תקיית Transcode",
+    "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי."
 }

+ 20 - 4
Emby.Server.Implementations/Localization/Core/is.json

@@ -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"
 }

+ 23 - 1
Emby.Server.Implementations/Localization/Core/lt-LT.json

@@ -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"
 }

+ 4 - 4
Emby.Server.Implementations/Localization/Core/pt-BR.json

@@ -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",

+ 71 - 0
Emby.Server.Implementations/Localization/Core/th.json

@@ -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": "อัลบั้ม"
+}

+ 36 - 18
Emby.Server.Implementations/Localization/Core/zh-HK.json

@@ -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": "庫"
 }

+ 17 - 0
Emby.Server.Implementations/Session/SessionManager.cs

@@ -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);

+ 244 - 1
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -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;
+                }
+            }
+        }
     }
 }

+ 517 - 0
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -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()
+            };
+        }
+    }
+}

+ 398 - 0
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -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!");
+            }
+        }
+    }
+}

+ 13 - 3
Emby.Server.Implementations/Udp/UdpServer.cs

@@ -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)
                 {

+ 5 - 0
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -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));

+ 1 - 1
Jellyfin.Api/Jellyfin.Api.csproj

@@ -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>

+ 3 - 3
Jellyfin.Data/Jellyfin.Data.csproj

@@ -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>

+ 5 - 2
Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj

@@ -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>

+ 2 - 2
Jellyfin.Server/Jellyfin.Server.csproj

@@ -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" />

+ 12 - 0
Jellyfin.Server/StartupOptions.cs

@@ -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;
         }
     }

+ 302 - 0
MediaBrowser.Api/SyncPlay/SyncPlayService.cs

@@ -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);
+        }
+    }
+}

+ 52 - 0
MediaBrowser.Api/SyncPlay/TimeSyncService.cs

@@ -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;
+        }
+    }
+}

+ 11 - 28
MediaBrowser.Api/UserService.cs

@@ -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));
-                }
-
-                if (!_networkManager.IsInLocalNetwork(Request.RemoteIp))
-                {
-                    result = result.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
-                }
+                    IsDisabled = false
+                });
             }
 
-            return ToOptimizedResult(result
-                    .OrderBy(u => u.Username)
-                    .Select(i => _userManager.GetPublicUserDto(i, Request.RemoteIp))
-                    .ToArray()
-                );
+            return Get(new GetUsers
+            {
+                IsHidden = false,
+                IsDisabled = false
+            }, true, true);
         }
 
         /// <summary>

+ 2 - 2
MediaBrowser.Common/MediaBrowser.Common.csproj

@@ -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>
 

+ 0 - 8
MediaBrowser.Controller/Library/IUserManager.cs

@@ -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>

+ 2 - 2
MediaBrowser.Controller/MediaBrowser.Controller.csproj

@@ -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>

+ 6 - 0
MediaBrowser.Controller/Net/IWebSocketConnection.cs

@@ -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>

+ 19 - 0
MediaBrowser.Controller/Session/ISessionManager.cs

@@ -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>

+ 169 - 0
MediaBrowser.Controller/SyncPlay/GroupInfo.cs

@@ -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;
+        }
+    }
+}

+ 28 - 0
MediaBrowser.Controller/SyncPlay/GroupMember.cs

@@ -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; }
+    }
+}

+ 67 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs

@@ -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();
+    }
+}

+ 69 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs

@@ -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);
+    }
+}

+ 14 - 0
MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs

@@ -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; }
     }
 }

+ 10 - 0
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -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
             {

+ 23 - 0
MediaBrowser.Model/Configuration/SyncplayAccess.cs

@@ -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
+    }
+}

+ 0 - 48
MediaBrowser.Model/Dto/PublicUserDto.cs

@@ -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();
-        }
-    }
-}

+ 43 - 9
MediaBrowser.Model/Entities/MediaStream.cs

@@ -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";

+ 2 - 2
MediaBrowser.Model/MediaBrowser.Model.csproj

@@ -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>

+ 2 - 0
MediaBrowser.Model/Net/MimeTypes.cs

@@ -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" },

+ 40 - 0
MediaBrowser.Model/SyncPlay/GroupInfoView.cs

@@ -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; }
+    }
+}

+ 26 - 0
MediaBrowser.Model/SyncPlay/GroupUpdate.cs

@@ -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; }
+    }
+}

+ 53 - 0
MediaBrowser.Model/SyncPlay/GroupUpdateType.cs

@@ -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
+    }
+}

+ 22 - 0
MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs

@@ -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; }
+    }
+}

+ 34 - 0
MediaBrowser.Model/SyncPlay/PlaybackRequest.cs

@@ -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; }
+    }
+}

+ 33 - 0
MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs

@@ -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
+    }
+}

+ 38 - 0
MediaBrowser.Model/SyncPlay/SendCommand.cs

@@ -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; }
+    }
+}

+ 21 - 0
MediaBrowser.Model/SyncPlay/SendCommandType.cs

@@ -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
+    }
+}

+ 20 - 0
MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs

@@ -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; }
+    }
+}

+ 7 - 0
MediaBrowser.Model/Users/UserPolicy.cs

@@ -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;
         }
     }
 }

+ 2 - 2
MediaBrowser.Providers/MediaBrowser.Providers.csproj

@@ -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" />

+ 4 - 4
MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html

@@ -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);
                 });

+ 4 - 4
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html

@@ -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);
                 });

+ 51 - 38
MediaBrowser.Providers/Tmdb/Movies/TmdbSearch.cs

@@ -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))
-            {
-                var parsedName = _libraryManager.ParseName(name);
-                var yearInName = parsedName.Year;
-                name = parsedName.Name;
-                year = year ?? yearInName;
-            }
+            // TODO: Investigate: Does this mean we are reparsing already parsed ItemLookupInfo?
+            var parsedName = _libraryManager.ParseName(name);
+            var yearInName = parsedName.Year;
+            name = parsedName.Name;
+            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;
-
-                name = name.Replace(",", " ");
-                name = name.Replace(".", " ");
-                name = name.Replace("_", " ");
-                name = name.Replace("-", " ");
-                name = name.Replace("!", " ");
-                name = name.Replace("?", " ");
-
-                var parenthIndex = name.IndexOf('(');
-                if (parenthIndex != -1)
-                {
-                    name = name.Substring(0, parenthIndex);
-                }
+                var name2 = parsedName.Name;
 
-                name = name.Trim();
+                // Remove things enclosed in []{}() etc
+                name2 = _cleanEnclosed.Replace(name2, string.Empty);
+
+                // Replace sequences of non-word characters with space
+                name2 = _cleanNonWord.Replace(name2, " ");
+
+                // 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;
 

+ 1 - 1
tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj

@@ -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" />

+ 11 - 1
tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj

@@ -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>
@@ -21,5 +21,15 @@
   <ItemGroup>
     <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>

+ 3 - 3
tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs

@@ -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]

+ 1 - 1
tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs

@@ -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
tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs

@@ -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);
         }
     }
 }

+ 1 - 2
tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs

@@ -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 - 2
tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs

@@ -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);

+ 3 - 3
tests/Jellyfin.Naming.Tests/TV/EpisodeWithoutSeasonTests.cs

@@ -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);
         }
     }
 }

+ 1 - 1
tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs

@@ -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);
         }
     }
 }

+ 3 - 3
tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs

@@ -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);
         }
     }
 }

+ 2 - 2
tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs

@@ -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]

+ 14 - 29
tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs

@@ -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);

+ 5 - 5
tests/Jellyfin.Naming.Tests/Video/StackTests.cs

@@ -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();

+ 2 - 2
tests/Jellyfin.Naming.Tests/Video/StubTests.cs

@@ -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);
 

+ 2 - 20
tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs

@@ -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);

+ 11 - 12
tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs

@@ -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);
         }
     }
 }

+ 21 - 0
tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs

@@ -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));
+        }
+    }
+}

+ 1 - 1
tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj

@@ -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" />