浏览代码

Move userId in API from route to optional query parameter (#11074)

* Move userId in API from route to optional query parameter

* Standardize UserViewsController

* Move userId to query in ImageController

* Move userId to query in ItemsController

* Move userId to query in PlaystateController

* Move userId to query in SuggestionsController

* Move userId from route to query in UserLibraryController

* Clean up routes

* Move userId to query in UserController

* fix bad merge

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
Cody Robibero 1 年之前
父节点
当前提交
6e5ec99ea1

+ 168 - 167
Jellyfin.Api/Controllers/ImageController.cs

@@ -11,7 +11,9 @@ using System.Security.Cryptography;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Extensions;
 using MediaBrowser.Common.Api;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Configuration;
@@ -86,31 +88,26 @@ public class ImageController : BaseJellyfinApiController
     /// Sets the user image.
     /// </summary>
     /// <param name="userId">User Id.</param>
-    /// <param name="imageType">(Unused) Image type.</param>
-    /// <param name="index">(Unused) Image index.</param>
     /// <response code="204">Image updated.</response>
     /// <response code="403">User does not have permission to delete the image.</response>
     /// <returns>A <see cref="NoContentResult"/>.</returns>
-    [HttpPost("Users/{userId}/Images/{imageType}")]
+    [HttpPost("UserImage")]
     [Authorize]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
     public async Task<ActionResult> PostUserImage(
-        [FromRoute, Required] Guid userId,
-        [FromRoute, Required] ImageType imageType,
-        [FromQuery] int? index = null)
+        [FromQuery] Guid? userId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
         }
 
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
         }
@@ -142,6 +139,28 @@ public class ImageController : BaseJellyfinApiController
         }
     }
 
+    /// <summary>
+    /// Sets the user image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <response code="204">Image updated.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/Images/{imageType}")]
+    [Authorize]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [AcceptsImageFile]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    public Task<ActionResult> PostUserImageLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType)
+        => PostUserImage(userId);
+
     /// <summary>
     /// Sets the user image.
     /// </summary>
@@ -153,81 +172,41 @@ public class ImageController : BaseJellyfinApiController
     /// <returns>A <see cref="NoContentResult"/>.</returns>
     [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
     [Authorize]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
-    public async Task<ActionResult> PostUserImageByIndex(
+    public Task<ActionResult> PostUserImageByIndexLegacy(
         [FromRoute, Required] Guid userId,
         [FromRoute, Required] ImageType imageType,
         [FromRoute] int index)
-    {
-        var user = _userManager.GetUserById(userId);
-        if (user is null)
-        {
-            return NotFound();
-        }
-
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-        {
-            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
-        }
-
-        if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension))
-        {
-            return BadRequest("Incorrect ContentType.");
-        }
-
-        var stream = GetFromBase64Stream(Request.Body);
-        await using (stream.ConfigureAwait(false))
-        {
-            // Handle image/png; charset=utf-8
-            var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
-            if (user.ProfileImage is not null)
-            {
-                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-            }
-
-            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
-
-            await _providerManager
-                .SaveImage(stream, mimeType, user.ProfileImage.Path)
-                .ConfigureAwait(false);
-            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
-
-            return NoContent();
-        }
-    }
+        => PostUserImage(userId);
 
     /// <summary>
     /// Delete the user's image.
     /// </summary>
     /// <param name="userId">User Id.</param>
-    /// <param name="imageType">(Unused) Image type.</param>
-    /// <param name="index">(Unused) Image index.</param>
     /// <response code="204">Image deleted.</response>
     /// <response code="403">User does not have permission to delete the image.</response>
     /// <returns>A <see cref="NoContentResult"/>.</returns>
-    [HttpDelete("Users/{userId}/Images/{imageType}")]
+    [HttpDelete("UserImage")]
     [Authorize]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public async Task<ActionResult> DeleteUserImage(
-        [FromRoute, Required] Guid userId,
-        [FromRoute, Required] ImageType imageType,
-        [FromQuery] int? index = null)
+        [FromQuery] Guid? userId)
     {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
         }
 
-        var user = _userManager.GetUserById(userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user?.ProfileImage is null)
         {
             return NoContent();
@@ -246,6 +225,29 @@ public class ImageController : BaseJellyfinApiController
         return NoContent();
     }
 
+    /// <summary>
+    /// Delete the user's image.
+    /// </summary>
+    /// <param name="userId">User Id.</param>
+    /// <param name="imageType">(Unused) Image type.</param>
+    /// <param name="index">(Unused) Image index.</param>
+    /// <response code="204">Image deleted.</response>
+    /// <response code="403">User does not have permission to delete the image.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/Images/{imageType}")]
+    [Authorize]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public Task<ActionResult> DeleteUserImageLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] int? index = null)
+        => DeleteUserImage(userId);
+
     /// <summary>
     /// Delete the user's image.
     /// </summary>
@@ -257,38 +259,17 @@ public class ImageController : BaseJellyfinApiController
     /// <returns>A <see cref="NoContentResult"/>.</returns>
     [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
     [Authorize]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
-    public async Task<ActionResult> DeleteUserImageByIndex(
+    public Task<ActionResult> DeleteUserImageByIndexLegacy(
         [FromRoute, Required] Guid userId,
         [FromRoute, Required] ImageType imageType,
         [FromRoute] int index)
-    {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true))
-        {
-            return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image.");
-        }
-
-        var user = _userManager.GetUserById(userId);
-        if (user?.ProfileImage is null)
-        {
-            return NoContent();
-        }
-
-        try
-        {
-            System.IO.File.Delete(user.ProfileImage.Path);
-        }
-        catch (IOException e)
-        {
-            _logger.LogError(e, "Error deleting user profile image:");
-        }
-
-        await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
-        return NoContent();
-    }
+        => DeleteUserImage(userId);
 
     /// <summary>
     /// Delete an item's image.
@@ -541,7 +522,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
     /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
     /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
     /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -571,7 +551,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
         [FromQuery] string? tag,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] ImageFormat? format,
         [FromQuery] double? percentPlayed,
         [FromQuery] int? unplayedCount,
@@ -622,7 +601,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
     /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
     /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
     /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -652,7 +630,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
         [FromQuery] string? tag,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] ImageFormat? format,
         [FromQuery] double? percentPlayed,
         [FromQuery] int? unplayedCount,
@@ -701,7 +678,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
     /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
     /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
     /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -731,7 +707,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
         [FromRoute, Required] string tag,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromRoute, Required] ImageFormat format,
         [FromRoute, Required] double percentPlayed,
         [FromRoute, Required] int unplayedCount,
@@ -784,7 +759,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -814,7 +788,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
@@ -864,7 +837,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -894,7 +866,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
@@ -945,7 +916,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -975,7 +945,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer)
@@ -1024,7 +993,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1054,7 +1022,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
@@ -1105,7 +1072,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1135,7 +1101,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer)
@@ -1184,7 +1149,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1214,7 +1178,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
@@ -1265,7 +1228,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1295,7 +1257,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer)
@@ -1344,7 +1305,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1374,7 +1334,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
@@ -1425,7 +1384,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1455,7 +1413,6 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer)
@@ -1492,7 +1449,6 @@ public class ImageController : BaseJellyfinApiController
     /// Get user profile image.
     /// </summary>
     /// <param name="userId">User id.</param>
-    /// <param name="imageType">Image type.</param>
     /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
     /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
     /// <param name="maxWidth">The maximum image width to return.</param>
@@ -1504,25 +1460,25 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
     /// <param name="imageIndex">Image index.</param>
     /// <response code="200">Image stream returned.</response>
+    /// <response code="400">User id not provided.</response>
     /// <response code="404">Item not found.</response>
     /// <returns>
     /// A <see cref="FileStreamResult"/> containing the file stream on success,
     /// or a <see cref="NotFoundResult"/> if item not found.
     /// </returns>
-    [HttpGet("Users/{userId}/Images/{imageType}")]
-    [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+    [HttpGet("UserImage")]
+    [HttpHead("UserImage", Name = "HeadUserImage")]
     [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [ProducesImageFile]
     public async Task<ActionResult> GetUserImage(
-        [FromRoute, Required] Guid userId,
-        [FromRoute, Required] ImageType imageType,
+        [FromQuery] Guid? userId,
         [FromQuery] string? tag,
         [FromQuery] ImageFormat? format,
         [FromQuery] int? maxWidth,
@@ -1534,13 +1490,18 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer,
         [FromQuery] int? imageIndex)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = userId ?? User.GetUserId();
+        if (requestUserId.IsEmpty())
+        {
+            return BadRequest("UserId is required if unauthenticated");
+        }
+
+        var user = _userManager.GetUserById(requestUserId);
         if (user?.ProfileImage is null)
         {
             return NotFound();
@@ -1565,7 +1526,7 @@ public class ImageController : BaseJellyfinApiController
 
         return await GetImageInternal(
                 user.Id,
-                imageType,
+                ImageType.Profile,
                 imageIndex,
                 tag,
                 format,
@@ -1586,6 +1547,75 @@ public class ImageController : BaseJellyfinApiController
             .ConfigureAwait(false);
     }
 
+    /// <summary>
+    /// Get user profile image.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="imageType">Image type.</param>
+    /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+    /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+    /// <param name="maxWidth">The maximum image width to return.</param>
+    /// <param name="maxHeight">The maximum image height to return.</param>
+    /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+    /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+    /// <param name="width">The fixed image width to return.</param>
+    /// <param name="height">The fixed image height to return.</param>
+    /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+    /// <param name="fillWidth">Width of box to fill.</param>
+    /// <param name="fillHeight">Height of box to fill.</param>
+    /// <param name="blur">Optional. Blur image.</param>
+    /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+    /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+    /// <param name="imageIndex">Image index.</param>
+    /// <response code="200">Image stream returned.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>
+    /// A <see cref="FileStreamResult"/> containing the file stream on success,
+    /// or a <see cref="NotFoundResult"/> if item not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/Images/{imageType}")]
+    [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImageLegacy")]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public Task<ActionResult> GetUserImageLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] ImageType imageType,
+        [FromQuery] string? tag,
+        [FromQuery] ImageFormat? format,
+        [FromQuery] int? maxWidth,
+        [FromQuery] int? maxHeight,
+        [FromQuery] double? percentPlayed,
+        [FromQuery] int? unplayedCount,
+        [FromQuery] int? width,
+        [FromQuery] int? height,
+        [FromQuery] int? quality,
+        [FromQuery] int? fillWidth,
+        [FromQuery] int? fillHeight,
+        [FromQuery] int? blur,
+        [FromQuery] string? backgroundColor,
+        [FromQuery] string? foregroundLayer,
+        [FromQuery] int? imageIndex)
+        => GetUserImage(
+            userId,
+            tag,
+            format,
+            maxWidth,
+            maxHeight,
+            percentPlayed,
+            unplayedCount,
+            width,
+            height,
+            quality,
+            fillWidth,
+            fillHeight,
+            blur,
+            backgroundColor,
+            foregroundLayer,
+            imageIndex);
+
     /// <summary>
     /// Get user profile image.
     /// </summary>
@@ -1603,7 +1633,6 @@ public class ImageController : BaseJellyfinApiController
     /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
     /// <param name="fillWidth">Width of box to fill.</param>
     /// <param name="fillHeight">Height of box to fill.</param>
-    /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
     /// <param name="blur">Optional. Blur image.</param>
     /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
     /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
@@ -1614,11 +1643,13 @@ public class ImageController : BaseJellyfinApiController
     /// or a <see cref="NotFoundResult"/> if item not found.
     /// </returns>
     [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
-    [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+    [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndexLegacy")]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [ProducesImageFile]
-    public async Task<ActionResult> GetUserImageByIndex(
+    public Task<ActionResult> GetUserImageByIndexLegacy(
         [FromRoute, Required] Guid userId,
         [FromRoute, Required] ImageType imageType,
         [FromRoute, Required] int imageIndex,
@@ -1633,56 +1664,26 @@ public class ImageController : BaseJellyfinApiController
         [FromQuery] int? quality,
         [FromQuery] int? fillWidth,
         [FromQuery] int? fillHeight,
-        [FromQuery, ParameterObsolete] bool? cropWhitespace,
         [FromQuery] int? blur,
         [FromQuery] string? backgroundColor,
         [FromQuery] string? foregroundLayer)
-    {
-        var user = _userManager.GetUserById(userId);
-        if (user?.ProfileImage is null)
-        {
-            return NotFound();
-        }
-
-        var info = new ItemImageInfo
-        {
-            Path = user.ProfileImage.Path,
-            Type = ImageType.Profile,
-            DateModified = user.ProfileImage.LastModified
-        };
-
-        if (width.HasValue)
-        {
-            info.Width = width.Value;
-        }
-
-        if (height.HasValue)
-        {
-            info.Height = height.Value;
-        }
-
-        return await GetImageInternal(
-                user.Id,
-                imageType,
-                imageIndex,
-                tag,
-                format,
-                maxWidth,
-                maxHeight,
-                percentPlayed,
-                unplayedCount,
-                width,
-                height,
-                quality,
-                fillWidth,
-                fillHeight,
-                blur,
-                backgroundColor,
-                foregroundLayer,
-                null,
-                info)
-            .ConfigureAwait(false);
-    }
+        => GetUserImage(
+            userId,
+            tag,
+            format,
+            maxWidth,
+            maxHeight,
+            percentPlayed,
+            unplayedCount,
+            width,
+            height,
+            quality,
+            fillWidth,
+            fillHeight,
+            blur,
+            backgroundColor,
+            foregroundLayer,
+            imageIndex);
 
     /// <summary>
     /// Generates or gets the splashscreen.

+ 114 - 16
Jellyfin.Api/Controllers/ItemsController.cs

@@ -612,8 +612,10 @@ public class ItemsController : BaseJellyfinApiController
     /// <param name="enableImages">Optional, include image information in output.</param>
     /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
     [HttpGet("Users/{userId}/Items")]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+    public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserIdLegacy(
         [FromRoute] Guid userId,
         [FromQuery] string? maxOfficialRating,
         [FromQuery] bool? hasThemeSong,
@@ -699,8 +701,7 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
         [FromQuery] bool enableTotalRecordCount = true,
         [FromQuery] bool? enableImages = true)
-    {
-        return GetItems(
+        => GetItems(
             userId,
             maxOfficialRating,
             hasThemeSong,
@@ -786,7 +787,6 @@ public class ItemsController : BaseJellyfinApiController
             genreIds,
             enableTotalRecordCount,
             enableImages);
-    }
 
     /// <summary>
     /// Gets items based on a query.
@@ -808,10 +808,10 @@ public class ItemsController : BaseJellyfinApiController
     /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
     /// <response code="200">Items returned.</response>
     /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
-    [HttpGet("Users/{userId}/Items/Resume")]
+    [HttpGet("UserItems/Resume")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] string? searchTerm,
@@ -827,7 +827,8 @@ public class ItemsController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool excludeActiveSessions = false)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -854,7 +855,7 @@ public class ItemsController : BaseJellyfinApiController
         if (excludeActiveSessions)
         {
             excludeItemIds = _sessionManager.Sessions
-                .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null)
+                .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
                 .Select(s => s.NowPlayingItem.Id)
                 .ToArray();
         }
@@ -887,6 +888,63 @@ public class ItemsController : BaseJellyfinApiController
             returnItems);
     }
 
+    /// <summary>
+    /// Gets items based on a query.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="startIndex">The start index.</param>
+    /// <param name="limit">The item limit.</param>
+    /// <param name="searchTerm">The search term.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+    /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+    /// <param name="enableUserData">Optional. Include user data.</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="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+    /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+    /// <param name="enableImages">Optional. Include image information in output.</param>
+    /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param>
+    /// <response code="200">Items returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
+    [HttpGet("Users/{userId}/Items/Resume")]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    public ActionResult<QueryResult<BaseItemDto>> GetResumeItemsLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] string? searchTerm,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool enableTotalRecordCount = true,
+        [FromQuery] bool? enableImages = true,
+        [FromQuery] bool excludeActiveSessions = false)
+    => GetResumeItems(
+        userId,
+        startIndex,
+        limit,
+        searchTerm,
+        parentId,
+        fields,
+        mediaTypes,
+        enableUserData,
+        imageTypeLimit,
+        enableImageTypes,
+        excludeItemTypes,
+        includeItemTypes,
+        enableTotalRecordCount,
+        enableImages,
+        excludeActiveSessions);
+
     /// <summary>
     /// Get Item User Data.
     /// </summary>
@@ -895,24 +953,43 @@ public class ItemsController : BaseJellyfinApiController
     /// <response code="200">return item user data.</response>
     /// <response code="404">Item is not found.</response>
     /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}/UserData")]
+    [HttpGet("UserItems/{itemId}/UserData")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public ActionResult<UserItemDataDto> GetItemUserData(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromRoute, Required] Guid itemId)
     {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to view this item user data.");
         }
 
-        var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException();
+        var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
         var item = _libraryManager.GetItemById(itemId);
 
         return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user);
     }
 
+    /// <summary>
+    /// Get Item User Data.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <response code="200">return item user data.</response>
+    /// <response code="404">Item is not found.</response>
+    /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/UserData")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> GetItemUserDataLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => GetItemUserData(userId, itemId);
+
     /// <summary>
     /// Update Item User Data.
     /// </summary>
@@ -922,20 +999,21 @@ public class ItemsController : BaseJellyfinApiController
     /// <response code="200">return updated user item data.</response>
     /// <response code="404">Item is not found.</response>
     /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
-    [HttpPost("Users/{userId}/Items/{itemId}/UserData")]
+    [HttpPost("UserItems/{itemId}/UserData")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public ActionResult<UserItemDataDto> UpdateItemUserData(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromRoute, Required] Guid itemId,
         [FromBody, Required] UpdateUserItemDataDto userDataDto)
     {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update this item user data.");
         }
 
-        var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException();
+        var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
         var item = _libraryManager.GetItemById(itemId);
         if (item == null)
         {
@@ -946,4 +1024,24 @@ public class ItemsController : BaseJellyfinApiController
 
         return _userDataRepository.GetUserDataDto(item, user);
     }
+
+    /// <summary>
+    /// Update Item User Data.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="userDataDto">New user data object.</param>
+    /// <response code="200">return updated user item data.</response>
+    /// <response code="404">Item is not found.</response>
+    /// <returns>Return <see cref="UserItemDataDto"/>.</returns>
+    [HttpPost("Users/{userId}/Items/{itemId}/UserData")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> UpdateItemUserDataLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromBody, Required] UpdateUserItemDataDto userDataDto)
+        => UpdateItemUserData(userId, itemId, userDataDto);
 }

+ 149 - 19
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -68,15 +68,16 @@ public class PlaystateController : BaseJellyfinApiController
     /// <response code="200">Item marked as played.</response>
     /// <response code="404">Item not found.</response>
     /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
-    [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+    [HttpPost("UserPlayedItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromRoute, Required] Guid itemId,
         [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -105,6 +106,26 @@ public class PlaystateController : BaseJellyfinApiController
         return dto;
     }
 
+    /// <summary>
+    /// Marks an item as played for user.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="datePlayed">Optional. The date the item was played.</param>
+    /// <response code="200">Item marked as played.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+    [HttpPost("Users/{userId}/PlayedItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult<UserItemDataDto>> MarkPlayedItemLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
+        => MarkPlayedItem(userId, itemId, datePlayed);
+
     /// <summary>
     /// Marks an item as unplayed for user.
     /// </summary>
@@ -113,12 +134,15 @@ public class PlaystateController : BaseJellyfinApiController
     /// <response code="200">Item marked as unplayed.</response>
     /// <response code="404">Item not found.</response>
     /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
-    [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
+    [HttpDelete("UserPlayedItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -147,6 +171,24 @@ public class PlaystateController : BaseJellyfinApiController
         return dto;
     }
 
+    /// <summary>
+    /// Marks an item as unplayed for user.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item marked as unplayed.</response>
+    /// <response code="404">Item not found.</response>
+    /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
+    [HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult<UserItemDataDto>> MarkUnplayedItemLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => MarkUnplayedItem(userId, itemId);
+
     /// <summary>
     /// Reports playback has started within a session.
     /// </summary>
@@ -215,9 +257,8 @@ public class PlaystateController : BaseJellyfinApiController
     }
 
     /// <summary>
-    /// Reports that a user has begun playing an item.
+    /// Reports that a session has begun playing an item.
     /// </summary>
-    /// <param name="userId">User id.</param>
     /// <param name="itemId">Item id.</param>
     /// <param name="mediaSourceId">The id of the MediaSource.</param>
     /// <param name="audioStreamIndex">The audio stream index.</param>
@@ -228,11 +269,9 @@ public class PlaystateController : BaseJellyfinApiController
     /// <param name="canSeek">Indicates if the client can seek.</param>
     /// <response code="204">Play start recorded.</response>
     /// <returns>A <see cref="NoContentResult"/>.</returns>
-    [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
+    [HttpPost("PlayingItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
     public async Task<ActionResult> OnPlaybackStart(
-        [FromRoute, Required] Guid userId,
         [FromRoute, Required] Guid itemId,
         [FromQuery] string? mediaSourceId,
         [FromQuery] int? audioStreamIndex,
@@ -261,11 +300,41 @@ public class PlaystateController : BaseJellyfinApiController
     }
 
     /// <summary>
-    /// Reports a user's playback progress.
+    /// Reports that a user has begun playing an item.
     /// </summary>
     /// <param name="userId">User id.</param>
     /// <param name="itemId">Item id.</param>
     /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="playMethod">The play method.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="canSeek">Indicates if the client can seek.</param>
+    /// <response code="204">Play start recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/PlayingItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public Task<ActionResult> OnPlaybackStartLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] PlayMethod? playMethod,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] bool canSeek = false)
+        => OnPlaybackStart(itemId, mediaSourceId, audioStreamIndex, subtitleStreamIndex, playMethod, liveStreamId, playSessionId, canSeek);
+
+    /// <summary>
+    /// Reports a session's playback progress.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
     /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
     /// <param name="audioStreamIndex">The audio stream index.</param>
     /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
@@ -278,11 +347,9 @@ public class PlaystateController : BaseJellyfinApiController
     /// <param name="isMuted">Indicates if the player is muted.</param>
     /// <response code="204">Play progress recorded.</response>
     /// <returns>A <see cref="NoContentResult"/>.</returns>
-    [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
+    [HttpPost("PlayingItems/{itemId}/Progress")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
     public async Task<ActionResult> OnPlaybackProgress(
-        [FromRoute, Required] Guid userId,
         [FromRoute, Required] Guid itemId,
         [FromQuery] string? mediaSourceId,
         [FromQuery] long? positionTicks,
@@ -319,22 +386,58 @@ public class PlaystateController : BaseJellyfinApiController
     }
 
     /// <summary>
-    /// Reports that a user has stopped playing an item.
+    /// Reports a user's playback progress.
     /// </summary>
     /// <param name="userId">User id.</param>
     /// <param name="itemId">Item id.</param>
     /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+    /// <param name="audioStreamIndex">The audio stream index.</param>
+    /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+    /// <param name="volumeLevel">Scale of 0-100.</param>
+    /// <param name="playMethod">The play method.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <param name="repeatMode">The repeat mode.</param>
+    /// <param name="isPaused">Indicates if the player is paused.</param>
+    /// <param name="isMuted">Indicates if the player is muted.</param>
+    /// <response code="204">Play progress recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public Task<ActionResult> OnPlaybackProgressLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] long? positionTicks,
+        [FromQuery] int? audioStreamIndex,
+        [FromQuery] int? subtitleStreamIndex,
+        [FromQuery] int? volumeLevel,
+        [FromQuery] PlayMethod? playMethod,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId,
+        [FromQuery] RepeatMode? repeatMode,
+        [FromQuery] bool isPaused = false,
+        [FromQuery] bool isMuted = false)
+        => OnPlaybackProgress(itemId, mediaSourceId, positionTicks, audioStreamIndex, subtitleStreamIndex, volumeLevel, playMethod, liveStreamId, playSessionId, repeatMode, isPaused, isMuted);
+
+    /// <summary>
+    /// Reports that a session has stopped playing an item.
+    /// </summary>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
     /// <param name="nextMediaType">The next media type that will play.</param>
     /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
     /// <param name="liveStreamId">The live stream id.</param>
     /// <param name="playSessionId">The play session id.</param>
     /// <response code="204">Playback stop recorded.</response>
     /// <returns>A <see cref="NoContentResult"/>.</returns>
-    [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
+    [HttpDelete("PlayingItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
-    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
     public async Task<ActionResult> OnPlaybackStopped(
-        [FromRoute, Required] Guid userId,
         [FromRoute, Required] Guid itemId,
         [FromQuery] string? mediaSourceId,
         [FromQuery] string? nextMediaType,
@@ -363,6 +466,33 @@ public class PlaystateController : BaseJellyfinApiController
         return NoContent();
     }
 
+    /// <summary>
+    /// Reports that a user has stopped playing an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="mediaSourceId">The id of the MediaSource.</param>
+    /// <param name="nextMediaType">The next media type that will play.</param>
+    /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+    /// <param name="liveStreamId">The live stream id.</param>
+    /// <param name="playSessionId">The play session id.</param>
+    /// <response code="204">Playback stop recorded.</response>
+    /// <returns>A <see cref="NoContentResult"/>.</returns>
+    [HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+    public Task<ActionResult> OnPlaybackStoppedLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] string? mediaSourceId,
+        [FromQuery] string? nextMediaType,
+        [FromQuery] long? positionTicks,
+        [FromQuery] string? liveStreamId,
+        [FromQuery] string? playSessionId)
+        => OnPlaybackStopped(itemId, mediaSourceId, nextMediaType, positionTicks, liveStreamId, playSessionId);
+
     /// <summary>
     /// Updates the played status.
     /// </summary>

+ 38 - 5
Jellyfin.Api/Controllers/SuggestionsController.cs

@@ -1,7 +1,9 @@
 using System;
 using System.ComponentModel.DataAnnotations;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using MediaBrowser.Controller.Dto;
@@ -53,19 +55,26 @@ public class SuggestionsController : BaseJellyfinApiController
     /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
     /// <response code="200">Suggestions returned.</response>
     /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
-    [HttpGet("Users/{userId}/Suggestions")]
+    [HttpGet("Items/Suggestions")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
         [FromQuery] bool enableTotalRecordCount = false)
     {
-        var user = userId.IsEmpty()
-            ? null
-            : _userManager.GetUserById(userId);
+        User? user;
+        if (userId.IsNullOrEmpty())
+        {
+            user = null;
+        }
+        else
+        {
+            var requestUserId = RequestHelpers.GetUserId(User, userId);
+            user = _userManager.GetUserById(requestUserId);
+        }
 
         var dtoOptions = new DtoOptions().AddClientFields(User);
         var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
@@ -88,4 +97,28 @@ public class SuggestionsController : BaseJellyfinApiController
             result.TotalRecordCount,
             dtoList);
     }
+
+    /// <summary>
+    /// Gets suggestions.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="mediaType">The media types.</param>
+    /// <param name="type">The type.</param>
+    /// <param name="startIndex">Optional. The start index.</param>
+    /// <param name="limit">Optional. The limit.</param>
+    /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
+    /// <response code="200">Suggestions returned.</response>
+    /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
+    [HttpGet("Users/{userId}/Suggestions")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<QueryResult<BaseItemDto>> GetSuggestionsLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type,
+        [FromQuery] int? startIndex,
+        [FromQuery] int? limit,
+        [FromQuery] bool enableTotalRecordCount = false)
+        => GetSuggestions(userId, mediaType, type, startIndex, limit, enableTotalRecordCount);
 }

+ 79 - 13
Jellyfin.Api/Controllers/UserController.cs

@@ -178,6 +178,7 @@ public class UserController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [Obsolete("Authenticate with username instead")]
     public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
         [FromRoute, Required] Guid userId,
@@ -263,21 +264,22 @@ public class UserController : BaseJellyfinApiController
     /// <response code="403">User is not allowed to update the password.</response>
     /// <response code="404">User not found.</response>
     /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
-    [HttpPost("{userId}/Password")]
+    [HttpPost("Password")]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public async Task<ActionResult> UpdateUserPassword(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromBody, Required] UpdateUserPassword request)
     {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        var requestUserId = userId ?? User.GetUserId();
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
         }
 
-        var user = _userManager.GetUserById(userId);
+        var user = _userManager.GetUserById(requestUserId);
 
         if (user is null)
         {
@@ -290,7 +292,7 @@ public class UserController : BaseJellyfinApiController
         }
         else
         {
-            if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId))
+            if (!User.IsInRole(UserRoles.Administrator) || (userId.HasValue && User.GetUserId().Equals(userId.Value)))
             {
                 var success = await _userManager.AuthenticateUser(
                     user.Username,
@@ -315,6 +317,27 @@ public class UserController : BaseJellyfinApiController
         return NoContent();
     }
 
+    /// <summary>
+    /// Updates a user's password.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
+    /// <response code="204">Password successfully reset.</response>
+    /// <response code="403">User is not allowed to update the password.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
+    [HttpPost("{userId}/Password")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult> UpdateUserPasswordLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UpdateUserPassword request)
+        => UpdateUserPassword(userId, request);
+
     /// <summary>
     /// Updates a user's easy password.
     /// </summary>
@@ -326,6 +349,7 @@ public class UserController : BaseJellyfinApiController
     /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
     [HttpPost("{userId}/EasyPassword")]
     [Obsolete("Use Quick Connect instead")]
+    [ApiExplorerSettings(IgnoreApi = true)]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -346,22 +370,23 @@ public class UserController : BaseJellyfinApiController
     /// <response code="400">User information was not supplied.</response>
     /// <response code="403">User update forbidden.</response>
     /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
-    [HttpPost("{userId}")]
+    [HttpPost]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public async Task<ActionResult> UpdateUser(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromBody, Required] UserDto updateUser)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = userId ?? User.GetUserId();
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
         }
 
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
         }
@@ -376,6 +401,27 @@ public class UserController : BaseJellyfinApiController
         return NoContent();
     }
 
+    /// <summary>
+    /// Updates a user.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="updateUser">The updated user model.</param>
+    /// <response code="204">User updated.</response>
+    /// <response code="400">User information was not supplied.</response>
+    /// <response code="403">User update forbidden.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
+    [HttpPost("{userId}")]
+    [Authorize]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult> UpdateUserLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UserDto updateUser)
+        => UpdateUser(userId, updateUser);
+
     /// <summary>
     /// Updates a user policy.
     /// </summary>
@@ -440,24 +486,44 @@ public class UserController : BaseJellyfinApiController
     /// <response code="204">User configuration updated.</response>
     /// <response code="403">User configuration update forbidden.</response>
     /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
-    [HttpPost("{userId}/Configuration")]
+    [HttpPost("Configuration")]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public async Task<ActionResult> UpdateUserConfiguration(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromBody, Required] UserConfiguration userConfig)
     {
-        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true))
+        var requestUserId = userId ?? User.GetUserId();
+        if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, requestUserId, true))
         {
             return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
         }
 
-        await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
+        await _userManager.UpdateConfigurationAsync(requestUserId, userConfig).ConfigureAwait(false);
 
         return NoContent();
     }
 
+    /// <summary>
+    /// Updates a user configuration.
+    /// </summary>
+    /// <param name="userId">The user id.</param>
+    /// <param name="userConfig">The new user configuration.</param>
+    /// <response code="204">User configuration updated.</response>
+    /// <response code="403">User configuration update forbidden.</response>
+    /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+    [HttpPost("{userId}/Configuration")]
+    [Authorize]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status403Forbidden)]
+    public Task<ActionResult> UpdateUserConfigurationLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromBody, Required] UserConfiguration userConfig)
+        => UpdateUserConfiguration(userId, userConfig);
+
     /// <summary>
     /// Creates a user.
     /// </summary>

+ 249 - 38
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
@@ -13,12 +14,10 @@ using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Lyrics;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -39,7 +38,6 @@ public class UserLibraryController : BaseJellyfinApiController
     private readonly IDtoService _dtoService;
     private readonly IUserViewManager _userViewManager;
     private readonly IFileSystem _fileSystem;
-    private readonly ILyricManager _lyricManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="UserLibraryController"/> class.
@@ -50,15 +48,13 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
     /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
     /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
-    /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
     public UserLibraryController(
         IUserManager userManager,
         IUserDataManager userDataRepository,
         ILibraryManager libraryManager,
         IDtoService dtoService,
         IUserViewManager userViewManager,
-        IFileSystem fileSystem,
-        ILyricManager lyricManager)
+        IFileSystem fileSystem)
     {
         _userManager = userManager;
         _userDataRepository = userDataRepository;
@@ -66,7 +62,6 @@ public class UserLibraryController : BaseJellyfinApiController
         _dtoService = dtoService;
         _userViewManager = userViewManager;
         _fileSystem = fileSystem;
-        _lyricManager = lyricManager;
     }
 
     /// <summary>
@@ -76,11 +71,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Item returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the item.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}")]
+    [HttpGet("Items/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public async Task<ActionResult<BaseItemDto>> GetItem(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -109,17 +107,34 @@ public class UserLibraryController : BaseJellyfinApiController
         return _dtoService.GetBaseItemDto(item, dtoOptions, user);
     }
 
+    /// <summary>
+    /// Gets an item from a user's library.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the item.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult<BaseItemDto>> GetItemLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => GetItem(userId, itemId);
+
     /// <summary>
     /// Gets the root folder from a user's library.
     /// </summary>
     /// <param name="userId">User id.</param>
     /// <response code="200">Root folder returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
-    [HttpGet("Users/{userId}/Items/Root")]
+    [HttpGet("Items/Root")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId)
+    public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -130,6 +145,20 @@ public class UserLibraryController : BaseJellyfinApiController
         return _dtoService.GetBaseItemDto(item, dtoOptions, user);
     }
 
+    /// <summary>
+    /// Gets the root folder from a user's library.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <response code="200">Root folder returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
+    [HttpGet("Users/{userId}/Items/Root")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<BaseItemDto> GetRootFolderLegacy(
+        [FromRoute, Required] Guid userId)
+        => GetRootFolder(userId);
+
     /// <summary>
     /// Gets intros to play before the main media item plays.
     /// </summary>
@@ -137,11 +166,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Intros returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
+    [HttpGet("Items/{itemId}/Intros")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -170,6 +202,22 @@ public class UserLibraryController : BaseJellyfinApiController
         return new QueryResult<BaseItemDto>(dtos);
     }
 
+    /// <summary>
+    /// Gets intros to play before the main media item plays.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Intros returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/Intros")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public Task<ActionResult<QueryResult<BaseItemDto>>> GetIntrosLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => GetIntros(userId, itemId);
+
     /// <summary>
     /// Marks an item as a favorite.
     /// </summary>
@@ -177,11 +225,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Item marked as favorite.</response>
     /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-    [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
+    [HttpPost("UserFavoriteItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public ActionResult<UserItemDataDto> MarkFavoriteItem(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -206,6 +257,22 @@ public class UserLibraryController : BaseJellyfinApiController
         return MarkFavorite(user, item, true);
     }
 
+    /// <summary>
+    /// Marks an item as a favorite.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item marked as favorite.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> MarkFavoriteItemLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => MarkFavoriteItem(userId, itemId);
+
     /// <summary>
     /// Unmarks item as a favorite.
     /// </summary>
@@ -213,11 +280,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Item unmarked as favorite.</response>
     /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-    [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
+    [HttpDelete("UserFavoriteItems/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public ActionResult<UserItemDataDto> UnmarkFavoriteItem(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -242,6 +312,22 @@ public class UserLibraryController : BaseJellyfinApiController
         return MarkFavorite(user, item, false);
     }
 
+    /// <summary>
+    /// Unmarks item as a favorite.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Item unmarked as favorite.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> UnmarkFavoriteItemLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => UnmarkFavoriteItem(userId, itemId);
+
     /// <summary>
     /// Deletes a user's saved personal rating for an item.
     /// </summary>
@@ -249,11 +335,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Personal rating removed.</response>
     /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-    [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
+    [HttpDelete("UserItems/{itemId}/Rating")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public ActionResult<UserItemDataDto> DeleteUserItemRating(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -278,6 +367,22 @@ public class UserLibraryController : BaseJellyfinApiController
         return UpdateUserItemRatingInternal(user, item, null);
     }
 
+    /// <summary>
+    /// Deletes a user's saved personal rating for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Personal rating removed.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> DeleteUserItemRatingLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => DeleteUserItemRating(userId, itemId);
+
     /// <summary>
     /// Updates a user's rating for an item.
     /// </summary>
@@ -286,11 +391,15 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
     /// <response code="200">Item rating updated.</response>
     /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
-    [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
+    [HttpPost("UserItems/{itemId}/Rating")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
+    public ActionResult<UserItemDataDto> UpdateUserItemRating(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? likes)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -315,6 +424,24 @@ public class UserLibraryController : BaseJellyfinApiController
         return UpdateUserItemRatingInternal(user, item, likes);
     }
 
+    /// <summary>
+    /// Updates a user's rating for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
+    /// <response code="200">Item rating updated.</response>
+    /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+    [HttpPost("Users/{userId}/Items/{itemId}/Rating")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<UserItemDataDto> UpdateUserItemRatingLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId,
+        [FromQuery] bool? likes)
+        => UpdateUserItemRating(userId, itemId, likes);
+
     /// <summary>
     /// Gets local trailers for an item.
     /// </summary>
@@ -322,11 +449,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
     /// <returns>The items local trailers.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
+    [HttpGet("Items/{itemId}/LocalTrailers")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -360,6 +490,22 @@ public class UserLibraryController : BaseJellyfinApiController
             .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
     }
 
+    /// <summary>
+    /// Gets local trailers for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
+    /// <returns>The items local trailers.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailersLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => GetLocalTrailers(userId, itemId);
+
     /// <summary>
     /// Gets special features for an item.
     /// </summary>
@@ -367,11 +513,14 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Special features returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
-    [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
+    [HttpGet("Items/{itemId}/SpecialFeatures")]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
+    public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures(
+        [FromQuery] Guid? userId,
+        [FromRoute, Required] Guid itemId)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -401,6 +550,22 @@ public class UserLibraryController : BaseJellyfinApiController
             .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
     }
 
+    /// <summary>
+    /// Gets special features for an item.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="itemId">Item id.</param>
+    /// <response code="200">Special features returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
+    [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeaturesLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromRoute, Required] Guid itemId)
+        => GetSpecialFeatures(userId, itemId);
+
     /// <summary>
     /// Gets latest media.
     /// </summary>
@@ -417,10 +582,10 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="groupItems">Whether or not to group items into a parent container.</param>
     /// <response code="200">Latest media returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
-    [HttpGet("Users/{userId}/Items/Latest")]
+    [HttpGet("Items/Latest")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromQuery] Guid? parentId,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
@@ -432,7 +597,8 @@ public class UserLibraryController : BaseJellyfinApiController
         [FromQuery] int limit = 20,
         [FromQuery] bool groupItems = true)
     {
-        var user = _userManager.GetUserById(userId);
+        var requestUserId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(requestUserId);
         if (user is null)
         {
             return NotFound();
@@ -458,7 +624,7 @@ public class UserLibraryController : BaseJellyfinApiController
                 IsPlayed = isPlayed,
                 Limit = limit,
                 ParentId = parentId ?? Guid.Empty,
-                UserId = userId,
+                UserId = requestUserId,
             },
             dtoOptions);
 
@@ -483,6 +649,51 @@ public class UserLibraryController : BaseJellyfinApiController
         return Ok(dtos);
     }
 
+    /// <summary>
+    /// Gets latest media.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+    /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
+    /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+    /// <param name="isPlayed">Filter by items that are played, or not.</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="limit">Return item limit.</param>
+    /// <param name="groupItems">Whether or not to group items into a parent container.</param>
+    /// <response code="200">Latest media returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
+    [HttpGet("Users/{userId}/Items/Latest")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<IEnumerable<BaseItemDto>> GetLatestMediaLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? parentId,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
+        [FromQuery] bool? isPlayed,
+        [FromQuery] bool? enableImages,
+        [FromQuery] int? imageTypeLimit,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+        [FromQuery] bool? enableUserData,
+        [FromQuery] int limit = 20,
+        [FromQuery] bool groupItems = true)
+        => GetLatestMedia(
+            userId,
+            parentId,
+            fields,
+            includeItemTypes,
+            isPlayed,
+            enableImages,
+            imageTypeLimit,
+            enableImageTypes,
+            enableUserData,
+            limit,
+            groupItems);
+
     private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
     {
         if (item is Person)

+ 50 - 11
Jellyfin.Api/Controllers/UserViewsController.cs

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.UserViewDtos;
 using Jellyfin.Data.Enums;
@@ -59,19 +60,17 @@ public class UserViewsController : BaseJellyfinApiController
     /// <param name="includeHidden">Whether or not to include hidden content.</param>
     /// <response code="200">User views returned.</response>
     /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
-    [HttpGet("Users/{userId}/Views")]
+    [HttpGet("UserViews")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public QueryResult<BaseItemDto> GetUserViews(
-        [FromRoute, Required] Guid userId,
+        [FromQuery] Guid? userId,
         [FromQuery] bool? includeExternalContent,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
         [FromQuery] bool includeHidden = false)
     {
-        var query = new UserViewQuery
-        {
-            UserId = userId,
-            IncludeHidden = includeHidden
-        };
+        userId = RequestHelpers.GetUserId(User, userId);
+
+        var query = new UserViewQuery { UserId = userId.Value, IncludeHidden = includeHidden };
 
         if (includeExternalContent.HasValue)
         {
@@ -92,7 +91,7 @@ public class UserViewsController : BaseJellyfinApiController
         fields.Add(ItemFields.DisplayPreferencesId);
         dtoOptions.Fields = fields.ToArray();
 
-        var user = _userManager.GetUserById(userId);
+        var user = _userManager.GetUserById(userId.Value);
 
         var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
             .ToArray();
@@ -100,6 +99,26 @@ public class UserViewsController : BaseJellyfinApiController
         return new QueryResult<BaseItemDto>(dtos);
     }
 
+    /// <summary>
+    /// Get user views.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
+    /// <param name="presetViews">Preset views.</param>
+    /// <param name="includeHidden">Whether or not to include hidden content.</param>
+    /// <response code="200">User views returned.</response>
+    /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
+    [HttpGet("Users/{userId}/Views")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public QueryResult<BaseItemDto> GetUserViewsLegacy(
+        [FromRoute, Required] Guid userId,
+        [FromQuery] bool? includeExternalContent,
+        [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews,
+        [FromQuery] bool includeHidden = false)
+        => GetUserViews(userId, includeExternalContent, presetViews, includeHidden);
+
     /// <summary>
     /// Get user view grouping options.
     /// </summary>
@@ -110,12 +129,13 @@ public class UserViewsController : BaseJellyfinApiController
     /// An <see cref="OkResult"/> containing the user view grouping options
     /// or a <see cref="NotFoundResult"/> if user not found.
     /// </returns>
-    [HttpGet("Users/{userId}/GroupingOptions")]
+    [HttpGet("UserViews/GroupingOptions")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId)
+    public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromQuery] Guid? userId)
     {
-        var user = _userManager.GetUserById(userId);
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = _userManager.GetUserById(userId.Value);
         if (user is null)
         {
             return NotFound();
@@ -133,4 +153,23 @@ public class UserViewsController : BaseJellyfinApiController
             .OrderBy(i => i.Name)
             .AsEnumerable());
     }
+
+    /// <summary>
+    /// Get user view grouping options.
+    /// </summary>
+    /// <param name="userId">User id.</param>
+    /// <response code="200">User view grouping options returned.</response>
+    /// <response code="404">User not found.</response>
+    /// <returns>
+    /// An <see cref="OkResult"/> containing the user view grouping options
+    /// or a <see cref="NotFoundResult"/> if user not found.
+    /// </returns>
+    [HttpGet("Users/{userId}/GroupingOptions")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [Obsolete("Kept for backwards compatibility")]
+    [ApiExplorerSettings(IgnoreApi = true)]
+    public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptionsLegacy(
+        [FromRoute, Required] Guid userId)
+        => GetGroupingOptions(userId);
 }