diff --git a/MediaBrowser.Api/ConnectService.cs b/MediaBrowser.Api/ConnectService.cs index 9ea75d4ace..3a863316ba 100644 --- a/MediaBrowser.Api/ConnectService.cs +++ b/MediaBrowser.Api/ConnectService.cs @@ -1,6 +1,8 @@ using MediaBrowser.Controller.Connect; using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Connect; using ServiceStack; +using System.Collections.Generic; using System.Threading.Tasks; namespace MediaBrowser.Api @@ -22,6 +24,30 @@ namespace MediaBrowser.Api public string Id { get; set; } } + [Route("/Connect/Invite", "POST", Summary = "Creates a Connect link for a user")] + public class CreateConnectInvite : IReturn + { + [ApiMember(Name = "ConnectUsername", Description = "Connect username", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] + public string ConnectUsername { get; set; } + + [ApiMember(Name = "SendingUserId", Description = "Sending User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string SendingUserId { get; set; } + } + + + [Route("/Connect/Pending", "GET", Summary = "Creates a Connect link for a user")] + public class GetPendingGuests : IReturn> + { + } + + + [Route("/Connect/Pending", "DELETE", Summary = "Deletes a Connect link for a user")] + public class DeleteAuthorization : IReturnVoid + { + [ApiMember(Name = "Id", Description = "Authorization Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string Id { get; set; } + } + [Authenticated(Roles = "Admin")] public class ConnectService : BaseApiService { @@ -37,11 +63,30 @@ namespace MediaBrowser.Api return _connectManager.LinkUser(request.Id, request.ConnectUsername); } + public object Post(CreateConnectInvite request) + { + return _connectManager.InviteUser(request.SendingUserId, request.ConnectUsername); + } + public void Delete(DeleteConnectLink request) { var task = _connectManager.RemoveLink(request.Id); Task.WaitAll(task); } + + public async Task Get(GetPendingGuests request) + { + var result = await _connectManager.GetPendingGuests().ConfigureAwait(false); + + return ToOptimizedResult(result); + } + + public void Delete(DeleteAuthorization request) + { + var task = _connectManager.CancelAuthorization(request.Id); + + Task.WaitAll(task); + } } } diff --git a/MediaBrowser.Api/Devices/DeviceService.cs b/MediaBrowser.Api/Devices/DeviceService.cs index 87419e4405..bb8d8eda3f 100644 --- a/MediaBrowser.Api/Devices/DeviceService.cs +++ b/MediaBrowser.Api/Devices/DeviceService.cs @@ -1,6 +1,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Session; using ServiceStack; using ServiceStack.Web; using System.Collections.Generic; @@ -49,6 +50,27 @@ namespace MediaBrowser.Api.Devices public Stream RequestStream { get; set; } } + [Route("/Devices/Info", "GET", Summary = "Gets device info")] + public class GetDeviceInfo : IReturn + { + [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string Id { get; set; } + } + + [Route("/Devices/Capabilities", "GET", Summary = "Gets device capabilities")] + public class GetDeviceCapabilities : IReturn + { + [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string Id { get; set; } + } + + [Route("/Devices/Options", "POST", Summary = "Updates device options")] + public class PostDeviceOptions : DeviceOptions, IReturnVoid + { + [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string Id { get; set; } + } + [Authenticated] public class DeviceService : BaseApiService { @@ -59,6 +81,27 @@ namespace MediaBrowser.Api.Devices _deviceManager = deviceManager; } + public void Post(PostDeviceOptions request) + { + var task = _deviceManager.UpdateDeviceInfo(request.Id, new DeviceOptions + { + CustomName = request.CustomName, + CameraUploadPath = request.CameraUploadPath + }); + + Task.WaitAll(task); + } + + public object Get(GetDeviceInfo request) + { + return ToOptimizedResult(_deviceManager.GetDevice(request.Id)); + } + + public object Get(GetDeviceCapabilities request) + { + return ToOptimizedResult(_deviceManager.GetCapabilities(request.Id)); + } + public object Get(GetDevices request) { var devices = _deviceManager.GetDevices(); @@ -67,7 +110,7 @@ namespace MediaBrowser.Api.Devices { var val = request.SupportsContentUploading.Value; - devices = devices.Where(i => i.Capabilities.SupportsContentUploading == val); + devices = devices.Where(i => _deviceManager.GetCapabilities(i.Id).SupportsContentUploading == val); } return ToOptimizedResult(devices.ToList()); diff --git a/MediaBrowser.Api/Session/SessionsService.cs b/MediaBrowser.Api/Session/SessionsService.cs index 014bedbd97..74dccb7afd 100644 --- a/MediaBrowser.Api/Session/SessionsService.cs +++ b/MediaBrowser.Api/Session/SessionsService.cs @@ -499,9 +499,9 @@ namespace MediaBrowser.Api.Session } _sessionManager.ReportCapabilities(request.Id, new ClientCapabilities { - PlayableMediaTypes = request.PlayableMediaTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(), + PlayableMediaTypes = (request.PlayableMediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(), - SupportedCommands = request.SupportedCommands.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(), + SupportedCommands = (request.SupportedCommands ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(), SupportsMediaControl = request.SupportsMediaControl, diff --git a/MediaBrowser.Common/Net/HttpRequestOptions.cs b/MediaBrowser.Common/Net/HttpRequestOptions.cs index ad7aad1d33..09bf6e2328 100644 --- a/MediaBrowser.Common/Net/HttpRequestOptions.cs +++ b/MediaBrowser.Common/Net/HttpRequestOptions.cs @@ -129,7 +129,7 @@ namespace MediaBrowser.Common.Net public enum CacheMode { - None = 1, - Unconditional = 2 + None = 0, + Unconditional = 1 } } diff --git a/MediaBrowser.Controller/Connect/ConnectUser.cs b/MediaBrowser.Controller/Connect/ConnectUser.cs index 389330cecb..c6a9eba552 100644 --- a/MediaBrowser.Controller/Connect/ConnectUser.cs +++ b/MediaBrowser.Controller/Connect/ConnectUser.cs @@ -7,6 +7,7 @@ namespace MediaBrowser.Controller.Connect public string Name { get; set; } public string Email { get; set; } public bool IsActive { get; set; } + public string ImageUrl { get; set; } } public class ConnectUserQuery diff --git a/MediaBrowser.Controller/Connect/IConnectManager.cs b/MediaBrowser.Controller/Connect/IConnectManager.cs index 7c1e14c302..afb61cb239 100644 --- a/MediaBrowser.Controller/Connect/IConnectManager.cs +++ b/MediaBrowser.Controller/Connect/IConnectManager.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using MediaBrowser.Model.Connect; +using System.Collections.Generic; +using System.Threading.Tasks; namespace MediaBrowser.Controller.Connect { @@ -24,5 +26,26 @@ namespace MediaBrowser.Controller.Connect /// The user identifier. /// Task. Task RemoveLink(string userId); + + /// + /// Invites the user. + /// + /// The sending user identifier. + /// The connect username. + /// Task<UserLinkResult>. + Task InviteUser(string sendingUserId, string connectUsername); + + /// + /// Gets the pending guests. + /// + /// Task<List<ConnectAuthorization>>. + Task> GetPendingGuests(); + + /// + /// Cancels the authorization. + /// + /// The identifier. + /// Task. + Task CancelAuthorization(string id); } } diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index e66de42bb1..af184e6e93 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -1,5 +1,7 @@ using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Events; using MediaBrowser.Model.Session; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -8,6 +10,11 @@ namespace MediaBrowser.Controller.Devices { public interface IDeviceManager { + /// + /// Occurs when [device options updated]. + /// + event EventHandler> DeviceOptionsUpdated; + /// /// Registers the device. /// @@ -16,7 +23,7 @@ namespace MediaBrowser.Controller.Devices /// Name of the application. /// The used by user identifier. /// Task. - Task RegisterDevice(string reportedId, string name, string appName, string usedByUserId); + Task RegisterDevice(string reportedId, string name, string appName, string usedByUserId); /// /// Saves the capabilities. @@ -40,6 +47,14 @@ namespace MediaBrowser.Controller.Devices /// DeviceInfo. DeviceInfo GetDevice(string id); + /// + /// Updates the device information. + /// + /// The identifier. + /// The options. + /// Task. + Task UpdateDeviceInfo(string id, DeviceOptions options); + /// /// Gets the devices. /// diff --git a/MediaBrowser.Model.Portable/MediaBrowser.Model.Portable.csproj b/MediaBrowser.Model.Portable/MediaBrowser.Model.Portable.csproj index f8d2de85a3..dfd5eb2bb0 100644 --- a/MediaBrowser.Model.Portable/MediaBrowser.Model.Portable.csproj +++ b/MediaBrowser.Model.Portable/MediaBrowser.Model.Portable.csproj @@ -92,6 +92,9 @@ ApiClient\IApiClient.cs + + ApiClient\IClientWebSocket.cs + ApiClient\IConnectionManager.cs @@ -212,6 +215,9 @@ Configuration\XbmcMetadataOptions.cs + + Connect\ConnectAuthorization.cs + Connect\UserLinkType.cs @@ -221,6 +227,9 @@ Devices\DeviceInfo.cs + + Devices\DeviceOptions.cs + Devices\DevicesOptions.cs diff --git a/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj b/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj index 9018627401..c8ac202ab1 100644 --- a/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj +++ b/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj @@ -181,6 +181,9 @@ Configuration\XbmcMetadataOptions.cs + + Connect\ConnectAuthorization.cs + Connect\UserLinkType.cs @@ -190,6 +193,9 @@ Devices\DeviceInfo.cs + + Devices\DeviceOptions.cs + Devices\DevicesOptions.cs diff --git a/MediaBrowser.Model/ApiClient/ConnectionResult.cs b/MediaBrowser.Model/ApiClient/ConnectionResult.cs index 0b6c0bfe95..86c94c3d58 100644 --- a/MediaBrowser.Model/ApiClient/ConnectionResult.cs +++ b/MediaBrowser.Model/ApiClient/ConnectionResult.cs @@ -1,15 +1,17 @@ - +using System.Collections.Generic; + namespace MediaBrowser.Model.ApiClient { public class ConnectionResult { public ConnectionState State { get; set; } - public ServerInfo ServerInfo { get; set; } + public List Servers { get; set; } public IApiClient ApiClient { get; set; } public ConnectionResult() { State = ConnectionState.Unavailable; + Servers = new List(); } } } diff --git a/MediaBrowser.Model/ApiClient/ConnectionState.cs b/MediaBrowser.Model/ApiClient/ConnectionState.cs index 9374c77f6f..63e156eb1f 100644 --- a/MediaBrowser.Model/ApiClient/ConnectionState.cs +++ b/MediaBrowser.Model/ApiClient/ConnectionState.cs @@ -4,6 +4,7 @@ namespace MediaBrowser.Model.ApiClient { Unavailable = 1, ServerSignIn = 2, - SignedIn = 3 + SignedIn = 3, + ServerSelection = 4 } } \ No newline at end of file diff --git a/MediaBrowser.Model/ApiClient/IApiClient.cs b/MediaBrowser.Model/ApiClient/IApiClient.cs index 1d2145b3a6..13088945a4 100644 --- a/MediaBrowser.Model/ApiClient/IApiClient.cs +++ b/MediaBrowser.Model/ApiClient/IApiClient.cs @@ -1350,5 +1350,12 @@ namespace MediaBrowser.Model.ApiClient /// /// Task<DevicesOptions>. Task GetDevicesOptions(); + + /// + /// Opens the web socket. + /// + /// The web socket factory. + /// The keep alive timer ms. + void OpenWebSocket(Func webSocketFactory, int keepAliveTimerMs = 60000); } } \ No newline at end of file diff --git a/MediaBrowser.Model/ApiClient/IClientWebSocket.cs b/MediaBrowser.Model/ApiClient/IClientWebSocket.cs new file mode 100644 index 0000000000..ca3a761d4c --- /dev/null +++ b/MediaBrowser.Model/ApiClient/IClientWebSocket.cs @@ -0,0 +1,54 @@ +using MediaBrowser.Model.Net; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Model.ApiClient +{ + /// + /// Interface IClientWebSocket + /// + public interface IClientWebSocket : IDisposable + { + /// + /// Occurs when [closed]. + /// + event EventHandler Closed; + + /// + /// Gets or sets the state. + /// + /// The state. + WebSocketState State { get; } + + /// + /// Connects the async. + /// + /// The URL. + /// The cancellation token. + /// Task. + Task ConnectAsync(string url, CancellationToken cancellationToken); + + /// + /// Gets or sets the receive action. + /// + /// The receive action. + Action OnReceiveBytes { get; set; } + + /// + /// Gets or sets the on receive. + /// + /// The on receive. + Action OnReceive { get; set; } + + /// + /// Sends the async. + /// + /// The bytes. + /// The type. + /// if set to true [end of message]. + /// The cancellation token. + /// Task. + Task SendAsync(byte[] bytes, WebSocketMessageType type, bool endOfMessage, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Model/ApiClient/IConnectionManager.cs b/MediaBrowser.Model/ApiClient/IConnectionManager.cs index 03d3472d25..d76ef67d29 100644 --- a/MediaBrowser.Model/ApiClient/IConnectionManager.cs +++ b/MediaBrowser.Model/ApiClient/IConnectionManager.cs @@ -53,5 +53,17 @@ namespace MediaBrowser.Model.ApiClient /// /// Task<ConnectionResult>. Task Logout(); + + /// + /// Logins to connect. + /// + /// Task. + Task LoginToConnect(string username, string password); + + /// + /// Gets the active api client instance + /// + [Obsolete] + IApiClient CurrentApiClient { get; } } } diff --git a/MediaBrowser.Model/ApiClient/ServerInfo.cs b/MediaBrowser.Model/ApiClient/ServerInfo.cs index 0656337bac..af46cc6608 100644 --- a/MediaBrowser.Model/ApiClient/ServerInfo.cs +++ b/MediaBrowser.Model/ApiClient/ServerInfo.cs @@ -12,6 +12,7 @@ namespace MediaBrowser.Model.ApiClient public String UserId { get; set; } public String AccessToken { get; set; } public List WakeOnLanInfos { get; set; } + public DateTime DateLastAccessed { get; set; } public ServerInfo() { diff --git a/MediaBrowser.Model/Connect/ConnectAuthorization.cs b/MediaBrowser.Model/Connect/ConnectAuthorization.cs new file mode 100644 index 0000000000..329d805f1e --- /dev/null +++ b/MediaBrowser.Model/Connect/ConnectAuthorization.cs @@ -0,0 +1,11 @@ + +namespace MediaBrowser.Model.Connect +{ + public class ConnectAuthorization + { + public string ConnectUserId { get; set; } + public string UserName { get; set; } + public string ImageUrl { get; set; } + public string Id { get; set; } + } +} diff --git a/MediaBrowser.Model/Connect/UserLinkType.cs b/MediaBrowser.Model/Connect/UserLinkType.cs index e8ea15a4e7..4ac5bfde1b 100644 --- a/MediaBrowser.Model/Connect/UserLinkType.cs +++ b/MediaBrowser.Model/Connect/UserLinkType.cs @@ -6,10 +6,10 @@ namespace MediaBrowser.Model.Connect /// /// The linked user /// - LinkedUser = 1, + LinkedUser = 0, /// /// The guest /// - Guest = 2 + Guest = 1 } } diff --git a/MediaBrowser.Model/Devices/DeviceInfo.cs b/MediaBrowser.Model/Devices/DeviceInfo.cs index fce3b02f60..e5efe9f605 100644 --- a/MediaBrowser.Model/Devices/DeviceInfo.cs +++ b/MediaBrowser.Model/Devices/DeviceInfo.cs @@ -1,15 +1,35 @@ -using System; -using MediaBrowser.Model.Session; +using MediaBrowser.Model.Session; +using System; namespace MediaBrowser.Model.Devices { public class DeviceInfo { /// - /// Gets or sets the name. + /// Gets or sets the name of the reported. + /// + /// The name of the reported. + public string ReportedName { get; set; } + /// + /// Gets or sets the name of the custom. + /// + /// The name of the custom. + public string CustomName { get; set; } + /// + /// Gets or sets the camera upload path. + /// + /// The camera upload path. + public string CameraUploadPath { get; set; } + + /// + /// Gets the name. /// /// The name. - public string Name { get; set; } + public string Name + { + get { return string.IsNullOrEmpty(CustomName) ? ReportedName : CustomName; } + } + /// /// Gets or sets the identifier. /// diff --git a/MediaBrowser.Model/Devices/DeviceOptions.cs b/MediaBrowser.Model/Devices/DeviceOptions.cs new file mode 100644 index 0000000000..2524a2f999 --- /dev/null +++ b/MediaBrowser.Model/Devices/DeviceOptions.cs @@ -0,0 +1,17 @@ + +namespace MediaBrowser.Model.Devices +{ + public class DeviceOptions + { + /// + /// Gets or sets the name of the custom. + /// + /// The name of the custom. + public string CustomName { get; set; } + /// + /// Gets or sets the camera upload path. + /// + /// The camera upload path. + public string CameraUploadPath { get; set; } + } +} diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index e51edae1b0..80fda4bc2f 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -232,6 +232,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the recursive unplayed item count. /// /// The recursive unplayed item count. + [Obsolete] public int? RecursiveUnplayedItemCount { get; set; } /// diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index e1ce7eccab..fa0c2d466f 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -66,6 +66,7 @@ + @@ -95,7 +96,9 @@ + + diff --git a/MediaBrowser.Server.Implementations/Connect/ConnectData.cs b/MediaBrowser.Server.Implementations/Connect/ConnectData.cs index dfbeccd4d3..e26fdc6076 100644 --- a/MediaBrowser.Server.Implementations/Connect/ConnectData.cs +++ b/MediaBrowser.Server.Implementations/Connect/ConnectData.cs @@ -1,4 +1,4 @@ -using System; +using MediaBrowser.Model.Connect; using System.Collections.Generic; namespace MediaBrowser.Server.Implementations.Connect @@ -20,22 +20,11 @@ namespace MediaBrowser.Server.Implementations.Connect /// Gets or sets the authorizations. /// /// The authorizations. - public List Authorizations { get; set; } + public List PendingAuthorizations { get; set; } public ConnectData() { - Authorizations = new List(); - } - } - - public class ConnectAuthorization - { - public string LocalUserId { get; set; } - public string AccessToken { get; set; } - - public ConnectAuthorization() - { - AccessToken = new Guid().ToString("N"); + PendingAuthorizations = new List(); } } } diff --git a/MediaBrowser.Server.Implementations/Connect/ConnectManager.cs b/MediaBrowser.Server.Implementations/Connect/ConnectManager.cs index a4535a57c3..4e3bbbcb29 100644 --- a/MediaBrowser.Server.Implementations/Connect/ConnectManager.cs +++ b/MediaBrowser.Server.Implementations/Connect/ConnectManager.cs @@ -5,8 +5,10 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Connect; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Connect; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; @@ -24,7 +26,7 @@ namespace MediaBrowser.Server.Implementations.Connect { public class ConnectManager : IConnectManager { - private SemaphoreSlim _operationLock = new SemaphoreSlim(1,1); + private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; @@ -34,6 +36,7 @@ namespace MediaBrowser.Server.Implementations.Connect private readonly IServerApplicationHost _appHost; private readonly IServerConfigurationManager _config; private readonly IUserManager _userManager; + private readonly IProviderManager _providerManager; private ConnectData _data = new ConnectData(); @@ -90,7 +93,7 @@ namespace MediaBrowser.Server.Implementations.Connect IEncryptionManager encryption, IHttpClient httpClient, IServerApplicationHost appHost, - IServerConfigurationManager config, IUserManager userManager) + IServerConfigurationManager config, IUserManager userManager, IProviderManager providerManager) { _logger = logger; _appPaths = appPaths; @@ -100,6 +103,7 @@ namespace MediaBrowser.Server.Implementations.Connect _appHost = appHost; _config = config; _userManager = userManager; + _providerManager = providerManager; LoadCachedData(); } @@ -165,13 +169,15 @@ namespace MediaBrowser.Server.Implementations.Connect } await RefreshAuthorizationsInternal(CancellationToken.None).ConfigureAwait(false); + + await RefreshUserInfosInternal(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error registering with Connect", ex); } } - + private async Task CreateServerRegistration(string wanApiAddress) { var url = "Servers"; @@ -181,7 +187,7 @@ namespace MediaBrowser.Server.Implementations.Connect { {"name", _appHost.FriendlyName}, {"url", wanApiAddress}, - {"systemid", _appHost.SystemId} + {"systemId", _appHost.SystemId} }; using (var stream = await _httpClient.Post(url, postData, CancellationToken.None).ConfigureAwait(false)) @@ -211,7 +217,7 @@ namespace MediaBrowser.Server.Implementations.Connect { {"name", _appHost.FriendlyName}, {"url", wanApiAddress}, - {"systemid", _appHost.SystemId} + {"systemId", _appHost.SystemId} }); SetServerAccessToken(options); @@ -222,6 +228,7 @@ namespace MediaBrowser.Server.Implementations.Connect } } + private readonly object _dataFileLock = new object(); private string CacheFilePath { get { return Path.Combine(_appPaths.DataPath, "connect.txt"); } @@ -239,7 +246,10 @@ namespace MediaBrowser.Server.Implementations.Connect var encrypted = _encryption.EncryptString(json); - File.WriteAllText(path, encrypted, Encoding.UTF8); + lock (_dataFileLock) + { + File.WriteAllText(path, encrypted, Encoding.UTF8); + } } catch (Exception ex) { @@ -253,11 +263,14 @@ namespace MediaBrowser.Server.Implementations.Connect try { - var encrypted = File.ReadAllText(path, Encoding.UTF8); + lock (_dataFileLock) + { + var encrypted = File.ReadAllText(path, Encoding.UTF8); - var json = _encryption.DecryptString(encrypted); + var json = _encryption.DecryptString(encrypted); - _data = _json.DeserializeFromString(json); + _data = _json.DeserializeFromString(json); + } } catch (IOException) { @@ -287,6 +300,20 @@ namespace MediaBrowser.Server.Implementations.Connect } public async Task LinkUser(string userId, string connectUsername) + { + await _operationLock.WaitAsync().ConfigureAwait(false); + + try + { + return await LinkUserInternal(userId, connectUsername).ConfigureAwait(false); + } + finally + { + _operationLock.Release(); + } + } + + private async Task LinkUserInternal(string userId, string connectUsername) { if (string.IsNullOrWhiteSpace(connectUsername)) { @@ -350,56 +377,98 @@ namespace MediaBrowser.Server.Implementations.Connect await user.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + user.Configuration.SyncConnectImage = user.ConnectLinkType == UserLinkType.Guest; + user.Configuration.SyncConnectName = user.ConnectLinkType == UserLinkType.Guest; + _userManager.UpdateConfiguration(user, user.Configuration); + + await RefreshAuthorizationsInternal(CancellationToken.None).ConfigureAwait(false); + return result; } - - public Task RemoveLink(string userId) + + public async Task InviteUser(string sendingUserId, string connectUsername) { - var user = GetUser(userId); + await _operationLock.WaitAsync().ConfigureAwait(false); - return RemoveLink(user, user.ConnectUserId); + try + { + return await InviteUserInternal(sendingUserId, connectUsername).ConfigureAwait(false); + } + finally + { + _operationLock.Release(); + } } - private async Task RemoveLink(User user, string connectUserId) + private async Task InviteUserInternal(string sendingUserId, string connectUsername) { - if (!string.IsNullOrWhiteSpace(connectUserId)) + if (string.IsNullOrWhiteSpace(connectUsername)) { - var url = GetConnectUrl("ServerAuthorizations"); + throw new ArgumentNullException("connectUsername"); + } - var options = new HttpRequestOptions - { - Url = url, - CancellationToken = CancellationToken.None - }; + var connectUser = await GetConnectUser(new ConnectUserQuery + { + Name = connectUsername - var postData = new Dictionary - { - {"serverId", ConnectServerId}, - {"userId", connectUserId} - }; + }, CancellationToken.None).ConfigureAwait(false); + + if (!connectUser.IsActive) + { + throw new ArgumentException("The Media Browser account has been disabled."); + } - options.SetPostData(postData); + var url = GetConnectUrl("ServerAuthorizations"); - SetServerAccessToken(options); + var options = new HttpRequestOptions + { + Url = url, + CancellationToken = CancellationToken.None + }; - try - { - // No need to examine the response - using (var stream = (await _httpClient.SendAsync(options, "DELETE").ConfigureAwait(false)).Content) - { - } - } - catch (HttpException ex) - { - // If connect says the auth doesn't exist, we can handle that gracefully since this is a remove operation + var accessToken = Guid.NewGuid().ToString("N"); + var sendingUser = GetUser(sendingUserId); - if (!ex.StatusCode.HasValue || ex.StatusCode.Value != HttpStatusCode.NotFound) - { - throw; - } + var postData = new Dictionary + { + {"serverId", ConnectServerId}, + {"userId", connectUser.Id}, + {"userType", "Guest"}, + {"accessToken", accessToken}, + {"requesterUserName", sendingUser.ConnectUserName} + }; - _logger.Debug("Connect returned a 404 when removing a user auth link. Handling it."); - } + options.SetPostData(postData); + + SetServerAccessToken(options); + + var result = new UserLinkResult(); + + // No need to examine the response + using (var stream = (await _httpClient.Post(options).ConfigureAwait(false)).Content) + { + var response = _json.DeserializeFromStream(stream); + + result.IsPending = string.Equals(response.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase); + } + + await RefreshAuthorizationsInternal(CancellationToken.None).ConfigureAwait(false); + + return result; + } + + public Task RemoveLink(string userId) + { + var user = GetUser(userId); + + return RemoveLink(user, user.ConnectUserId); + } + + private async Task RemoveLink(User user, string connectUserId) + { + if (!string.IsNullOrWhiteSpace(connectUserId)) + { + await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false); } user.ConnectAccessKey = null; @@ -472,24 +541,19 @@ namespace MediaBrowser.Server.Implementations.Connect { var url = GetConnectUrl("ServerAuthorizations"); + url += "?serverId=" + ConnectServerId; + var options = new HttpRequestOptions { Url = url, CancellationToken = cancellationToken }; - var postData = new Dictionary - { - {"serverId", ConnectServerId} - }; - - options.SetPostData(postData); - SetServerAccessToken(options); try { - using (var stream = (await _httpClient.SendAsync(options, "POST").ConfigureAwait(false)).Content) + using (var stream = (await _httpClient.SendAsync(options, "GET").ConfigureAwait(false)).Content) { var list = _json.DeserializeFromStream>(stream); @@ -521,10 +585,11 @@ namespace MediaBrowser.Server.Implementations.Connect user.ConnectUserName = null; await _userManager.UpdateUser(user).ConfigureAwait(false); - + if (user.ConnectLinkType == UserLinkType.Guest) { - await _userManager.DeleteUser(user).ConfigureAwait(false); + _logger.Debug("Deleting guest user {0}", user.Name); + //await _userManager.DeleteUser(user).ConfigureAwait(false); } } else @@ -544,19 +609,195 @@ namespace MediaBrowser.Server.Implementations.Connect users = _userManager.Users.ToList(); + var pending = new List(); + // TODO: Handle newly added guests that we don't know about foreach (var connectEntry in list) { - if (string.Equals(connectEntry.UserType, "guest", StringComparison.OrdinalIgnoreCase) && - string.Equals(connectEntry.AcceptStatus, "accepted", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(connectEntry.UserType, "guest", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(connectEntry.AcceptStatus, "accepted", StringComparison.OrdinalIgnoreCase)) + { + var user = users.FirstOrDefault(i => string.Equals(i.ConnectUserId, connectEntry.UserId, StringComparison.OrdinalIgnoreCase)); + + if (user == null) + { + // Add user + } + } + else if (string.Equals(connectEntry.AcceptStatus, "waiting", StringComparison.OrdinalIgnoreCase)) + { + pending.Add(new ConnectAuthorization + { + ConnectUserId = connectEntry.UserId, + ImageUrl = connectEntry.ImageUrl, + UserName = connectEntry.UserName, + Id = connectEntry.Id + }); + } + } + } + + _data.PendingAuthorizations = pending; + CacheData(); + } + + public async Task RefreshUserInfos(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await RefreshUserInfosInternal(cancellationToken).ConfigureAwait(false); + } + finally + { + _operationLock.Release(); + } + } + + private readonly SemaphoreSlim _connectImageSemaphore = new SemaphoreSlim(5, 5); + + private async Task RefreshUserInfosInternal(CancellationToken cancellationToken) + { + var users = _userManager.Users + .Where(i => !string.IsNullOrEmpty(i.ConnectUserId) && + (i.Configuration.SyncConnectImage || i.Configuration.SyncConnectName)) + .ToList(); + + foreach (var user in users) + { + cancellationToken.ThrowIfCancellationRequested(); + + var connectUser = await GetConnectUser(new ConnectUserQuery { - var user = users.FirstOrDefault(i => string.Equals(i.ConnectUserId, connectEntry.UserId, StringComparison.OrdinalIgnoreCase)); + Id = user.ConnectUserId - if (user == null) + }, cancellationToken).ConfigureAwait(false); + + if (user.Configuration.SyncConnectName) + { + var changed = !string.Equals(connectUser.Name, user.Name, StringComparison.OrdinalIgnoreCase); + + if (changed) { - // Add user + await user.Rename(connectUser.Name).ConfigureAwait(false); } } + + if (user.Configuration.SyncConnectImage) + { + var imageUrl = connectUser.ImageUrl; + + if (!string.IsNullOrWhiteSpace(imageUrl)) + { + var changed = false; + + if (!user.HasImage(ImageType.Primary)) + { + changed = true; + } + else + { + using (var response = await _httpClient.SendAsync(new HttpRequestOptions + { + Url = imageUrl, + CancellationToken = cancellationToken, + BufferContent = false + + }, "HEAD").ConfigureAwait(false)) + { + var length = response.ContentLength; + + if (length != new FileInfo(user.GetImageInfo(ImageType.Primary, 0).Path).Length) + { + changed = true; + } + } + } + + if (changed) + { + await _providerManager.SaveImage(user, imageUrl, _connectImageSemaphore, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); + + await user.RefreshMetadata(new MetadataRefreshOptions + { + ForceSave = true, + + }, cancellationToken).ConfigureAwait(false); + } + } + } + } + } + + public async Task> GetPendingGuests() + { + return _data.PendingAuthorizations.ToList(); + } + + public async Task CancelAuthorization(string id) + { + await _operationLock.WaitAsync().ConfigureAwait(false); + + try + { + await CancelAuthorizationInternal(id).ConfigureAwait(false); + } + finally + { + _operationLock.Release(); + } + } + + private async Task CancelAuthorizationInternal(string id) + { + var connectUserId = _data.PendingAuthorizations + .First(i => string.Equals(i.Id, id, StringComparison.Ordinal)) + .ConnectUserId; + + await CancelAuthorizationByConnectUserId(connectUserId).ConfigureAwait(false); + + await RefreshAuthorizationsInternal(CancellationToken.None).ConfigureAwait(false); + } + + private async Task CancelAuthorizationByConnectUserId(string connectUserId) + { + var url = GetConnectUrl("ServerAuthorizations"); + + var options = new HttpRequestOptions + { + Url = url, + CancellationToken = CancellationToken.None + }; + + var postData = new Dictionary + { + {"serverId", ConnectServerId}, + {"userId", connectUserId} + }; + + options.SetPostData(postData); + + SetServerAccessToken(options); + + try + { + // No need to examine the response + using (var stream = (await _httpClient.SendAsync(options, "DELETE").ConfigureAwait(false)).Content) + { + } + } + catch (HttpException ex) + { + // If connect says the auth doesn't exist, we can handle that gracefully since this is a remove operation + + if (!ex.StatusCode.HasValue || ex.StatusCode.Value != HttpStatusCode.NotFound) + { + throw; + } + + _logger.Debug("Connect returned a 404 when removing a user auth link. Handling it."); } } } diff --git a/MediaBrowser.Server.Implementations/Connect/Responses.cs b/MediaBrowser.Server.Implementations/Connect/Responses.cs index eeb56d1c91..862e795599 100644 --- a/MediaBrowser.Server.Implementations/Connect/Responses.cs +++ b/MediaBrowser.Server.Implementations/Connect/Responses.cs @@ -36,5 +36,7 @@ namespace MediaBrowser.Server.Implementations.Connect public bool IsActive { get; set; } public string AcceptStatus { get; set; } public string UserType { get; set; } + public string ImageUrl { get; set; } + public string UserName { get; set; } } } diff --git a/MediaBrowser.Server.Implementations/Devices/DeviceManager.cs b/MediaBrowser.Server.Implementations/Devices/DeviceManager.cs index 6d4238bdf5..e2c729b2d9 100644 --- a/MediaBrowser.Server.Implementations/Devices/DeviceManager.cs +++ b/MediaBrowser.Server.Implementations/Devices/DeviceManager.cs @@ -1,8 +1,11 @@ using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Events; using MediaBrowser.Common.IO; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Logging; using MediaBrowser.Model.Session; using System; using System.Collections.Generic; @@ -19,24 +22,31 @@ namespace MediaBrowser.Server.Implementations.Devices private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IConfigurationManager _config; - - public DeviceManager(IDeviceRepository repo, IUserManager userManager, IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IConfigurationManager config) + private readonly ILogger _logger; + + /// + /// Occurs when [device options updated]. + /// + public event EventHandler> DeviceOptionsUpdated; + + public DeviceManager(IDeviceRepository repo, IUserManager userManager, IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IConfigurationManager config, ILogger logger) { _repo = repo; _userManager = userManager; _fileSystem = fileSystem; _libraryMonitor = libraryMonitor; _config = config; + _logger = logger; } - public Task RegisterDevice(string reportedId, string name, string appName, string usedByUserId) + public async Task RegisterDevice(string reportedId, string name, string appName, string usedByUserId) { var device = GetDevice(reportedId) ?? new DeviceInfo { Id = reportedId }; - device.Name = name; + device.ReportedName = name; device.AppName = appName; if (!string.IsNullOrWhiteSpace(usedByUserId)) @@ -49,7 +59,9 @@ namespace MediaBrowser.Server.Implementations.Devices device.DateLastModified = DateTime.UtcNow; - return _repo.SaveDevice(device); + await _repo.SaveDevice(device).ConfigureAwait(false); + + return device; } public Task SaveCapabilities(string reportedId, ClientCapabilities capabilities) @@ -114,10 +126,13 @@ namespace MediaBrowser.Server.Implementations.Devices private string GetUploadPath(string deviceId) { - var config = _config.GetUploadOptions(); - var device = GetDevice(deviceId); + if (!string.IsNullOrWhiteSpace(device.CameraUploadPath)) + { + return device.CameraUploadPath; + } + var config = _config.GetUploadOptions(); if (!string.IsNullOrWhiteSpace(config.CameraUploadPath)) { return config.CameraUploadPath; @@ -132,6 +147,18 @@ namespace MediaBrowser.Server.Implementations.Devices return path; } + + public async Task UpdateDeviceInfo(string id, DeviceOptions options) + { + var device = GetDevice(id); + + device.CustomName = options.CustomName; + device.CameraUploadPath = options.CameraUploadPath; + + await _repo.SaveDevice(device).ConfigureAwait(false); + + EventHelper.FireEventIfNotNull(DeviceOptionsUpdated, this, new GenericEventArgs(device), _logger); + } } public class DevicesConfigStore : IConfigurationFactory diff --git a/MediaBrowser.Server.Implementations/IO/LibraryMonitor.cs b/MediaBrowser.Server.Implementations/IO/LibraryMonitor.cs index 09e0e91f58..ce62242953 100644 --- a/MediaBrowser.Server.Implementations/IO/LibraryMonitor.cs +++ b/MediaBrowser.Server.Implementations/IO/LibraryMonitor.cs @@ -310,12 +310,23 @@ namespace MediaBrowser.Server.Implementations.IO /// The watcher. private void DisposeWatcher(FileSystemWatcher watcher) { - Logger.Info("Stopping directory watching for path {0}", watcher.Path); - - watcher.EnableRaisingEvents = false; - watcher.Dispose(); + try + { + using (watcher) + { + Logger.Info("Stopping directory watching for path {0}", watcher.Path); - RemoveWatcherFromList(watcher); + watcher.EnableRaisingEvents = false; + } + } + catch + { + + } + finally + { + RemoveWatcherFromList(watcher); + } } /// diff --git a/MediaBrowser.Server.Implementations/Library/UserViewManager.cs b/MediaBrowser.Server.Implementations/Library/UserViewManager.cs index b12ae32a85..768c29ce08 100644 --- a/MediaBrowser.Server.Implementations/Library/UserViewManager.cs +++ b/MediaBrowser.Server.Implementations/Library/UserViewManager.cs @@ -2,9 +2,7 @@ using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Localization; diff --git a/MediaBrowser.Server.Implementations/Localization/Server/server.json b/MediaBrowser.Server.Implementations/Localization/Server/server.json index d7a99638e9..e576180c47 100644 --- a/MediaBrowser.Server.Implementations/Localization/Server/server.json +++ b/MediaBrowser.Server.Implementations/Localization/Server/server.json @@ -75,7 +75,7 @@ "TabSecurity": "Security", "ButtonAddUser": "Add User", "ButtonAddLocalUser": "Add Local User", - "ButtonInviteMediaBrowserUser": "Invite Media Browser User", + "ButtonInviteUser": "Invite User", "ButtonSave": "Save", "ButtonResetPassword": "Reset Password", "LabelNewPassword": "New password:", @@ -1225,5 +1225,11 @@ "LabelCameraUploadPath": "Camera upload path:", "LabelCameraUploadPathHelp": "Select a custom upload path, if desired. If unspecified a default folder will be used.", "LabelCreateCameraUploadSubfolder": "Create a subfolder for each device", - "LabelCreateCameraUploadSubfolderHelp": "Specific folders can be assigned to a device by clicking on it from the Devices page." + "LabelCreateCameraUploadSubfolderHelp": "Specific folders can be assigned to a device by clicking on it from the Devices page.", + "LabelCustomDeviceDisplayName": "Display name:", + "LabelCustomDeviceDisplayNameHelp": "Supply a custom display name or leave empty to use the name reported by the device.", + "HeaderInviteUser": "Invite User", + "LabelConnectInviteHelp": "This is the username or email used to sign in to the Media Browser website.", + "HeaderInviteUserHelp": "Sharing your media with friends is easier than ever before with Media Browser Connect.", + "ButtonSendInvitation": "Send Invitation" } diff --git a/MediaBrowser.Server.Implementations/Session/SessionManager.cs b/MediaBrowser.Server.Implementations/Session/SessionManager.cs index c16044bd36..21467ccda9 100644 --- a/MediaBrowser.Server.Implementations/Session/SessionManager.cs +++ b/MediaBrowser.Server.Implementations/Session/SessionManager.cs @@ -14,6 +14,7 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Devices; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Events; using MediaBrowser.Model.Library; @@ -129,6 +130,19 @@ namespace MediaBrowser.Server.Implementations.Session _httpClient = httpClient; _authRepo = authRepo; _deviceManager = deviceManager; + + _deviceManager.DeviceOptionsUpdated += _deviceManager_DeviceOptionsUpdated; + } + + void _deviceManager_DeviceOptionsUpdated(object sender, GenericEventArgs e) + { + foreach (var session in Sessions) + { + if (string.Equals(session.DeviceId, e.Argument.Id)) + { + session.DeviceName = e.Argument.Name; + } + } } /// @@ -397,6 +411,7 @@ namespace MediaBrowser.Server.Implementations.Session try { SessionInfo connection; + DeviceInfo device = null; if (!_activeConnections.TryGetValue(key, out connection)) { @@ -421,10 +436,17 @@ namespace MediaBrowser.Server.Implementations.Session if (!string.IsNullOrEmpty(deviceId)) { var userIdString = userId.HasValue ? userId.Value.ToString("N") : null; - await _deviceManager.RegisterDevice(deviceId, deviceName, clientType, userIdString).ConfigureAwait(false); + device = await _deviceManager.RegisterDevice(deviceId, deviceName, clientType, userIdString).ConfigureAwait(false); } } + device = device ?? _deviceManager.GetDevice(deviceId); + + if (!string.IsNullOrEmpty(device.CustomName)) + { + deviceName = device.CustomName; + } + connection.DeviceName = deviceName; connection.UserId = userId; connection.UserName = username; diff --git a/MediaBrowser.ServerApplication/ApplicationHost.cs b/MediaBrowser.ServerApplication/ApplicationHost.cs index 8e41ef03ea..2c9319e39e 100644 --- a/MediaBrowser.ServerApplication/ApplicationHost.cs +++ b/MediaBrowser.ServerApplication/ApplicationHost.cs @@ -457,10 +457,10 @@ namespace MediaBrowser.ServerApplication var encryptionManager = new EncryptionManager(); RegisterSingleInstance(encryptionManager); - ConnectManager = new ConnectManager(LogManager.GetLogger("Connect"), ApplicationPaths, JsonSerializer, encryptionManager, HttpClient, this, ServerConfigurationManager, UserManager); + ConnectManager = new ConnectManager(LogManager.GetLogger("Connect"), ApplicationPaths, JsonSerializer, encryptionManager, HttpClient, this, ServerConfigurationManager, UserManager, ProviderManager); RegisterSingleInstance(ConnectManager); - DeviceManager = new DeviceManager(new DeviceRepository(ApplicationPaths, JsonSerializer), UserManager, FileSystemManager, LibraryMonitor, ConfigurationManager); + DeviceManager = new DeviceManager(new DeviceRepository(ApplicationPaths, JsonSerializer), UserManager, FileSystemManager, LibraryMonitor, ConfigurationManager, LogManager.GetLogger("DeviceManager")); RegisterSingleInstance(DeviceManager); SessionManager = new SessionManager(UserDataManager, ServerConfigurationManager, Logger, UserRepository, LibraryManager, UserManager, musicManager, DtoService, ImageProcessor, ItemRepository, JsonSerializer, this, HttpClient, AuthenticationRepository, DeviceManager); diff --git a/Nuget/MediaBrowser.Common.Internal.nuspec b/Nuget/MediaBrowser.Common.Internal.nuspec index 6f9d320df9..8d68f0c13f 100644 --- a/Nuget/MediaBrowser.Common.Internal.nuspec +++ b/Nuget/MediaBrowser.Common.Internal.nuspec @@ -2,7 +2,7 @@ MediaBrowser.Common.Internal - 3.0.477 + 3.0.482 MediaBrowser.Common.Internal Luke ebr,Luke,scottisafool @@ -12,7 +12,7 @@ Contains common components shared by Media Browser Theater and Media Browser Server. Not intended for plugin developer consumption. Copyright © Media Browser 2013 - + diff --git a/Nuget/MediaBrowser.Common.nuspec b/Nuget/MediaBrowser.Common.nuspec index a2fa43075c..90ae9dbac1 100644 --- a/Nuget/MediaBrowser.Common.nuspec +++ b/Nuget/MediaBrowser.Common.nuspec @@ -2,7 +2,7 @@ MediaBrowser.Common - 3.0.477 + 3.0.482 MediaBrowser.Common Media Browser Team ebr,Luke,scottisafool diff --git a/Nuget/MediaBrowser.Model.Signed.nuspec b/Nuget/MediaBrowser.Model.Signed.nuspec index cd18acd572..90f107af9c 100644 --- a/Nuget/MediaBrowser.Model.Signed.nuspec +++ b/Nuget/MediaBrowser.Model.Signed.nuspec @@ -2,7 +2,7 @@ MediaBrowser.Model.Signed - 3.0.477 + 3.0.482 MediaBrowser.Model - Signed Edition Media Browser Team ebr,Luke,scottisafool diff --git a/Nuget/MediaBrowser.Server.Core.nuspec b/Nuget/MediaBrowser.Server.Core.nuspec index 988533e649..545f52e2c5 100644 --- a/Nuget/MediaBrowser.Server.Core.nuspec +++ b/Nuget/MediaBrowser.Server.Core.nuspec @@ -2,7 +2,7 @@ MediaBrowser.Server.Core - 3.0.477 + 3.0.482 Media Browser.Server.Core Media Browser Team ebr,Luke,scottisafool @@ -12,7 +12,7 @@ Contains core components required to build plugins for Media Browser Server. Copyright © Media Browser 2013 - +