aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/tasks.json12
-rw-r--r--Dockerfile.arm2
-rw-r--r--Emby.Drawing/ImageProcessor.cs21
-rw-r--r--Emby.Drawing/NullImageEncoder.cs6
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs4
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs29
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs94
-rw-r--r--Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.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/LibraryManager.cs93
-rw-r--r--Emby.Server.Implementations/Library/UserManager.cs25
-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/ta.json99
-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
-rw-r--r--Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj2
-rw-r--r--Jellyfin.Drawing.Skia/SkiaEncoder.cs15
-rw-r--r--Jellyfin.Server/StartupOptions.cs12
-rw-r--r--MediaBrowser.Api/Images/ImageService.cs19
-rw-r--r--MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs2
-rw-r--r--MediaBrowser.Api/SyncPlay/SyncPlayService.cs302
-rw-r--r--MediaBrowser.Api/SyncPlay/TimeSyncService.cs52
-rw-r--r--MediaBrowser.Api/UserService.cs38
-rw-r--r--MediaBrowser.Api/VideosService.cs1
-rw-r--r--MediaBrowser.Controller/Drawing/IImageEncoder.cs9
-rw-r--r--MediaBrowser.Controller/Drawing/IImageProcessor.cs8
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs42
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs5
-rw-r--r--MediaBrowser.Controller/Entities/ItemImageInfo.cs6
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs3
-rw-r--r--MediaBrowser.Controller/Library/IUserManager.cs8
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketConnection.cs6
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs19
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupInfo.cs169
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupMember.cs28
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs67
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs69
-rw-r--r--MediaBrowser.Model/Configuration/SyncplayAccess.cs23
-rw-r--r--MediaBrowser.Model/Dto/BaseItemDto.cs7
-rw-r--r--MediaBrowser.Model/Dto/ImageInfo.cs6
-rw-r--r--MediaBrowser.Model/Dto/PublicUserDto.cs48
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupInfoView.cs40
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdate.cs26
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdateType.cs53
-rw-r--r--MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs22
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequest.cs34
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs33
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommand.cs38
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommandType.cs21
-rw-r--r--MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs20
-rw-r--r--MediaBrowser.Model/Users/UserPolicy.cs7
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html8
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html8
-rw-r--r--MediaBrowser.WebDashboard/Api/DashboardService.cs155
-rw-r--r--MediaBrowser.WebDashboard/Api/PackageCreator.cs161
60 files changed, 2872 insertions, 475 deletions
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ac517e10c..2289fd991 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -10,6 +10,16 @@
"${workspaceFolder}/Jellyfin.Server/Jellyfin.Server.csproj"
],
"problemMatcher": "$msCompile"
+ },
+ {
+ "label": "api tests",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "test",
+ "${workspaceFolder}/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj"
+ ],
+ "problemMatcher": "$msCompile"
}
]
-} \ No newline at end of file
+}
diff --git a/Dockerfile.arm b/Dockerfile.arm
index 39beaa479..59b8a8c98 100644
--- a/Dockerfile.arm
+++ b/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 && \
diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs
index 0b3bbe29e..89bb3068b 100644
--- a/Emby.Drawing/ImageProcessor.cs
+++ b/Emby.Drawing/ImageProcessor.cs
@@ -314,6 +314,27 @@ namespace Emby.Drawing
=> _imageEncoder.GetImageSize(path);
/// <inheritdoc />
+ public string GetImageBlurHash(string path)
+ {
+ var size = GetImageDimensions(path);
+ if (size.Width <= 0 || size.Height <= 0)
+ {
+ return string.Empty;
+ }
+
+ // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
+ // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
+ // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
+ float xCompF = MathF.Sqrt(16.0f * size.Width / size.Height);
+ float yCompF = xCompF * size.Height / size.Width;
+
+ int xComp = Math.Min((int)xCompF + 1, 9);
+ int yComp = Math.Min((int)yCompF + 1, 9);
+
+ return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+ }
+
+ /// <inheritdoc />
public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
=> (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
diff --git a/Emby.Drawing/NullImageEncoder.cs b/Emby.Drawing/NullImageEncoder.cs
index 5af7f1622..bbb5c1716 100644
--- a/Emby.Drawing/NullImageEncoder.cs
+++ b/Emby.Drawing/NullImageEncoder.cs
@@ -42,5 +42,11 @@ namespace Emby.Drawing
{
throw new NotImplementedException();
}
+
+ /// <inheritdoc />
+ public string GetImageBlurHash(int xComp, int yComp, string path)
+ {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index e6410f857..be4e05a64 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/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index ca5cd6fdd..608801ac3 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -1141,24 +1141,24 @@ namespace Emby.Server.Implementations.Data
public string ToValueString(ItemImageInfo image)
{
- var delimeter = "*";
+ const string Delimeter = "*";
- var path = image.Path;
-
- if (path == null)
- {
- path = string.Empty;
- }
+ var path = image.Path ?? string.Empty;
+ var hash = image.BlurHash ?? string.Empty;
return GetPathToSave(path) +
- delimeter +
+ Delimeter +
image.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) +
- delimeter +
+ Delimeter +
image.Type +
- delimeter +
+ Delimeter +
image.Width.ToString(CultureInfo.InvariantCulture) +
- delimeter +
- image.Height.ToString(CultureInfo.InvariantCulture);
+ Delimeter +
+ image.Height.ToString(CultureInfo.InvariantCulture) +
+ Delimeter +
+ // Replace delimiters with other characters.
+ // This can be removed when we migrate to a proper DB.
+ hash.Replace('*', '/').Replace('|', '\\');
}
public ItemImageInfo ItemImageInfoFromValueString(string value)
@@ -1192,6 +1192,11 @@ namespace Emby.Server.Implementations.Data
image.Width = width;
image.Height = height;
}
+
+ if (parts.Length >= 6)
+ {
+ image.BlurHash = parts[5].Replace('/', '*').Replace('\\', '|');
+ }
}
return image;
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index c4b65d265..38c4f940d 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -605,7 +605,7 @@ namespace Emby.Server.Implementations.Dto
if (dictionary.TryGetValue(person.Name, out Person entity))
{
- baseItemPerson.PrimaryImageTag = GetImageCacheTag(entity, ImageType.Primary);
+ baseItemPerson.PrimaryImageTag = GetTagAndFillBlurhash(dto, entity, ImageType.Primary);
baseItemPerson.Id = entity.Id.ToString("N", CultureInfo.InvariantCulture);
list.Add(baseItemPerson);
}
@@ -654,6 +654,70 @@ namespace Emby.Server.Implementations.Dto
return _libraryManager.GetGenreId(name);
}
+ private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ImageType imageType, int imageIndex = 0)
+ {
+ var image = item.GetImageInfo(imageType, imageIndex);
+ if (image != null)
+ {
+ return GetTagAndFillBlurhash(dto, item, image);
+ }
+
+ return null;
+ }
+
+ private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ItemImageInfo image)
+ {
+ var tag = GetImageCacheTag(item, image);
+ if (!string.IsNullOrEmpty(image.BlurHash))
+ {
+ if (dto.ImageBlurHashes == null)
+ {
+ dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>();
+ }
+
+ if (!dto.ImageBlurHashes.ContainsKey(image.Type))
+ {
+ dto.ImageBlurHashes[image.Type] = new Dictionary<string, string>();
+ }
+
+ dto.ImageBlurHashes[image.Type][tag] = image.BlurHash;
+ }
+
+ return tag;
+ }
+
+ private string[] GetTagsAndFillBlurhashes(BaseItemDto dto, BaseItem item, ImageType imageType, int limit)
+ {
+ return GetTagsAndFillBlurhashes(dto, item, imageType, item.GetImages(imageType).Take(limit).ToList());
+ }
+
+ private string[] GetTagsAndFillBlurhashes(BaseItemDto dto, BaseItem item, ImageType imageType, List<ItemImageInfo> images)
+ {
+ var tags = GetImageTags(item, images);
+ var hashes = new Dictionary<string, string>();
+ for (int i = 0; i < images.Count; i++)
+ {
+ var img = images[i];
+ if (!string.IsNullOrEmpty(img.BlurHash))
+ {
+ var tag = tags[i];
+ hashes[tag] = img.BlurHash;
+ }
+ }
+
+ if (hashes.Count > 0)
+ {
+ if (dto.ImageBlurHashes == null)
+ {
+ dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>();
+ }
+
+ dto.ImageBlurHashes[imageType] = hashes;
+ }
+
+ return tags;
+ }
+
/// <summary>
/// Sets simple property values on a DTOBaseItem
/// </summary>
@@ -674,8 +738,8 @@ namespace Emby.Server.Implementations.Dto
dto.LockData = item.IsLocked;
dto.ForcedSortName = item.ForcedSortName;
}
- dto.Container = item.Container;
+ dto.Container = item.Container;
dto.EndDate = item.EndDate;
if (options.ContainsField(ItemFields.ExternalUrls))
@@ -694,10 +758,12 @@ namespace Emby.Server.Implementations.Dto
dto.AspectRatio = hasAspectRatio.AspectRatio;
}
+ dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>();
+
var backdropLimit = options.GetImageLimit(ImageType.Backdrop);
if (backdropLimit > 0)
{
- dto.BackdropImageTags = GetImageTags(item, item.GetImages(ImageType.Backdrop).Take(backdropLimit).ToList());
+ dto.BackdropImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Backdrop, backdropLimit);
}
if (options.ContainsField(ItemFields.ScreenshotImageTags))
@@ -705,7 +771,7 @@ namespace Emby.Server.Implementations.Dto
var screenshotLimit = options.GetImageLimit(ImageType.Screenshot);
if (screenshotLimit > 0)
{
- dto.ScreenshotImageTags = GetImageTags(item, item.GetImages(ImageType.Screenshot).Take(screenshotLimit).ToList());
+ dto.ScreenshotImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Screenshot, screenshotLimit);
}
}
@@ -721,12 +787,11 @@ namespace Emby.Server.Implementations.Dto
// Prevent implicitly captured closure
var currentItem = item;
- foreach (var image in currentItem.ImageInfos.Where(i => !currentItem.AllowsMultipleImages(i.Type))
- .ToList())
+ foreach (var image in currentItem.ImageInfos.Where(i => !currentItem.AllowsMultipleImages(i.Type)))
{
if (options.GetImageLimit(image.Type) > 0)
{
- var tag = GetImageCacheTag(item, image);
+ var tag = GetTagAndFillBlurhash(dto, item, image);
if (tag != null)
{
@@ -871,8 +936,7 @@ namespace Emby.Server.Implementations.Dto
if (albumParent != null)
{
dto.AlbumId = albumParent.Id;
-
- dto.AlbumPrimaryImageTag = GetImageCacheTag(albumParent, ImageType.Primary);
+ dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary);
}
//if (options.ContainsField(ItemFields.MediaSourceCount))
@@ -1099,7 +1163,7 @@ namespace Emby.Server.Implementations.Dto
episodeSeries = episodeSeries ?? episode.Series;
if (episodeSeries != null)
{
- dto.SeriesPrimaryImageTag = GetImageCacheTag(episodeSeries, ImageType.Primary);
+ dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary);
}
}
@@ -1145,7 +1209,7 @@ namespace Emby.Server.Implementations.Dto
series = series ?? season.Series;
if (series != null)
{
- dto.SeriesPrimaryImageTag = GetImageCacheTag(series, ImageType.Primary);
+ dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary);
}
}
}
@@ -1275,7 +1339,7 @@ namespace Emby.Server.Implementations.Dto
if (image != null)
{
dto.ParentLogoItemId = GetDtoId(parent);
- dto.ParentLogoImageTag = GetImageCacheTag(parent, image);
+ dto.ParentLogoImageTag = GetTagAndFillBlurhash(dto, parent, image);
}
}
if (artLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && dto.ParentArtItemId == null)
@@ -1285,7 +1349,7 @@ namespace Emby.Server.Implementations.Dto
if (image != null)
{
dto.ParentArtItemId = GetDtoId(parent);
- dto.ParentArtImageTag = GetImageCacheTag(parent, image);
+ dto.ParentArtImageTag = GetTagAndFillBlurhash(dto, parent, image);
}
}
if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && !(parent is ICollectionFolder) && !(parent is UserView))
@@ -1295,7 +1359,7 @@ namespace Emby.Server.Implementations.Dto
if (image != null)
{
dto.ParentThumbItemId = GetDtoId(parent);
- dto.ParentThumbImageTag = GetImageCacheTag(parent, image);
+ dto.ParentThumbImageTag = GetTagAndFillBlurhash(dto, parent, image);
}
}
if (backdropLimit > 0 && !((dto.BackdropImageTags != null && dto.BackdropImageTags.Length > 0) || (dto.ParentBackdropImageTags != null && dto.ParentBackdropImageTags.Length > 0)))
@@ -1305,7 +1369,7 @@ namespace Emby.Server.Implementations.Dto
if (images.Count > 0)
{
dto.ParentBackdropItemId = GetDtoId(parent);
- dto.ParentBackdropImageTags = GetImageTags(parent, images);
+ dto.ParentBackdropImageTags = GetTagsAndFillBlurhashes(dto, parent, ImageType.Backdrop, images);
}
}
diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
index 50ba0f8fa..6929c81f9 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/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index 095725c50..0680c5ffe 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 16b68170b..acae702f3 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/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 0b86b2db7..15362182c 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -21,6 +21,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
@@ -35,6 +36,7 @@ using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -67,6 +69,7 @@ namespace Emby.Server.Implementations.Library
private readonly IFileSystem _fileSystem;
private readonly IItemRepository _itemRepository;
private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache;
+ private readonly IImageProcessor _imageProcessor;
private NamingOptions _namingOptions;
private string[] _videoFileExtensions;
@@ -147,7 +150,8 @@ namespace Emby.Server.Implementations.Library
Lazy<IProviderManager> providerManagerFactory,
Lazy<IUserViewManager> userviewManagerFactory,
IMediaEncoder mediaEncoder,
- IItemRepository itemRepository)
+ IItemRepository itemRepository,
+ IImageProcessor imageProcessor)
{
_appHost = appHost;
_logger = logger;
@@ -161,6 +165,7 @@ namespace Emby.Server.Implementations.Library
_userviewManagerFactory = userviewManagerFactory;
_mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
+ _imageProcessor = imageProcessor;
_libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>();
@@ -1815,10 +1820,90 @@ namespace Emby.Server.Implementations.Library
}
}
- public void UpdateImages(BaseItem item)
+ private bool ImageNeedsRefresh(ItemImageInfo image)
{
- _itemRepository.SaveImages(item);
+ if (image.Path != null && image.IsLocalFile)
+ {
+ if (image.Width == 0 || image.Height == 0 || string.IsNullOrEmpty(image.BlurHash))
+ {
+ return true;
+ }
+ try
+ {
+ return _fileSystem.GetLastWriteTimeUtc(image.Path) != image.DateModified;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot get file info for {0}", image.Path);
+ return false;
+ }
+ }
+
+ return image.Path != null && !image.IsLocalFile;
+ }
+
+ public void UpdateImages(BaseItem item, bool forceUpdate = false)
+ {
+ if (item == null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path != null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
+ if (outdated.Length == 0)
+ {
+ RegisterItem(item);
+ return;
+ }
+
+ foreach (var img in outdated)
+ {
+ var image = img;
+ if (!img.IsLocalFile)
+ {
+ try
+ {
+ var index = item.GetImageIndex(img);
+ image = ConvertImageToLocal(item, img, index).ConfigureAwait(false).GetAwaiter().GetResult();
+ }
+ catch (ArgumentException)
+ {
+ _logger.LogWarning("Cannot get image index for {0}", img.Path);
+ continue;
+ }
+ catch (InvalidOperationException)
+ {
+ _logger.LogWarning("Cannot fetch image from {0}", img.Path);
+ continue;
+ }
+ }
+
+ ImageDimensions size = _imageProcessor.GetImageDimensions(item, image);
+ image.Width = size.Width;
+ image.Height = size.Height;
+
+ try
+ {
+ image.BlurHash = _imageProcessor.GetImageBlurHash(image.Path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot compute blurhash for {0}", image.Path);
+ image.BlurHash = string.Empty;
+ }
+
+ try
+ {
+ image.DateModified = _fileSystem.GetLastWriteTimeUtc(image.Path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot update DateModified for {0}", image.Path);
+ }
+ }
+
+ _itemRepository.SaveImages(item);
RegisterItem(item);
}
@@ -1839,7 +1924,7 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
- RegisterItem(item);
+ UpdateImages(item, updateReason >= ItemUpdateType.ImageUpdate);
}
_itemRepository.SaveItems(itemsList, cancellationToken);
diff --git a/Emby.Server.Implementations/Library/UserManager.cs b/Emby.Server.Implementations/Library/UserManager.cs
index b8feb5535..d63bc6bda 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/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
new file mode 100644
index 000000000..b0fdc8386
--- /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 150952d8b..47ebe1254 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/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
new file mode 100644
index 000000000..f722dd8c0
--- /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/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index df98a35bc..2b09a93ef 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 d4e4ba1f2..e7b4b0ec3 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 000000000..d430d4d16
--- /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 000000000..1f76dd4e3
--- /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 c91d137a7..a26f714b1 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)
{
diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
index a6e1f490a..d3fdb5de4 100644
--- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
+++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
@@ -18,6 +18,8 @@
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="BlurHashSharp" Version="1.0.1" />
+ <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.0.0" />
<PackageReference Include="SkiaSharp" Version="1.68.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="1.68.1" />
<PackageReference Include="Jellyfin.SkiaSharp.NativeAssets.LinuxArm" Version="1.68.1" />
diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 5c7462ee2..dae0e94d4 100644
--- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using BlurHashSharp.SkiaSharp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Extensions;
@@ -229,6 +230,20 @@ namespace Jellyfin.Drawing.Skia
}
}
+ /// <inheritdoc />
+ /// <exception cref="ArgumentNullException">The path is null.</exception>
+ /// <exception cref="FileNotFoundException">The path is not valid.</exception>
+ /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
+ public string GetImageBlurHash(int xComp, int yComp, string path)
+ {
+ if (path == null)
+ {
+ throw new ArgumentNullException(nameof(path));
+ }
+
+ return BlurHashEncoder.Encode(xComp, yComp, path);
+ }
+
private static bool HasDiacritics(string text)
=> !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal);
diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index 6e15d058f..cc250b06e 100644
--- a/Jellyfin.Server/StartupOptions.cs
+++ b/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;
}
}
diff --git a/MediaBrowser.Api/Images/ImageService.cs b/MediaBrowser.Api/Images/ImageService.cs
index eaff22fff..fdb15d96a 100644
--- a/MediaBrowser.Api/Images/ImageService.cs
+++ b/MediaBrowser.Api/Images/ImageService.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
@@ -280,9 +281,16 @@ namespace MediaBrowser.Api.Images
public List<ImageInfo> GetItemImageInfos(BaseItem item)
{
var list = new List<ImageInfo>();
-
var itemImages = item.ImageInfos;
+ if (itemImages.Length == 0)
+ {
+ // short-circuit
+ return list;
+ }
+
+ _libraryManager.UpdateImages(item); // this makes sure dimensions and hashes are correct
+
foreach (var image in itemImages)
{
if (!item.AllowsMultipleImages(image.Type))
@@ -323,6 +331,7 @@ namespace MediaBrowser.Api.Images
{
int? width = null;
int? height = null;
+ string blurhash = null;
long length = 0;
try
@@ -332,10 +341,9 @@ namespace MediaBrowser.Api.Images
var fileInfo = _fileSystem.GetFileInfo(info.Path);
length = fileInfo.Length;
- ImageDimensions size = _imageProcessor.GetImageDimensions(item, info);
- _libraryManager.UpdateImages(item);
- width = size.Width;
- height = size.Height;
+ blurhash = info.BlurHash;
+ width = info.Width;
+ height = info.Height;
if (width <= 0 || height <= 0)
{
@@ -358,6 +366,7 @@ namespace MediaBrowser.Api.Images
ImageType = info.Type,
ImageTag = _imageProcessor.GetImageCacheTag(item, info),
Size = length,
+ BlurHash = blurhash,
Width = width,
Height = height
};
diff --git a/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs b/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs
index 061316cb8..b8ea9c6da 100644
--- a/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs
+++ b/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs
@@ -865,7 +865,7 @@ namespace MediaBrowser.Api.Playback.Hls
{
framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
}
- else if (state.VideoStream.RealFrameRate.HasValue)
+ else if (state.VideoStream?.RealFrameRate != null)
{
framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
}
diff --git a/MediaBrowser.Api/SyncPlay/SyncPlayService.cs b/MediaBrowser.Api/SyncPlay/SyncPlayService.cs
new file mode 100644
index 000000000..1e14ea552
--- /dev/null
+++ b/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);
+ }
+ }
+}
diff --git a/MediaBrowser.Api/SyncPlay/TimeSyncService.cs b/MediaBrowser.Api/SyncPlay/TimeSyncService.cs
new file mode 100644
index 000000000..4a9307e62
--- /dev/null
+++ b/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;
+ }
+ }
+}
diff --git a/MediaBrowser.Api/UserService.cs b/MediaBrowser.Api/UserService.cs
index 7d4d5fcf9..78fc6c694 100644
--- a/MediaBrowser.Api/UserService.cs
+++ b/MediaBrowser.Api/UserService.cs
@@ -35,7 +35,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[]>
{
}
@@ -266,38 +266,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(item => !item.Policy.IsDisabled);
-
- 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.Policy.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.Policy.EnableRemoteAccess);
- }
+ IsDisabled = false
+ });
}
- return ToOptimizedResult(result
- .OrderBy(u => u.Name)
- .Select(i => _userManager.GetPublicUserDto(i, Request.RemoteIp))
- .ToArray()
- );
+ return Get(new GetUsers
+ {
+ IsHidden = false,
+ IsDisabled = false
+ }, true, true);
}
/// <summary>
diff --git a/MediaBrowser.Api/VideosService.cs b/MediaBrowser.Api/VideosService.cs
index b11fd48d3..957a279f8 100644
--- a/MediaBrowser.Api/VideosService.cs
+++ b/MediaBrowser.Api/VideosService.cs
@@ -128,6 +128,7 @@ namespace MediaBrowser.Api
var items = request.Ids.Split(',')
.Select(i => _libraryManager.GetItemById(i))
.OfType<Video>()
+ .OrderBy(i => i.Id)
.ToList();
if (items.Count < 2)
diff --git a/MediaBrowser.Controller/Drawing/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs
index 88e67b648..e09ccd204 100644
--- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs
+++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs
@@ -44,6 +44,15 @@ namespace MediaBrowser.Controller.Drawing
ImageDimensions GetImageSize(string path);
/// <summary>
+ /// Gets the blurhash of an image.
+ /// </summary>
+ /// <param name="xComp">Amount of X components of DCT to take.</param>
+ /// <param name="yComp">Amount of Y components of DCT to take.</param>
+ /// <param name="path">The filepath of the image.</param>
+ /// <returns>The blurhash.</returns>
+ string GetImageBlurHash(int xComp, int yComp, string path);
+
+ /// <summary>
/// Encode an image.
/// </summary>
string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat);
diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs
index 36c746624..8800fdf99 100644
--- a/MediaBrowser.Controller/Drawing/IImageProcessor.cs
+++ b/MediaBrowser.Controller/Drawing/IImageProcessor.cs
@@ -41,12 +41,20 @@ namespace MediaBrowser.Controller.Drawing
ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info);
/// <summary>
+ /// Gets the blurhash of the image.
+ /// </summary>
+ /// <param name="path">Path to the image file.</param>
+ /// <returns>BlurHash</returns>
+ string GetImageBlurHash(string path);
+
+ /// <summary>
/// Gets the image cache tag.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="image">The image.</param>
/// <returns>Guid.</returns>
string GetImageCacheTag(BaseItem item, ItemImageInfo image);
+
string GetImageCacheTag(BaseItem item, ChapterInfo info);
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 7ed8fa767..f4b71d8bf 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -1374,6 +1374,7 @@ namespace MediaBrowser.Controller.Entities
new List<FileSystemMetadata>();
var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
+ LibraryManager.UpdateImages(this); // ensure all image properties in DB are fresh
if (ownedItemsChanged)
{
@@ -2222,6 +2223,7 @@ namespace MediaBrowser.Controller.Entities
existingImage.DateModified = image.DateModified;
existingImage.Width = image.Width;
existingImage.Height = image.Height;
+ existingImage.BlurHash = image.BlurHash;
}
else
{
@@ -2373,6 +2375,46 @@ namespace MediaBrowser.Controller.Entities
.ElementAtOrDefault(imageIndex);
}
+ /// <summary>
+ /// Computes image index for given image or raises if no matching image found.
+ /// </summary>
+ /// <param name="image">Image to compute index for.</param>
+ /// <exception cref="ArgumentException">Image index cannot be computed as no matching image found.
+ /// </exception>
+ /// <returns>Image index.</returns>
+ public int GetImageIndex(ItemImageInfo image)
+ {
+ if (image == null)
+ {
+ throw new ArgumentNullException(nameof(image));
+ }
+
+ if (image.Type == ImageType.Chapter)
+ {
+ var chapters = ItemRepository.GetChapters(this);
+ for (var i = 0; i < chapters.Count; i++)
+ {
+ if (chapters[i].ImagePath == image.Path)
+ {
+ return i;
+ }
+ }
+
+ throw new ArgumentException("No chapter index found for image path", image.Path);
+ }
+
+ var images = GetImages(image.Type).ToArray();
+ for (var i = 0; i < images.Length; i++)
+ {
+ if (images[i].Path == image.Path)
+ {
+ return i;
+ }
+ }
+
+ throw new ArgumentException("No image index found for image path", image.Path);
+ }
+
public IEnumerable<ItemImageInfo> GetImages(ImageType imageType)
{
if (imageType == ImageType.Chapter)
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index a468e0c35..29040f92e 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -341,6 +341,11 @@ namespace MediaBrowser.Controller.Entities
{
currentChild.UpdateToRepository(ItemUpdateType.MetadataImport, cancellationToken);
}
+ else
+ {
+ // metadata is up-to-date; make sure DB has correct images dimensions and hash
+ LibraryManager.UpdateImages(currentChild);
+ }
continue;
}
diff --git a/MediaBrowser.Controller/Entities/ItemImageInfo.cs b/MediaBrowser.Controller/Entities/ItemImageInfo.cs
index fc46dec2e..12f5db2e0 100644
--- a/MediaBrowser.Controller/Entities/ItemImageInfo.cs
+++ b/MediaBrowser.Controller/Entities/ItemImageInfo.cs
@@ -28,6 +28,12 @@ namespace MediaBrowser.Controller.Entities
public int Height { get; set; }
+ /// <summary>
+ /// Gets or sets the blurhash.
+ /// </summary>
+ /// <value>The blurhash.</value>
+ public string BlurHash { get; set; }
+
[JsonIgnore]
public bool IsLocalFile => Path == null || !Path.StartsWith("http", StringComparison.OrdinalIgnoreCase);
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 2e1c97f67..916e4fda7 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Library
/// </summary>
void QueueLibraryScan();
- void UpdateImages(BaseItem item);
+ void UpdateImages(BaseItem item, bool forceUpdate = false);
/// <summary>
/// Gets the default view.
@@ -195,6 +195,7 @@ namespace MediaBrowser.Controller.Library
/// Updates the item.
/// </summary>
void UpdateItems(IEnumerable<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
+
void UpdateItem(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
/// <summary>
diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs
index ec6cb35eb..be7b4ce59 100644
--- a/MediaBrowser.Controller/Library/IUserManager.cs
+++ b/MediaBrowser.Controller/Library/IUserManager.cs
@@ -144,14 +144,6 @@ namespace MediaBrowser.Controller.Library
UserDto GetUserDto(User user, string remoteEndPoint = null);
/// <summary>
- /// Gets the user public dto.
- /// </summary>
- /// <param name="user">Ther 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>
Task<User> AuthenticateUser(string username, string password, string passwordSha1, string remoteEndPoint, bool isUserSession);
diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs
index 09e43c683..3ef8e5f6d 100644
--- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs
@@ -24,6 +24,12 @@ namespace MediaBrowser.Controller.Net
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>
/// <value>The query string.</value>
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index 771027103..4c2f834cb 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/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
{
@@ -141,6 +142,24 @@ namespace MediaBrowser.Controller.Session
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>
/// <param name="controllingSessionId">The controlling session identifier.</param>
diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs
new file mode 100644
index 000000000..28a3ac505
--- /dev/null
+++ b/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;
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs
new file mode 100644
index 000000000..a3975c334
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
new file mode 100644
index 000000000..de1fcd259
--- /dev/null
+++ b/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();
+ }
+} \ No newline at end of file
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
new file mode 100644
index 000000000..006fb687b
--- /dev/null
+++ b/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);
+ }
+}
diff --git a/MediaBrowser.Model/Configuration/SyncplayAccess.cs b/MediaBrowser.Model/Configuration/SyncplayAccess.cs
new file mode 100644
index 000000000..d891a8167
--- /dev/null
+++ b/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
+ }
+}
diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs
index 607355d8d..fc1cb01db 100644
--- a/MediaBrowser.Model/Dto/BaseItemDto.cs
+++ b/MediaBrowser.Model/Dto/BaseItemDto.cs
@@ -511,6 +511,13 @@ namespace MediaBrowser.Model.Dto
public string SeriesThumbImageTag { get; set; }
/// <summary>
+ /// Gets or sets the blurhashes for the image tags.
+ /// Maps image type to dictionary mapping image tag to blurhash value.
+ /// </summary>
+ /// <value>The blurhashes.</value>
+ public Dictionary<ImageType, Dictionary<string, string>> ImageBlurHashes { get; set; }
+
+ /// <summary>
/// Gets or sets the series studio.
/// </summary>
/// <value>The series studio.</value>
diff --git a/MediaBrowser.Model/Dto/ImageInfo.cs b/MediaBrowser.Model/Dto/ImageInfo.cs
index 57942ac23..664ea332e 100644
--- a/MediaBrowser.Model/Dto/ImageInfo.cs
+++ b/MediaBrowser.Model/Dto/ImageInfo.cs
@@ -31,6 +31,12 @@ namespace MediaBrowser.Model.Dto
public string Path { get; set; }
/// <summary>
+ /// Gets or sets the blurhash.
+ /// </summary>
+ /// <value>The blurhash.</value>
+ public string BlurHash { get; set; }
+
+ /// <summary>
/// Gets or sets the height.
/// </summary>
/// <value>The height.</value>
diff --git a/MediaBrowser.Model/Dto/PublicUserDto.cs b/MediaBrowser.Model/Dto/PublicUserDto.cs
deleted file mode 100644
index b6bfaf2e9..000000000
--- a/MediaBrowser.Model/Dto/PublicUserDto.cs
+++ /dev/null
@@ -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();
- }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs
new file mode 100644
index 000000000..f28ecf16d
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
new file mode 100644
index 000000000..895702f3d
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
new file mode 100644
index 000000000..89d245787
--- /dev/null
+++ b/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
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
new file mode 100644
index 000000000..d67b6bd55
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
new file mode 100644
index 000000000..9de23194e
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
new file mode 100644
index 000000000..f1e175fde
--- /dev/null
+++ b/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
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs
new file mode 100644
index 000000000..0f06e381f
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs
new file mode 100644
index 000000000..113719871
--- /dev/null
+++ b/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
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
new file mode 100644
index 000000000..0a6036154
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs
index ae2b3fd4e..3e027e831 100644
--- a/MediaBrowser.Model/Users/UserPolicy.cs
+++ b/MediaBrowser.Model/Users/UserPolicy.cs
@@ -80,6 +80,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;
@@ -125,6 +131,7 @@ namespace MediaBrowser.Model.Users
EnableContentDownloading = true;
EnablePublicSharing = true;
EnableRemoteAccess = true;
+ SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
}
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
index 34494644d..fbf413f2b 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
+++ b/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);
});
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
index 1f02461da..90196b046 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
+++ b/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);
});
diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs
index 133a35527..55fc463d0 100644
--- a/MediaBrowser.WebDashboard/Api/DashboardService.cs
+++ b/MediaBrowser.WebDashboard/Api/DashboardService.cs
@@ -52,12 +52,6 @@ namespace MediaBrowser.WebDashboard.Api
public string Name { get; set; }
}
- [Route("/web/Package", "GET", IsHidden = true)]
- public class GetDashboardPackage
- {
- public string Mode { get; set; }
- }
-
[Route("/robots.txt", "GET", IsHidden = true)]
public class GetRobotsTxt
{
@@ -225,7 +219,7 @@ namespace MediaBrowser.WebDashboard.Api
return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream));
}
- return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => PackageCreator.ModifyHtml(false, stream, null, _appHost.ApplicationVersionString, null));
+ return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream));
}
throw new ResourceNotFoundException();
@@ -328,154 +322,19 @@ namespace MediaBrowser.WebDashboard.Api
throw new ResourceNotFoundException();
}
- var path = request.ResourceName;
-
- var contentType = MimeTypes.GetMimeType(path);
+ var path = request?.ResourceName;
var basePath = DashboardUIPath;
// Bounce them to the startup wizard if it hasn't been completed yet
- if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted &&
- Request.RawUrl.IndexOf("wizard", StringComparison.OrdinalIgnoreCase) == -1 &&
- PackageCreator.IsCoreHtml(path))
- {
- // But don't redirect if an html import is being requested.
- if (path.IndexOf("bower_components", StringComparison.OrdinalIgnoreCase) == -1)
- {
- Request.Response.Redirect("index.html?start=wizard#!/wizardstart.html");
- return null;
- }
- }
-
- var localizationCulture = GetLocalizationCulture();
-
- // Don't cache if not configured to do so
- // But always cache images to simulate production
- if (!_serverConfigurationManager.Configuration.EnableDashboardResponseCaching &&
- !contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) &&
- !contentType.StartsWith("font/", StringComparison.OrdinalIgnoreCase))
- {
- var stream = await GetResourceStream(basePath, path, localizationCulture).ConfigureAwait(false);
- return _resultFactory.GetResult(Request, stream, contentType);
- }
-
- TimeSpan? cacheDuration = null;
-
- // Cache images unconditionally - updates to image files will require new filename
- // If there's a version number in the query string we can cache this unconditionally
- if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("font/", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(request.V))
+ if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted
+ && !Request.RawUrl.Contains("wizard", StringComparison.OrdinalIgnoreCase)
+ && Request.RawUrl.Contains("index", StringComparison.OrdinalIgnoreCase))
{
- cacheDuration = TimeSpan.FromDays(365);
- }
-
- var cacheKey = (_appHost.ApplicationVersionString + (localizationCulture ?? string.Empty) + path).GetMD5();
-
- // html gets modified on the fly
- if (contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
- {
- return await _resultFactory.GetStaticResult(Request, cacheKey, null, cacheDuration, contentType, () => GetResourceStream(basePath, path, localizationCulture)).ConfigureAwait(false);
+ Request.Response.Redirect("index.html?start=wizard#!/wizardstart.html");
+ return null;
}
return await _resultFactory.GetStaticFileResult(Request, _resourceFileManager.GetResourcePath(basePath, path)).ConfigureAwait(false);
}
-
- private string GetLocalizationCulture()
- {
- return _serverConfigurationManager.Configuration.UICulture;
- }
-
- /// <summary>
- /// Gets the resource stream.
- /// </summary>
- private Task<Stream> GetResourceStream(string basePath, string virtualPath, string localizationCulture)
- {
- return GetPackageCreator(basePath)
- .GetResource(virtualPath, null, localizationCulture, _appHost.ApplicationVersionString);
- }
-
- private PackageCreator GetPackageCreator(string basePath)
- {
- return new PackageCreator(basePath, _resourceFileManager);
- }
-
- public async Task<object> Get(GetDashboardPackage request)
- {
- if (!_appConfig.HostWebClient() || DashboardUIPath == null)
- {
- throw new ResourceNotFoundException();
- }
-
- var mode = request.Mode;
-
- var inputPath = string.IsNullOrWhiteSpace(mode) ?
- DashboardUIPath
- : "C:\\dev\\emby-web-mobile-master\\dist";
-
- var targetPath = !string.IsNullOrWhiteSpace(mode) ?
- inputPath
- : "C:\\dev\\emby-web-mobile\\src";
-
- var packageCreator = GetPackageCreator(inputPath);
-
- if (!string.Equals(inputPath, targetPath, StringComparison.OrdinalIgnoreCase))
- {
- try
- {
- Directory.Delete(targetPath, true);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting {Path}", targetPath);
- }
-
- CopyDirectory(inputPath, targetPath);
- }
-
- var appVersion = _appHost.ApplicationVersionString;
-
- await DumpHtml(packageCreator, inputPath, targetPath, mode, appVersion).ConfigureAwait(false);
-
- return string.Empty;
- }
-
- private async Task DumpHtml(PackageCreator packageCreator, string source, string destination, string mode, string appVersion)
- {
- foreach (var file in _fileSystem.GetFiles(source))
- {
- var filename = file.Name;
-
- if (!string.Equals(file.Extension, ".html", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- await DumpFile(packageCreator, filename, Path.Combine(destination, filename), mode, appVersion).ConfigureAwait(false);
- }
- }
-
- private async Task DumpFile(PackageCreator packageCreator, string resourceVirtualPath, string destinationFilePath, string mode, string appVersion)
- {
- using (var stream = await packageCreator.GetResource(resourceVirtualPath, mode, null, appVersion).ConfigureAwait(false))
- using (var fs = new FileStream(destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.Read))
- {
- await stream.CopyToAsync(fs).ConfigureAwait(false);
- }
- }
-
- private void CopyDirectory(string source, string destination)
- {
- Directory.CreateDirectory(destination);
-
- // Now Create all of the directories
- foreach (var dirPath in _fileSystem.GetDirectories(source, true))
- {
- Directory.CreateDirectory(dirPath.FullName.Replace(source, destination, StringComparison.Ordinal));
- }
-
- // Copy all the files & Replaces any files with the same name
- foreach (var newPath in _fileSystem.GetFiles(source, true))
- {
- File.Copy(newPath.FullName, newPath.FullName.Replace(source, destination, StringComparison.Ordinal), true);
- }
- }
}
}
diff --git a/MediaBrowser.WebDashboard/Api/PackageCreator.cs b/MediaBrowser.WebDashboard/Api/PackageCreator.cs
deleted file mode 100644
index b7c15a840..000000000
--- a/MediaBrowser.WebDashboard/Api/PackageCreator.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Threading.Tasks;
-using MediaBrowser.Controller;
-
-namespace MediaBrowser.WebDashboard.Api
-{
- public class PackageCreator
- {
- private readonly string _basePath;
- private readonly IResourceFileManager _resourceFileManager;
-
- public PackageCreator(string basePath, IResourceFileManager resourceFileManager)
- {
- _basePath = basePath;
- _resourceFileManager = resourceFileManager;
- }
-
- public async Task<Stream> GetResource(
- string virtualPath,
- string mode,
- string localizationCulture,
- string appVersion)
- {
- var resourcePath = _resourceFileManager.GetResourcePath(_basePath, virtualPath);
- Stream resourceStream = File.OpenRead(resourcePath);
-
- if (resourceStream != null && IsCoreHtml(virtualPath))
- {
- bool isMainIndexPage = string.Equals(virtualPath, "index.html", StringComparison.OrdinalIgnoreCase);
- resourceStream = await ModifyHtml(isMainIndexPage, resourceStream, mode, appVersion, localizationCulture).ConfigureAwait(false);
- }
-
- return resourceStream;
- }
-
- public static bool IsCoreHtml(string path)
- {
- if (path.IndexOf(".template.html", StringComparison.OrdinalIgnoreCase) != -1)
- {
- return false;
- }
-
- return string.Equals(Path.GetExtension(path), ".html", StringComparison.OrdinalIgnoreCase);
- }
-
- /// <summary>
- /// Modifies the source HTML stream by adding common meta tags, css and js.
- /// </summary>
- /// <param name="isMainIndexPage">True if the stream contains content for the main index page.</param>
- /// <param name="sourceStream">The stream whose content should be modified.</param>
- /// <param name="mode">The client mode ('cordova', 'android', etc).</param>
- /// <param name="appVersion">The application version.</param>
- /// <param name="localizationCulture">The localization culture.</param>
- /// <returns>
- /// A task that represents the async operation to read and modify the input stream.
- /// The task result contains a stream containing the modified HTML content.
- /// </returns>
- public static async Task<Stream> ModifyHtml(
- bool isMainIndexPage,
- Stream sourceStream,
- string mode,
- string appVersion,
- string localizationCulture)
- {
- string html;
- using (var reader = new StreamReader(sourceStream, Encoding.UTF8))
- {
- html = await reader.ReadToEndAsync().ConfigureAwait(false);
- }
-
- if (isMainIndexPage && !string.IsNullOrWhiteSpace(localizationCulture))
- {
- var lang = localizationCulture.Split('-')[0];
-
- html = html.Replace("<html", "<html data-culture=\"" + localizationCulture + "\" lang=\"" + lang + "\"", StringComparison.Ordinal);
- }
-
- if (isMainIndexPage)
- {
- html = html.Replace("<head>", "<head>" + GetMetaTags(mode), StringComparison.Ordinal);
- }
-
- // Disable embedded scripts from plugins. We'll run them later once resources have loaded
- if (html.IndexOf("<script", StringComparison.OrdinalIgnoreCase) != -1)
- {
- html = html.Replace("<script", "<!--<script", StringComparison.Ordinal);
- html = html.Replace("</script>", "</script>-->", StringComparison.Ordinal);
- }
-
- if (isMainIndexPage)
- {
- html = html.Replace("</body>", GetCommonJavascript(mode, appVersion) + "</body>", StringComparison.Ordinal);
- }
-
- var bytes = Encoding.UTF8.GetBytes(html);
-
- return new MemoryStream(bytes);
- }
-
- /// <summary>
- /// Gets the meta tags.
- /// </summary>
- /// <returns>System.String.</returns>
- private static string GetMetaTags(string mode)
- {
- var sb = new StringBuilder();
-
- if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase)
- || string.Equals(mode, "android", StringComparison.OrdinalIgnoreCase))
- {
- sb.Append("<meta http-equiv=\"Content-Security-Policy\" content=\"default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: file: filesystem: ws: wss:;\">");
- }
-
- return sb.ToString();
- }
-
- /// <summary>
- /// Gets the common javascript.
- /// </summary>
- /// <param name="mode">The mode.</param>
- /// <param name="version">The version.</param>
- /// <returns>System.String.</returns>
- private static string GetCommonJavascript(string mode, string version)
- {
- var builder = new StringBuilder();
-
- builder.Append("<script>");
- if (!string.IsNullOrWhiteSpace(mode))
- {
- builder.AppendFormat(CultureInfo.InvariantCulture, "window.appMode='{0}';", mode);
- }
- else
- {
- builder.AppendFormat(CultureInfo.InvariantCulture, "window.dashboardVersion='{0}';", version);
- }
-
- builder.Append("</script>");
-
- if (string.Equals(mode, "cordova", StringComparison.OrdinalIgnoreCase))
- {
- builder.Append("<script src=\"cordova.js\" defer></script>");
- }
-
- builder.Append("<script src=\"scripts/apploader.js");
- if (!string.IsNullOrWhiteSpace(version))
- {
- builder.Append("?v=");
- builder.Append(version);
- }
-
- builder.Append("\" defer></script>");
-
- return builder.ToString();
- }
- }
-}