aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs4
-rw-r--r--Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs9
-rw-r--r--Emby.Server.Implementations/HttpServer/HttpListenerHost.cs9
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs40
-rw-r--r--Emby.Server.Implementations/IStartupOptions.cs7
-rw-r--r--Emby.Server.Implementations/Library/IgnorePatterns.cs1
-rw-r--r--Emby.Server.Implementations/Library/UserManager.cs25
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json70
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json117
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json24
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json99
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json71
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json54
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs17
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs245
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayController.cs517
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs398
-rw-r--r--Emby.Server.Implementations/Udp/UdpServer.cs16
23 files changed, 1649 insertions, 99 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index e6410f8570..be4e05a648 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/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;
@@ -613,6 +615,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
+ serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
+
serviceCollection.AddSingleton<LiveTvDtoService>();
serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
index 50ba0f8fac..6929c81f92 100644
--- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
+++ b/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);
}
diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
index 0438122900..7de4f168c1 100644
--- a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
+++ b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs
@@ -496,6 +496,15 @@ namespace Emby.Server.Implementations.HttpServer
{
var requestInnerEx = GetActualException(requestEx);
var statusCode = GetStatusCode(requestInnerEx);
+
+ 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
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index 095725c504..0680c5ffe7 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/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();
+ }
}
}
}
diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs
index 16b68170be..acae702f30 100644
--- a/Emby.Server.Implementations/IStartupOptions.cs
+++ b/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; }
}
}
diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs
index 49a36495af..d12b5855b2 100644
--- a/Emby.Server.Implementations/Library/IgnorePatterns.cs
+++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs
@@ -25,6 +25,7 @@ namespace Emby.Server.Implementations.Library
"**/extrathumbs/**",
"**/.actors/**",
"**/.wd_tv/**",
+ "**/lost+found/**",
// WMC temp recording directories that will constantly be written to
"**/TempRec/**",
diff --git a/Emby.Server.Implementations/Library/UserManager.cs b/Emby.Server.Implementations/Library/UserManager.cs
index b8feb5535f..d63bc6bda8 100644
--- a/Emby.Server.Implementations/Library/UserManager.cs
+++ b/Emby.Server.Implementations/Library/UserManager.cs
@@ -608,31 +608,6 @@ namespace Emby.Server.Implementations.Library
return dto;
}
- public PublicUserDto GetPublicUserDto(User user, string remoteEndPoint = null)
- {
- if (user == null)
- {
- throw new ArgumentNullException(nameof(user));
- }
-
- IAuthenticationProvider authenticationProvider = GetAuthenticationProvider(user);
- bool hasConfiguredPassword = authenticationProvider.HasPassword(user);
- bool hasConfiguredEasyPassword = !string.IsNullOrEmpty(authenticationProvider.GetEasyPasswordHash(user));
-
- bool hasPassword = user.Configuration.EnableLocalPassword &&
- !string.IsNullOrEmpty(remoteEndPoint) &&
- _networkManager.IsInLocalNetwork(remoteEndPoint) ? hasConfiguredEasyPassword : hasConfiguredPassword;
-
- PublicUserDto dto = new PublicUserDto
- {
- Name = user.Name,
- HasPassword = hasPassword,
- HasConfiguredPassword = hasConfiguredPassword,
- };
-
- return dto;
- }
-
public UserDto GetOfflineUserDto(User user)
{
var dto = GetUserDto(user);
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index f313039a69..d68928fce4 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -9,7 +9,7 @@
"Channels": "القنوات",
"ChapterNameValue": "الفصل {0}",
"Collections": "مجموعات",
- "DeviceOfflineWithName": "قُطِع الاتصال بـ{0}",
+ "DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
"DeviceOnlineWithName": "{0} متصل",
"FailedLoginAttemptWithUserName": "عملية تسجيل الدخول فشلت من {0}",
"Favorites": "المفضلة",
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index 992bb9df37..464ca28ca0 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/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",
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index d93920f433..20b37ec9f2 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/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",
diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
new file mode 100644
index 0000000000..b0fdc8386c
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -0,0 +1,117 @@
+{
+ "LabelRunningTimeValue": "Duración: {0}",
+ "ValueSpecialEpisodeName": "Especial - {0}",
+ "Sync": "Sincronizar",
+ "Songs": "Canciones",
+ "Shows": "Programas",
+ "Playlists": "Listas de reproducción",
+ "Photos": "Fotos",
+ "Movies": "Películas",
+ "HeaderNextUp": "A continuación",
+ "HeaderLiveTV": "TV en vivo",
+ "HeaderFavoriteSongs": "Canciones favoritas",
+ "HeaderFavoriteArtists": "Artistas favoritos",
+ "HeaderFavoriteAlbums": "Álbumes favoritos",
+ "HeaderFavoriteEpisodes": "Episodios favoritos",
+ "HeaderFavoriteShows": "Programas favoritos",
+ "HeaderContinueWatching": "Continuar viendo",
+ "HeaderAlbumArtists": "Artistas del álbum",
+ "Genres": "Géneros",
+ "Folders": "Carpetas",
+ "Favorites": "Favoritos",
+ "Collections": "Colecciones",
+ "Channels": "Canales",
+ "Books": "Libros",
+ "Artists": "Artistas",
+ "Albums": "Álbumes",
+ "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": "Elimina archivos transcodificados que tengan más de un día.",
+ "TaskCleanTranscode": "Limpiar directorio de transcodificado",
+ "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.",
+ "TaskUpdatePlugins": "Actualizar complementos",
+ "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",
+ "TasksMaintenanceCategory": "Mantenimiento",
+ "VersionNumber": "Versión {0}",
+ "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios",
+ "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}",
+ "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}",
+ "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}",
+ "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}",
+ "UserOnlineFromDevice": "{0} está en línea desde {1}",
+ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}",
+ "UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
+ "UserDownloadingItemWithValues": "{0} está descargando {1}",
+ "UserDeletedWithName": "El usuario {0} ha sido eliminado",
+ "UserCreatedWithName": "El usuario {0} ha sido creado",
+ "User": "Usuario",
+ "TvShows": "Programas de TV",
+ "System": "Sistema",
+ "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
+ "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.",
+ "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado",
+ "ScheduledTaskStartedWithName": "{0} iniciado",
+ "ScheduledTaskFailedWithName": "{0} falló",
+ "ProviderValue": "Proveedor: {0}",
+ "PluginUpdatedWithName": "{0} fue actualizado",
+ "PluginUninstalledWithName": "{0} fue desinstalado",
+ "PluginInstalledWithName": "{0} fue instalado",
+ "Plugin": "Complemento",
+ "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida",
+ "NotificationOptionVideoPlayback": "Reproducción de video iniciada",
+ "NotificationOptionUserLockedOut": "Usuario bloqueado",
+ "NotificationOptionTaskFailed": "Falla de tarea programada",
+ "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor",
+ "NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada",
+ "NotificationOptionPluginUninstalled": "Complemento desinstalado",
+ "NotificationOptionPluginInstalled": "Complemento instalado",
+ "NotificationOptionPluginError": "Falla de complemento",
+ "NotificationOptionNewLibraryContent": "Nuevo contenido agregado",
+ "NotificationOptionInstallationFailed": "Falla de instalación",
+ "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida",
+ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida",
+ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada",
+ "NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada",
+ "NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible",
+ "NewVersionIsAvailable": "Una nueva versión del Servidor Jellyfin está disponible para descargar.",
+ "NameSeasonUnknown": "Temporada desconocida",
+ "NameSeasonNumber": "Temporada {0}",
+ "NameInstallFailed": "Falló la instalación de {0}",
+ "MusicVideos": "Videos musicales",
+ "Music": "Música",
+ "MixedContent": "Contenido mezclado",
+ "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor",
+ "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}",
+ "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado",
+ "Latest": "Recientes",
+ "LabelIpAddressValue": "Dirección IP: {0}",
+ "ItemRemovedWithName": "{0} fue removido de la biblioteca",
+ "ItemAddedWithName": "{0} fue agregado a la biblioteca",
+ "Inherit": "Heredar",
+ "HomeVideos": "Videos caseros",
+ "HeaderRecordingGroups": "Grupos de grabación",
+ "HeaderCameraUploads": "Subidas desde la cámara",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}",
+ "DeviceOnlineWithName": "{0} está conectado",
+ "DeviceOfflineWithName": "{0} se ha desconectado",
+ "ChapterNameValue": "Capítulo {0}",
+ "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}",
+ "AuthenticationSucceededWithUserName": "{0} autenticado con éxito",
+ "Application": "Aplicación",
+ "AppDeviceValues": "App: {0}, Dispositivo: {1}"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
index 150952d8ba..47ebe12540 100644
--- a/Emby.Server.Implementations/Localization/Core/fr.json
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -5,7 +5,7 @@
"Artists": "Artistes",
"AuthenticationSucceededWithUserName": "{0} authentifié avec succès",
"Books": "Livres",
- "CameraImageUploadedFrom": "Une nouvelle photographie a été chargée depuis {0}",
+ "CameraImageUploadedFrom": "Une photo a été chargée depuis {0}",
"Channels": "Chaînes",
"ChapterNameValue": "Chapitre {0}",
"Collections": "Collections",
@@ -15,7 +15,7 @@
"Favorites": "Favoris",
"Folders": "Dossiers",
"Genres": "Genres",
- "HeaderAlbumArtists": "Artistes de l'album",
+ "HeaderAlbumArtists": "Artistes",
"HeaderCameraUploads": "Photos transférées",
"HeaderContinueWatching": "Continuer à regarder",
"HeaderFavoriteAlbums": "Albums favoris",
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 4e54b9f7ad..682f5325b5 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/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": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי."
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index ef2a57e8e8..0f0f9130b0 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index 3a69b6d7a5..275195640b 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/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",
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
new file mode 100644
index 0000000000..f722dd8c07
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -0,0 +1,99 @@
+{
+ "VersionNumber": "பதிப்பு {0}",
+ "ValueSpecialEpisodeName": "சிறப்பு - {0}",
+ "TasksMaintenanceCategory": "பராமரிப்பு",
+ "TaskCleanCache": "தற்காலிக சேமிப்பு கோப்பகத்தை சுத்தம் செய்யவும்",
+ "TaskRefreshChapterImages": "அத்தியாயப் படங்களை பிரித்தெடுக்கவும்",
+ "TaskRefreshPeople": "மக்களைப் புதுப்பிக்கவும்",
+ "TaskCleanTranscode": "டிரான்ஸ்கோட் கோப்பகத்தை சுத்தம் செய்யவும்",
+ "TaskRefreshChannelsDescription": "இணையச் சேனல் தகவல்களைப் புதுப்பிக்கிறது.",
+ "System": "ஒருங்கியம்",
+ "NotificationOptionTaskFailed": "திட்டமிடப்பட்ட பணி தோல்வியடைந்தது",
+ "NotificationOptionPluginUpdateInstalled": "உட்செருகி புதுப்பிக்கப்பட்டது",
+ "NotificationOptionPluginUninstalled": "உட்செருகி நீக்கப்பட்டது",
+ "NotificationOptionPluginInstalled": "உட்செருகி நிறுவப்பட்டது",
+ "NotificationOptionPluginError": "உட்செருகி செயலிழந்தது",
+ "NotificationOptionCameraImageUploaded": "புகைப்படம் பதிவேற்றப்பட்டது",
+ "MixedContent": "கலப்பு உள்ளடக்கங்கள்",
+ "MessageServerConfigurationUpdated": "சேவையக அமைப்புகள் புதுப்பிக்கப்பட்டன",
+ "MessageApplicationUpdatedTo": "ஜெல்லிஃபின் சேவையகம் {0} இற்கு புதுப்பிக்கப்பட்டது",
+ "MessageApplicationUpdated": "ஜெல்லிஃபின் சேவையகம் புதுப்பிக்கப்பட்டது",
+ "Inherit": "மரபரிமையாகப் பெறு",
+ "HeaderRecordingGroups": "பதிவு குழுக்கள்",
+ "HeaderCameraUploads": "புகைப்பட பதிவேற்றங்கள்",
+ "Folders": "கோப்புறைகள்",
+ "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
+ "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
+ "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
+ "Collections": "தொகுப்புகள்",
+ "CameraImageUploadedFrom": "{0} இலிருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது",
+ "AppDeviceValues": "செயலி: {0}, சாதனம்: {1}",
+ "TaskDownloadMissingSubtitles": "விடுபட்டுபோன வசன வரிகளைப் பதிவிறக்கு",
+ "TaskRefreshChannels": "சேனல்களை புதுப்பி",
+ "TaskUpdatePlugins": "உட்செருகிகளை புதுப்பி",
+ "TaskRefreshLibrary": "மீடியா நூலகத்தை ஆராய்",
+ "TasksChannelsCategory": "இணைய சேனல்கள்",
+ "TasksApplicationCategory": "செயலி",
+ "TasksLibraryCategory": "நூலகம்",
+ "UserPolicyUpdatedWithName": "பயனர் கொள்கை {0} இற்கு புதுப்பிக்கப்பட்டுள்ளது",
+ "UserPasswordChangedWithName": "{0} பயனருக்கு கடவுச்சொல் மாற்றப்பட்டுள்ளது",
+ "UserLockedOutWithName": "பயனர் {0} முடக்கப்பட்டார்",
+ "UserDownloadingItemWithValues": "{0} ஆல் {1} பதிவிறக்கப்படுகிறது",
+ "UserDeletedWithName": "பயனர் {0} நீக்கப்பட்டார்",
+ "UserCreatedWithName": "பயனர் {0} உருவாக்கப்பட்டார்",
+ "User": "பயனர்",
+ "TvShows": "தொலைக்காட்சித் தொடர்கள்",
+ "Sync": "ஒத்திசைவு",
+ "StartupEmbyServerIsLoading": "ஜெல்லிஃபின் சேவையகம் துவங்குகிறது. சிறிது நேரம் கழித்து முயற்சிக்கவும்.",
+ "Songs": "பாட்டுகள்",
+ "Shows": "தொடர்கள்",
+ "ServerNameNeedsToBeRestarted": "{0} மறுதொடக்கம் செய்யப்பட வேண்டும்",
+ "ScheduledTaskStartedWithName": "{0} துவங்கியது",
+ "ScheduledTaskFailedWithName": "{0} தோல்வியடைந்தது",
+ "ProviderValue": "வழங்குநர்: {0}",
+ "PluginUpdatedWithName": "{0} புதுப்பிக்கப்பட்டது",
+ "PluginUninstalledWithName": "{0} நீக்கப்பட்டது",
+ "PluginInstalledWithName": "{0} நிறுவப்பட்டது",
+ "Plugin": "உட்செருகி",
+ "Playlists": "தொடர் பட்டியல்கள்",
+ "Photos": "புகைப்படங்கள்",
+ "NotificationOptionVideoPlaybackStopped": "நிகழ்பட ஒளிபரப்பு நிறுத்தப்பட்டது",
+ "NotificationOptionVideoPlayback": "நிகழ்பட ஒளிபரப்பு துவங்கியது",
+ "NotificationOptionUserLockedOut": "பயனர் கணக்கு முடக்கப்பட்டது",
+ "NotificationOptionServerRestartRequired": "சேவையக மறுதொடக்கம் தேவை",
+ "NotificationOptionNewLibraryContent": "புதிய உள்ளடக்கங்கள் சேர்க்கப்பட்டன",
+ "NotificationOptionInstallationFailed": "நிறுவல் தோல்வியடைந்தது",
+ "NotificationOptionAudioPlaybackStopped": "ஒலி இசைத்தல் நிறுத்தப்பட்டது",
+ "NotificationOptionAudioPlayback": "ஒலி இசைக்கத் துவங்கியுள்ளது",
+ "NotificationOptionApplicationUpdateInstalled": "செயலி புதுப்பிக்கப்பட்டது",
+ "NotificationOptionApplicationUpdateAvailable": "செயலியினை புதுப்பிக்கலாம்",
+ "NameSeasonUnknown": "பருவம் அறியப்படாதவை",
+ "NameSeasonNumber": "பருவம் {0}",
+ "NameInstallFailed": "{0} நிறுவல் தோல்வியடைந்தது",
+ "MusicVideos": "இசைப்படங்கள்",
+ "Music": "இசை",
+ "Movies": "திரைப்படங்கள்",
+ "Latest": "புதியன",
+ "LabelRunningTimeValue": "ஓடும் நேரம்: {0}",
+ "LabelIpAddressValue": "ஐபி முகவரி: {0}",
+ "ItemRemovedWithName": "{0} நூலகத்திலிருந்து அகற்றப்பட்டது",
+ "ItemAddedWithName": "{0} நூலகத்தில் சேர்க்கப்பட்டது",
+ "HeaderNextUp": "அடுத்ததாக",
+ "HeaderLiveTV": "நேரடித் தொலைக்காட்சி",
+ "HeaderFavoriteSongs": "பிடித்த பாட்டுகள்",
+ "HeaderFavoriteShows": "பிடித்த தொடர்கள்",
+ "HeaderFavoriteEpisodes": "பிடித்த அத்தியாயங்கள்",
+ "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்",
+ "HeaderFavoriteAlbums": "பிடித்த ஆல்பங்கள்",
+ "HeaderContinueWatching": "தொடர்ந்து பார்",
+ "HeaderAlbumArtists": "இசைக் கலைஞர்கள்",
+ "Genres": "வகைகள்",
+ "Favorites": "பிடித்தவை",
+ "ChapterNameValue": "அத்தியாயம் {0}",
+ "Channels": "சேனல்கள்",
+ "Books": "புத்தகங்கள்",
+ "AuthenticationSucceededWithUserName": "{0} வெற்றிகரமாக அங்கீகரிக்கப்பட்டது",
+ "Artists": "கலைஞர்கள்",
+ "Application": "செயலி",
+ "Albums": "ஆல்பங்கள்"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
new file mode 100644
index 0000000000..32538ac035
--- /dev/null
+++ b/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": "อัลบั้ม"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index a67a67582f..0804fc9279 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/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": "庫"
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index df98a35bc0..2b09a93efc 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -25,6 +25,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;
namespace Emby.Server.Implementations.Session
@@ -1153,6 +1154,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);
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index d4e4ba1f2f..e7b4b0ec35 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/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;
@@ -14,6 +19,21 @@ namespace Emby.Server.Implementations.Session
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>
private readonly ISessionManager _sessionManager;
@@ -27,6 +47,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>
/// <param name="logger">The logger.</param>
@@ -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;
+ }
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
new file mode 100644
index 0000000000..d430d4d162
--- /dev/null
+++ b/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()
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
new file mode 100644
index 0000000000..1f76dd4e36
--- /dev/null
+++ b/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!");
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs
index c91d137a72..a26f714b12 100644
--- a/Emby.Server.Implementations/Udp/UdpServer.cs
+++ b/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)
{