aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/media_playback.md34
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--.github/workflows/ci-openapi.yml4
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--Emby.Naming/Common/NamingOptions.cs12
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs3
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs13
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs13
-rw-r--r--Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs25
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs15
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs40
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs1
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs2
-rw-r--r--Jellyfin.Api/Controllers/LyricsController.cs267
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs3
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs7
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs46
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs45
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs2
-rw-r--r--Jellyfin.Data/Entities/User.cs1
-rw-r--r--Jellyfin.Data/Enums/PermissionKind.cs7
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs101
-rw-r--r--Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs1
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs2
-rw-r--r--Jellyfin.Server/Startup.cs5
-rw-r--r--MediaBrowser.Common/Api/Policies.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Audio/Audio.cs12
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs19
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricManager.cs100
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricParser.cs4
-rw-r--r--MediaBrowser.Controller/Lyrics/ILyricProvider.cs34
-rw-r--r--MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs26
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs2
-rw-r--r--MediaBrowser.Model/Configuration/LibraryOptions.cs5
-rw-r--r--MediaBrowser.Model/Configuration/MetadataPluginType.cs3
-rw-r--r--MediaBrowser.Model/Dlna/DlnaProfileType.cs3
-rw-r--r--MediaBrowser.Model/Entities/MediaStreamType.cs7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricDto.cs (renamed from MediaBrowser.Controller/Lyrics/LyricResponse.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricFile.cs (renamed from MediaBrowser.Controller/Lyrics/LyricFile.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricLine.cs (renamed from MediaBrowser.Controller/Lyrics/LyricLine.cs)2
-rw-r--r--MediaBrowser.Model/Lyrics/LyricMetadata.cs (renamed from MediaBrowser.Controller/Lyrics/LyricMetadata.cs)7
-rw-r--r--MediaBrowser.Model/Lyrics/LyricResponse.cs19
-rw-r--r--MediaBrowser.Model/Lyrics/LyricSearchRequest.cs59
-rw-r--r--MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs22
-rw-r--r--MediaBrowser.Model/Lyrics/UploadLyricDto.cs16
-rw-r--r--MediaBrowser.Model/Providers/LyricProviderInfo.cs17
-rw-r--r--MediaBrowser.Model/Providers/RemoteLyricInfo.cs29
-rw-r--r--MediaBrowser.Model/Users/UserPolicy.cs6
-rw-r--r--MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs69
-rw-r--r--MediaBrowser.Providers/Lyric/ILyricProvider.cs36
-rw-r--r--MediaBrowser.Providers/Lyric/LrcLyricParser.cs15
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs428
-rw-r--r--MediaBrowser.Providers/Lyric/TxtLyricParser.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs18
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs115
-rw-r--r--MediaBrowser.Providers/MediaInfo/LyricResolver.cs39
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs97
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs50
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs14
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs13
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs2
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs10
-rw-r--r--src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs9
-rw-r--r--src/Jellyfin.LiveTv/DefaultLiveTvService.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs)10
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs19
-rw-r--r--src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs2
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs197
-rw-r--r--src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs220
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs)9
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs (renamed from src/Jellyfin.LiveTv/RecordingNotifier.cs)2
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs (renamed from src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs)10
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs1
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs1
-rw-r--r--src/Jellyfin.LiveTv/Timers/TimerManager.cs4
-rw-r--r--tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs4
81 files changed, 1869 insertions, 646 deletions
diff --git a/.github/ISSUE_TEMPLATE/media_playback.md b/.github/ISSUE_TEMPLATE/media_playback.md
deleted file mode 100644
index b51500f87..000000000
--- 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**
-<!-- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo) or copy the info from the web ui for the file with the playback issue. -->
-
-**Logs**
-<!-- Please paste any log messages from during the playback issue. -->
-
-**FFmpeg Logs**
-<!-- Please paste any FFmpeg logs if remuxing or transcoding appears to be part of the issue. -->
-
-**Stats for Nerds Screenshots**
-<!-- If available, add screenshots of the stats for nerds screen to help show the issue problem. -->
-
-**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 f8012e90e..20307dd7d 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@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3
+ uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3
+ uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3
+ uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 97f1a33e7..c56349941 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@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
+ uses: actions/download-artifact@87c55149d96e628cc2ef7e6fc2aab372015aec85 # v4.1.3
with:
name: openapi-base
path: openapi-base
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 4b5db14ae..8ee6b3028 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/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index b63c8f10e..4bd226d95 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",
@@ -792,6 +799,11 @@ namespace Emby.Naming.Common
public string[] SubtitleFileExtensions { get; set; }
/// <summary>
+ /// Gets the list of lyric file extensions.
+ /// </summary>
+ public string[] LyricFileExtensions { get; }
+
+ /// <summary>
/// Gets or sets list of episode regular expressions.
/// </summary>
public EpisodeExpression[] EpisodeExpressions { get; set; }
diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
index 4080ba10d..9d54533c2 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/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index d372277e0..7812687ea 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;
@@ -53,7 +52,6 @@ namespace Emby.Server.Implementations.Dto
private readonly IMediaSourceManager _mediaSourceManager;
private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
- private readonly ILyricManager _lyricManager;
private readonly ITrickplayManager _trickplayManager;
public DtoService(
@@ -67,7 +65,6 @@ namespace Emby.Server.Implementations.Dto
IApplicationHost appHost,
IMediaSourceManager mediaSourceManager,
Lazy<ILiveTvManager> livetvManagerFactory,
- ILyricManager lyricManager,
ITrickplayManager trickplayManager)
{
_logger = logger;
@@ -80,7 +77,6 @@ namespace Emby.Server.Implementations.Dto
_appHost = appHost;
_mediaSourceManager = mediaSourceManager;
_livetvManagerFactory = livetvManagerFactory;
- _lyricManager = lyricManager;
_trickplayManager = trickplayManager;
}
@@ -152,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))
@@ -275,6 +267,11 @@ namespace Emby.Server.Implementations.Dto
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 7998ce34a..13a381060 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;
}
+ /// <inheritdoc />
+ public T GetItemById<T>(Guid id)
+ where T : BaseItem
+ {
+ var item = GetItemById(id);
+ if (item is T typedItem)
+ {
+ return typedItem;
+ }
+
+ return null;
+ }
+
public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent)
{
if (query.Recursive && !query.ParentId.IsEmpty())
diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs
index e72bec46f..764c0a435 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,15 +26,27 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
{
- var user = _userManager.GetUserById(context.User.GetUserId());
- if (user is null)
+ // Api keys have global permissions, so just succeed the requirement.
+ if (context.User.GetIsApiKey())
{
- throw new ResourceNotFoundException();
+ context.Succeed(requirement);
}
-
- if (user.HasPermission(requirement.RequiredPermission))
+ else
{
- context.Succeed(requirement);
+ 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 6f0006832..1cad66326 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<DisplayPreferencesDto> 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<IndexingKind>(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/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index e7ff1f986..3cf485299 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -53,7 +53,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <summary>
/// Creates an instant playlist based on a given song.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -63,10 +63,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Songs/{id}/InstantMix")]
+ [HttpGet("Songs/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> 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
/// <summary>
/// Creates an instant playlist based on a given album.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -100,10 +100,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Albums/{id}/InstantMix")]
+ [HttpGet("Albums/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> 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
/// <summary>
/// Creates an instant playlist based on a given playlist.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -137,10 +137,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Playlists/{id}/InstantMix")]
+ [HttpGet("Playlists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> 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
/// <summary>
/// Creates an instant playlist based on a given artist.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -210,10 +210,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Artists/{id}/InstantMix")]
+ [HttpGet("Artists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> 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
/// <summary>
/// Creates an instant playlist based on a given item.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
@@ -247,10 +247,10 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Items/{id}/InstantMix")]
+ [HttpGet("Items/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> 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/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index e357588d1..984dc7789 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 78dd7a71c..7768b3c45 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -636,7 +636,7 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> 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)
{
diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs
new file mode 100644
index 000000000..4fccf2cb4
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LyricsController.cs
@@ -0,0 +1,267 @@
+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;
+
+/// <summary>
+/// Lyrics controller.
+/// </summary>
+[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;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ public LyricsController(
+ ILibraryManager libraryManager,
+ ILyricManager lyricManager,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ IUserManager userManager)
+ {
+ _libraryManager = libraryManager;
+ _lyricManager = lyricManager;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets an item's lyrics.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Lyrics returned.</response>
+ /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
+ [HttpGet("Audio/{itemId}/Lyrics")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId)
+ {
+ var isApiKey = User.GetIsApiKey();
+ var userId = User.GetUserId();
+ if (!isApiKey && userId.IsEmpty())
+ {
+ return BadRequest();
+ }
+
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ if (!isApiKey)
+ {
+ var user = _userManager.GetUserById(userId);
+ if (user is null)
+ {
+ return NotFound();
+ }
+
+ // Check the item is visible for the user
+ if (!audio.IsVisible(user))
+ {
+ return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}.");
+ }
+ }
+
+ var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false);
+ if (result is not null)
+ {
+ return Ok(result);
+ }
+
+ return NotFound();
+ }
+
+ /// <summary>
+ /// Upload an external lyric file.
+ /// </summary>
+ /// <param name="itemId">The item the lyric belongs to.</param>
+ /// <param name="fileName">Name of the file being uploaded.</param>
+ /// <response code="200">Lyrics uploaded.</response>
+ /// <response code="400">Error processing upload.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>The uploaded lyric.</returns>
+ [HttpPost("Audio/{itemId}/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [AcceptsFile(MediaTypeNames.Text.Plain)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> UploadLyrics(
+ [FromRoute, Required] Guid itemId,
+ [FromQuery, Required] string fileName)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ if (Request.ContentLength.GetValueOrDefault(0) == 0)
+ {
+ return BadRequest("No lyrics uploaded");
+ }
+
+ // Utilize Path.GetExtension as it provides extra path validation.
+ var format = Path.GetExtension(fileName.AsSpan()).RightPart('.').ToString();
+ if (string.IsNullOrEmpty(format))
+ {
+ return BadRequest("Extension is required on filename");
+ }
+
+ var stream = new MemoryStream();
+ await using (stream.ConfigureAwait(false))
+ {
+ await Request.Body.CopyToAsync(stream).ConfigureAwait(false);
+ var uploadedLyric = await _lyricManager.UploadLyricAsync(
+ audio,
+ new LyricResponse
+ {
+ Format = format,
+ Stream = stream
+ }).ConfigureAwait(false);
+
+ if (uploadedLyric is null)
+ {
+ return BadRequest();
+ }
+
+ _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ return Ok(uploadedLyric);
+ }
+ }
+
+ /// <summary>
+ /// Deletes an external lyric file.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <response code="204">Lyric deleted.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Audio/{itemId}/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> DeleteLyrics(
+ [FromRoute, Required] Guid itemId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Search remote lyrics.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <response code="200">Lyrics retrieved.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>An array of <see cref="RemoteLyricInfo"/>.</returns>
+ [HttpGet("Audio/{itemId}/RemoteSearch/Lyrics")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false);
+ return Ok(results);
+ }
+
+ /// <summary>
+ /// Downloads a remote lyric.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="lyricId">The lyric id.</param>
+ /// <response code="200">Lyric downloaded.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> DownloadRemoteLyrics(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string lyricId)
+ {
+ var audio = _libraryManager.GetItemById<Audio>(itemId);
+ if (audio is null)
+ {
+ return NotFound();
+ }
+
+ var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false);
+ if (downloadedLyrics is null)
+ {
+ return NotFound();
+ }
+
+ _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ return Ok(downloadedLyrics);
+ }
+
+ /// <summary>
+ /// Gets the remote lyrics.
+ /// </summary>
+ /// <param name="lyricId">The remote provider item id.</param>
+ /// <response code="200">File returned.</response>
+ /// <response code="404">Lyric not found.</response>
+ /// <returns>A <see cref="FileStreamResult"/> with the lyric file.</returns>
+ [HttpGet("Providers/Lyrics/{lyricId}")]
+ [Authorize(Policy = Policies.LyricManagement)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<LyricDto>> GetRemoteLyrics([FromRoute, Required] string lyricId)
+ {
+ var result = await _lyricManager.GetRemoteLyricsAsync(lyricId, CancellationToken.None).ConfigureAwait(false);
+ if (result is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(result);
+ }
+}
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index bea545cfd..742012b71 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -64,8 +64,9 @@ public class MediaInfoController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
[HttpGet("Items/{itemId}/PlaybackInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId)
+ public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
+ userId = RequestHelpers.GetUserId(User, userId);
return await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId)
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 921cc6031..0e7c3f155 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -174,7 +174,7 @@ public class PlaylistsController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
[FromRoute, Required] Guid playlistId,
- [FromQuery, Required] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
@@ -183,15 +183,16 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
+ userId = RequestHelpers.GetUserId(User, userId);
var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
if (playlist is null)
{
return NotFound();
}
- var user = userId.IsEmpty()
+ var user = userId.IsNullOrEmpty()
? null
- : _userManager.GetUserById(userId);
+ : _userManager.GetUserById(userId.Value);
var items = playlist.GetManageableItems().ToArray();
var count = items.Length;
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index 49ca058bd..cc2a630e1 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -11,7 +11,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Models.SubtitleDtos;
using MediaBrowser.Common.Api;
@@ -162,17 +161,17 @@ public class SubtitleController : BaseJellyfinApiController
/// <summary>
/// Gets the remote subtitles.
/// </summary>
- /// <param name="id">The item id.</param>
+ /// <param name="subtitleId">The item id.</param>
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
- [HttpGet("Providers/Subtitles/Subtitles/{id}")]
+ [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
[ProducesFile("text/*")]
- public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id)
+ public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string subtitleId)
{
- var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+ var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false);
return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
}
@@ -407,22 +406,29 @@ public class SubtitleController : BaseJellyfinApiController
[FromBody, Required] UploadSubtitleDto body)
{
var video = (Video)_libraryManager.GetItemById(itemId);
- var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read);
- await using (stream.ConfigureAwait(false))
- {
- await _subtitleManager.UploadSubtitle(
- video,
- new SubtitleResponse
- {
- Format = body.Format,
- Language = body.Language,
- IsForced = body.IsForced,
- IsHearingImpaired = body.IsHearingImpaired,
- Stream = stream
- }).ConfigureAwait(false);
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
- return NoContent();
+ var bytes = Encoding.UTF8.GetBytes(body.Data);
+ var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
+ await using (memoryStream.ConfigureAwait(false))
+ {
+ using var transform = new FromBase64Transform();
+ var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read);
+ await using (stream.ConfigureAwait(false))
+ {
+ await _subtitleManager.UploadSubtitle(
+ video,
+ new SubtitleResponse
+ {
+ Format = body.Format,
+ Language = body.Language,
+ IsForced = body.IsForced,
+ IsHearingImpaired = body.IsHearingImpaired,
+ Stream = stream
+ }).ConfigureAwait(false);
+ _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+
+ return NoContent();
+ }
}
}
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 264e0a3db..e3bfd4ea9 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -18,6 +18,7 @@ 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;
@@ -539,48 +540,4 @@ public class UserLibraryController : BaseJellyfinApiController
return _userDataRepository.GetUserDataDto(item, user);
}
-
- /// <summary>
- /// Gets an item's lyrics.
- /// </summary>
- /// <param name="userId">User id.</param>
- /// <param name="itemId">Item id.</param>
- /// <response code="200">Lyrics returned.</response>
- /// <response code="404">Something went wrong. No Lyrics will be returned.</response>
- /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
- [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
- {
- var user = _userManager.GetUserById(userId);
-
- if (user is null)
- {
- return NotFound();
- }
-
- var item = itemId.IsEmpty()
- ? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
- if (item is null)
- {
- return NotFound();
- }
-
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
- var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
- if (result is not null)
- {
- return Ok(result);
- }
-
- return NotFound();
- }
}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index 6a30de5e6..8482b1cf1 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -22,7 +22,7 @@ public class GetProgramsDto
/// <summary>
/// Gets or sets optional. Filter by user id.
/// </summary>
- public Guid UserId { get; set; }
+ public Guid? UserId { get; set; }
/// <summary>
/// Gets or sets the minimum premiere start date.
diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs
index ea0de3016..2c9cc8d78 100644
--- a/Jellyfin.Data/Entities/User.cs
+++ b/Jellyfin.Data/Entities/User.cs
@@ -506,6 +506,7 @@ namespace Jellyfin.Data.Entities
Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false));
Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false));
Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false));
+ Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false));
}
/// <summary>
diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs
index 6644f0151..c3d6705c2 100644
--- a/Jellyfin.Data/Enums/PermissionKind.cs
+++ b/Jellyfin.Data/Enums/PermissionKind.cs
@@ -118,6 +118,11 @@ namespace Jellyfin.Data.Enums
/// <summary>
/// Whether the user can edit subtitles.
/// </summary>
- EnableSubtitleManagement = 22
+ EnableSubtitleManagement = 22,
+
+ /// <summary>
+ /// Whether the user can edit lyrics.
+ /// </summary>
+ EnableLyricManagement = 23,
}
}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs
new file mode 100644
index 000000000..bd717b0af
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Library;
+
+/// <summary>
+/// Creates an entry in the activity log whenever a lyric download fails.
+/// </summary>
+public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEventArgs>
+{
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricDownloadFailureLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public LyricDownloadFailureLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(LyricDownloadFailureEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"),
+ eventArgs.Provider,
+ GetItemName(eventArgs.Item)),
+ "LyricDownloadFailure",
+ Guid.Empty)
+ {
+ ItemId = eventArgs.Item.Id.ToString("N", CultureInfo.InvariantCulture),
+ ShortOverview = eventArgs.Exception.Message
+ }).ConfigureAwait(false);
+ }
+
+ private static string GetItemName(BaseItem item)
+ {
+ var name = item.Name;
+ if (item is Episode episode)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "Ep{0} - {1}",
+ episode.IndexNumber.Value,
+ name);
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "S{0}, {1}",
+ episode.ParentIndexNumber.Value,
+ name);
+ }
+ }
+
+ if (item is IHasSeries hasSeries)
+ {
+ name = hasSeries.SeriesName + " - " + name;
+ }
+
+ if (item is IHasAlbumArtist hasAlbumArtist)
+ {
+ var artists = hasAlbumArtist.AlbumArtists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+ else if (item is IHasArtist hasArtist)
+ {
+ var artists = hasArtist.Artists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+
+ return name;
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
index 9626817e9..d1db6d3b4 100644
--- a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -30,6 +31,7 @@ namespace Jellyfin.Server.Implementations.Events
public static void AddEventServices(this IServiceCollection collection)
{
// Library consumers
+ collection.AddScoped<IEventConsumer<LyricDownloadFailureEventArgs>, LyricDownloadFailureLogger>();
collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>();
// Security consumers
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index c4a2bfdb8..41f1ac351 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -688,6 +688,7 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
+ user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 46df173bf..597643ed1 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -37,7 +37,6 @@ using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
-using IPNetwork = System.Net.IPNetwork;
namespace Jellyfin.Server.Extensions
{
@@ -83,6 +82,7 @@ namespace Jellyfin.Server.Extensions
options.AddPolicy(Policies.SyncPlayJoinGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup));
options.AddPolicy(Policies.SyncPlayIsInGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup));
options.AddPolicy(Policies.SubtitleManagement, new UserPermissionRequirement(PermissionKind.EnableSubtitleManagement));
+ options.AddPolicy(Policies.LyricManagement, new UserPermissionRequirement(PermissionKind.EnableLyricManagement));
options.AddPolicy(
Policies.RequiresElevation,
policy => policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication)
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 558ad5b7b..e9fb3e4c2 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -6,9 +6,8 @@ using System.Net.Mime;
using System.Text;
using Emby.Server.Implementations.EntryPoints;
using Jellyfin.Api.Middleware;
-using Jellyfin.LiveTv;
-using Jellyfin.LiveTv.EmbyTV;
using Jellyfin.LiveTv.Extensions;
+using Jellyfin.LiveTv.Recordings;
using Jellyfin.MediaEncoding.Hls.Extensions;
using Jellyfin.Networking;
using Jellyfin.Networking.HappyEyeballs;
@@ -128,7 +127,7 @@ namespace Jellyfin.Server
services.AddHlsPlaylistGenerator();
services.AddLiveTvServices();
- services.AddHostedService<LiveTvHost>();
+ services.AddHostedService<RecordingsHost>();
services.AddHostedService<AutoDiscoveryHost>();
services.AddHostedService<PortForwardingHost>();
services.AddHostedService<NfoUserDataSaver>();
diff --git a/MediaBrowser.Common/Api/Policies.cs b/MediaBrowser.Common/Api/Policies.cs
index e5427b8ef..435f4798f 100644
--- a/MediaBrowser.Common/Api/Policies.cs
+++ b/MediaBrowser.Common/Api/Policies.cs
@@ -89,4 +89,9 @@ public static class Policies
/// Policy name for accessing subtitles management.
/// </summary>
public const string SubtitleManagement = "SubtitleManagement";
+
+ /// <summary>
+ /// Policy name for accessing lyric management.
+ /// </summary>
+ public const string LyricManagement = "LyricManagement";
}
diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs
index 243d2f04f..709d4b70c 100644
--- a/MediaBrowser.Controller/Entities/Audio/Audio.cs
+++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
@@ -27,6 +28,7 @@ namespace MediaBrowser.Controller.Entities.Audio
{
Artists = Array.Empty<string>();
AlbumArtists = Array.Empty<string>();
+ LyricFiles = Array.Empty<string>();
}
/// <inheritdoc />
@@ -65,6 +67,16 @@ namespace MediaBrowser.Controller.Entities.Audio
[JsonIgnore]
public override MediaType MediaType => MediaType.Audio;
+ /// <summary>
+ /// Gets or sets a value indicating whether this audio has lyrics.
+ /// </summary>
+ public bool? HasLyrics { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of lyric paths.
+ /// </summary>
+ public IReadOnlyList<string> LyricFiles { get; set; }
+
public override double GetDefaultPrimaryImageAspectRatio()
{
return 1;
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 9ec22324f..e44c09783 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -169,6 +169,15 @@ namespace MediaBrowser.Controller.Library
BaseItem GetItemById(Guid id);
/// <summary>
+ /// Gets the item by id, as T.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <typeparam name="T">The type of item.</typeparam>
+ /// <returns>The item.</returns>
+ T GetItemById<T>(Guid id)
+ where T : BaseItem;
+
+ /// <summary>
/// Gets the intros.
/// </summary>
/// <param name="item">The item.</param>
diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
index ed08cdc47..c0e46ba24 100644
--- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
+++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
@@ -10,7 +10,6 @@ using Jellyfin.Data.Entities;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
@@ -106,16 +105,6 @@ namespace MediaBrowser.Controller.LiveTv
Task<QueryResult<SeriesTimerInfoDto>> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken);
/// <summary>
- /// Gets the channel stream.
- /// </summary>
- /// <param name="id">The identifier.</param>
- /// <param name="mediaSourceId">The media source identifier.</param>
- /// <param name="currentLiveStreams">The current live streams.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task{StreamResponseInfo}.</returns>
- Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken);
-
- /// <summary>
/// Gets the program.
/// </summary>
/// <param name="id">The identifier.</param>
@@ -221,14 +210,6 @@ namespace MediaBrowser.Controller.LiveTv
QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken);
/// <summary>
- /// Gets the channel media sources.
- /// </summary>
- /// <param name="item">Item to search for.</param>
- /// <param name="cancellationToken">CancellationToken to use for operation.</param>
- /// <returns>Channel media sources wrapped in a task.</returns>
- Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken);
-
- /// <summary>
/// Adds the information to program dto.
/// </summary>
/// <param name="programs">The programs.</param>
diff --git a/MediaBrowser.Controller/Lyrics/ILyricManager.cs b/MediaBrowser.Controller/Lyrics/ILyricManager.cs
index bb93e1e4c..f4376a1ee 100644
--- a/MediaBrowser.Controller/Lyrics/ILyricManager.cs
+++ b/MediaBrowser.Controller/Lyrics/ILyricManager.cs
@@ -1,5 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
namespace MediaBrowser.Controller.Lyrics;
@@ -9,16 +16,93 @@ namespace MediaBrowser.Controller.Lyrics;
public interface ILyricManager
{
/// <summary>
- /// Gets the lyrics.
+ /// Occurs when a lyric download fails.
/// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>A task representing found lyrics the passed item.</returns>
- Task<LyricResponse?> GetLyrics(BaseItem item);
+ event EventHandler<LyricDownloadFailureEventArgs> LyricDownloadFailure;
/// <summary>
- /// Checks if requested item has a matching local lyric file.
+ /// Search for lyrics for the specified song.
/// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>True if item has a matching lyric file; otherwise false.</returns>
- bool HasLyricFile(BaseItem item);
+ /// <param name="audio">The song.</param>
+ /// <param name="isAutomated">Whether the request is automated.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The list of lyrics.</returns>
+ Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+ Audio audio,
+ bool isAutomated,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Search for lyrics.
+ /// </summary>
+ /// <param name="request">The search request.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The list of lyrics.</returns>
+ Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(
+ LyricSearchRequest request,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Download the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio.</param>
+ /// <param name="lyricId">The remote lyric id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The downloaded lyrics.</returns>
+ Task<LyricDto?> DownloadLyricsAsync(
+ Audio audio,
+ string lyricId,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Download the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio.</param>
+ /// <param name="libraryOptions">The library options to use.</param>
+ /// <param name="lyricId">The remote lyric id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The downloaded lyrics.</returns>
+ Task<LyricDto?> DownloadLyricsAsync(
+ Audio audio,
+ LibraryOptions libraryOptions,
+ string lyricId,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Upload new lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file the lyrics belong to.</param>
+ /// <param name="lyricResponse">The lyric response.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse);
+
+ /// <summary>
+ /// Get the remote lyrics.
+ /// </summary>
+ /// <param name="id">The remote lyrics id.</param>
+ /// <param name="cancellationToken">CancellationToken to use for the operation.</param>
+ /// <returns>The lyric response.</returns>
+ Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes the lyrics.
+ /// </summary>
+ /// <param name="audio">The audio file to remove lyrics from.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task DeleteLyricsAsync(Audio audio);
+
+ /// <summary>
+ /// Get the list of lyric providers.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>Lyric providers.</returns>
+ IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item);
+
+ /// <summary>
+ /// Get the existing lyric for the audio.
+ /// </summary>
+ /// <param name="audio">The audio item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The parsed lyric model.</returns>
+ Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Lyrics/ILyricParser.cs b/MediaBrowser.Controller/Lyrics/ILyricParser.cs
index 65a9471a3..819950d09 100644
--- a/MediaBrowser.Controller/Lyrics/ILyricParser.cs
+++ b/MediaBrowser.Controller/Lyrics/ILyricParser.cs
@@ -1,5 +1,5 @@
using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Providers.Lyric;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Controller.Lyrics;
@@ -24,5 +24,5 @@ public interface ILyricParser
/// </summary>
/// <param name="lyrics">The raw lyrics content.</param>
/// <returns>The parsed lyrics or null if invalid.</returns>
- LyricResponse? ParseLyrics(LyricFile lyrics);
+ LyricDto? ParseLyrics(LyricFile lyrics);
}
diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs
new file mode 100644
index 000000000..0831a4c4e
--- /dev/null
+++ b/MediaBrowser.Controller/Lyrics/ILyricProvider.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Controller.Lyrics;
+
+/// <summary>
+/// Interface ILyricsProvider.
+/// </summary>
+public interface ILyricProvider
+{
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ string Name { get; }
+
+ /// <summary>
+ /// Search for lyrics.
+ /// </summary>
+ /// <param name="request">The search request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The list of remote lyrics.</returns>
+ Task<IEnumerable<RemoteLyricInfo>> SearchAsync(LyricSearchRequest request, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Get the lyrics.
+ /// </summary>
+ /// <param name="id">The remote lyric id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The lyric response.</returns>
+ Task<LyricResponse?> GetLyricsAsync(string id, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs
new file mode 100644
index 000000000..1b1f36020
--- /dev/null
+++ b/MediaBrowser.Controller/Lyrics/LyricDownloadFailureEventArgs.cs
@@ -0,0 +1,26 @@
+using System;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Lyrics
+{
+ /// <summary>
+ /// An event that occurs when subtitle downloading fails.
+ /// </summary>
+ public class LyricDownloadFailureEventArgs : EventArgs
+ {
+ /// <summary>
+ /// Gets or sets the item.
+ /// </summary>
+ public required BaseItem Item { get; set; }
+
+ /// <summary>
+ /// Gets or sets the provider.
+ /// </summary>
+ public required string Provider { get; set; }
+
+ /// <summary>
+ /// Gets or sets the exception.
+ /// </summary>
+ public required Exception Exception { get; set; }
+ }
+}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index 146b30643..8bace15c6 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -437,7 +437,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
}
}
- using var process = new Process
+ var process = new Process
{
StartInfo = new ProcessStartInfo
{
diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs
index 1c071067d..42148a276 100644
--- a/MediaBrowser.Model/Configuration/LibraryOptions.cs
+++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.ComponentModel;
namespace MediaBrowser.Model.Configuration
{
@@ -20,6 +21,7 @@ namespace MediaBrowser.Model.Configuration
AutomaticallyAddToCollection = false;
EnablePhotos = true;
SaveSubtitlesWithMedia = true;
+ SaveLyricsWithMedia = true;
PathInfos = Array.Empty<MediaPathInfo>();
EnableAutomaticSeriesGrouping = true;
SeasonZeroDisplayName = "Specials";
@@ -92,6 +94,9 @@ namespace MediaBrowser.Model.Configuration
public bool SaveSubtitlesWithMedia { get; set; }
+ [DefaultValue(true)]
+ public bool SaveLyricsWithMedia { get; set; }
+
public bool AutomaticallyAddToCollection { get; set; }
public EmbeddedSubtitleOptions AllowEmbeddedSubtitles { get; set; }
diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
index 4c5e95266..ef303726d 100644
--- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs
+++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
@@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Configuration
LocalMetadataProvider,
MetadataFetcher,
MetadataSaver,
- SubtitleFetcher
+ SubtitleFetcher,
+ LyricFetcher
}
}
diff --git a/MediaBrowser.Model/Dlna/DlnaProfileType.cs b/MediaBrowser.Model/Dlna/DlnaProfileType.cs
index c1a663bf1..1bb885c44 100644
--- a/MediaBrowser.Model/Dlna/DlnaProfileType.cs
+++ b/MediaBrowser.Model/Dlna/DlnaProfileType.cs
@@ -7,6 +7,7 @@ namespace MediaBrowser.Model.Dlna
Audio = 0,
Video = 1,
Photo = 2,
- Subtitle = 3
+ Subtitle = 3,
+ Lyric = 4
}
}
diff --git a/MediaBrowser.Model/Entities/MediaStreamType.cs b/MediaBrowser.Model/Entities/MediaStreamType.cs
index 83751a6a7..0964bb769 100644
--- a/MediaBrowser.Model/Entities/MediaStreamType.cs
+++ b/MediaBrowser.Model/Entities/MediaStreamType.cs
@@ -28,6 +28,11 @@ namespace MediaBrowser.Model.Entities
/// <summary>
/// The data.
/// </summary>
- Data
+ Data,
+
+ /// <summary>
+ /// The lyric.
+ /// </summary>
+ Lyric
}
}
diff --git a/MediaBrowser.Controller/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricDto.cs
index 0d52b5ec5..7a9bffc99 100644
--- a/MediaBrowser.Controller/Lyrics/LyricResponse.cs
+++ b/MediaBrowser.Model/Lyrics/LyricDto.cs
@@ -1,12 +1,11 @@
-using System;
using System.Collections.Generic;
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// LyricResponse model.
/// </summary>
-public class LyricResponse
+public class LyricDto
{
/// <summary>
/// Gets or sets Metadata for the lyrics.
@@ -16,5 +15,5 @@ public class LyricResponse
/// <summary>
/// Gets or sets a collection of individual lyric lines.
/// </summary>
- public IReadOnlyList<LyricLine> Lyrics { get; set; } = Array.Empty<LyricLine>();
+ public IReadOnlyList<LyricLine> Lyrics { get; set; } = [];
}
diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Model/Lyrics/LyricFile.cs
index ede89403c..3912b037e 100644
--- a/MediaBrowser.Controller/Lyrics/LyricFile.cs
+++ b/MediaBrowser.Model/Lyrics/LyricFile.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Providers.Lyric;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// The information for a raw lyrics file before parsing.
diff --git a/MediaBrowser.Controller/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs
index c406f92fc..64d1f64c2 100644
--- a/MediaBrowser.Controller/Lyrics/LyricLine.cs
+++ b/MediaBrowser.Model/Lyrics/LyricLine.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// Lyric model.
diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Model/Lyrics/LyricMetadata.cs
index c4f033489..4f819d6c9 100644
--- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs
+++ b/MediaBrowser.Model/Lyrics/LyricMetadata.cs
@@ -1,4 +1,4 @@
-namespace MediaBrowser.Controller.Lyrics;
+namespace MediaBrowser.Model.Lyrics;
/// <summary>
/// LyricMetadata model.
@@ -49,4 +49,9 @@ public class LyricMetadata
/// Gets or sets the version of the creator used.
/// </summary>
public string? Version { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this lyric is synced.
+ /// </summary>
+ public bool? IsSynced { get; set; }
}
diff --git a/MediaBrowser.Model/Lyrics/LyricResponse.cs b/MediaBrowser.Model/Lyrics/LyricResponse.cs
new file mode 100644
index 000000000..b04adeb7b
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/LyricResponse.cs
@@ -0,0 +1,19 @@
+using System.IO;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// LyricResponse model.
+/// </summary>
+public class LyricResponse
+{
+ /// <summary>
+ /// Gets or sets the lyric stream.
+ /// </summary>
+ public required Stream Stream { get; set; }
+
+ /// <summary>
+ /// Gets or sets the lyric format.
+ /// </summary>
+ public required string Format { get; set; }
+}
diff --git a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
new file mode 100644
index 000000000..48c442a55
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Lyric search request.
+/// </summary>
+public class LyricSearchRequest : IHasProviderIds
+{
+ /// <summary>
+ /// Gets or sets the media path.
+ /// </summary>
+ public string? MediaPath { get; set; }
+
+ /// <summary>
+ /// Gets or sets the artist name.
+ /// </summary>
+ public IReadOnlyList<string>? ArtistNames { get; set; }
+
+ /// <summary>
+ /// Gets or sets the album name.
+ /// </summary>
+ public string? AlbumName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the song name.
+ /// </summary>
+ public string? SongName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the track duration in ticks.
+ /// </summary>
+ public long? Duration { get; set; }
+
+ /// <inheritdoc />
+ public Dictionary<string, string> ProviderIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to search all providers.
+ /// </summary>
+ public bool SearchAllProviders { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the list of disabled lyric fetcher names.
+ /// </summary>
+ public IReadOnlyList<string> DisabledLyricFetchers { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets the order of lyric fetchers.
+ /// </summary>
+ public IReadOnlyList<string> LyricFetcherOrder { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this request is automated.
+ /// </summary>
+ public bool IsAutomated { get; set; }
+}
diff --git a/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
new file mode 100644
index 000000000..dda56d198
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/RemoteLyricInfoDto.cs
@@ -0,0 +1,22 @@
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// The remote lyric info dto.
+/// </summary>
+public class RemoteLyricInfoDto
+{
+ /// <summary>
+ /// Gets or sets the id for the lyric.
+ /// </summary>
+ public required string Id { get; set; }
+
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string ProviderName { get; init; }
+
+ /// <summary>
+ /// Gets the lyrics.
+ /// </summary>
+ public required LyricDto Lyrics { get; init; }
+}
diff --git a/MediaBrowser.Model/Lyrics/UploadLyricDto.cs b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs
new file mode 100644
index 000000000..0ea8a4c63
--- /dev/null
+++ b/MediaBrowser.Model/Lyrics/UploadLyricDto.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Http;
+
+namespace MediaBrowser.Model.Lyrics;
+
+/// <summary>
+/// Upload lyric dto.
+/// </summary>
+public class UploadLyricDto
+{
+ /// <summary>
+ /// Gets or sets the lyrics file.
+ /// </summary>
+ [Required]
+ public IFormFile Lyrics { get; set; } = null!;
+}
diff --git a/MediaBrowser.Model/Providers/LyricProviderInfo.cs b/MediaBrowser.Model/Providers/LyricProviderInfo.cs
new file mode 100644
index 000000000..ea9c94185
--- /dev/null
+++ b/MediaBrowser.Model/Providers/LyricProviderInfo.cs
@@ -0,0 +1,17 @@
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// Lyric provider info.
+/// </summary>
+public class LyricProviderInfo
+{
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string Name { get; init; }
+
+ /// <summary>
+ /// Gets the provider id.
+ /// </summary>
+ public required string Id { get; init; }
+}
diff --git a/MediaBrowser.Model/Providers/RemoteLyricInfo.cs b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs
new file mode 100644
index 000000000..9fb340a58
--- /dev/null
+++ b/MediaBrowser.Model/Providers/RemoteLyricInfo.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Model.Lyrics;
+
+namespace MediaBrowser.Model.Providers;
+
+/// <summary>
+/// The remote lyric info.
+/// </summary>
+public class RemoteLyricInfo
+{
+ /// <summary>
+ /// Gets or sets the id for the lyric.
+ /// </summary>
+ public required string Id { get; set; }
+
+ /// <summary>
+ /// Gets the provider name.
+ /// </summary>
+ public required string ProviderName { get; init; }
+
+ /// <summary>
+ /// Gets the lyric metadata.
+ /// </summary>
+ public required LyricMetadata Metadata { get; init; }
+
+ /// <summary>
+ /// Gets the lyrics.
+ /// </summary>
+ public required LyricResponse Lyrics { get; init; }
+}
diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs
index 219ed5d5f..951e05763 100644
--- a/MediaBrowser.Model/Users/UserPolicy.cs
+++ b/MediaBrowser.Model/Users/UserPolicy.cs
@@ -93,6 +93,12 @@ namespace MediaBrowser.Model.Users
public bool EnableSubtitleManagement { get; set; }
/// <summary>
+ /// Gets or sets a value indicating whether this user can manage lyrics.
+ /// </summary>
+ [DefaultValue(false)]
+ public bool EnableLyricManagement { get; set; }
+
+ /// <summary>
/// Gets or sets a value indicating whether this instance is disabled.
/// </summary>
/// <value><c>true</c> if this instance is disabled; otherwise, <c>false</c>.</value>
diff --git a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs
deleted file mode 100644
index ab09f278a..000000000
--- a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <inheritdoc />
-public class DefaultLyricProvider : ILyricProvider
-{
- private static readonly string[] _lyricExtensions = { ".lrc", ".elrc", ".txt" };
-
- /// <inheritdoc />
- public string Name => "DefaultLyricProvider";
-
- /// <inheritdoc />
- public ResolverPriority Priority => ResolverPriority.First;
-
- /// <inheritdoc />
- public bool HasLyrics(BaseItem item)
- {
- var path = GetLyricsPath(item);
- return path is not null;
- }
-
- /// <inheritdoc />
- public async Task<LyricFile?> GetLyrics(BaseItem item)
- {
- var path = GetLyricsPath(item);
- if (path is not null)
- {
- var content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
- if (!string.IsNullOrEmpty(content))
- {
- return new LyricFile(path, content);
- }
- }
-
- return null;
- }
-
- private string? GetLyricsPath(BaseItem item)
- {
- // Ensure the path to the item is not null
- string? itemDirectoryPath = Path.GetDirectoryName(item.Path);
- if (itemDirectoryPath is null)
- {
- return null;
- }
-
- // Ensure the directory path exists
- if (!Directory.Exists(itemDirectoryPath))
- {
- return null;
- }
-
- foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(item.Path)}.*"))
- {
- if (_lyricExtensions.Contains(Path.GetExtension(lyricFilePath.AsSpan()), StringComparison.OrdinalIgnoreCase))
- {
- return lyricFilePath;
- }
- }
-
- return null;
- }
-}
diff --git a/MediaBrowser.Providers/Lyric/ILyricProvider.cs b/MediaBrowser.Providers/Lyric/ILyricProvider.cs
deleted file mode 100644
index 27ceba72b..000000000
--- a/MediaBrowser.Providers/Lyric/ILyricProvider.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Resolvers;
-
-namespace MediaBrowser.Providers.Lyric;
-
-/// <summary>
-/// Interface ILyricsProvider.
-/// </summary>
-public interface ILyricProvider
-{
- /// <summary>
- /// Gets a value indicating the provider name.
- /// </summary>
- string Name { get; }
-
- /// <summary>
- /// Gets the priority.
- /// </summary>
- /// <value>The priority.</value>
- ResolverPriority Priority { get; }
-
- /// <summary>
- /// Checks if an item has lyrics available.
- /// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>Whether lyrics where found or not.</returns>
- bool HasLyrics(BaseItem item);
-
- /// <summary>
- /// Gets the lyrics.
- /// </summary>
- /// <param name="item">The media item.</param>
- /// <returns>A task representing found lyrics.</returns>
- Task<LyricFile?> GetLyrics(BaseItem item);
-}
diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
index a10ff198b..67b26e457 100644
--- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
+++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
@@ -8,6 +8,7 @@ using LrcParser.Model;
using LrcParser.Parser;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
@@ -18,8 +19,8 @@ public class LrcLyricParser : ILyricParser
{
private readonly LyricParser _lrcLyricParser;
- private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc" };
- private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" };
+ private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc"];
+ private static readonly string[] _acceptedTimeFormats = ["HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss"];
/// <summary>
/// Initializes a new instance of the <see cref="LrcLyricParser"/> class.
@@ -39,7 +40,7 @@ public class LrcLyricParser : ILyricParser
public ResolverPriority Priority => ResolverPriority.Fourth;
/// <inheritdoc />
- public LyricResponse? ParseLyrics(LyricFile lyrics)
+ public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
@@ -95,7 +96,7 @@ public class LrcLyricParser : ILyricParser
return null;
}
- List<LyricLine> lyricList = new();
+ List<LyricLine> lyricList = [];
for (int i = 0; i < sortedLyricData.Count; i++)
{
@@ -106,7 +107,7 @@ public class LrcLyricParser : ILyricParser
}
long ticks = TimeSpan.FromMilliseconds(timeData.Value).Ticks;
- lyricList.Add(new LyricLine(sortedLyricData[i].Text, ticks));
+ lyricList.Add(new LyricLine(sortedLyricData[i].Text.Trim(), ticks));
}
if (fileMetaData.Count != 0)
@@ -114,10 +115,10 @@ public class LrcLyricParser : ILyricParser
// Map metaData values from LRC file to LyricMetadata properties
LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData);
- return new LyricResponse { Metadata = lyricMetadata, Lyrics = lyricList };
+ return new LyricDto { Metadata = lyricMetadata, Lyrics = lyricList };
}
- return new LyricResponse { Lyrics = lyricList };
+ return new LyricDto { Lyrics = lyricList };
}
/// <summary>
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 6da811927..60734b89a 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -1,8 +1,25 @@
+using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
using System.Linq;
+using System.Text;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Lyrics;
+using MediaBrowser.Model.Providers;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Lyric;
@@ -11,37 +28,246 @@ namespace MediaBrowser.Providers.Lyric;
/// </summary>
public class LyricManager : ILyricManager
{
+ private readonly ILogger<LyricManager> _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IMediaSourceManager _mediaSourceManager;
+
private readonly ILyricProvider[] _lyricProviders;
private readonly ILyricParser[] _lyricParsers;
/// <summary>
/// Initializes a new instance of the <see cref="LyricManager"/> class.
/// </summary>
- /// <param name="lyricProviders">All found lyricProviders.</param>
- /// <param name="lyricParsers">All found lyricParsers.</param>
- public LyricManager(IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers)
+ /// <param name="logger">Instance of the <see cref="ILogger{LyricManager}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
+ /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
+ public LyricManager(
+ ILogger<LyricManager> logger,
+ IFileSystem fileSystem,
+ ILibraryMonitor libraryMonitor,
+ IMediaSourceManager mediaSourceManager,
+ IEnumerable<ILyricProvider> lyricProviders,
+ IEnumerable<ILyricParser> lyricParsers)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _libraryMonitor = libraryMonitor;
+ _mediaSourceManager = mediaSourceManager;
+ _lyricProviders = lyricProviders
+ .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
+ .ToArray();
+ _lyricParsers = lyricParsers
+ .OrderBy(l => l.Priority)
+ .ToArray();
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<LyricDownloadFailureEventArgs>? LyricDownloadFailure;
+
+ /// <inheritdoc />
+ public Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(Audio audio, bool isAutomated, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+
+ var request = new LyricSearchRequest
+ {
+ MediaPath = audio.Path,
+ SongName = audio.Name,
+ AlbumName = audio.Album,
+ ArtistNames = audio.GetAllArtists().ToList(),
+ Duration = audio.RunTimeTicks,
+ IsAutomated = isAutomated
+ };
+
+ return SearchLyricsAsync(request, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public async Task<IReadOnlyList<RemoteLyricInfoDto>> SearchLyricsAsync(LyricSearchRequest request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var providers = _lyricProviders
+ .Where(i => !request.DisabledLyricFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))
+ .OrderBy(i =>
+ {
+ var index = request.LyricFetcherOrder.IndexOf(i.Name);
+ return index == -1 ? int.MaxValue : index;
+ })
+ .ToArray();
+
+ // If not searching all, search one at a time until something is found
+ if (!request.SearchAllProviders)
+ {
+ foreach (var provider in providers)
+ {
+ var providerResult = await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false);
+ if (providerResult.Count > 0)
+ {
+ return providerResult;
+ }
+ }
+
+ return [];
+ }
+
+ var tasks = providers.Select(async provider => await InternalSearchProviderAsync(provider, request, cancellationToken).ConfigureAwait(false));
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ return results.SelectMany(i => i).ToArray();
+ }
+
+ /// <inheritdoc />
+ public Task<LyricDto?> DownloadLyricsAsync(Audio audio, string lyricId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+ return DownloadLyricsAsync(audio, libraryOptions, lyricId, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> DownloadLyricsAsync(Audio audio, LibraryOptions libraryOptions, string lyricId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentNullException.ThrowIfNull(libraryOptions);
+ ArgumentException.ThrowIfNullOrWhiteSpace(lyricId);
+
+ var provider = GetProvider(lyricId.AsSpan().LeftPart('_').ToString());
+ if (provider is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var response = await InternalGetRemoteLyricsAsync(lyricId, cancellationToken).ConfigureAwait(false);
+ if (response is null)
+ {
+ _logger.LogDebug("Unable to download lyrics for {LyricId}", lyricId);
+ return null;
+ }
+
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(response, cancellationToken).ConfigureAwait(false);
+ if (parsedLyrics is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, response).ConfigureAwait(false);
+ return parsedLyrics;
+ }
+ catch (RateLimitExceededException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ LyricDownloadFailure?.Invoke(this, new LyricDownloadFailureEventArgs
+ {
+ Item = audio,
+ Exception = ex,
+ Provider = provider.Name
+ });
+
+ throw;
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> UploadLyricAsync(Audio audio, LyricResponse lyricResponse)
{
- _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray();
- _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray();
+ ArgumentNullException.ThrowIfNull(audio);
+ ArgumentNullException.ThrowIfNull(lyricResponse);
+ var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(audio);
+
+ var parsed = await InternalParseRemoteLyricsAsync(lyricResponse, CancellationToken.None).ConfigureAwait(false);
+ if (parsed is null)
+ {
+ return null;
+ }
+
+ await TrySaveLyric(audio, libraryOptions, lyricResponse).ConfigureAwait(false);
+ return parsed;
+ }
+
+ /// <inheritdoc />
+ public async Task<LyricDto?> GetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+
+ var lyricResponse = await InternalGetRemoteLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+ if (lyricResponse is null)
+ {
+ return null;
+ }
+
+ return await InternalParseRemoteLyricsAsync(lyricResponse, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
- public async Task<LyricResponse?> GetLyrics(BaseItem item)
+ public Task DeleteLyricsAsync(Audio audio)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ ArgumentNullException.ThrowIfNull(audio);
+ var streams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery
+ {
+ ItemId = audio.Id,
+ Type = MediaStreamType.Lyric
+ });
+
+ foreach (var stream in streams)
{
- var lyrics = await provider.GetLyrics(item).ConfigureAwait(false);
- if (lyrics is null)
+ var path = stream.Path;
+ _libraryMonitor.ReportFileSystemChangeBeginning(path);
+
+ try
{
- continue;
+ _fileSystem.DeleteFile(path);
}
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(path, false);
+ }
+ }
+
+ return audio.RefreshMetadata(CancellationToken.None);
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyList<LyricProviderInfo> GetSupportedProviders(BaseItem item)
+ {
+ if (item is not Audio)
+ {
+ return [];
+ }
+
+ return _lyricProviders.Select(p => new LyricProviderInfo { Name = p.Name, Id = GetProviderId(p.Name) }).ToList();
+ }
- foreach (ILyricParser parser in _lyricParsers)
+ /// <inheritdoc />
+ public async Task<LyricDto?> GetLyricsAsync(Audio audio, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(audio);
+
+ var lyricStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric);
+ foreach (var lyricStream in lyricStreams)
+ {
+ var lyricContents = await File.ReadAllTextAsync(lyricStream.Path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
+
+ var lyricFile = new LyricFile(Path.GetFileName(lyricStream.Path), lyricContents);
+ foreach (var parser in _lyricParsers)
{
- var result = parser.ParseLyrics(lyrics);
- if (result is not null)
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
+ if (parsedLyrics is not null)
{
- return result;
+ return parsedLyrics;
}
}
}
@@ -49,22 +275,180 @@ public class LyricManager : ILyricManager
return null;
}
- /// <inheritdoc />
- public bool HasLyricFile(BaseItem item)
+ private ILyricProvider? GetProvider(string providerId)
+ {
+ var provider = _lyricProviders.FirstOrDefault(p => string.Equals(providerId, GetProviderId(p.Name), StringComparison.Ordinal));
+ if (provider is null)
+ {
+ _logger.LogWarning("Unknown provider id: {ProviderId}", providerId.ReplaceLineEndings(string.Empty));
+ }
+
+ return provider;
+ }
+
+ private string GetProviderId(string name)
+ => name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+ private async Task<LyricDto?> InternalParseRemoteLyricsAsync(LyricResponse lyricResponse, CancellationToken cancellationToken)
+ {
+ lyricResponse.Stream.Seek(0, SeekOrigin.Begin);
+ using var streamReader = new StreamReader(lyricResponse.Stream, leaveOpen: true);
+ var lyrics = await streamReader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var lyricFile = new LyricFile($"lyric.{lyricResponse.Format}", lyrics);
+ foreach (var parser in _lyricParsers)
+ {
+ var parsedLyrics = parser.ParseLyrics(lyricFile);
+ if (parsedLyrics is not null)
+ {
+ return parsedLyrics;
+ }
+ }
+
+ return null;
+ }
+
+ private async Task<LyricResponse?> InternalGetRemoteLyricsAsync(string id, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
+ var parts = id.Split('_', 2);
+ var provider = GetProvider(parts[0]);
+ if (provider is null)
+ {
+ return null;
+ }
+
+ id = parts[^1];
+
+ return await provider.GetLyricsAsync(id, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task<IReadOnlyList<RemoteLyricInfoDto>> InternalSearchProviderAsync(
+ ILyricProvider provider,
+ LyricSearchRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var providerId = GetProviderId(provider.Name);
+ var searchResults = await provider.SearchAsync(request, cancellationToken).ConfigureAwait(false);
+ var parsedResults = new List<RemoteLyricInfoDto>();
+ foreach (var result in searchResults)
+ {
+ var parsedLyrics = await InternalParseRemoteLyricsAsync(result.Lyrics, cancellationToken).ConfigureAwait(false);
+ if (parsedLyrics is null)
+ {
+ continue;
+ }
+
+ parsedLyrics.Metadata = result.Metadata;
+ parsedResults.Add(new RemoteLyricInfoDto
+ {
+ Id = $"{providerId}_{result.Id}",
+ ProviderName = result.ProviderName,
+ Lyrics = parsedLyrics
+ });
+ }
+
+ return parsedResults;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error downloading lyrics from {Provider}", provider.Name);
+ return [];
+ }
+ }
+
+ private async Task TrySaveLyric(
+ Audio audio,
+ LibraryOptions libraryOptions,
+ LyricResponse lyricResponse)
{
- foreach (ILyricProvider provider in _lyricProviders)
+ var saveInMediaFolder = libraryOptions.SaveLyricsWithMedia;
+
+ var memoryStream = new MemoryStream();
+ await using (memoryStream.ConfigureAwait(false))
{
- if (item is null)
+ var stream = lyricResponse.Stream;
+
+ await using (stream.ConfigureAwait(false))
{
- continue;
+ stream.Seek(0, SeekOrigin.Begin);
+ await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
+ memoryStream.Seek(0, SeekOrigin.Begin);
}
- if (provider.HasLyrics(item))
+ var savePaths = new List<string>();
+ var saveFileName = Path.GetFileNameWithoutExtension(audio.Path) + "." + lyricResponse.Format.ReplaceLineEndings(string.Empty).ToLowerInvariant();
+
+ if (saveInMediaFolder)
{
- return true;
+ var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
+ // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
+ if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ {
+ savePaths.Add(mediaFolderPath);
+ }
+ }
+
+ var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
+
+ // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
+ if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ {
+ savePaths.Add(internalPath);
+ }
+
+ if (savePaths.Count > 0)
+ {
+ await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
+ }
+ else
+ {
+ _logger.LogError("An uploaded lyric could not be saved because the resulting paths were invalid.");
}
}
+ }
- return false;
+ private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
+ {
+ List<Exception>? exs = null;
+
+ foreach (var savePath in savePaths)
+ {
+ _logger.LogInformation("Saving lyrics to {SavePath}", savePath.ReplaceLineEndings(string.Empty));
+
+ _libraryMonitor.ReportFileSystemChangeBeginning(savePath);
+
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
+
+ var fileOptions = AsyncFile.WriteOptions;
+ fileOptions.Mode = FileMode.Create;
+ fileOptions.PreallocationSize = stream.Length;
+ var fs = new FileStream(savePath, fileOptions);
+ await using (fs.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(fs).ConfigureAwait(false);
+ }
+
+ return;
+ }
+ catch (Exception ex)
+ {
+ (exs ??= []).Add(ex);
+ }
+ finally
+ {
+ _libraryMonitor.ReportFileSystemChangeComplete(savePath, false);
+ }
+
+ stream.Position = 0;
+ }
+
+ if (exs is not null)
+ {
+ throw new AggregateException(exs);
+ }
}
}
diff --git a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
index 706f13dbc..a8188da28 100644
--- a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
+++ b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs
@@ -3,6 +3,7 @@ using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.Lyrics;
namespace MediaBrowser.Providers.Lyric;
@@ -11,8 +12,8 @@ namespace MediaBrowser.Providers.Lyric;
/// </summary>
public class TxtLyricParser : ILyricParser
{
- private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc", ".txt" };
- private static readonly string[] _lineBreakCharacters = { "\r\n", "\r", "\n" };
+ private static readonly string[] _supportedMediaTypes = [".lrc", ".elrc", ".txt"];
+ private static readonly string[] _lineBreakCharacters = ["\r\n", "\r", "\n"];
/// <inheritdoc />
public string Name => "TxtLyricProvider";
@@ -24,7 +25,7 @@ public class TxtLyricParser : ILyricParser
public ResolverPriority Priority => ResolverPriority.Fifth;
/// <inheritdoc />
- public LyricResponse? ParseLyrics(LyricFile lyrics)
+ public LyricDto? ParseLyrics(LyricFile lyrics)
{
if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase))
{
@@ -36,9 +37,9 @@ public class TxtLyricParser : ILyricParser
for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++)
{
- lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]);
+ lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex].Trim());
}
- return new LyricResponse { Lyrics = lyricList };
+ return new LyricDto { Lyrics = lyricList };
}
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 2e9547bf3..81a299015 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -21,6 +21,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
@@ -52,6 +53,7 @@ namespace MediaBrowser.Providers.Manager
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly ISubtitleManager _subtitleManager;
+ private readonly ILyricManager _lyricManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IBaseItemManager _baseItemManager;
private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new();
@@ -78,6 +80,7 @@ namespace MediaBrowser.Providers.Manager
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="baseItemManager">The BaseItem manager.</param>
+ /// <param name="lyricManager">The lyric manager.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -87,7 +90,8 @@ namespace MediaBrowser.Providers.Manager
IFileSystem fileSystem,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager,
- IBaseItemManager baseItemManager)
+ IBaseItemManager baseItemManager,
+ ILyricManager lyricManager)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -98,6 +102,7 @@ namespace MediaBrowser.Providers.Manager
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
_baseItemManager = baseItemManager;
+ _lyricManager = lyricManager;
}
/// <inheritdoc/>
@@ -503,15 +508,22 @@ namespace MediaBrowser.Providers.Manager
AddMetadataPlugins(pluginList, dummy, libraryOptions, options);
AddImagePlugins(pluginList, imageProviders);
- var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
-
// Subtitle fetchers
+ var subtitleProviders = _subtitleManager.GetSupportedProviders(dummy);
pluginList.AddRange(subtitleProviders.Select(i => new MetadataPlugin
{
Name = i.Name,
Type = MetadataPluginType.SubtitleFetcher
}));
+ // Lyric fetchers
+ var lyricProviders = _lyricManager.GetSupportedProviders(dummy);
+ pluginList.AddRange(lyricProviders.Select(i => new MetadataPlugin
+ {
+ Name = i.Name,
+ Type = MetadataPluginType.LyricFetcher
+ }));
+
summary.Plugins = pluginList.ToArray();
var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>()
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index f718325df..0df624bdb 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -35,6 +35,7 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly LyricResolver _lyricResolver;
/// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
@@ -44,18 +45,21 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
public AudioFileProber(
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IItemRepository itemRepo,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ LyricResolver lyricResolver)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
+ _lyricResolver = lyricResolver;
}
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
@@ -103,7 +107,7 @@ namespace MediaBrowser.Providers.MediaInfo
cancellationToken.ThrowIfCancellationRequested();
- Fetch(item, result, cancellationToken);
+ Fetch(item, result, options, cancellationToken);
}
var libraryOptions = _libraryManager.GetLibraryOptions(item);
@@ -205,8 +209,13 @@ namespace MediaBrowser.Providers.MediaInfo
/// </summary>
/// <param name="audio">The <see cref="Audio"/>.</param>
/// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
+ /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
- protected void Fetch(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, CancellationToken cancellationToken)
+ protected void Fetch(
+ Audio audio,
+ Model.MediaInfo.MediaInfo mediaInfo,
+ MetadataRefreshOptions options,
+ CancellationToken cancellationToken)
{
audio.Container = mediaInfo.Container;
audio.TotalBitrate = mediaInfo.Bitrate;
@@ -216,17 +225,23 @@ namespace MediaBrowser.Providers.MediaInfo
if (!audio.IsLocked)
{
- FetchDataFromTags(audio);
+ FetchDataFromTags(audio, options);
}
- _itemRepo.SaveMediaStreams(audio.Id, mediaInfo.MediaStreams, cancellationToken);
+ var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
+ AddExternalLyrics(audio, mediaStreams, options);
+
+ audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
+
+ _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
}
/// <summary>
/// Fetches data from the tags.
/// </summary>
/// <param name="audio">The <see cref="Audio"/>.</param>
- private void FetchDataFromTags(Audio audio)
+ /// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
+ private void FetchDataFromTags(Audio audio, MetadataRefreshOptions options)
{
var file = TagLib.File.Create(audio.Path);
var tagTypes = file.TagTypesOnDisk;
@@ -305,14 +320,45 @@ namespace MediaBrowser.Providers.MediaInfo
}
_libraryManager.UpdatePeople(audio, people);
- audio.Artists = performers;
- audio.AlbumArtists = albumArtists;
+
+ if (options.ReplaceAllMetadata && performers.Length != 0)
+ {
+ audio.Artists = performers;
+ }
+ else if (!options.ReplaceAllMetadata
+ && (audio.Artists is null || audio.Artists.Count == 0))
+ {
+ audio.Artists = performers;
+ }
+
+ if (options.ReplaceAllMetadata && albumArtists.Length != 0)
+ {
+ audio.AlbumArtists = albumArtists;
+ }
+ else if (!options.ReplaceAllMetadata
+ && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
+ {
+ audio.AlbumArtists = albumArtists;
+ }
}
- audio.Name = tags.Title;
- audio.Album = tags.Album;
- audio.IndexNumber = Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ if (!audio.LockedFields.Contains(MetadataField.Name))
+ {
+ audio.Name = options.ReplaceAllMetadata || string.IsNullOrEmpty(audio.Name) ? tags.Title : audio.Name;
+ }
+
+ if (options.ReplaceAllMetadata)
+ {
+ audio.Album = tags.Album;
+ audio.IndexNumber = Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ }
+ else
+ {
+ audio.Album ??= tags.Album;
+ audio.IndexNumber ??= Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ }
if (tags.Year != 0)
{
@@ -323,15 +369,48 @@ namespace MediaBrowser.Providers.MediaInfo
if (!audio.LockedFields.Contains(MetadataField.Genres))
{
- audio.Genres = tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
+ ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+ : audio.Genres;
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
}
- audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
- audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
- audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
+ }
}
}
+
+ private void AddExternalLyrics(
+ Audio audio,
+ List<MediaStream> currentStreams,
+ MetadataRefreshOptions options)
+ {
+ var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
+ var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false);
+
+ audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
+ currentStreams.AddRange(externalLyricFiles);
+ }
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/LyricResolver.cs b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs
new file mode 100644
index 000000000..52af5ea08
--- /dev/null
+++ b/MediaBrowser.Providers/MediaInfo/LyricResolver.cs
@@ -0,0 +1,39 @@
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.MediaInfo;
+
+/// <summary>
+/// Resolves external lyric files for <see cref="Audio"/>.
+/// </summary>
+public class LyricResolver : MediaInfoResolver
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LyricResolver"/> class for external subtitle file processing.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="mediaEncoder">The media encoder.</param>
+ /// <param name="fileSystem">The file system.</param>
+ /// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
+ public LyricResolver(
+ ILogger<LyricResolver> logger,
+ ILocalizationManager localizationManager,
+ IMediaEncoder mediaEncoder,
+ IFileSystem fileSystem,
+ NamingOptions namingOptions)
+ : base(
+ logger,
+ localizationManager,
+ mediaEncoder,
+ fileSystem,
+ namingOptions,
+ DlnaProfileType.Lyric)
+ {
+ }
+}
diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
index f846aa5de..fbec4e963 100644
--- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
+++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Naming.ExternalFiles;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dlna;
@@ -148,7 +149,49 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- return mediaStreams.AsReadOnly();
+ return mediaStreams;
+ }
+
+ /// <summary>
+ /// Retrieves the external streams for the provided audio.
+ /// </summary>
+ /// <param name="audio">The <see cref="Audio"/> object to search external streams for.</param>
+ /// <param name="startIndex">The stream index to start adding external streams at.</param>
+ /// <param name="directoryService">The directory service to search for files.</param>
+ /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+ /// <returns>The external streams located.</returns>
+ public IReadOnlyList<MediaStream> GetExternalStreams(
+ Audio audio,
+ int startIndex,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ if (!audio.IsFileProtocol)
+ {
+ return Array.Empty<MediaStream>();
+ }
+
+ var pathInfos = GetExternalFiles(audio, directoryService, clearCache);
+
+ if (pathInfos.Count == 0)
+ {
+ return Array.Empty<MediaStream>();
+ }
+
+ var mediaStreams = new MediaStream[pathInfos.Count];
+
+ for (var i = 0; i < pathInfos.Count; i++)
+ {
+ mediaStreams[i] = new MediaStream
+ {
+ Type = MediaStreamType.Lyric,
+ Path = pathInfos[i].Path,
+ Language = pathInfos[i].Language,
+ Index = startIndex++
+ };
+ }
+
+ return mediaStreams;
}
/// <summary>
@@ -210,6 +253,58 @@ namespace MediaBrowser.Providers.MediaInfo
}
/// <summary>
+ /// Returns the external file infos for the given audio.
+ /// </summary>
+ /// <param name="audio">The <see cref="Audio"/> object to search external files for.</param>
+ /// <param name="directoryService">The directory service to search for files.</param>
+ /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param>
+ /// <returns>The external file paths located.</returns>
+ public IReadOnlyList<ExternalPathParserResult> GetExternalFiles(
+ Audio audio,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ if (!audio.IsFileProtocol)
+ {
+ return Array.Empty<ExternalPathParserResult>();
+ }
+
+ string folder = audio.ContainingFolderPath;
+ var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
+ files.Remove(audio.Path);
+ var internalMetadataPath = audio.GetInternalMetadataPath();
+ if (_fileSystem.DirectoryExists(internalMetadataPath))
+ {
+ files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
+ }
+
+ if (files.Count == 0)
+ {
+ return Array.Empty<ExternalPathParserResult>();
+ }
+
+ var externalPathInfos = new List<ExternalPathParserResult>();
+ ReadOnlySpan<char> prefix = audio.FileNameWithoutExtension;
+ foreach (var file in files)
+ {
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
+ if (fileNameWithoutExtension.Length >= prefix.Length
+ && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
+ && (fileNameWithoutExtension.Length == prefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[prefix.Length])))
+ {
+ var externalPathInfo = _externalPathParser.ParseFile(file, fileNameWithoutExtension[prefix.Length..].ToString());
+
+ if (externalPathInfo is not null)
+ {
+ externalPathInfos.Add(externalPathInfo);
+ }
+ }
+ }
+
+ return externalPathInfos;
+ }
+
+ /// <summary>
/// Returns the media info of the given file.
/// </summary>
/// <param name="path">The path to the file.</param>
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 114a92975..8bb874f0d 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -43,6 +43,7 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly ILogger<ProbeProvider> _logger;
private readonly AudioResolver _audioResolver;
private readonly SubtitleResolver _subtitleResolver;
+ private readonly LyricResolver _lyricResolver;
private readonly FFProbeVideoInfo _videoProber;
private readonly AudioFileProber _audioProber;
private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None);
@@ -79,9 +80,10 @@ namespace MediaBrowser.Providers.MediaInfo
NamingOptions namingOptions)
{
_logger = loggerFactory.CreateLogger<ProbeProvider>();
- _audioProber = new AudioFileProber(loggerFactory.CreateLogger<AudioFileProber>(), mediaSourceManager, mediaEncoder, itemRepo, libraryManager);
_audioResolver = new AudioResolver(loggerFactory.CreateLogger<AudioResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
_subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger<SubtitleResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+ _lyricResolver = new LyricResolver(loggerFactory.CreateLogger<LyricResolver>(), localization, mediaEncoder, fileSystem, namingOptions);
+
_videoProber = new FFProbeVideoInfo(
loggerFactory.CreateLogger<FFProbeVideoInfo>(),
mediaSourceManager,
@@ -96,6 +98,14 @@ namespace MediaBrowser.Providers.MediaInfo
libraryManager,
_audioResolver,
_subtitleResolver);
+
+ _audioProber = new AudioFileProber(
+ loggerFactory.CreateLogger<AudioFileProber>(),
+ mediaSourceManager,
+ mediaEncoder,
+ itemRepo,
+ libraryManager,
+ _lyricResolver);
}
/// <inheritdoc />
@@ -123,23 +133,37 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
- && !video.SubtitleFiles.SequenceEqual(
- _subtitleResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
- StringComparer.Ordinal))
+ if (video is not null
+ && item.SupportsLocalMetadata
+ && !video.IsPlaceHolder)
{
- _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
- return true;
+ if (!video.SubtitleFiles.SequenceEqual(
+ _subtitleResolver.GetExternalFiles(video, directoryService, false)
+ .Select(info => info.Path).ToList(),
+ StringComparer.Ordinal))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
+ return true;
+ }
+
+ if (!video.AudioFiles.SequenceEqual(
+ _audioResolver.GetExternalFiles(video, directoryService, false)
+ .Select(info => info.Path).ToList(),
+ StringComparer.Ordinal))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+ return true;
+ }
}
- if (item.SupportsLocalMetadata && video is not null && !video.IsPlaceHolder
- && !video.AudioFiles.SequenceEqual(
- _audioResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
+ if (item is Audio audio
+ && item.SupportsLocalMetadata
+ && !audio.LyricFiles.SequenceEqual(
+ _lyricResolver.GetExternalFiles(audio, directoryService, false)
+ .Select(info => info.Path).ToList(),
StringComparer.Ordinal))
{
- _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
+ _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
return true;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index a4c6cb47d..18cdba7a0 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -46,14 +46,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index bfec48e7c..1696a2c49 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -47,15 +47,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop,
- ImageType.Logo
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Logo,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index 192fb052d..2cb4fe1c1 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -46,15 +46,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
/// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new ImageType[]
- {
- ImageType.Primary,
- ImageType.Backdrop,
- ImageType.Logo
- };
- }
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) =>
+ [
+ ImageType.Primary,
+ ImageType.Backdrop,
+ ImageType.Logo,
+ ImageType.Thumb
+ ];
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 82f2c54f1..d704a5f49 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -591,6 +591,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
var image = images[i];
+ var imageType = type;
+ var language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage);
+
+ // Return Backdrops with a language specified (it has text) as Thumb.
+ if (imageType == ImageType.Backdrop && !string.IsNullOrEmpty(language))
+ {
+ imageType = ImageType.Thumb;
+ }
+
yield return new RemoteImageInfo
{
Url = GetUrl(size, image.FilePath),
@@ -598,9 +607,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
VoteCount = image.VoteCount,
Width = scaleImage ? null : image.Width,
Height = scaleImage ? null : image.Height,
- Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage),
+ Language = language,
ProviderName = TmdbUtils.ProviderName,
- Type = type,
+ Type = imageType,
RatingType = RatingType.Score
};
}
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index 87fd2a3cd..f68b3cee6 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -74,7 +74,7 @@ namespace MediaBrowser.Providers.Subtitles
.Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase))
.OrderBy(i =>
{
- var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name);
+ var index = request.SubtitleFetcherOrder.IndexOf(i.Name);
return index == -1 ? int.MaxValue : index;
})
.ToArray();
diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs
index fd8f7e59a..9d8afc23c 100644
--- a/src/Jellyfin.Extensions/StringExtensions.cs
+++ b/src/Jellyfin.Extensions/StringExtensions.cs
@@ -61,6 +61,11 @@ namespace Jellyfin.Extensions
/// <returns>The part left of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.IndexOf(needle);
return pos == -1 ? haystack : haystack[..pos];
}
@@ -73,6 +78,11 @@ namespace Jellyfin.Extensions
/// <returns>The part right of the <paramref name="needle" />.</returns>
public static ReadOnlySpan<char> RightPart(this ReadOnlySpan<char> haystack, char needle)
{
+ if (haystack.IsEmpty)
+ {
+ return ReadOnlySpan<char>.Empty;
+ }
+
var pos = haystack.LastIndexOf(needle);
if (pos == -1)
{
diff --git a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
index 67d0e5295..f7888496f 100644
--- a/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
+++ b/src/Jellyfin.LiveTv/Configuration/LiveTvConfigurationExtensions.cs
@@ -1,4 +1,5 @@
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.LiveTv;
namespace Jellyfin.LiveTv.Configuration;
@@ -15,4 +16,12 @@ public static class LiveTvConfigurationExtensions
/// <returns>The <see cref="LiveTvOptions"/>.</returns>
public static LiveTvOptions GetLiveTvConfiguration(this IConfigurationManager configurationManager)
=> configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+
+ /// <summary>
+ /// Gets the <see cref="XbmcMetadataOptions"/>.
+ /// </summary>
+ /// <param name="configurationManager">The <see cref="IConfigurationManager"/>.</param>
+ /// <returns>The <see cref="XbmcMetadataOptions"/>.</returns>
+ public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
+ => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
index 06a0ea4e9..318cc7acd 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs
+++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs
@@ -24,13 +24,13 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv
{
- public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds
+ public sealed class DefaultLiveTvService : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds
{
public const string ServiceName = "Emby";
- private readonly ILogger<EmbyTV> _logger;
+ private readonly ILogger<DefaultLiveTvService> _logger;
private readonly IServerConfigurationManager _config;
private readonly ITunerHostManager _tunerHostManager;
private readonly IListingsManager _listingsManager;
@@ -40,8 +40,8 @@ namespace Jellyfin.LiveTv.EmbyTV
private readonly TimerManager _timerManager;
private readonly SeriesTimerManager _seriesTimerManager;
- public EmbyTV(
- ILogger<EmbyTV> logger,
+ public DefaultLiveTvService(
+ ILogger<DefaultLiveTvService> logger,
IServerConfigurationManager config,
ITunerHostManager tunerHostManager,
IListingsManager listingsManager,
diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
deleted file mode 100644
index e8570f0e0..000000000
--- a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-
-namespace Jellyfin.LiveTv.EmbyTV
-{
- /// <summary>
- /// Class containing extension methods for working with the nfo configuration.
- /// </summary>
- public static class NfoConfigurationExtensions
- {
- /// <summary>
- /// Gets the nfo configuration.
- /// </summary>
- /// <param name="configurationManager">The configuration manager.</param>
- /// <returns>The nfo configuration.</returns>
- public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
- => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
- }
-}
diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
index e247ecb44..73729c950 100644
--- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
+++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs
@@ -37,7 +37,7 @@ public static class LiveTvServiceCollectionExtensions
services.AddSingleton<IGuideManager, GuideManager>();
services.AddSingleton<IRecordingsManager, RecordingsManager>();
- services.AddSingleton<ILiveTvService, EmbyTV.EmbyTV>();
+ services.AddSingleton<ILiveTvService, DefaultLiveTvService>();
services.AddSingleton<ITunerHost, HdHomerunHost>();
services.AddSingleton<ITunerHost, M3UTunerHost>();
services.AddSingleton<IListingsProvider, SchedulesDirect>();
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 056bb6e6d..39f174cc2 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -141,7 +141,7 @@ public class GuideManager : IGuideManager
CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
}
- var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
+ var coreService = _liveTvManager.Services.OfType<DefaultLiveTvService>().FirstOrDefault();
if (coreService is not null)
{
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index f7b9604af..c19d8195c 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -12,7 +12,6 @@ using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.LiveTv.Configuration;
-using Jellyfin.LiveTv.IO;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
@@ -72,7 +71,7 @@ namespace Jellyfin.LiveTv
_recordingsManager = recordingsManager;
_services = services.ToArray();
- var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
+ var defaultService = _services.OfType<DefaultLiveTvService>().First();
defaultService.TimerCreated += OnEmbyTvTimerCreated;
defaultService.TimerCancelled += OnEmbyTvTimerCancelled;
}
@@ -152,73 +151,6 @@ namespace Jellyfin.LiveTv
return _libraryManager.GetItemsResult(internalQuery);
}
- public async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
- {
- if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
- {
- mediaSourceId = null;
- }
-
- var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
-
- bool isVideo = channel.ChannelType == ChannelType.TV;
- var service = GetService(channel);
- _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
-
- MediaSourceInfo info;
-#pragma warning disable CA1859 // TODO: Analyzer bug?
- ILiveStream liveStream;
-#pragma warning restore CA1859
- if (service is ISupportsDirectStreamProvider supportsManagedStream)
- {
- liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
- info = liveStream.MediaSource;
- }
- else
- {
- info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
- var openedId = info.Id;
- Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
-
- liveStream = new ExclusiveLiveStream(info, closeFn);
-
- var startTime = DateTime.UtcNow;
- await liveStream.Open(cancellationToken).ConfigureAwait(false);
- var endTime = DateTime.UtcNow;
- _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
- }
-
- info.RequiresClosing = true;
-
- var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
-
- info.LiveStreamId = idPrefix + info.Id;
-
- Normalize(info, service, isVideo);
-
- return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
- }
-
- public async Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
- {
- var baseItem = (LiveTvChannel)item;
- var service = GetService(baseItem);
-
- var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
-
- if (sources.Count == 0)
- {
- throw new NotImplementedException();
- }
-
- foreach (var source in sources)
- {
- Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
- }
-
- return sources;
- }
-
private ILiveTvService GetService(LiveTvChannel item)
{
var name = item.ServiceName;
@@ -240,127 +172,6 @@ namespace Jellyfin.LiveTv
"No service with the name '{0}' can be found.",
name));
- private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
- {
- // Not all of the plugins are setting this
- mediaSource.IsInfiniteStream = true;
-
- if (mediaSource.MediaStreams.Count == 0)
- {
- if (isVideo)
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Video,
- // Set the index to -1 because we don't know the exact index of the video stream within the container
- Index = -1,
-
- // Set to true if unknown to enable deinterlacing
- IsInterlaced = true
- },
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- else
- {
- mediaSource.MediaStreams = new MediaStream[]
- {
- new MediaStream
- {
- Type = MediaStreamType.Audio,
- // Set the index to -1 because we don't know the exact index of the audio stream within the container
- Index = -1
- }
- };
- }
- }
-
- // Clean some bad data coming from providers
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.BitRate.HasValue && stream.BitRate <= 0)
- {
- stream.BitRate = null;
- }
-
- if (stream.Channels.HasValue && stream.Channels <= 0)
- {
- stream.Channels = null;
- }
-
- if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0)
- {
- stream.AverageFrameRate = null;
- }
-
- if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0)
- {
- stream.RealFrameRate = null;
- }
-
- if (stream.Width.HasValue && stream.Width <= 0)
- {
- stream.Width = null;
- }
-
- if (stream.Height.HasValue && stream.Height <= 0)
- {
- stream.Height = null;
- }
-
- if (stream.SampleRate.HasValue && stream.SampleRate <= 0)
- {
- stream.SampleRate = null;
- }
-
- if (stream.Level.HasValue && stream.Level <= 0)
- {
- stream.Level = null;
- }
- }
-
- var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList();
-
- // If there are duplicate stream indexes, set them all to unknown
- if (indexes.Count != mediaSource.MediaStreams.Count)
- {
- foreach (var stream in mediaSource.MediaStreams)
- {
- stream.Index = -1;
- }
- }
-
- // Set the total bitrate if not already supplied
- mediaSource.InferTotalBitrate();
-
- if (service is not EmbyTV.EmbyTV)
- {
- // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says
- // mediaSource.SupportsDirectPlay = false;
- // mediaSource.SupportsDirectStream = false;
- mediaSource.SupportsTranscoding = true;
- foreach (var stream in mediaSource.MediaStreams)
- {
- if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
- {
- stream.NalLengthSize = "0";
- }
-
- if (stream.Type == MediaStreamType.Video)
- {
- stream.IsInterlaced = true;
- }
- }
- }
- }
-
public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
{
var program = _libraryManager.GetItemById(id);
@@ -769,7 +580,7 @@ namespace Jellyfin.LiveTv
var channel = string.IsNullOrWhiteSpace(info.ChannelId)
? null
- : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(EmbyTV.EmbyTV.ServiceName, info.ChannelId));
+ : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(DefaultLiveTvService.ServiceName, info.ChannelId));
dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
? null
@@ -1005,7 +816,7 @@ namespace Jellyfin.LiveTv
await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id)));
}
@@ -1314,7 +1125,7 @@ namespace Jellyfin.LiveTv
_logger.LogInformation("New recording scheduled");
- if (service is not EmbyTV.EmbyTV)
+ if (service is not DefaultLiveTvService)
{
TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
new TimerEventInfo(newTimerId)
diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
index c6874e4db..40ac5ce0f 100644
--- a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
+++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
@@ -8,11 +8,15 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.LiveTv.IO;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
@@ -23,19 +27,27 @@ namespace Jellyfin.LiveTv
// Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
private const char StreamIdDelimiter = '_';
- private readonly ILiveTvManager _liveTvManager;
- private readonly IRecordingsManager _recordingsManager;
private readonly ILogger<LiveTvMediaSourceProvider> _logger;
- private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
+ private readonly IRecordingsManager _recordingsManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILiveTvService[] _services;
- public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IRecordingsManager recordingsManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+ public LiveTvMediaSourceProvider(
+ ILogger<LiveTvMediaSourceProvider> logger,
+ IServerApplicationHost appHost,
+ IRecordingsManager recordingsManager,
+ IMediaSourceManager mediaSourceManager,
+ ILibraryManager libraryManager,
+ IEnumerable<ILiveTvService> services)
{
- _liveTvManager = liveTvManager;
- _recordingsManager = recordingsManager;
_logger = logger;
- _mediaSourceManager = mediaSourceManager;
_appHost = appHost;
+ _recordingsManager = recordingsManager;
+ _mediaSourceManager = mediaSourceManager;
+ _libraryManager = libraryManager;
+ _services = services.ToArray();
}
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
@@ -68,7 +80,7 @@ namespace Jellyfin.LiveTv
}
else
{
- sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
+ sources = await GetChannelMediaSources(item, cancellationToken)
.ConfigureAwait(false);
}
}
@@ -121,10 +133,200 @@ namespace Jellyfin.LiveTv
var keys = openToken.Split(StreamIdDelimiter, 3);
var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
- var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ var info = await GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
var liveStream = info.Item2;
return liveStream;
}
+
+ private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo)
+ {
+ // Not all of the plugins are setting this
+ mediaSource.IsInfiniteStream = true;
+
+ if (mediaSource.MediaStreams.Count == 0)
+ {
+ if (isVideo)
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ // Set the index to -1 because we don't know the exact index of the video stream within the container
+ Index = -1,
+ // Set to true if unknown to enable deinterlacing
+ IsInterlaced = true
+ },
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ else
+ {
+ mediaSource.MediaStreams = new[]
+ {
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ // Set the index to -1 because we don't know the exact index of the audio stream within the container
+ Index = -1
+ }
+ };
+ }
+ }
+
+ // Clean some bad data coming from providers
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.BitRate is <= 0)
+ {
+ stream.BitRate = null;
+ }
+
+ if (stream.Channels is <= 0)
+ {
+ stream.Channels = null;
+ }
+
+ if (stream.AverageFrameRate is <= 0)
+ {
+ stream.AverageFrameRate = null;
+ }
+
+ if (stream.RealFrameRate is <= 0)
+ {
+ stream.RealFrameRate = null;
+ }
+
+ if (stream.Width is <= 0)
+ {
+ stream.Width = null;
+ }
+
+ if (stream.Height is <= 0)
+ {
+ stream.Height = null;
+ }
+
+ if (stream.SampleRate is <= 0)
+ {
+ stream.SampleRate = null;
+ }
+
+ if (stream.Level is <= 0)
+ {
+ stream.Level = null;
+ }
+ }
+
+ var indexCount = mediaSource.MediaStreams.Select(i => i.Index).Distinct().Count();
+
+ // If there are duplicate stream indexes, set them all to unknown
+ if (indexCount != mediaSource.MediaStreams.Count)
+ {
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ stream.Index = -1;
+ }
+ }
+
+ // Set the total bitrate if not already supplied
+ mediaSource.InferTotalBitrate();
+
+ if (service is not DefaultLiveTvService)
+ {
+ mediaSource.SupportsTranscoding = true;
+ foreach (var stream in mediaSource.MediaStreams)
+ {
+ if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize))
+ {
+ stream.NalLengthSize = "0";
+ }
+
+ if (stream.Type == MediaStreamType.Video)
+ {
+ stream.IsInterlaced = true;
+ }
+ }
+ }
+ }
+
+ private async Task<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(
+ string id,
+ string mediaSourceId,
+ List<ILiveStream> currentLiveStreams,
+ CancellationToken cancellationToken)
+ {
+ if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaSourceId = null;
+ }
+
+ var channel = (LiveTvChannel)_libraryManager.GetItemById(id);
+
+ bool isVideo = channel.ChannelType == ChannelType.TV;
+ var service = GetService(channel.ServiceName);
+ _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId);
+
+ MediaSourceInfo info;
+#pragma warning disable CA1859 // TODO: Analyzer bug?
+ ILiveStream liveStream;
+#pragma warning restore CA1859
+ if (service is ISupportsDirectStreamProvider supportsManagedStream)
+ {
+ liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ info = liveStream.MediaSource;
+ }
+ else
+ {
+ info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false);
+ var openedId = info.Id;
+ Func<Task> closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None);
+
+ liveStream = new ExclusiveLiveStream(info, closeFn);
+
+ var startTime = DateTime.UtcNow;
+ await liveStream.Open(cancellationToken).ConfigureAwait(false);
+ var endTime = DateTime.UtcNow;
+ _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
+ }
+
+ info.RequiresClosing = true;
+
+ var idPrefix = service.GetType().FullName!.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_";
+
+ info.LiveStreamId = idPrefix + info.Id;
+
+ Normalize(info, service, isVideo);
+
+ return new Tuple<MediaSourceInfo, ILiveStream>(info, liveStream);
+ }
+
+ private async Task<List<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken)
+ {
+ var baseItem = (LiveTvChannel)item;
+ var service = GetService(baseItem.ServiceName);
+
+ var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false);
+ if (sources.Count == 0)
+ {
+ throw new NotImplementedException();
+ }
+
+ foreach (var source in sources)
+ {
+ Normalize(source, service, baseItem.ChannelType == ChannelType.TV);
+ }
+
+ return sources;
+ }
+
+ private ILiveTvService GetService(string name)
+ => _services.First(service => string.Equals(service.Name, name, StringComparison.OrdinalIgnoreCase));
}
}
diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
index 6bda231b2..2b7564045 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingHelper.cs
@@ -1,19 +1,12 @@
-#pragma warning disable CS1591
-
using System;
using System.Globalization;
using System.Text;
using MediaBrowser.Controller.LiveTv;
-namespace Jellyfin.LiveTv.EmbyTV
+namespace Jellyfin.LiveTv.Recordings
{
internal static class RecordingHelper
{
- public static DateTime GetStartTime(TimerInfo timer)
- {
- return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds);
- }
-
public static string GetRecordingName(TimerInfo info)
{
var name = info.Name;
diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
index 226d525e7..e63afa626 100644
--- a/src/Jellyfin.LiveTv/RecordingNotifier.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
@@ -11,7 +11,7 @@ using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.LiveTv
+namespace Jellyfin.LiveTv.Recordings
{
/// <summary>
/// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed.
diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
index 18ff6a949..f4daa0975 100644
--- a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsHost.cs
@@ -4,22 +4,22 @@ using Jellyfin.LiveTv.Timers;
using MediaBrowser.Controller.LiveTv;
using Microsoft.Extensions.Hosting;
-namespace Jellyfin.LiveTv.EmbyTV;
+namespace Jellyfin.LiveTv.Recordings;
/// <summary>
-/// <see cref="IHostedService"/> responsible for initializing Live TV.
+/// <see cref="IHostedService"/> responsible for Live TV recordings.
/// </summary>
-public sealed class LiveTvHost : IHostedService
+public sealed class RecordingsHost : IHostedService
{
private readonly IRecordingsManager _recordingsManager;
private readonly TimerManager _timerManager;
/// <summary>
- /// Initializes a new instance of the <see cref="LiveTvHost"/> class.
+ /// Initializes a new instance of the <see cref="RecordingsHost"/> class.
/// </summary>
/// <param name="recordingsManager">The <see cref="IRecordingsManager"/>.</param>
/// <param name="timerManager">The <see cref="TimerManager"/>.</param>
- public LiveTvHost(IRecordingsManager recordingsManager, TimerManager timerManager)
+ public RecordingsHost(IRecordingsManager recordingsManager, TimerManager timerManager)
{
_recordingsManager = recordingsManager;
_timerManager = timerManager;
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
index 20f89ec8f..92605a1eb 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -11,7 +11,6 @@ using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Data.Enums;
using Jellyfin.LiveTv.Configuration;
-using Jellyfin.LiveTv.EmbyTV;
using Jellyfin.LiveTv.IO;
using Jellyfin.LiveTv.Timers;
using MediaBrowser.Common.Configuration;
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
index 0a71a4d46..b2b82332d 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs
@@ -9,7 +9,6 @@ using System.Xml;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using Jellyfin.LiveTv.Configuration;
-using Jellyfin.LiveTv.EmbyTV;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Dto;
diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
index 6bcbd3324..da5deea36 100644
--- a/src/Jellyfin.LiveTv/Timers/TimerManager.cs
+++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
@@ -7,7 +7,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Events;
-using Jellyfin.LiveTv.EmbyTV;
+using Jellyfin.LiveTv.Recordings;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
@@ -95,7 +95,7 @@ namespace Jellyfin.LiveTv.Timers
return;
}
- var startDate = RecordingHelper.GetStartTime(item);
+ var startDate = item.StartDate.AddSeconds(-item.PrePaddingSeconds);
var now = DateTime.UtcNow;
if (startDate < now)
diff --git a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
index b4960dc0b..6a33a6699 100644
--- a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs
@@ -1,5 +1,5 @@
using System;
-using Jellyfin.LiveTv.EmbyTV;
+using Jellyfin.LiveTv.Recordings;
using MediaBrowser.Controller.LiveTv;
using Xunit;
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 1e0851993..478db6941 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
@@ -570,7 +571,8 @@ namespace Jellyfin.Providers.Tests.Manager
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
libraryManager.Object,
- baseItemManager!);
+ baseItemManager!,
+ Mock.Of<ILyricManager>());
return providerManager;
}