diff options
Diffstat (limited to 'Jellyfin.Api/Controllers')
17 files changed, 2862 insertions, 17 deletions
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs new file mode 100644 index 000000000..4ae7cf506 --- /dev/null +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -0,0 +1,56 @@ +#pragma warning disable CA1801 + +using System; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Activity log controller. + /// </summary> + [Route("/System/ActivityLog")] + [Authorize(Policy = Policies.RequiresElevation)] + public class ActivityLogController : BaseJellyfinApiController + { + private readonly IActivityManager _activityManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogController"/> class. + /// </summary> + /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param> + public ActivityLogController(IActivityManager activityManager) + { + _activityManager = activityManager; + } + + /// <summary> + /// Gets activity log entries. + /// </summary> + /// <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="minDate">Optional. The minimum date. Format = ISO.</param> + /// <param name="hasUserId">Optional. Only returns activities that have a user associated.</param> + /// <response code="200">Activity log returned.</response> + /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> + [HttpGet("Entries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + bool? hasUserId) + { + var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>( + entries => entries.Where(entry => entry.DateCreated >= minDate)); + + return _activityManager.GetPagedResult(filterFunc, startIndex, limit); + } + } +} diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs new file mode 100644 index 000000000..ed521c1fc --- /dev/null +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -0,0 +1,97 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Authentication controller. + /// </summary> + [Route("/Auth")] + public class ApiKeyController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IServerApplicationHost _appHost; + private readonly IAuthenticationRepository _authRepo; + + /// <summary> + /// Initializes a new instance of the <see cref="ApiKeyController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param> + public ApiKeyController( + ISessionManager sessionManager, + IServerApplicationHost appHost, + IAuthenticationRepository authRepo) + { + _sessionManager = sessionManager; + _appHost = appHost; + _authRepo = authRepo; + } + + /// <summary> + /// Get all keys. + /// </summary> + /// <response code="200">Api keys retrieved.</response> + /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> + [HttpGet("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<AuthenticationInfo>> GetKeys() + { + var result = _authRepo.Get(new AuthenticationInfoQuery + { + HasUser = false + }); + + return result; + } + + /// <summary> + /// Create a new api key. + /// </summary> + /// <param name="app">Name of the app using the authentication key.</param> + /// <response code="204">Api key created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateKey([FromQuery, Required] string app) + { + _authRepo.Create(new AuthenticationInfo + { + AppName = app, + AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + DateCreated = DateTime.UtcNow, + DeviceId = _appHost.SystemId, + DeviceName = _appHost.FriendlyName, + AppVersion = _appHost.ApplicationVersionString + }); + return NoContent(); + } + + /// <summary> + /// Remove an api key. + /// </summary> + /// <param name="key">The access token to delete.</param> + /// <response code="204">Api key deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Keys/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RevokeKey([FromRoute] string key) + { + _sessionManager.RevokeToken(key); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs new file mode 100644 index 000000000..74f1677bd --- /dev/null +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.ConfigurationDtos; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Configuration Controller. + /// </summary> + [Route("System")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ConfigurationController : BaseJellyfinApiController + { + private readonly IServerConfigurationManager _configurationManager; + private readonly IMediaEncoder _mediaEncoder; + + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationController"/> class. + /// </summary> + /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public ConfigurationController( + IServerConfigurationManager configurationManager, + IMediaEncoder mediaEncoder) + { + _configurationManager = configurationManager; + _mediaEncoder = mediaEncoder; + } + + /// <summary> + /// Gets application configuration. + /// </summary> + /// <response code="200">Application configuration returned.</response> + /// <returns>Application configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ServerConfiguration> GetConfiguration() + { + return _configurationManager.Configuration; + } + + /// <summary> + /// Updates application configuration. + /// </summary> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration) + { + _configurationManager.ReplaceConfiguration(configuration); + return NoContent(); + } + + /// <summary> + /// Gets a named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="200">Configuration returned.</response> + /// <returns>Configuration.</returns> + [HttpGet("Configuration/{Key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<object> GetNamedConfiguration([FromRoute] string key) + { + return _configurationManager.GetConfiguration(key); + } + + /// <summary> + /// Updates named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="204">Named configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration/{Key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [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 NoContent(); + } + + /// <summary> + /// Gets a default MetadataOptions object. + /// </summary> + /// <response code="200">Metadata options returned.</response> + /// <returns>Default MetadataOptions.</returns> + [HttpGet("Configuration/MetadataOptions/Default")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<MetadataOptions> GetDefaultMetadataOptions() + { + return new MetadataOptions(); + } + + /// <summary> + /// Updates the path to the media encoder. + /// </summary> + /// <param name="mediaEncoderPath">Media encoder path form body.</param> + /// <response code="204">Media encoder path updated.</response> + /// <returns>Status.</returns> + [HttpPost("MediaEncoder/Path")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath) + { + _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs new file mode 100644 index 000000000..78368eed6 --- /dev/null +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -0,0 +1,154 @@ +using System; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Devices Controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class DevicesController : BaseJellyfinApiController + { + private readonly IDeviceManager _deviceManager; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly ISessionManager _sessionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DevicesController"/> class. + /// </summary> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + public DevicesController( + IDeviceManager deviceManager, + IAuthenticationRepository authenticationRepository, + ISessionManager sessionManager) + { + _deviceManager = deviceManager; + _authenticationRepository = authenticationRepository; + _sessionManager = sessionManager; + } + + /// <summary> + /// Get Devices. + /// </summary> + /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> + /// <param name="userId">Gets or sets the user identifier.</param> + /// <response code="200">Devices retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> + [HttpGet] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + { + var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty }; + return _deviceManager.GetDevices(deviceQuery); + } + + /// <summary> + /// Get info for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device info retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Info")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id) + { + var deviceInfo = _deviceManager.GetDevice(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// <summary> + /// Get options for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device options retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Options")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id) + { + var deviceInfo = _deviceManager.GetDeviceOptions(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// <summary> + /// Update device options. + /// </summary> + /// <param name="id">Device Id.</param> + /// <param name="deviceOptions">Device Options.</param> + /// <response code="204">Device options updated.</response> + /// <response code="404">Device not found.</response> + /// <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.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateDeviceOptions( + [FromQuery, BindRequired] string id, + [FromBody, BindRequired] DeviceOptions deviceOptions) + { + var existingDeviceOptions = _deviceManager.GetDeviceOptions(id); + if (existingDeviceOptions == null) + { + return NotFound(); + } + + _deviceManager.UpdateDeviceOptions(id, deviceOptions); + return NoContent(); + } + + /// <summary> + /// Deletes a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="204">Device deleted.</response> + /// <response code="404">Device not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteDevice([FromQuery, BindRequired] string id) + { + var existingDevice = _deviceManager.GetDevice(id); + if (existingDevice == null) + { + return NotFound(); + } + + var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items; + + foreach (var session in sessions) + { + _sessionManager.Logout(session); + } + + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs new file mode 100644 index 000000000..6a6e6a64a --- /dev/null +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -0,0 +1,220 @@ +#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/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs new file mode 100644 index 000000000..70f46ffa4 --- /dev/null +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mime; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Images By Name Controller. + /// </summary> + [Route("Images")] + public class ImageByNameController : BaseJellyfinApiController + { + private readonly IServerApplicationPaths _applicationPaths; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="ImageByNameController" /> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager" /> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem" /> interface.</param> + public ImageByNameController( + IServerConfigurationManager serverConfigurationManager, + IFileSystem fileSystem) + { + _applicationPaths = serverConfigurationManager.ApplicationPaths; + _fileSystem = fileSystem; + } + + /// <summary> + /// Get all general images. + /// </summary> + /// <response code="200">Retrieved list of images.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("General")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages() + { + return GetImageList(_applicationPaths.GeneralPath, false); + } + + /// <summary> + /// Get General Image. + /// </summary> + /// <param name="name">The name of the image.</param> + /// <param name="type">Image Type (primary, backdrop, logo, etc).</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("General/{Name}/{Type}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type) + { + var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase) + ? "folder" + : type; + + var path = BaseItem.SupportedImageExtensions + .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (path == null) + { + return NotFound(); + } + + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + + /// <summary> + /// Get all general images. + /// </summary> + /// <response code="200">Retrieved list of images.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("Ratings")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages() + { + return GetImageList(_applicationPaths.RatingsPath, false); + } + + /// <summary> + /// Get rating image. + /// </summary> + /// <param name="theme">The theme to get the image from.</param> + /// <param name="name">The name of the image.</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("Ratings/{Theme}/{Name}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetRatingImage( + [FromRoute] string theme, + [FromRoute] string name) + { + return GetImageFile(_applicationPaths.RatingsPath, theme, name); + } + + /// <summary> + /// Get all media info images. + /// </summary> + /// <response code="200">Image list retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> + [HttpGet("MediaInfo")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages() + { + return GetImageList(_applicationPaths.MediaInfoImagesPath, false); + } + + /// <summary> + /// Get media info image. + /// </summary> + /// <param name="theme">The theme to get the image from.</param> + /// <param name="name">The name of the image.</param> + /// <response code="200">Image stream retrieved.</response> + /// <response code="404">Image not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + [HttpGet("MediaInfo/{Theme}/{Name}")] + [AllowAnonymous] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<FileStreamResult> GetMediaInfoImage( + [FromRoute] string theme, + [FromRoute] string name) + { + return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name); + } + + /// <summary> + /// Internal FileHelper. + /// </summary> + /// <param name="basePath">Path to begin search.</param> + /// <param name="theme">Theme to search.</param> + /// <param name="name">File name to search for.</param> + /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> + private ActionResult<FileStreamResult> GetImageFile(string basePath, string theme, string name) + { + var themeFolder = Path.Combine(basePath, theme); + if (Directory.Exists(themeFolder)) + { + var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) + { + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + } + + var allFolder = Path.Combine(basePath, "all"); + if (Directory.Exists(allFolder)) + { + var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i)) + .FirstOrDefault(System.IO.File.Exists); + + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) + { + var contentType = MimeTypes.GetMimeType(path); + return File(System.IO.File.OpenRead(path), contentType); + } + } + + return NotFound(); + } + + private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes) + { + try + { + return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true) + .Select(i => new ImageByNameInfo + { + Name = _fileSystem.GetFileNameWithoutExtension(i), + FileLength = i.Length, + + // For themeable images, use the Theme property + // For general images, the same object structure is fine, + // but it's not owned by a theme, so call it Context + Theme = supportsThemes ? GetThemeName(i.FullName, path) : null, + Context = supportsThemes ? null : GetThemeName(i.FullName, path), + Format = i.Extension.ToLowerInvariant().TrimStart('.') + }) + .OrderBy(i => i.Name) + .ToList(); + } + catch (IOException) + { + return new List<ImageByNameInfo>(); + } + } + + private string? GetThemeName(string path, string rootImagePath) + { + var parentName = Path.GetDirectoryName(path); + + if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + parentName = Path.GetFileName(parentName); + + return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? null : parentName; + } + } +} diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs new file mode 100644 index 000000000..a1df22e41 --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -0,0 +1,88 @@ +#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/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs new file mode 100644 index 000000000..ca2905b11 --- /dev/null +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -0,0 +1,341 @@ +#pragma warning disable CA1801 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The library structure controller. + /// </summary> + [Route("/Library/VirtualFolders")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + public class LibraryStructureController : BaseJellyfinApiController + { + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + + /// <summary> + /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + public LibraryStructureController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor) + { + _appPaths = serverConfigurationManager.ApplicationPaths; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + } + + /// <summary> + /// Gets all virtual folders. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">Virtual folders retrieved.</response> + /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders([FromQuery] string userId) + { + return _libraryManager.GetVirtualFolders(true); + } + + /// <summary> + /// Adds a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="collectionType">The type of the collection.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <param name="paths">The paths of the virtual folder.</param> + /// <param name="libraryOptions">The library options.</param> + /// <response code="204">Folder added.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddVirtualFolder( + [FromQuery] string name, + [FromQuery] string collectionType, + [FromQuery] bool refreshLibrary, + [FromQuery] string[] paths, + [FromQuery] LibraryOptions libraryOptions) + { + libraryOptions ??= new LibraryOptions(); + + if (paths != null && paths.Length > 0) + { + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray(); + } + + await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Removes a virtual folder. + /// </summary> + /// <param name="name">The name of the folder.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder removed.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveVirtualFolder( + [FromQuery] string name, + [FromQuery] bool refreshLibrary) + { + await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Renames a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="newName">The new name.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder renamed.</response> + /// <response code="404">Library doesn't exist.</response> + /// <response code="409">Library already exists.</response> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <exception cref="ArgumentNullException">The new name may not be null.</exception> + [HttpPost("Name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult RenameVirtualFolder( + [FromQuery] string name, + [FromQuery] string newName, + [FromQuery] bool refreshLibrary) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(newName)) + { + throw new ArgumentNullException(nameof(newName)); + } + + var rootFolderPath = _appPaths.DefaultUserViewsPath; + + var currentPath = Path.Combine(rootFolderPath, name); + var newPath = Path.Combine(rootFolderPath, newName); + + if (!Directory.Exists(currentPath)) + { + return NotFound("The media collection does not exist."); + } + + if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + { + return Conflict($"The media library already exists at {newPath}."); + } + + _libraryMonitor.Stop(); + + try + { + // Changing capitalization. Handle windows case insensitivity + if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + var tempPath = Path.Combine( + rootFolderPath, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.Move(currentPath, tempPath); + currentPath = tempPath; + } + + Directory.Move(currentPath, newPath); + } + finally + { + CollectionFolder.OnCollectionFolderChange(); + + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Add a media path to a library. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="path">The path to add.</param> + /// <param name="pathInfo">The path info.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path added.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddMediaPath( + [FromQuery] string name, + [FromQuery] string path, + [FromQuery] MediaPathInfo pathInfo, + [FromQuery] bool refreshLibrary) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryMonitor.Stop(); + + try + { + var mediaPath = pathInfo ?? new MediaPathInfo { Path = path }; + + _libraryManager.AddMediaPath(name, mediaPath); + } + finally + { + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Updates a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="pathInfo">The path info.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path updated.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths/Update")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaPath( + [FromQuery] string name, + [FromQuery] MediaPathInfo pathInfo) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryManager.UpdateMediaPath(name, pathInfo); + return NoContent(); + } + + /// <summary> + /// Remove a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="path">The path to remove.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path removed.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpDelete("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveMediaPath( + [FromQuery] string name, + [FromQuery] string path, + [FromQuery] bool refreshLibrary) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryMonitor.Stop(); + + try + { + _libraryManager.RemoveMediaPath(name, path); + } + finally + { + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } + + /// <summary> + /// Update library options. + /// </summary> + /// <param name="id">The library name.</param> + /// <param name="libraryOptions">The library options.</param> + /// <response code="204">Library updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("LibraryOptions")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateLibraryOptions( + [FromQuery] string id, + [FromQuery] LibraryOptions libraryOptions) + { + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id); + + collectionFolder.UpdateLibraryOptions(libraryOptions); + return NoContent(); + } + } +} 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 new file mode 100644 index 000000000..a1f9b9e8f --- /dev/null +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -0,0 +1,162 @@ +#pragma warning disable CA1801 + +using System; +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; +using MediaBrowser.Model.Notifications; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The notification controller. + /// </summary> + public class NotificationsController : BaseJellyfinApiController + { + private readonly INotificationManager _notificationManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="NotificationsController" /> class. + /// </summary> + /// <param name="notificationManager">The notification manager.</param> + /// <param name="userManager">The user manager.</param> + public NotificationsController(INotificationManager notificationManager, IUserManager userManager) + { + _notificationManager = notificationManager; + _userManager = userManager; + } + + /// <summary> + /// Gets a user's notifications. + /// </summary> + /// <param name="userId">The user's ID.</param> + /// <param name="isRead">An optional filter by notification read state.</param> + /// <param name="startIndex">The optional index to start at. All notifications with a lower index will be omitted from the results.</param> + /// <param name="limit">An optional limit on the number of notifications returned.</param> + /// <response code="200">Notifications returned.</response> + /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns> + [HttpGet("{UserID}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<NotificationResultDto> GetNotifications( + [FromRoute] string userId, + [FromQuery] bool? isRead, + [FromQuery] int? startIndex, + [FromQuery] int? limit) + { + return new NotificationResultDto(); + } + + /// <summary> + /// Gets a user's notification summary. + /// </summary> + /// <param name="userId">The user's ID.</param> + /// <response code="200">Summary of user's notifications returned.</response> + /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns> + [HttpGet("{UserID}/Summary")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<NotificationsSummaryDto> GetNotificationsSummary( + [FromRoute] string userId) + { + return new NotificationsSummaryDto(); + } + + /// <summary> + /// Gets notification types. + /// </summary> + /// <response code="200">All notification types returned.</response> + /// <returns>An <cref see="OkResult"/> containing a list of all notification types.</returns> + [HttpGet("Types")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<NotificationTypeInfo> GetNotificationTypes() + { + return _notificationManager.GetNotificationTypes(); + } + + /// <summary> + /// Gets notification services. + /// </summary> + /// <response code="200">All notification services returned.</response> + /// <returns>An <cref see="OkResult"/> containing a list of all notification services.</returns> + [HttpGet("Services")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<NameIdPair> GetNotificationServices() + { + return _notificationManager.GetNotificationServices(); + } + + /// <summary> + /// Sends a notification to all admins. + /// </summary> + /// <param name="name">The name of the notification.</param> + /// <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="204">Notification sent.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("Admin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateAdminNotification( + [FromQuery] string name, + [FromQuery] string description, + [FromQuery] string? url, + [FromQuery] NotificationLevel? level) + { + var notification = new NotificationRequest + { + Name = name, + Description = description, + Url = url, + Level = level ?? NotificationLevel.Normal, + UserIds = _userManager.Users + .Where(user => user.HasPermission(PermissionKind.IsAdministrator)) + .Select(user => user.Id) + .ToArray(), + Date = DateTime.UtcNow, + }; + + _notificationManager.SendNotification(notification, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Sets notifications as read. + /// </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="204">Notifications set as read.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("{UserID}/Read")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRead( + [FromRoute] string userId, + [FromQuery] string ids) + { + return NoContent(); + } + + /// <summary> + /// Sets notifications as unread. + /// </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="204">Notifications set as unread.</response> + /// <returns>A <cref see="NoContentResult"/>.</returns> + [HttpPost("{UserID}/Unread")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetUnread( + [FromRoute] string userId, + [FromQuery] string ids) + { + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs new file mode 100644 index 000000000..943c23f8e --- /dev/null +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Updates; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Package Controller. + /// </summary> + [Route("Packages")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PackageController : BaseJellyfinApiController + { + private readonly IInstallationManager _installationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PackageController"/> class. + /// </summary> + /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>Installation Manager.</param> + public PackageController(IInstallationManager installationManager) + { + _installationManager = installationManager; + } + + /// <summary> + /// Gets a package by name or assembly GUID. + /// </summary> + /// <param name="name">The name of the package.</param> + /// <param name="assemblyGuid">The GUID of the associated assembly.</param> + /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> + [HttpGet("/{Name}")] + [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)] + public async Task<ActionResult<PackageInfo>> GetPackageInfo( + [FromRoute] [Required] string name, + [FromQuery] string? assemblyGuid) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var result = _installationManager.FilterPackages( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault(); + + return result; + } + + /// <summary> + /// Gets available packages. + /// </summary> + /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> + [HttpGet] + [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)] + public async Task<IEnumerable<PackageInfo>> GetPackages() + { + IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + + return packages; + } + + /// <summary> + /// Installs a package. + /// </summary> + /// <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="204">Package found.</response> + /// <response code="404">Package not found.</response> + /// <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.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> InstallPackage( + [FromRoute] [Required] string name, + [FromQuery] string assemblyGuid, + [FromQuery] string version) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var package = _installationManager.GetCompatibleVersions( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid), + string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault(); + + if (package == null) + { + return NotFound(); + } + + await _installationManager.InstallPackage(package).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Cancels a package installation. + /// </summary> + /// <param name="id">Installation Id.</param> + /// <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 NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs new file mode 100644 index 000000000..fdb2f4c35 --- /dev/null +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -0,0 +1,188 @@ +#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/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs new file mode 100644 index 000000000..80983ee64 --- /dev/null +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -0,0 +1,265 @@ +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 +{ + /// <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/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs new file mode 100644 index 000000000..d971889db --- /dev/null +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -0,0 +1,269 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Search; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Search controller. + /// </summary> + [Route("/Search/Hints")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class SearchController : BaseJellyfinApiController + { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + + /// <summary> + /// Initializes a new instance of the <see cref="SearchController"/> class. + /// </summary> + /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param> + public SearchController( + ISearchEngine searchEngine, + ILibraryManager libraryManager, + IDtoService dtoService, + IImageProcessor imageProcessor) + { + _searchEngine = searchEngine; + _libraryManager = libraryManager; + _dtoService = dtoService; + _imageProcessor = imageProcessor; + } + + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <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="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param> + /// <param name="searchTerm">The search term to filter on.</param> + /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimeted.</param> + /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimeted.</param> + /// <param name="parentId">If specified, only children of the parent are returned.</param> + /// <param name="isMovie">Optional filter for movies.</param> + /// <param name="isSeries">Optional filter for series.</param> + /// <param name="isNews">Optional filter for news.</param> + /// <param name="isKids">Optional filter for kids.</param> + /// <param name="isSports">Optional filter for sports.</param> + /// <param name="includePeople">Optional filter whether to include people.</param> + /// <param name="includeMedia">Optional filter whether to include media.</param> + /// <param name="includeGenres">Optional filter whether to include genres.</param> + /// <param name="includeStudios">Optional filter whether to include studios.</param> + /// <param name="includeArtists">Optional filter whether to include artists.</param> + /// <response code="200">Search hint returned.</response> + /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns> + [HttpGet] + [Description("Gets search hints based on a search term")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<SearchHintResult> Get( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] Guid userId, + [FromQuery, Required] string searchTerm, + [FromQuery] string includeItemTypes, + [FromQuery] string excludeItemTypes, + [FromQuery] string mediaTypes, + [FromQuery] string parentId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool includePeople = true, + [FromQuery] bool includeMedia = true, + [FromQuery] bool includeGenres = true, + [FromQuery] bool includeStudios = true, + [FromQuery] bool includeArtists = true) + { + var result = _searchEngine.GetSearchHints(new SearchQuery + { + Limit = limit, + SearchTerm = searchTerm, + IncludeArtists = includeArtists, + IncludeGenres = includeGenres, + IncludeMedia = includeMedia, + IncludePeople = includePeople, + IncludeStudios = includeStudios, + StartIndex = startIndex, + UserId = userId, + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ParentId = parentId, + + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); + + return new SearchHintResult + { + TotalRecordCount = result.TotalRecordCount, + SearchHints = result.Items.Select(GetSearchHintResult).ToArray() + }; + } + + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <param name="hintInfo">The hint info.</param> + /// <returns>SearchHintResult.</returns> + private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + { + var item = hintInfo.Item; + + var result = new SearchHint + { + Name = item.Name, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + Id = item.Id, + Type = item.GetClientTypeName(), + MediaType = item.MediaType, + MatchedTerm = hintInfo.MatchedTerm, + RunTimeTicks = item.RunTimeTicks, + ProductionYear = item.ProductionYear, + ChannelId = item.ChannelId, + EndDate = item.EndDate + }; + + // legacy + result.ItemId = result.Id; + + if (item.IsFolder) + { + result.IsFolder = true; + } + + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + + if (primaryImageTag != null) + { + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); + } + + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); + + switch (item) + { + case IHasSeries hasSeries: + result.Series = hasSeries.SeriesName; + break; + case LiveTvProgram program: + result.StartDate = program.StartDate; + break; + case Series series: + if (series.Status.HasValue) + { + result.Status = series.Status.Value.ToString(); + } + + break; + case MusicAlbum album: + result.Artists = album.Artists; + result.AlbumArtist = album.AlbumArtist; + break; + case Audio song: + result.AlbumArtist = song.AlbumArtists?[0]; + result.Artists = song.Artists; + + MusicAlbum musicAlbum = song.AlbumEntity; + + if (musicAlbum != null) + { + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; + } + else + { + result.Album = song.Album; + } + + break; + } + + if (!item.ChannelId.Equals(Guid.Empty)) + { + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; + } + + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage == null && item is Episode) + { + itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); + } + + if (itemWithImage == null) + { + itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb); + } + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); + + if (tag != null) + { + hint.ThumbImageTag = tag; + hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) + ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); + + if (tag != null) + { + hint.BackdropImageTag = tag; + hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private T GetParentWithImage<T>(BaseItem item, ImageType type) + where T : BaseItem + { + return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); + } + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index afc9b8f3d..d96b0f993 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -5,6 +5,7 @@ using Jellyfin.Api.Models.StartupDtos; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers @@ -30,22 +31,28 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Api endpoint for completing the startup wizard. + /// Completes the startup wizard. /// </summary> + /// <response code="204">Startup wizard completed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Complete")] - public void CompleteWizard() + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CompleteWizard() { _config.Configuration.IsStartupWizardCompleted = true; _config.SetOptimalValues(); _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for getting the initial startup wizard configuration. + /// Gets the initial startup wizard configuration. /// </summary> - /// <returns>The initial startup wizard configuration.</returns> + /// <response code="200">Initial startup wizard configuration retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> [HttpGet("Configuration")] - public StartupConfigurationDto GetStartupConfiguration() + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<StartupConfigurationDto> GetStartupConfiguration() { var result = new StartupConfigurationDto { @@ -58,13 +65,16 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Endpoint for updating the initial startup wizard configuration. + /// Sets the initial startup wizard configuration. /// </summary> /// <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="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Configuration")] - public void UpdateInitialConfiguration( + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateInitialConfiguration( [FromForm] string uiCulture, [FromForm] string metadataCountryCode, [FromForm] string preferredMetadataLanguage) @@ -73,54 +83,71 @@ namespace Jellyfin.Api.Controllers _config.Configuration.MetadataCountryCode = metadataCountryCode; _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage; _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for (dis)allowing remote access and UPnP. + /// Sets remote access and UPnP. /// </summary> /// <param name="enableRemoteAccess">Enable remote access.</param> /// <param name="enableAutomaticPortMapping">Enable UPnP.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoteAccess")] - public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) { _config.Configuration.EnableRemoteAccess = enableRemoteAccess; _config.Configuration.EnableUPnP = enableAutomaticPortMapping; _config.SaveConfiguration(); + return NoContent(); } /// <summary> - /// Endpoint for returning the first user. + /// Gets the first user. /// </summary> + /// <response code="200">Initial user retrieved.</response> /// <returns>The first user.</returns> [HttpGet("User")] - public StartupUserDto GetFirstUser() + [HttpGet("FirstUser")] + [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, + Name = user.Username, Password = user.Password }; } /// <summary> - /// Endpoint for updating the user name and password. + /// Sets the user name and password. /// </summary> /// <param name="startupUserDto">The DTO containing username and password.</param> - /// <returns>The async task.</returns> + /// <response code="204">Updated user name and password.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous update operation. + /// The task result contains a <see cref="NoContentResult"/> indicating success. + /// </returns> [HttpPost("User")] - public async Task UpdateUser([FromForm] StartupUserDto startupUserDto) + [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 NoContent(); } } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs new file mode 100644 index 000000000..69b83379d --- /dev/null +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -0,0 +1,348 @@ +#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(Policy = Policies.DefaultAuthorization)] + [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(Policy = Policies.DefaultAuthorization)] + [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(Policy = Policies.DefaultAuthorization)] + [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(Policy = Policies.DefaultAuthorization)] + [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/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs new file mode 100644 index 000000000..2528fd75d --- /dev/null +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -0,0 +1,83 @@ +using System; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Attachments controller. + /// </summary> + [Route("Videos")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public class VideoAttachmentsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> + public VideoAttachmentsController( + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) + { + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } + + /// <summary> + /// Get video attachment. + /// </summary> + /// <param name="videoId">Video ID.</param> + /// <param name="mediaSourceId">Media Source ID.</param> + /// <param name="index">Attachment Index.</param> + /// <response code="200">Attachment retrieved.</response> + /// <response code="404">Video or attachment not found.</response> + /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> + [HttpGet("{VideoID}/{MediaSourceID}/Attachments/{Index}")] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<FileStreamResult>> GetAttachment( + [FromRoute] Guid videoId, + [FromRoute] string mediaSourceId, + [FromRoute] int index) + { + try + { + var item = _libraryManager.GetItemById(videoId); + if (item == null) + { + return NotFound(); + } + + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); + + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; + + return new FileStreamResult(stream, contentType); + } + catch (ResourceNotFoundException e) + { + return NotFound(e.Message); + } + } + } +} |
