diff options
Diffstat (limited to 'Jellyfin.Api')
| -rw-r--r-- | Jellyfin.Api/Auth/CustomAuthenticationHandler.cs | 5 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/ConfigurationController.cs | 18 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/DevicesController.cs | 16 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/FilterController.cs | 221 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/Images/RemoteImageController.cs | 267 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/ItemRefreshController.cs | 89 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/LocalizationController.cs | 76 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/NotificationsController.cs | 30 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/PackageController.cs | 15 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/PluginsController.cs | 189 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/StartupController.cs | 44 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/SubtitleController.cs | 349 | ||||
| -rw-r--r-- | Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs | 42 | ||||
| -rw-r--r-- | Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs | 20 |
14 files changed, 1323 insertions, 58 deletions
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 100054096..a5c4e9974 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -56,10 +57,10 @@ namespace Jellyfin.Api.Auth var claims = new[] { - new Claim(ClaimTypes.Name, user.Name), + new Claim(ClaimTypes.Name, user.Username), new Claim( ClaimTypes.Role, - value: user.Policy.IsAdministrator ? UserRoles.Administrator : UserRoles.User) + value: user.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User) }; var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index 2a1dce74d..780a38aa8 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -53,15 +53,15 @@ namespace Jellyfin.Api.Controllers /// Updates application configuration. /// </summary> /// <param name="configuration">Configuration.</param> - /// <response code="200">Configuration updated.</response> + /// <response code="204">Configuration updated.</response> /// <returns>Update status.</returns> [HttpPost("Configuration")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration) { _configurationManager.ReplaceConfiguration(configuration); - return Ok(); + return NoContent(); } /// <summary> @@ -81,17 +81,17 @@ namespace Jellyfin.Api.Controllers /// Updates named configuration. /// </summary> /// <param name="key">Configuration key.</param> - /// <response code="200">Named configuration updated.</response> + /// <response code="204">Named configuration updated.</response> /// <returns>Update status.</returns> [HttpPost("Configuration/{Key}")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key) { var configurationType = _configurationManager.GetConfigurationType(key); var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false); _configurationManager.SaveConfiguration(key, configuration); - return Ok(); + return NoContent(); } /// <summary> @@ -111,15 +111,15 @@ namespace Jellyfin.Api.Controllers /// Updates the path to the media encoder. /// </summary> /// <param name="mediaEncoderPath">Media encoder path form body.</param> - /// <response code="200">Media encoder path updated.</response> + /// <response code="204">Media encoder path updated.</response> /// <returns>Status.</returns> [HttpPost("MediaEncoder/Path")] [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath) { _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); - return Ok(); + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 1e7557903..1754b0cbd 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -105,12 +105,12 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="id">Device Id.</param> /// <param name="deviceOptions">Device Options.</param> - /// <response code="200">Device options updated.</response> + /// <response code="204">Device options updated.</response> /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpPost("Options")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateDeviceOptions( [FromQuery, BindRequired] string id, @@ -123,18 +123,18 @@ namespace Jellyfin.Api.Controllers } _deviceManager.UpdateDeviceOptions(id, deviceOptions); - return Ok(); + return NoContent(); } /// <summary> /// Deletes a device. /// </summary> /// <param name="id">Device Id.</param> - /// <response code="200">Device deleted.</response> + /// <response code="204">Device deleted.</response> /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpDelete] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteDevice([FromQuery, BindRequired] string id) { var existingDevice = _deviceManager.GetDevice(id); @@ -150,7 +150,7 @@ namespace Jellyfin.Api.Controllers _sessionManager.Logout(session); } - return Ok(); + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs new file mode 100644 index 000000000..46911ce93 --- /dev/null +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -0,0 +1,221 @@ +#nullable enable +#pragma warning disable CA1801 + +using System; +using System.Linq; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Filters controller. + /// </summary> + [Authorize] + public class FilterController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="FilterController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager) + { + _libraryManager = libraryManager; + _userManager = userManager; + } + + /// <summary> + /// Gets legacy query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Parent id.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <response code="200">Legacy filters retrieved.</response> + /// <returns>Legacy query filters.</returns> + [HttpGet("/Items/Filters")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( + [FromQuery] Guid? userId, + [FromQuery] string? parentId, + [FromQuery] string? includeItemTypes, + [FromQuery] string? mediaTypes) + { + var parentItem = string.IsNullOrEmpty(parentId) + ? null + : _libraryManager.GetItemById(parentId); + + var user = userId == null || userId == Guid.Empty + ? null + : _userManager.GetUserById(userId.Value); + + if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + { + parentItem = null; + } + + var item = string.IsNullOrEmpty(parentId) + ? user == null + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder() + : parentItem; + + var query = new InternalItemsQuery + { + User = user, + MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + Recursive = true, + EnableTotalRecordCount = false, + DtoOptions = new DtoOptions + { + Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + EnableImages = false, + EnableUserData = false + } + }; + + var itemList = ((Folder)item!).GetItemList(query); + return new QueryFiltersLegacy + { + Years = itemList.Select(i => i.ProductionYear ?? -1) + .Where(i => i > 0) + .Distinct() + .OrderBy(i => i) + .ToArray(), + + Genres = itemList.SelectMany(i => i.Genres) + .DistinctNames() + .OrderBy(i => i) + .ToArray(), + + Tags = itemList + .SelectMany(i => i.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) + .ToArray(), + + OfficialRatings = itemList + .Select(i => i.OfficialRating) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) + .ToArray() + }; + } + + /// <summary> + /// Gets query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">[Unused] Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="isAiring">Optional. Is item airing.</param> + /// <param name="isMovie">Optional. Is item movie.</param> + /// <param name="isSports">Optional. Is item sports.</param> + /// <param name="isKids">Optional. Is item kids.</param> + /// <param name="isNews">Optional. Is item news.</param> + /// <param name="isSeries">Optional. Is item series.</param> + /// <param name="recursive">Optional. Search recursive.</param> + /// <response code="200">Filters retrieved.</response> + /// <returns>Query filters.</returns> + [HttpGet("/Items/Filters2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFilters> GetQueryFilters( + [FromQuery] Guid? userId, + [FromQuery] string? parentId, + [FromQuery] string? includeItemTypes, + [FromQuery] string? mediaTypes, + [FromQuery] bool? isAiring, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSports, + [FromQuery] bool? isKids, + [FromQuery] bool? isNews, + [FromQuery] bool? isSeries, + [FromQuery] bool? recursive) + { + var parentItem = string.IsNullOrEmpty(parentId) + ? null + : _libraryManager.GetItemById(parentId); + + var user = userId == null || userId == Guid.Empty + ? null + : _userManager.GetUserById(userId.Value); + + if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + { + parentItem = null; + } + + var filters = new QueryFilters(); + var genreQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = + (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) + { + genreQuery.AncestorIds = parentItem == null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + } + else + { + genreQuery.Parent = parentItem; + } + + if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) + { + filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item1.Name, + Id = i.Item1.Id + }).ToArray(); + } + else + { + filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item1.Name, + Id = i.Item1.Id + }).ToArray(); + } + + return filters; + } + } +} diff --git a/Jellyfin.Api/Controllers/Images/RemoteImageController.cs b/Jellyfin.Api/Controllers/Images/RemoteImageController.cs new file mode 100644 index 000000000..7c5f17e9e --- /dev/null +++ b/Jellyfin.Api/Controllers/Images/RemoteImageController.cs @@ -0,0 +1,267 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers.Images +{ + /// <summary> + /// Remote Images Controller. + /// </summary> + [Route("Images")] + [Authorize] + public class RemoteImageController : BaseJellyfinApiController + { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _applicationPaths; + private readonly IHttpClient _httpClient; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RemoteImageController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public RemoteImageController( + IProviderManager providerManager, + IServerApplicationPaths applicationPaths, + IHttpClient httpClient, + ILibraryManager libraryManager) + { + _providerManager = providerManager; + _applicationPaths = applicationPaths; + _httpClient = httpClient; + _libraryManager = libraryManager; + } + + /// <summary> + /// Gets available remote images for an item. + /// </summary> + /// <param name="id">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="providerName">Optional. The image provider to use.</param> + /// <param name="includeAllLanguages">Optional. Include all languages.</param> + /// <response code="200">Remote Images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Remote Image Result.</returns> + [HttpGet("{Id}/RemoteImages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( + [FromRoute] string id, + [FromQuery] ImageType? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string providerName, + [FromQuery] bool includeAllLanguages) + { + var item = _libraryManager.GetItemById(id); + if (item == null) + { + return NotFound(); + } + + var images = await _providerManager.GetAvailableRemoteImages( + item, + new RemoteImageQuery(providerName) + { + IncludeAllLanguages = includeAllLanguages, + IncludeDisabledProviders = true, + ImageType = type + }, CancellationToken.None) + .ConfigureAwait(false); + + var imageArray = images.ToArray(); + var allProviders = _providerManager.GetRemoteImageProviderInfo(item); + if (type.HasValue) + { + allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); + } + + var result = new RemoteImageResult + { + TotalRecordCount = imageArray.Length, + Providers = allProviders.Select(o => o.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + + if (startIndex.HasValue) + { + imageArray = imageArray.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + imageArray = imageArray.Take(limit.Value).ToArray(); + } + + result.Images = imageArray; + return result; + } + + /// <summary> + /// Gets available remote image providers for an item. + /// </summary> + /// <param name="id">Item Id.</param> + /// <response code="200">Returned remote image providers.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of remote image providers.</returns> + [HttpGet("{Id}/RemoteImages/Providers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] string id) + { + var item = _libraryManager.GetItemById(id); + if (item == null) + { + return NotFound(); + } + + return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + } + + /// <summary> + /// Gets a remote image. + /// </summary> + /// <param name="imageUrl">The image url.</param> + /// <response code="200">Remote image returned.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Image Stream.</returns> + [HttpGet("Remote")] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<FileStreamResult>> GetRemoteImage([FromQuery, BindRequired] string imageUrl) + { + var urlHash = imageUrl.GetMD5(); + var pointerCachePath = GetFullCachePath(urlHash.ToString()); + + string? contentPath = null; + var hasFile = false; + + try + { + contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + if (System.IO.File.Exists(contentPath)) + { + hasFile = true; + } + } + catch (FileNotFoundException) + { + // The file isn't cached yet + } + catch (IOException) + { + // The file isn't cached yet + } + + if (!hasFile) + { + await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); + contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(contentPath)) + { + return NotFound(); + } + + var contentType = MimeTypes.GetMimeType(contentPath); + return File(System.IO.File.OpenRead(contentPath), contentType); + } + + /// <summary> + /// Downloads a remote image for an item. + /// </summary> + /// <param name="id">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="imageUrl">The image url.</param> + /// <response code="200">Remote image downloaded.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Download status.</returns> + [HttpPost("{Id}/RemoteImages/Download")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DownloadRemoteImage( + [FromRoute] string id, + [FromQuery, BindRequired] ImageType type, + [FromQuery] string imageUrl) + { + var item = _libraryManager.GetItemById(id); + if (item == null) + { + return NotFound(); + } + + await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) + .ConfigureAwait(false); + + item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); + return Ok(); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + { + return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + } + + /// <summary> + /// Downloads the image. + /// </summary> + /// <param name="url">The URL.</param> + /// <param name="urlHash">The URL hash.</param> + /// <param name="pointerCachePath">The pointer cache path.</param> + /// <returns>Task.</returns> + private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath) + { + using var result = await _httpClient.GetResponse(new HttpRequestOptions + { + Url = url, + BufferContent = false + }).ConfigureAwait(false); + var ext = result.ContentType.Split('/').Last(); + + var fullCachePath = GetFullCachePath(urlHash + "." + ext); + + Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + await using (var stream = result.Content) + { + await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None) + .ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs new file mode 100644 index 000000000..d9b8357d2 --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -0,0 +1,89 @@ +#nullable enable +#pragma warning disable CA1801 + +using System.ComponentModel; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Item Refresh Controller. + /// </summary> + /// [Authenticated] + [Route("/Items")] + [Authorize] + public class ItemRefreshController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + public ItemRefreshController( + ILibraryManager libraryManager, + IProviderManager providerManager, + IFileSystem fileSystem) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + } + + /// <summary> + /// Refreshes metadata for an item. + /// </summary> + /// <param name="id">Item id.</param> + /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> + /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> + /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> + /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> + /// <param name="recursive">(Unused) Indicates if the refresh should occur recursively.</param> + /// <response code="200">Item metadata refresh queued.</response> + /// <response code="404">Item to refresh not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("{Id}/Refresh")] + [Description("Refreshes metadata for an item.")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult Post( + [FromRoute] string id, + [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, + [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, + [FromQuery] bool replaceAllMetadata = false, + [FromQuery] bool replaceAllImages = false, + [FromQuery] bool recursive = false) + { + var item = _libraryManager.GetItemById(id); + if (item == null) + { + return NotFound(); + } + + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = metadataRefreshMode, + ImageRefreshMode = imageRefreshMode, + ReplaceAllImages = replaceAllImages, + ReplaceAllMetadata = replaceAllMetadata, + ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh + || imageRefreshMode == MetadataRefreshMode.FullRefresh + || replaceAllImages + || replaceAllMetadata, + IsAutomated = false + }; + + _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); + return Ok(); + } + } +} diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs new file mode 100644 index 000000000..1466dd3ec --- /dev/null +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using Jellyfin.Api.Constants; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Localization controller. + /// </summary> + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + public class LocalizationController : BaseJellyfinApiController + { + private readonly ILocalizationManager _localization; + + /// <summary> + /// Initializes a new instance of the <see cref="LocalizationController"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public LocalizationController(ILocalizationManager localization) + { + _localization = localization; + } + + /// <summary> + /// Gets known cultures. + /// </summary> + /// <response code="200">Known cultures returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> + [HttpGet("Cultures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CultureDto>> GetCultures() + { + return Ok(_localization.GetCultures()); + } + + /// <summary> + /// Gets known countries. + /// </summary> + /// <response code="200">Known countries returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> + [HttpGet("Countries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CountryInfo>> GetCountries() + { + return Ok(_localization.GetCountries()); + } + + /// <summary> + /// Gets known parental ratings. + /// </summary> + /// <response code="200">Known parental ratings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> + [HttpGet("ParentalRatings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() + { + return Ok(_localization.GetParentalRatings()); + } + + /// <summary> + /// Gets localization options. + /// </summary> + /// <response code="200">Localization options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() + { + return Ok(_localization.GetLocalizationOptions()); + } + } +} diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index 8d82ca10f..a76675d5a 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using Jellyfin.Api.Models.NotificationDtos; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Notifications; using MediaBrowser.Model.Dto; @@ -99,10 +100,10 @@ namespace Jellyfin.Api.Controllers /// <param name="description">The description of the notification.</param> /// <param name="url">The URL of the notification.</param> /// <param name="level">The level of the notification.</param> - /// <response code="200">Notification sent.</response> - /// <returns>An <cref see="OkResult"/>.</returns> + /// <response code="204">Notification sent.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("Admin")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CreateAdminNotification( [FromQuery] string name, [FromQuery] string description, @@ -115,13 +116,16 @@ namespace Jellyfin.Api.Controllers Description = description, Url = url, Level = level ?? NotificationLevel.Normal, - UserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToArray(), + UserIds = _userManager.Users + .Where(user => user.HasPermission(PermissionKind.IsAdministrator)) + .Select(user => user.Id) + .ToArray(), Date = DateTime.UtcNow, }; _notificationManager.SendNotification(notification, CancellationToken.None); - return Ok(); + return NoContent(); } /// <summary> @@ -129,15 +133,15 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">The userID.</param> /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as read.</param> - /// <response code="200">Notifications set as read.</response> - /// <returns>An <cref see="OkResult"/>.</returns> + /// <response code="204">Notifications set as read.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("{UserID}/Read")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRead( [FromRoute] string userId, [FromQuery] string ids) { - return Ok(); + return NoContent(); } /// <summary> @@ -145,15 +149,15 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">The userID.</param> /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as unread.</param> - /// <response code="200">Notifications set as unread.</response> - /// <returns>An <cref see="OkResult"/>.</returns> + /// <response code="204">Notifications set as unread.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("{UserID}/Unread")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetUnread( [FromRoute] string userId, [FromQuery] string ids) { - return Ok(); + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index f37319c19..8200f891c 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -72,11 +72,11 @@ namespace Jellyfin.Api.Controllers /// <param name="name">Package name.</param> /// <param name="assemblyGuid">GUID of the associated assembly.</param> /// <param name="version">Optional version. Defaults to latest version.</param> - /// <response code="200">Package found.</response> + /// <response code="204">Package found.</response> /// <response code="404">Package not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> [HttpPost("/Installed/{Name}")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.RequiresElevation)] public async Task<ActionResult> InstallPackage( @@ -98,23 +98,24 @@ namespace Jellyfin.Api.Controllers await _installationManager.InstallPackage(package).ConfigureAwait(false); - return Ok(); + return NoContent(); } /// <summary> /// Cancels a package installation. /// </summary> /// <param name="id">Installation Id.</param> - /// <response code="200">Installation cancelled.</response> - /// <returns>An <see cref="OkResult"/> on successfully cancelling a package installation.</returns> + /// <response code="204">Installation cancelled.</response> + /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> [HttpDelete("/Installing/{id}")] [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public IActionResult CancelPackageInstallation( [FromRoute] [Required] string id) { _installationManager.CancelInstallation(new Guid(id)); - return Ok(); + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs new file mode 100644 index 000000000..59196a41a --- /dev/null +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -0,0 +1,189 @@ +#nullable enable +#pragma warning disable CA1801 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.PluginDtos; +using MediaBrowser.Common; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Plugins controller. + /// </summary> + [Authorize] + public class PluginsController : BaseJellyfinApiController + { + private readonly IApplicationHost _appHost; + private readonly IInstallationManager _installationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PluginsController"/> class. + /// </summary> + /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param> + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + public PluginsController( + IApplicationHost appHost, + IInstallationManager installationManager) + { + _appHost = appHost; + _installationManager = installationManager; + } + + /// <summary> + /// Gets a list of currently installed plugins. + /// </summary> + /// <param name="isAppStoreEnabled">Optional. Unused.</param> + /// <response code="200">Installed plugins returned.</response> + /// <returns>List of currently installed plugins.</returns> + [HttpGet] + public ActionResult<IEnumerable<PluginInfo>> GetPlugins([FromRoute] bool? isAppStoreEnabled) + { + return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo())); + } + + /// <summary> + /// Uninstalls a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpDelete("{pluginId}")] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult UninstallPlugin([FromRoute] Guid pluginId) + { + var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId); + if (plugin == null) + { + return NotFound(); + } + + _installationManager.UninstallPlugin(plugin); + return Ok(); + } + + /// <summary> + /// Gets plugin configuration. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin configuration returned.</response> + /// <response code="404">Plugin not found or plugin configuration not found.</response> + /// <returns>Plugin configuration.</returns> + [HttpGet("{pluginId}/Configuration")] + public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId) + { + if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + { + return NotFound(); + } + + return plugin.Configuration; + } + + /// <summary> + /// Updates plugin configuration. + /// </summary> + /// <remarks> + /// Accepts plugin configuration as JSON body. + /// </remarks> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin configuration updated.</response> + /// <response code="200">Plugin not found or plugin does not have configuration.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration. + /// The task result contains an <see cref="OkResult"/> indicating success, or <see cref="NotFoundResult"/> + /// when plugin not found or plugin doesn't have configuration. + /// </returns> + [HttpPost("{pluginId}/Configuration")] + public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId) + { + if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + { + return NotFound(); + } + + var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType) + .ConfigureAwait(false); + + plugin.UpdateConfiguration(configuration); + return Ok(); + } + + /// <summary> + /// Get plugin security info. + /// </summary> + /// <response code="200">Plugin security info returned.</response> + /// <returns>Plugin security info.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpGet("SecurityInfo")] + public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() + { + return new PluginSecurityInfo + { + IsMbSupporter = true, + SupporterKey = "IAmTotallyLegit" + }; + } + + /// <summary> + /// Updates plugin security info. + /// </summary> + /// <param name="pluginSecurityInfo">Plugin security info.</param> + /// <response code="200">Plugin security info updated.</response> + /// <returns>An <see cref="OkResult"/>.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpPost("SecurityInfo")] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult UpdatePluginSecurityInfo([FromBody, BindRequired] PluginSecurityInfo pluginSecurityInfo) + { + return Ok(); + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="200">Registration status returned.</response> + /// <returns>Mb registration record.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpPost("RegistrationRecords/{name}")] + public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name) + { + return new MBRegistrationRecord + { + IsRegistered = true, + RegChecked = true, + TrialVersion = false, + IsValid = true, + RegError = false + }; + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="501">Not implemented.</response> + /// <returns>Not Implemented.</returns> + /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception> + [Obsolete("Paid plugins are not supported")] + [HttpGet("/Registrations/{name}")] + public ActionResult GetRegistration([FromRoute] string name) + { + // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, + // delete all these registration endpoints. They are only kept for compatibility. + throw new NotImplementedException(); + } + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 57a02e62a..d96b0f993 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -33,16 +33,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Completes the startup wizard. /// </summary> - /// <response code="200">Startup wizard completed.</response> - /// <returns>An <see cref="OkResult"/> indicating success.</returns> + /// <response code="204">Startup wizard completed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Complete")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CompleteWizard() { _config.Configuration.IsStartupWizardCompleted = true; _config.SetOptimalValues(); _config.SaveConfiguration(); - return Ok(); + return NoContent(); } /// <summary> @@ -70,10 +70,10 @@ namespace Jellyfin.Api.Controllers /// <param name="uiCulture">The UI language culture.</param> /// <param name="metadataCountryCode">The metadata country code.</param> /// <param name="preferredMetadataLanguage">The preferred language for metadata.</param> - /// <response code="200">Configuration saved.</response> - /// <returns>An <see cref="OkResult"/> indicating success.</returns> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateInitialConfiguration( [FromForm] string uiCulture, [FromForm] string metadataCountryCode, @@ -83,7 +83,7 @@ namespace Jellyfin.Api.Controllers _config.Configuration.MetadataCountryCode = metadataCountryCode; _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage; _config.SaveConfiguration(); - return Ok(); + return NoContent(); } /// <summary> @@ -91,16 +91,16 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="enableRemoteAccess">Enable remote access.</param> /// <param name="enableAutomaticPortMapping">Enable UPnP.</param> - /// <response code="200">Configuration saved.</response> - /// <returns>An <see cref="OkResult"/> indicating success.</returns> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoteAccess")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) { _config.Configuration.EnableRemoteAccess = enableRemoteAccess; _config.Configuration.EnableUPnP = enableAutomaticPortMapping; _config.SaveConfiguration(); - return Ok(); + return NoContent(); } /// <summary> @@ -113,35 +113,41 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<StartupUserDto> GetFirstUser() { + // TODO: Remove this method when startup wizard no longer requires an existing user. + _userManager.Initialize(); var user = _userManager.Users.First(); - return new StartupUserDto { Name = user.Name, Password = user.Password }; + return new StartupUserDto + { + Name = user.Username, + Password = user.Password + }; } /// <summary> /// Sets the user name and password. /// </summary> /// <param name="startupUserDto">The DTO containing username and password.</param> - /// <response code="200">Updated user name and password.</response> + /// <response code="204">Updated user name and password.</response> /// <returns> /// A <see cref="Task" /> that represents the asynchronous update operation. - /// The task result contains an <see cref="OkResult"/> indicating success. + /// The task result contains a <see cref="NoContentResult"/> indicating success. /// </returns> [HttpPost("User")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateUser([FromForm] StartupUserDto startupUserDto) { var user = _userManager.Users.First(); - user.Name = startupUserDto.Name; + user.Username = startupUserDto.Name; - _userManager.UpdateUser(user); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); if (!string.IsNullOrEmpty(startupUserDto.Password)) { await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } - return Ok(); + return NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs new file mode 100644 index 000000000..97df8c60d --- /dev/null +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -0,0 +1,349 @@ +#nullable enable +#pragma warning disable CA1801 + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Subtitle controller. + /// </summary> + public class SubtitleController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly ISubtitleManager _subtitleManager; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IAuthorizationContext _authContext; + private readonly ILogger<SubtitleController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> + /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> + public SubtitleController( + ILibraryManager libraryManager, + ISubtitleManager subtitleManager, + ISubtitleEncoder subtitleEncoder, + IMediaSourceManager mediaSourceManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IAuthorizationContext authContext, + ILogger<SubtitleController> logger) + { + _libraryManager = libraryManager; + _subtitleManager = subtitleManager; + _subtitleEncoder = subtitleEncoder; + _mediaSourceManager = mediaSourceManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _authContext = authContext; + _logger = logger; + } + + /// <summary> + /// Deletes an external subtitle file. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="index">The index of the subtitle file.</param> + /// <response code="204">Subtitle deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("/Videos/{id}/Subtitles/{index}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<Task> DeleteSubtitle( + [FromRoute] Guid id, + [FromRoute] int index) + { + var item = _libraryManager.GetItemById(id); + + if (item == null) + { + return NotFound(); + } + + _subtitleManager.DeleteSubtitles(item, index); + return NoContent(); + } + + /// <summary> + /// Search remote subtitles. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="language">The language of the subtitles.</param> + /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> + /// <response code="200">Subtitles retrieved.</response> + /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> + [HttpGet("/Items/{id}/RemoteSearch/Subtitles/{language}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( + [FromRoute] Guid id, + [FromRoute] string language, + [FromQuery] bool? isPerfectMatch) + { + var video = (Video)_libraryManager.GetItemById(id); + + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Downloads a remote subtitle. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="subtitleId">The subtitle id.</param> + /// <response code="204">Subtitle downloaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DownloadRemoteSubtitles( + [FromRoute] Guid id, + [FromRoute] string subtitleId) + { + var video = (Video)_libraryManager.GetItemById(id); + + try + { + await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + .ConfigureAwait(false); + + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading subtitles"); + } + + return NoContent(); + } + + /// <summary> + /// Gets the remote subtitles. + /// </summary> + /// <param name="id">The item id.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> + [HttpGet("/Providers/Subtitles/Subtitles/{id}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Application.Octet)] + public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id) + { + var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + + return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + } + + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] + [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetSubtitle( + [FromRoute, Required] Guid id, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index, + [FromRoute, Required] string format, + [FromRoute] long startPositionTicks, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps, + [FromQuery] bool addVttTimeMap) + { + if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) + { + format = "json"; + } + + if (string.IsNullOrEmpty(format)) + { + var item = (Video)_libraryManager.GetItemById(id); + + var idString = id.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); + + var subtitleStream = mediaSource.MediaStreams + .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + + FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read); + return File(stream, MimeTypes.GetMimeType(subtitleStream.Path)); + } + + if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + { + await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + using var reader = new StreamReader(stream); + + var text = await reader.ReadToEndAsync().ConfigureAwait(false); + + text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + + return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); + } + + return File( + await EncodeSubtitles( + id, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks, + copyTimestamps).ConfigureAwait(false), + MimeTypes.GetMimeType("file." + format)); + } + + /// <summary> + /// Gets an HLS subtitle playlist. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="segmentLength">The subtitle segment length.</param> + /// <response code="200">Subtitle playlist retrieved.</response> + /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> + [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult> GetSubtitlePlaylist( + [FromRoute] Guid id, + // TODO: 'int index' is never used: CA1801 is disabled + [FromRoute] int index, + [FromRoute] string mediaSourceId, + [FromQuery, Required] int segmentLength) + { + var item = (Video)_libraryManager.GetItemById(id); + + var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); + + var builder = new StringBuilder(); + + var runtime = mediaSource.RunTimeTicks ?? -1; + + if (runtime <= 0) + { + throw new ArgumentException("HLS Subtitles are not supported for this media."); + } + + var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; + if (segmentLengthTicks <= 0) + { + throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); + } + + builder.AppendLine("#EXTM3U"); + builder.AppendLine("#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine("#EXT-X-VERSION:3"); + builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); + builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + + long positionTicks = 0; + + var accessToken = _authContext.GetAuthorizationInfo(Request).Token; + + while (positionTicks < runtime) + { + var remaining = runtime - positionTicks; + var lengthTicks = Math.Min(remaining, segmentLengthTicks); + + builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ","); + + var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); + + var url = string.Format( + CultureInfo.CurrentCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken); + + builder.AppendLine(url); + + positionTicks += segmentLengthTicks; + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + /// <summary> + /// Encodes a subtitle in the specified format. + /// </summary> + /// <param name="id">The media id.</param> + /// <param name="mediaSourceId">The source media id.</param> + /// <param name="index">The subtitle index.</param> + /// <param name="format">The format to convert to.</param> + /// <param name="startPositionTicks">The start position in ticks.</param> + /// <param name="endPositionTicks">The end position in ticks.</param> + /// <param name="copyTimestamps">Whether to copy the timestamps.</param> + /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> + private Task<Stream> EncodeSubtitles( + Guid id, + string mediaSourceId, + int index, + string format, + long startPositionTicks, + long? endPositionTicks, + bool copyTimestamps) + { + var item = _libraryManager.GetItemById(id); + + return _subtitleEncoder.GetSubtitles( + item, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks ?? 0, + copyTimestamps, + CancellationToken.None); + } + } +} diff --git a/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs new file mode 100644 index 000000000..aaaf54267 --- /dev/null +++ b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs @@ -0,0 +1,42 @@ +#nullable enable + +using System; + +namespace Jellyfin.Api.Models.PluginDtos +{ + /// <summary> + /// MB Registration Record. + /// </summary> + public class MBRegistrationRecord + { + /// <summary> + /// Gets or sets expiration date. + /// </summary> + public DateTime ExpirationDate { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is registered. + /// </summary> + public bool IsRegistered { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether reg checked. + /// </summary> + public bool RegChecked { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether reg error. + /// </summary> + public bool RegError { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether trial version. + /// </summary> + public bool TrialVersion { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is valid. + /// </summary> + public bool IsValid { get; set; } + } +} diff --git a/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs new file mode 100644 index 000000000..793002a6c --- /dev/null +++ b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs @@ -0,0 +1,20 @@ +#nullable enable + +namespace Jellyfin.Api.Models.PluginDtos +{ + /// <summary> + /// Plugin security info. + /// </summary> + public class PluginSecurityInfo + { + /// <summary> + /// Gets or sets the supporter key. + /// </summary> + public string? SupporterKey { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is mb supporter. + /// </summary> + public bool IsMbSupporter { get; set; } + } +} |
