commit
cbcf3bfaff
@ -1,240 +0,0 @@
|
|||||||
#pragma warning disable CS1591
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using MediaBrowser.Common.Json;
|
|
||||||
using MediaBrowser.Controller;
|
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
using MediaBrowser.Controller.Persistence;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using SQLitePCL.pretty;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Data
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Class SQLiteUserRepository
|
|
||||||
/// </summary>
|
|
||||||
public class SqliteUserRepository : BaseSqliteRepository, IUserRepository
|
|
||||||
{
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
|
||||||
|
|
||||||
public SqliteUserRepository(
|
|
||||||
ILogger<SqliteUserRepository> logger,
|
|
||||||
IServerApplicationPaths appPaths)
|
|
||||||
: base(logger)
|
|
||||||
{
|
|
||||||
_jsonOptions = JsonDefaults.GetOptions();
|
|
||||||
|
|
||||||
DbFilePath = Path.Combine(appPaths.DataPath, "users.db");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the name of the repository
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The name.</value>
|
|
||||||
public string Name => "SQLite";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the connection to the database.
|
|
||||||
/// </summary>
|
|
||||||
public void Initialize()
|
|
||||||
{
|
|
||||||
using (var connection = GetConnection())
|
|
||||||
{
|
|
||||||
var localUsersTableExists = TableExists(connection, "LocalUsersv2");
|
|
||||||
|
|
||||||
connection.RunQueries(new[] {
|
|
||||||
"create table if not exists LocalUsersv2 (Id INTEGER PRIMARY KEY, guid GUID NOT NULL, data BLOB NOT NULL)",
|
|
||||||
"drop index if exists idx_users"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!localUsersTableExists && TableExists(connection, "Users"))
|
|
||||||
{
|
|
||||||
TryMigrateToLocalUsersTable(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoveEmptyPasswordHashes(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryMigrateToLocalUsersTable(ManagedConnection connection)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
connection.RunQueries(new[]
|
|
||||||
{
|
|
||||||
"INSERT INTO LocalUsersv2 (guid, data) SELECT guid,data from users"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error migrating users database");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveEmptyPasswordHashes(ManagedConnection connection)
|
|
||||||
{
|
|
||||||
foreach (var user in RetrieveAllUsers(connection))
|
|
||||||
{
|
|
||||||
// If the user password is the sha1 hash of the empty string, remove it
|
|
||||||
if (!string.Equals(user.Password, "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal)
|
|
||||||
&& !string.Equals(user.Password, "$SHA1$DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Password = null;
|
|
||||||
var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
|
|
||||||
|
|
||||||
connection.RunInTransaction(db =>
|
|
||||||
{
|
|
||||||
using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId"))
|
|
||||||
{
|
|
||||||
statement.TryBind("@InternalId", user.InternalId);
|
|
||||||
statement.TryBind("@data", serialized);
|
|
||||||
statement.MoveNext();
|
|
||||||
}
|
|
||||||
}, TransactionMode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save a user in the repo
|
|
||||||
/// </summary>
|
|
||||||
public void CreateUser(User user)
|
|
||||||
{
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
|
|
||||||
|
|
||||||
using (var connection = GetConnection())
|
|
||||||
{
|
|
||||||
connection.RunInTransaction(db =>
|
|
||||||
{
|
|
||||||
using (var statement = db.PrepareStatement("insert into LocalUsersv2 (guid, data) values (@guid, @data)"))
|
|
||||||
{
|
|
||||||
statement.TryBind("@guid", user.Id.ToByteArray());
|
|
||||||
statement.TryBind("@data", serialized);
|
|
||||||
|
|
||||||
statement.MoveNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
var createdUser = GetUser(user.Id, connection);
|
|
||||||
|
|
||||||
if (createdUser == null)
|
|
||||||
{
|
|
||||||
throw new ApplicationException("created user should never be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
user.InternalId = createdUser.InternalId;
|
|
||||||
|
|
||||||
}, TransactionMode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateUser(User user)
|
|
||||||
{
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
var serialized = JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions);
|
|
||||||
|
|
||||||
using (var connection = GetConnection())
|
|
||||||
{
|
|
||||||
connection.RunInTransaction(db =>
|
|
||||||
{
|
|
||||||
using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId"))
|
|
||||||
{
|
|
||||||
statement.TryBind("@InternalId", user.InternalId);
|
|
||||||
statement.TryBind("@data", serialized);
|
|
||||||
statement.MoveNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
}, TransactionMode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private User GetUser(Guid guid, ManagedConnection connection)
|
|
||||||
{
|
|
||||||
using (var statement = connection.PrepareStatement("select id,guid,data from LocalUsersv2 where guid=@guid"))
|
|
||||||
{
|
|
||||||
statement.TryBind("@guid", guid);
|
|
||||||
|
|
||||||
foreach (var row in statement.ExecuteQuery())
|
|
||||||
{
|
|
||||||
return GetUser(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private User GetUser(IReadOnlyList<IResultSetValue> row)
|
|
||||||
{
|
|
||||||
var id = row[0].ToInt64();
|
|
||||||
var guid = row[1].ReadGuidFromBlob();
|
|
||||||
|
|
||||||
var user = JsonSerializer.Deserialize<User>(row[2].ToBlob(), _jsonOptions);
|
|
||||||
user.InternalId = id;
|
|
||||||
user.Id = guid;
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve all users from the database
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>IEnumerable{User}.</returns>
|
|
||||||
public List<User> RetrieveAllUsers()
|
|
||||||
{
|
|
||||||
using (var connection = GetConnection(true))
|
|
||||||
{
|
|
||||||
return new List<User>(RetrieveAllUsers(connection));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve all users from the database
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>IEnumerable{User}.</returns>
|
|
||||||
private IEnumerable<User> RetrieveAllUsers(ManagedConnection connection)
|
|
||||||
{
|
|
||||||
foreach (var row in connection.Query("select id,guid,data from LocalUsersv2"))
|
|
||||||
{
|
|
||||||
yield return GetUser(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes the user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user.</param>
|
|
||||||
/// <returns>Task.</returns>
|
|
||||||
/// <exception cref="ArgumentNullException">user</exception>
|
|
||||||
public void DeleteUser(User user)
|
|
||||||
{
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var connection = GetConnection())
|
|
||||||
{
|
|
||||||
connection.RunInTransaction(db =>
|
|
||||||
{
|
|
||||||
using (var statement = db.PrepareStatement("delete from LocalUsersv2 where Id=@id"))
|
|
||||||
{
|
|
||||||
statement.TryBind("@id", user.InternalId);
|
|
||||||
statement.MoveNext();
|
|
||||||
}
|
|
||||||
}, TransactionMode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using MediaBrowser.Controller.Library;
|
|
||||||
using MediaBrowser.Controller.Providers;
|
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using MediaBrowser.Model.Tasks;
|
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.EntryPoints
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Class RefreshUsersMetadata.
|
|
||||||
/// </summary>
|
|
||||||
public class RefreshUsersMetadata : IScheduledTask, IConfigurableScheduledTask
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The user manager.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IUserManager _userManager;
|
|
||||||
private readonly IFileSystem _fileSystem;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RefreshUsersMetadata" /> class.
|
|
||||||
/// </summary>
|
|
||||||
public RefreshUsersMetadata(IUserManager userManager, IFileSystem fileSystem)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_fileSystem = fileSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Name => "Refresh Users";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Key => "RefreshUsers";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Description => "Refresh user infos";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Category => "Library";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsHidden => true;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsEnabled => true;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsLogged => true;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress)
|
|
||||||
{
|
|
||||||
foreach (var user in _userManager.Users)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
await user.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
|
||||||
{
|
|
||||||
return new[]
|
|
||||||
{
|
|
||||||
new TaskTriggerInfo
|
|
||||||
{
|
|
||||||
IntervalTicks = TimeSpan.FromDays(1).Ticks,
|
|
||||||
Type = TaskTriggerInfo.TriggerInterval
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,76 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Threading;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Display Preferences Controller.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
public class DisplayPreferencesController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly IDisplayPreferencesRepository _displayPreferencesRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="displayPreferencesRepository">Instance of <see cref="IDisplayPreferencesRepository"/> interface.</param>
|
||||||
|
public DisplayPreferencesController(IDisplayPreferencesRepository displayPreferencesRepository)
|
||||||
|
{
|
||||||
|
_displayPreferencesRepository = displayPreferencesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get Display Preferences.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="displayPreferencesId">Display preferences id.</param>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="client">Client.</param>
|
||||||
|
/// <response code="200">Display preferences retrieved.</response>
|
||||||
|
/// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
|
||||||
|
[HttpGet("{displayPreferencesId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<DisplayPreferences> GetDisplayPreferences(
|
||||||
|
[FromRoute] string displayPreferencesId,
|
||||||
|
[FromQuery] [Required] string userId,
|
||||||
|
[FromQuery] [Required] string client)
|
||||||
|
{
|
||||||
|
return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update Display Preferences.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="displayPreferencesId">Display preferences id.</param>
|
||||||
|
/// <param name="userId">User Id.</param>
|
||||||
|
/// <param name="client">Client.</param>
|
||||||
|
/// <param name="displayPreferences">New Display Preferences object.</param>
|
||||||
|
/// <response code="204">Display preferences updated.</response>
|
||||||
|
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||||
|
[HttpPost("{displayPreferencesId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
|
||||||
|
public ActionResult UpdateDisplayPreferences(
|
||||||
|
[FromRoute] string displayPreferencesId,
|
||||||
|
[FromQuery, BindRequired] string userId,
|
||||||
|
[FromQuery, BindRequired] string client,
|
||||||
|
[FromBody, BindRequired] DisplayPreferences displayPreferences)
|
||||||
|
{
|
||||||
|
_displayPreferencesRepository.SaveDisplayPreferences(
|
||||||
|
displayPreferences,
|
||||||
|
userId,
|
||||||
|
client,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,230 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
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(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[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(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[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(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
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(Policy = Policies.DefaultAuthorization)]
|
||||||
|
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="itemId">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="204">Item metadata refresh queued.</response>
|
||||||
|
/// <response code="404">Item to refresh not found.</response>
|
||||||
|
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
|
||||||
|
[HttpPost("{itemId}/Refresh")]
|
||||||
|
[Description("Refreshes metadata for an item.")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "recursive", Justification = "Imported from ServiceStack")]
|
||||||
|
public ActionResult Post(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[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(itemId);
|
||||||
|
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 NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,341 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
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)]
|
||||||
|
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,266 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
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(Policy = Policies.DefaultAuthorization)]
|
||||||
|
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="itemId">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("{itemId}/RemoteImages")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromQuery] ImageType? type,
|
||||||
|
[FromQuery] int? startIndex,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] string providerName,
|
||||||
|
[FromQuery] bool includeAllLanguages)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
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="itemId">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("{itemId}/RemoteImages/Providers")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] Guid itemId)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
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="itemId">Item Id.</param>
|
||||||
|
/// <param name="type">The image type.</param>
|
||||||
|
/// <param name="imageUrl">The image url.</param>
|
||||||
|
/// <response code="204">Remote image downloaded.</response>
|
||||||
|
/// <response code="404">Remote image not found.</response>
|
||||||
|
/// <returns>Download status.</returns>
|
||||||
|
[HttpPost("{itemId}/RemoteImages/Download")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> DownloadRemoteImage(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromQuery, BindRequired] ImageType type,
|
||||||
|
[FromQuery] string imageUrl)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,347 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
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="itemId">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/{itemId}/Subtitles/{index}")]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult<Task> DeleteSubtitle(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromRoute] int index)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_subtitleManager.DeleteSubtitles(item, index);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search remote subtitles.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">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/{itemId}/RemoteSearch/Subtitles/{language}")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromRoute] string language,
|
||||||
|
[FromQuery] bool? isPerfectMatch)
|
||||||
|
{
|
||||||
|
var video = (Video)_libraryManager.GetItemById(itemId);
|
||||||
|
|
||||||
|
return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads a remote subtitle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">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/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<ActionResult> DownloadRemoteSubtitles(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromRoute] string subtitleId)
|
||||||
|
{
|
||||||
|
var video = (Video)_libraryManager.GetItemById(itemId);
|
||||||
|
|
||||||
|
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="itemId">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="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>
|
||||||
|
/// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
|
||||||
|
/// <response code="200">File returned.</response>
|
||||||
|
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
|
||||||
|
[HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
|
||||||
|
[HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult> GetSubtitle(
|
||||||
|
[FromRoute, Required] Guid itemId,
|
||||||
|
[FromRoute, Required] string mediaSourceId,
|
||||||
|
[FromRoute, Required] int index,
|
||||||
|
[FromRoute, Required] string format,
|
||||||
|
[FromQuery] long? endPositionTicks,
|
||||||
|
[FromQuery] bool copyTimestamps,
|
||||||
|
[FromQuery] bool addVttTimeMap,
|
||||||
|
[FromRoute] long startPositionTicks = 0)
|
||||||
|
{
|
||||||
|
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
format = "json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(format))
|
||||||
|
{
|
||||||
|
var item = (Video)_libraryManager.GetItemById(itemId);
|
||||||
|
|
||||||
|
var idString = itemId.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(itemId, 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(
|
||||||
|
itemId,
|
||||||
|
mediaSourceId,
|
||||||
|
index,
|
||||||
|
format,
|
||||||
|
startPositionTicks,
|
||||||
|
endPositionTicks,
|
||||||
|
copyTimestamps).ConfigureAwait(false),
|
||||||
|
MimeTypes.GetMimeType("file." + format));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an HLS subtitle playlist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">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/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
|
||||||
|
public async Task<ActionResult> GetSubtitlePlaylist(
|
||||||
|
[FromRoute] Guid itemId,
|
||||||
|
[FromRoute] int index,
|
||||||
|
[FromRoute] string mediaSourceId,
|
||||||
|
[FromQuery, Required] int segmentLength)
|
||||||
|
{
|
||||||
|
var item = (Video)_libraryManager.GetItemById(itemId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,380 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Api.Constants;
|
||||||
|
using Jellyfin.Api.Extensions;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.TV;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The tv shows controller.
|
||||||
|
/// </summary>
|
||||||
|
[Route("/Shows")]
|
||||||
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
|
public class TvShowsController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IDtoService _dtoService;
|
||||||
|
private readonly ITVSeriesManager _tvSeriesManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TvShowsController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||||
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
|
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||||
|
/// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param>
|
||||||
|
public TvShowsController(
|
||||||
|
IUserManager userManager,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IDtoService dtoService,
|
||||||
|
ITVSeriesManager tvSeriesManager)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_dtoService = dtoService;
|
||||||
|
_tvSeriesManager = tvSeriesManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a list of next up episodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id of the user to get the next up episodes for.</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="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
|
||||||
|
/// <param name="seriesId">Optional. Filter by series 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="enableImges">Optional. Include image information in output.</param>
|
||||||
|
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||||
|
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||||
|
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||||
|
/// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
|
||||||
|
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
|
||||||
|
[HttpGet("NextUp")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
|
||||||
|
[FromQuery] Guid userId,
|
||||||
|
[FromQuery] int? startIndex,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] string? fields,
|
||||||
|
[FromQuery] string? seriesId,
|
||||||
|
[FromQuery] string? parentId,
|
||||||
|
[FromQuery] bool? enableImges,
|
||||||
|
[FromQuery] int? imageTypeLimit,
|
||||||
|
[FromQuery] string? enableImageTypes,
|
||||||
|
[FromQuery] bool? enableUserData,
|
||||||
|
[FromQuery] bool enableTotalRecordCount = true)
|
||||||
|
{
|
||||||
|
var options = new DtoOptions()
|
||||||
|
.AddItemFields(fields!)
|
||||||
|
.AddClientFields(Request)
|
||||||
|
.AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
|
||||||
|
|
||||||
|
var result = _tvSeriesManager.GetNextUp(
|
||||||
|
new NextUpQuery
|
||||||
|
{
|
||||||
|
Limit = limit,
|
||||||
|
ParentId = parentId,
|
||||||
|
SeriesId = seriesId,
|
||||||
|
StartIndex = startIndex,
|
||||||
|
UserId = userId,
|
||||||
|
EnableTotalRecordCount = enableTotalRecordCount
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
|
||||||
|
var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
|
||||||
|
|
||||||
|
return new QueryResult<BaseItemDto>
|
||||||
|
{
|
||||||
|
TotalRecordCount = result.TotalRecordCount,
|
||||||
|
Items = returnItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a list of upcoming episodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id of the user to get the upcoming episodes for.</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="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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="enableImges">Optional. Include image information in output.</param>
|
||||||
|
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||||
|
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||||
|
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||||
|
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
|
||||||
|
[HttpGet("Upcoming")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
|
||||||
|
[FromQuery] Guid userId,
|
||||||
|
[FromQuery] int? startIndex,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] string? fields,
|
||||||
|
[FromQuery] string? parentId,
|
||||||
|
[FromQuery] bool? enableImges,
|
||||||
|
[FromQuery] int? imageTypeLimit,
|
||||||
|
[FromQuery] string? enableImageTypes,
|
||||||
|
[FromQuery] bool? enableUserData)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
|
||||||
|
var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
|
||||||
|
|
||||||
|
var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
|
||||||
|
|
||||||
|
var options = new DtoOptions()
|
||||||
|
.AddItemFields(fields!)
|
||||||
|
.AddClientFields(Request)
|
||||||
|
.AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!);
|
||||||
|
|
||||||
|
var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IncludeItemTypes = new[] { nameof(Episode) },
|
||||||
|
OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(),
|
||||||
|
MinPremiereDate = minPremiereDate,
|
||||||
|
StartIndex = startIndex,
|
||||||
|
Limit = limit,
|
||||||
|
ParentId = parentIdGuid,
|
||||||
|
Recursive = true,
|
||||||
|
DtoOptions = options
|
||||||
|
});
|
||||||
|
|
||||||
|
var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
|
||||||
|
|
||||||
|
return new QueryResult<BaseItemDto>
|
||||||
|
{
|
||||||
|
TotalRecordCount = itemsResult.Count,
|
||||||
|
Items = returnItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets episodes for a tv season.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId">The series id.</param>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
|
||||||
|
/// <param name="season">Optional filter by season number.</param>
|
||||||
|
/// <param name="seasonId">Optional. Filter by season id.</param>
|
||||||
|
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
|
||||||
|
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
|
||||||
|
/// <param name="startItemId">Optional. Skip through the list until a given item is found.</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="enableImages">Optional, include image information in output.</param>
|
||||||
|
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||||
|
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||||
|
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||||
|
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
|
||||||
|
/// <param name="sortOrder">Optional. Sort order: Ascending,Descending.</param>
|
||||||
|
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
|
||||||
|
[HttpGet("{seriesId}/Episodes")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "sortOrder", Justification = "Imported from ServiceStack")]
|
||||||
|
public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
|
||||||
|
[FromRoute] string seriesId,
|
||||||
|
[FromQuery] Guid userId,
|
||||||
|
[FromQuery] string? fields,
|
||||||
|
[FromQuery] int? season,
|
||||||
|
[FromQuery] string? seasonId,
|
||||||
|
[FromQuery] bool? isMissing,
|
||||||
|
[FromQuery] string? adjacentTo,
|
||||||
|
[FromQuery] string? startItemId,
|
||||||
|
[FromQuery] int? startIndex,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] bool? enableImages,
|
||||||
|
[FromQuery] int? imageTypeLimit,
|
||||||
|
[FromQuery] string? enableImageTypes,
|
||||||
|
[FromQuery] bool? enableUserData,
|
||||||
|
[FromQuery] string? sortBy,
|
||||||
|
[FromQuery] SortOrder? sortOrder)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
|
||||||
|
List<BaseItem> episodes;
|
||||||
|
|
||||||
|
var dtoOptions = new DtoOptions()
|
||||||
|
.AddItemFields(fields!)
|
||||||
|
.AddClientFields(Request)
|
||||||
|
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id.
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(new Guid(seasonId));
|
||||||
|
if (!(item is Season seasonItem))
|
||||||
|
{
|
||||||
|
return NotFound("No season exists with Id " + seasonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = seasonItem.GetEpisodes(user, dtoOptions);
|
||||||
|
}
|
||||||
|
else if (season.HasValue) // Season number was supplied. Get episodes by season number
|
||||||
|
{
|
||||||
|
if (!(_libraryManager.GetItemById(seriesId) is Series series))
|
||||||
|
{
|
||||||
|
return NotFound("Series not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasonItem = series
|
||||||
|
.GetSeasons(user, dtoOptions)
|
||||||
|
.FirstOrDefault(i => i.IndexNumber == season.Value);
|
||||||
|
|
||||||
|
episodes = seasonItem == null ?
|
||||||
|
new List<BaseItem>()
|
||||||
|
: ((Season)seasonItem).GetEpisodes(user, dtoOptions);
|
||||||
|
}
|
||||||
|
else // No season number or season id was supplied. Returning all episodes.
|
||||||
|
{
|
||||||
|
if (!(_libraryManager.GetItemById(seriesId) is Series series))
|
||||||
|
{
|
||||||
|
return NotFound("Series not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = series.GetEpisodes(user, dtoOptions).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter after the fact in case the ui doesn't want them
|
||||||
|
if (isMissing.HasValue)
|
||||||
|
{
|
||||||
|
var val = isMissing.Value;
|
||||||
|
episodes = episodes
|
||||||
|
.Where(i => ((Episode)i).IsMissingEpisode == val)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(startItemId))
|
||||||
|
{
|
||||||
|
episodes = episodes
|
||||||
|
.SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This must be the last filter
|
||||||
|
if (!string.IsNullOrEmpty(adjacentTo))
|
||||||
|
{
|
||||||
|
episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
episodes.Shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnItems = episodes;
|
||||||
|
|
||||||
|
if (startIndex.HasValue || limit.HasValue)
|
||||||
|
{
|
||||||
|
returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
|
||||||
|
|
||||||
|
return new QueryResult<BaseItemDto>
|
||||||
|
{
|
||||||
|
TotalRecordCount = episodes.Count,
|
||||||
|
Items = dtos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets seasons for a tv series.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId">The series id.</param>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
|
||||||
|
/// <param name="isSpecialSeason">Optional. Filter by special season.</param>
|
||||||
|
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
|
||||||
|
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
|
||||||
|
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||||
|
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||||
|
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||||
|
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||||
|
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
|
||||||
|
[HttpGet("{seriesId}/Seasons")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
|
||||||
|
[FromRoute] string seriesId,
|
||||||
|
[FromQuery] Guid userId,
|
||||||
|
[FromQuery] string fields,
|
||||||
|
[FromQuery] bool? isSpecialSeason,
|
||||||
|
[FromQuery] bool? isMissing,
|
||||||
|
[FromQuery] string adjacentTo,
|
||||||
|
[FromQuery] bool? enableImages,
|
||||||
|
[FromQuery] int? imageTypeLimit,
|
||||||
|
[FromQuery] string? enableImageTypes,
|
||||||
|
[FromQuery] bool? enableUserData)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
|
||||||
|
if (!(_libraryManager.GetItemById(seriesId) is Series series))
|
||||||
|
{
|
||||||
|
return NotFound("Series not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasons = series.GetItemList(new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
IsMissing = isMissing,
|
||||||
|
IsSpecialSeason = isSpecialSeason,
|
||||||
|
AdjacentTo = adjacentTo
|
||||||
|
});
|
||||||
|
|
||||||
|
var dtoOptions = new DtoOptions()
|
||||||
|
.AddItemFields(fields)
|
||||||
|
.AddClientFields(Request)
|
||||||
|
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
|
||||||
|
|
||||||
|
var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
|
||||||
|
|
||||||
|
return new QueryResult<BaseItemDto>
|
||||||
|
{
|
||||||
|
TotalRecordCount = returnItems.Count,
|
||||||
|
Items = returnItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the paging.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="items">The items.</param>
|
||||||
|
/// <param name="startIndex">The start index.</param>
|
||||||
|
/// <param name="limit">The limit.</param>
|
||||||
|
/// <returns>IEnumerable{BaseItem}.</returns>
|
||||||
|
private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit)
|
||||||
|
{
|
||||||
|
// Start at
|
||||||
|
if (startIndex.HasValue)
|
||||||
|
{
|
||||||
|
items = items.Skip(startIndex.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return limit
|
||||||
|
if (limit.HasValue)
|
||||||
|
{
|
||||||
|
items = items.Take(limit.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue