diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 81fe5add42..d9b689bb64 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.1", + "version": "8.0.2", "commands": [ "dotnet-ef" ] diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 5878028330..b690b82c24 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -6,7 +6,11 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this bug report! Please provide as much detail as necessary, most questions may not be applicable to you. If you need real-time help, join us on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [Discord](https://discord.gg/zHBxVSXdBV). + Thanks for taking the time to report an issue. Before submitting a report, please do the following: + 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/ + 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report. + 3. If you decide to open a new report, please provide as much detail as possible. + 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports. - type: textarea id: what-happened attributes: @@ -14,14 +18,18 @@ body: description: Also tell us, what did you expect to happen? placeholder: | The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. - - This is my issue. - - Steps to Reproduce - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... + If you are using an old release of Jellyfin, please also explain why. + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Reproduction Steps + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... validations: required: true - type: dropdown @@ -30,11 +38,10 @@ body: label: Jellyfin Version description: What version of Jellyfin are you running? options: - - 10.8.z - - 10.8.9 - - 10.7.7 - - 10.6.4 - - Other + - 10.8.13 + - 10.8.12 + - 10.8.11 or older (please specify) + - Unstable (master branch) validations: required: true - type: input @@ -77,6 +84,18 @@ body: - Networking: - Storage: render: markdown + validations: + required: true + - type: markdown + attributes: + value: | + When providing logs, please keep the following things in mind. + 1. **DO NOT** use external paste services. + 2. Please provide complete logs. + - For server logs, include everything you think is important plus *10 lines before and after* + - For ffmpeg logs, please provide the entire file unmodified. + 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default. + 4. Please do not include logs as screenshots, with the only exception being client logs in browsers. - type: textarea id: logs attributes: @@ -84,6 +103,8 @@ body: description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. placeholder: For playback issues, browser/client and FFmpeg logs may be more useful. render: shell + validations: + required: true - type: textarea id: ffmpeg-logs attributes: diff --git a/.github/ISSUE_TEMPLATE/media_playback.md b/.github/ISSUE_TEMPLATE/media_playback.md deleted file mode 100644 index b51500f870..0000000000 --- a/.github/ISSUE_TEMPLATE/media_playback.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Media playback issue -about: Create a media playback issue report -title: '' -labels: mediaplayback -assignees: '' - ---- - -**Media Info of the file** - - -**Logs** - - -**FFmpeg Logs** - - -**Stats for Nerds Screenshots** - - -**Server System (please complete the following information):** - - OS: [e.g. Docker on Linux, Docker on Windows, Debian, Windows] - - Jellyfin Version: [e.g. 10.0.1] - - Hardware settings & device: [e.g. NVENC on GTX1060, VAAPI on Intel i7 8700K] - - Reverse proxy: [e.g. no, nginx, apache, etc.] - - Other hardware notes: [e.g. Media mounted in CIFS/SMB share, Media mounted from Google Drive] - -**Client System (please complete the following information):** - - Device: [e.g. Apple iPhone XS, Xbox One S, LG OLED55C8, Samsung Galaxy Note9, Custom HTPC] - - OS: [e.g. iOS, Android, Windows, macOS] - - Client: [e.g. Web/Browser, webOS, Android, Android TV, Electron] - - Browser (if Web client): [e.g. Firefox, Chrome, Safari] - - Client and Browser Version: [e.g. 10.3.4 and 68.0] diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 839bebb96a..20307dd7dd 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 + uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 97f1a33e76..b5ccafb865 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -78,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 with: name: openapi-base path: openapi-base diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 4b5db14aef..8ee6b3028b 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1 + uses: danielpalme/ReportGenerator-GitHub-Action@b067e0c5d288fb4277b9f397b2dc6013f60381f0 # 5.2.2 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 59068a25d4..17d1973d06 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -180,6 +180,7 @@ - [TheMelmacian](https://github.com/TheMelmacian) _ [Barasingha](https://github.com/MaVdbussche) - [Gauvino](https://github.com/Gauvino) + - [felix920506](https://github.com/felix920506) # Emby Contributors @@ -251,3 +252,4 @@ - [Utku Özdemir](https://github.com/utkuozdemir) - [JPUC1143](https://github.com/Jpuc1143/) - [0x25CBFC4F](https://github.com/0x25CBFC4F) + - [Robert Lützner](https://github.com/rluetzner) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a236e5b64..7400edd93d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,27 +12,27 @@ - + - + - + - + - + - - - - - + + + + + @@ -41,13 +41,13 @@ - - + + - + @@ -72,20 +72,20 @@ - + - + - + - + diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index b63c8f10e5..4bd226d95e 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -173,6 +173,13 @@ namespace Emby.Naming.Common ".vtt", }; + LyricFileExtensions = new[] + { + ".lrc", + ".elrc", + ".txt" + }; + AlbumStackingPrefixes = new[] { "cd", @@ -791,6 +798,11 @@ namespace Emby.Naming.Common /// public string[] SubtitleFileExtensions { get; set; } + /// + /// Gets the list of lyric file extensions. + /// + public string[] LyricFileExtensions { get; } + /// /// Gets or sets list of episode regular expressions. /// diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 4080ba10d3..9d54533c24 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -45,7 +45,8 @@ namespace Emby.Naming.ExternalFiles var extension = Path.GetExtension(path.AsSpan()); if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) - && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) + && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { return null; } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 550c16b4c4..745753440d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -630,7 +630,7 @@ namespace Emby.Server.Implementations BaseItem.FileSystem = Resolve(); BaseItem.UserDataManager = Resolve(); BaseItem.ChannelManager = Resolve(); - Video.LiveTvManager = Resolve(); + Video.RecordingsManager = Resolve(); Folder.UserViewManager = Resolve(); UserView.TVSeriesManager = Resolve(); UserView.CollectionManager = Resolve(); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index d0d5bb81c1..7812687ea3 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -18,7 +18,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; @@ -47,12 +46,12 @@ namespace Emby.Server.Implementations.Dto private readonly IImageProcessor _imageProcessor; private readonly IProviderManager _providerManager; + private readonly IRecordingsManager _recordingsManager; private readonly IApplicationHost _appHost; private readonly IMediaSourceManager _mediaSourceManager; private readonly Lazy _livetvManagerFactory; - private readonly ILyricManager _lyricManager; private readonly ITrickplayManager _trickplayManager; public DtoService( @@ -62,10 +61,10 @@ namespace Emby.Server.Implementations.Dto IItemRepository itemRepo, IImageProcessor imageProcessor, IProviderManager providerManager, + IRecordingsManager recordingsManager, IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, - ILyricManager lyricManager, ITrickplayManager trickplayManager) { _logger = logger; @@ -74,10 +73,10 @@ namespace Emby.Server.Implementations.Dto _itemRepo = itemRepo; _imageProcessor = imageProcessor; _providerManager = providerManager; + _recordingsManager = recordingsManager; _appHost = appHost; _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; - _lyricManager = lyricManager; _trickplayManager = trickplayManager; } @@ -149,10 +148,6 @@ namespace Emby.Server.Implementations.Dto { LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult(); } - else if (item is Audio) - { - dto.HasLyrics = _lyricManager.HasLyricFile(item); - } if (item is IItemByName itemByName && options.ContainsField(ItemFields.ItemCounts)) @@ -256,8 +251,7 @@ namespace Emby.Server.Implementations.Dto dto.Etag = item.GetEtag(user); } - var liveTvManager = LivetvManager; - var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); + var activeRecording = _recordingsManager.GetActiveRecordingInfo(item.Path); if (activeRecording is not null) { dto.Type = BaseItemKind.Recording; @@ -270,7 +264,12 @@ namespace Emby.Server.Implementations.Dto dto.Name = dto.SeriesName; } - liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); + LivetvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); + } + + if (item is Audio audio) + { + dto.HasLyrics = audio.GetMediaStreams().Any(s => s.Type == MediaStreamType.Lyric); } return dto; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 7998ce34a7..a2abafd2ae 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1232,6 +1232,19 @@ namespace Emby.Server.Implementations.Library return item; } + /// + public T GetItemById(Guid id) + where T : BaseItem + { + var item = GetItemById(id); + if (item is T typedItem) + { + return typedItem; + } + + return null; + } + public List GetItemList(InternalItemsQuery query, bool allowExternalContent) { if (query.Recursive && !query.ParentId.IsEmpty()) @@ -1847,7 +1860,7 @@ namespace Emby.Server.Implementations.Library try { var index = item.GetImageIndex(img); - image = await ConvertImageToLocal(item, img, index).ConfigureAwait(false); + image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false); } catch (ArgumentException) { @@ -2774,7 +2787,7 @@ namespace Emby.Server.Implementations.Library await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); } - public async Task ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex) + public async Task ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure) { foreach (var url in image.Path.Split('|')) { @@ -2793,6 +2806,7 @@ namespace Emby.Server.Implementations.Library if (ex.StatusCode.HasValue && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) { + _logger.LogDebug(ex, "Error downloading image {Url}", url); continue; } @@ -2800,11 +2814,14 @@ namespace Emby.Server.Implementations.Library } } - // Remove this image to prevent it from retrying over and over - item.RemoveImage(image); - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + if (removeOnFailure) + { + // Remove this image to prevent it from retrying over and over + item.RemoveImage(image); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + } - throw new InvalidOperationException(); + throw new InvalidOperationException("Unable to convert any images to local"); } public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index c78ffa28c3..977307b065 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -31,7 +31,7 @@ "VersionNumber": "Versioon {0}", "ValueSpecialEpisodeName": "Eriepisood - {0}", "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", - "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}", + "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}", "UserPasswordChangedWithName": "Kasutaja {0} parool muudeti", "UserLockedOutWithName": "Kasutaja {0} lukustati", "UserDeletedWithName": "Kasutaja {0} kustutati", diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json new file mode 100644 index 0000000000..28e54bff57 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -0,0 +1,3 @@ +{ + "Albums": "Albaim" +} diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index cbccad87ff..7ef9079188 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -122,5 +122,6 @@ "TaskRefreshChapterImagesDescription": "Создава тамбнеил за видеата шти имаат поглавја.", "TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.", "TaskCleanActivityLog": "Избриши Лог на Активности", - "External": "Надворешен" + "External": "Надворешен", + "HearingImpaired": "Оштетен слух" } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index bbb3938dcf..40b3b0339e 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -394,6 +394,7 @@ namespace Emby.Server.Implementations.Session session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex; session.PlayState.PlayMethod = info.PlayMethod; session.PlayState.RepeatMode = info.RepeatMode; + session.PlayState.PlaybackOrder = info.PlaybackOrder; session.PlaylistItemId = info.PlaylistItemId; var nowPlayingQueue = info.NowPlayingQueue; diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs index e72bec46fd..764c0a435f 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -25,16 +26,28 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) { - var user = _userManager.GetUserById(context.User.GetUserId()); - if (user is null) - { - throw new ResourceNotFoundException(); - } - - if (user.HasPermission(requirement.RequiredPermission)) + // Api keys have global permissions, so just succeed the requirement. + if (context.User.GetIsApiKey()) { context.Succeed(requirement); } + else + { + var userId = context.User.GetUserId(); + if (!userId.IsEmpty()) + { + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.HasPermission(requirement.RequiredPermission)) + { + context.Succeed(requirement); + } + } + } return Task.CompletedTask; } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 6f0006832b..1cad663264 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -48,15 +49,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult GetDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client) { + userId = RequestHelpers.GetUserId(User, userId); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) { itemId = displayPreferencesId.GetMD5(); } - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); itemPreferences.ItemId = itemId; @@ -113,10 +116,12 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult UpdateDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client, [FromBody, Required] DisplayPreferencesDto displayPreferences) { + userId = RequestHelpers.GetUserId(User, userId); + HomeSectionType[] defaults = { HomeSectionType.SmallLibraryTiles, @@ -134,7 +139,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemId = displayPreferencesId.GetMD5(); } - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); existingDisplayPreferences.IndexBy = Enum.TryParse(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; @@ -204,7 +209,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemPrefs.ItemId = itemId; // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); return NoContent(); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index c031ce338d..6b38fa7d34 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -11,8 +11,9 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -87,31 +88,26 @@ public class ImageController : BaseJellyfinApiController /// Sets the user image. /// /// User Id. - /// (Unused) Image type. - /// (Unused) Image index. /// Image updated. /// User does not have permission to delete the image. /// A . - [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 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."); } @@ -143,6 +139,28 @@ public class ImageController : BaseJellyfinApiController } } + /// + /// Sets the user image. + /// + /// User Id. + /// (Unused) Image type. + /// Image updated. + /// User does not have permission to delete the image. + /// A . + [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 PostUserImageLegacy( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType) + => PostUserImage(userId); + /// /// Sets the user image. /// @@ -154,53 +172,57 @@ public class ImageController : BaseJellyfinApiController /// A . [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 PostUserImageByIndex( + public Task PostUserImageByIndexLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, [FromRoute] int index) + => PostUserImage(userId); + + /// + /// Delete the user's image. + /// + /// User Id. + /// Image deleted. + /// User does not have permission to delete the image. + /// A . + [HttpDelete("UserImage")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteUserImage( + [FromQuery] Guid? userId) { - var user = _userManager.GetUserById(userId); - if (user is null) + var requestUserId = RequestHelpers.GetUserId(User, userId); + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, requestUserId, true)) { - return NotFound(); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + var user = _userManager.GetUserById(requestUserId); + if (user?.ProfileImage is null) { - 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(); } + + 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(); } /// @@ -214,38 +236,17 @@ public class ImageController : BaseJellyfinApiController /// A . [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 async Task DeleteUserImage( + public Task DeleteUserImageLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, [FromQuery] int? index = null) - { - 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); /// /// Delete the user's image. @@ -258,38 +259,17 @@ public class ImageController : BaseJellyfinApiController /// A . [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 DeleteUserImageByIndex( + public Task 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); /// /// Delete an item's image. @@ -542,7 +522,6 @@ public class ImageController : BaseJellyfinApiController /// Width of box to fill. /// Height of box to fill. /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. The of the returned image. /// Optional. Percent to render for the percent played overlay. /// Optional. Unplayed count overlay to render. @@ -572,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, @@ -623,7 +601,6 @@ public class ImageController : BaseJellyfinApiController /// Width of box to fill. /// Height of box to fill. /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. The of the returned image. /// Optional. Percent to render for the percent played overlay. /// Optional. Unplayed count overlay to render. @@ -653,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, @@ -702,7 +678,6 @@ public class ImageController : BaseJellyfinApiController /// Width of box to fill. /// Height of box to fill. /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Determines the output format of the image - original,gif,jpg,png. /// Optional. Percent to render for the percent played overlay. /// Optional. Unplayed count overlay to render. @@ -732,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, @@ -785,7 +759,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -815,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, @@ -865,7 +837,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -895,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, @@ -946,7 +916,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -976,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) @@ -1025,7 +993,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1055,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, @@ -1106,7 +1072,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1136,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) @@ -1185,7 +1149,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1215,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, @@ -1266,7 +1228,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1296,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) @@ -1345,7 +1305,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1375,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, @@ -1426,7 +1384,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1456,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) @@ -1493,7 +1449,6 @@ public class ImageController : BaseJellyfinApiController /// Get user profile image. /// /// User id. - /// Image type. /// Optional. Supply the cache tag from the item object to receive strong caching headers. /// Determines the output format of the image - original,gif,jpg,png. /// The maximum image width to return. @@ -1505,25 +1460,25 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. /// Image index. /// Image stream returned. + /// User id not provided. /// Item not found. /// /// A containing the file stream on success, /// or a if item not found. /// - [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 GetUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, + [FromQuery] Guid? userId, [FromQuery] string? tag, [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, @@ -1535,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(); @@ -1566,7 +1526,7 @@ public class ImageController : BaseJellyfinApiController return await GetImageInternal( user.Id, - imageType, + ImageType.Profile, imageIndex, tag, format, @@ -1587,6 +1547,75 @@ public class ImageController : BaseJellyfinApiController .ConfigureAwait(false); } + /// + /// Get user profile image. + /// + /// User id. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [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 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); + /// /// Get user profile image. /// @@ -1604,7 +1633,6 @@ public class ImageController : BaseJellyfinApiController /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. /// Width of box to fill. /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. /// Optional. Blur image. /// Optional. Apply a background color for transparent images. /// Optional. Apply a foreground layer on top of the image. @@ -1615,11 +1643,13 @@ public class ImageController : BaseJellyfinApiController /// or a if item not found. /// [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 GetUserImageByIndex( + public Task GetUserImageByIndexLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, [FromRoute, Required] int imageIndex, @@ -1634,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); /// /// Generates or gets the splashscreen. @@ -1993,7 +1993,7 @@ public class ImageController : BaseJellyfinApiController { if (format.HasValue) { - return new[] { format.Value }; + return [format.Value]; } return GetClientSupportedFormats(); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index e7ff1f9868..3cf4852995 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -53,7 +53,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given song. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -63,10 +63,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Songs/{id}/InstantMix")] + [HttpGet("Songs/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromSong( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -75,7 +75,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -90,7 +90,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given album. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -100,10 +100,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Albums/{id}/InstantMix")] + [HttpGet("Albums/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromAlbum( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -112,7 +112,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var album = _libraryManager.GetItemById(id); + var album = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -127,7 +127,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given playlist. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -137,10 +137,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Playlists/{id}/InstantMix")] + [HttpGet("Playlists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromPlaylist( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -149,7 +149,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var playlist = (Playlist)_libraryManager.GetItemById(id); + var playlist = (Playlist)_libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -200,7 +200,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given artist. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -210,10 +210,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Artists/{id}/InstantMix")] + [HttpGet("Artists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromArtists( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -222,7 +222,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -237,7 +237,7 @@ public class InstantMixController : BaseJellyfinApiController /// /// Creates an instant playlist based on a given item. /// - /// The item id. + /// The item id. /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// Optional. Specify additional fields of information to return in the output. @@ -247,10 +247,10 @@ public class InstantMixController : BaseJellyfinApiController /// Optional. The image types to include in the output. /// Instant playlist returned. /// A with the playlist items. - [HttpGet("Items/{id}/InstantMix")] + [HttpGet("Items/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetInstantMixFromItem( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -259,7 +259,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 3b909dcc21..7748ee17ab 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -620,8 +620,10 @@ public class ItemsController : BaseJellyfinApiController /// Optional, include image information in output. /// A with the items. [HttpGet("Users/{userId}/Items")] + [Obsolete("Kept for backwards compatibility")] + [ApiExplorerSettings(IgnoreApi = true)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetItemsByUserId( + public ActionResult> GetItemsByUserIdLegacy( [FromRoute] Guid userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, @@ -709,8 +711,7 @@ public class ItemsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) - { - return GetItems( + => GetItems( userId, maxOfficialRating, hasThemeSong, @@ -798,7 +799,6 @@ public class ItemsController : BaseJellyfinApiController subtitleLanguages, enableTotalRecordCount, enableImages); - } /// /// Gets items based on a query. @@ -820,10 +820,10 @@ public class ItemsController : BaseJellyfinApiController /// Optional. Whether to exclude the currently active sessions. /// Items returned. /// A with the items that are resumable. - [HttpGet("Users/{userId}/Items/Resume")] + [HttpGet("UserItems/Resume")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetResumeItems( - [FromRoute, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, @@ -839,7 +839,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(); @@ -866,7 +867,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(); } @@ -899,6 +900,90 @@ public class ItemsController : BaseJellyfinApiController returnItems); } + /// + /// Gets items based on a query. + /// + /// The user id. + /// The start index. + /// The item limit. + /// The search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// 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. + /// Optional. Filter by MediaType. Allows multiple, comma delimited. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. + /// Optional. Enable the total record count. + /// Optional. Include image information in output. + /// Optional. Whether to exclude the currently active sessions. + /// Items returned. + /// A with the items that are resumable. + [HttpGet("Users/{userId}/Items/Resume")] + [Obsolete("Kept for backwards compatibility")] + [ApiExplorerSettings(IgnoreApi = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + /// + /// Get Item User Data. + /// + /// The user id. + /// The item id. + /// return item user data. + /// Item is not found. + /// Return . + [HttpGet("UserItems/{itemId}/UserData")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetItemUserData( + [FromQuery] Guid? userId, + [FromRoute, Required] Guid itemId) + { + 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(requestUserId) ?? throw new ResourceNotFoundException(); + var item = _libraryManager.GetItemById(itemId); + + return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user); + } + /// /// Get Item User Data. /// @@ -910,19 +995,46 @@ public class ItemsController : BaseJellyfinApiController [HttpGet("Users/{userId}/Items/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetItemUserData( + [Obsolete("Kept for backwards compatibility")] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetItemUserDataLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + => GetItemUserData(userId, itemId); + + /// + /// Update Item User Data. + /// + /// The user id. + /// The item id. + /// New user data object. + /// return updated user item data. + /// Item is not found. + /// Return . + [HttpPost("UserItems/{itemId}/UserData")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemUserData( + [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 view this item user data."); + 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) + { + return NotFound(); + } - return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user); + _userDataRepository.SaveUserData(user, item, userDataDto, UserDataSaveReason.UpdateUserData); + + return _userDataRepository.GetUserDataDto(item, user); } /// @@ -937,25 +1049,11 @@ public class ItemsController : BaseJellyfinApiController [HttpPost("Users/{userId}/Items/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemUserData( + [Obsolete("Kept for backwards compatibility")] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult UpdateItemUserDataLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromBody, Required] UpdateUserItemDataDto userDataDto) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update this item user data."); - } - - var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); - var item = _libraryManager.GetItemById(itemId); - if (item == null) - { - return NotFound(); - } - - _userDataRepository.SaveUserData(user, item, userDataDto, UserDataSaveReason.UpdateUserData); - - return _userDataRepository.GetUserDataDto(item, user); - } + => UpdateItemUserData(userId, itemId, userDataDto); } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index e357588d1d..984dc77896 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -913,6 +913,7 @@ public class LibraryController : BaseJellyfinApiController User.GetUserId()) { ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture) }).ConfigureAwait(false); } catch diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index da68c72c99..7768b3c45f 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -45,6 +45,8 @@ public class LiveTvController : BaseJellyfinApiController private readonly ILiveTvManager _liveTvManager; private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -59,6 +61,8 @@ public class LiveTvController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -70,6 +74,8 @@ public class LiveTvController : BaseJellyfinApiController ILiveTvManager liveTvManager, IGuideManager guideManager, ITunerHostManager tunerHostManager, + IListingsManager listingsManager, + IRecordingsManager recordingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -81,6 +87,8 @@ public class LiveTvController : BaseJellyfinApiController _liveTvManager = liveTvManager; _guideManager = guideManager; _tunerHostManager = tunerHostManager; + _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -628,7 +636,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] public async Task>> GetPrograms([FromBody] GetProgramsDto body) { - var user = body.UserId.IsEmpty() ? null : _userManager.GetUserById(body.UserId); + var user = body.UserId.IsNullOrEmpty() ? null : _userManager.GetUserById(body.UserId.Value); var query = new InternalItemsQuery(user) { @@ -1015,7 +1023,7 @@ public class LiveTvController : BaseJellyfinApiController listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); } /// @@ -1029,7 +1037,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteListingProvider([FromQuery] string? id) { - _liveTvManager.DeleteListingsProvider(id); + _listingsManager.DeleteListingsProvider(id); return NoContent(); } @@ -1050,9 +1058,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? type, [FromQuery] string? location, [FromQuery] string? country) - { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); - } + => await _listingsManager.GetLineups(type, id, country, location).ConfigureAwait(false); /// /// Gets available countries. @@ -1083,48 +1089,20 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("ChannelMappingOptions")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration("livetv"); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + public Task GetChannelMappingOptions([FromQuery] string? providerId) + => _listingsManager.GetChannelMappingOptions(providerId); /// /// Set channel mappings. /// - /// The set channel mapping dto. + /// The set channel mapping dto. /// Created channel mapping returned. /// An containing the created channel mapping. [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + public Task SetChannelMapping([FromBody, Required] SetChannelMappingDto dto) + => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId); /// /// Get tuner host types. @@ -1166,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesVideoFile] public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - + var path = _recordingsManager.GetActiveRecordingPath(recordingId); if (string.IsNullOrWhiteSpace(path)) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs new file mode 100644 index 0000000000..f2b312b478 --- /dev/null +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; +using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers; + +/// +/// Lyrics controller. +/// +[Route("")] +public class LyricsController : BaseJellyfinApiController +{ + private readonly ILibraryManager _libraryManager; + private readonly ILyricManager _lyricManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LyricsController( + ILibraryManager libraryManager, + ILyricManager lyricManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IUserManager userManager) + { + _libraryManager = libraryManager; + _lyricManager = lyricManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _userManager = userManager; + } + + /// + /// Gets an item's lyrics. + /// + /// Item id. + /// Lyrics returned. + /// Something went wrong. No Lyrics will be returned. + /// An containing the item's lyrics. + [HttpGet("Audio/{itemId}/Lyrics")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLyrics([FromRoute, Required] Guid itemId) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + if (!isApiKey && userId.IsEmpty()) + { + return BadRequest(); + } + + var audio = _libraryManager.GetItemById