diff options
| author | Shadowghost <Shadowghost@users.noreply.github.com> | 2026-09-15 11:15:58 -0400 |
|---|---|---|
| committer | Cody Robibero <cody@robibe.ro> | 2026-09-15 11:15:58 -0400 |
| commit | 20a9513edb58475401fb56a512b8b5ce2149af07 (patch) | |
| tree | b1543f2eea9c8afd0be2f56d388e3c201d2c9c77 | |
| parent | 4e1c55031f1d0d63b1cbf44410f3189f0dbf5b73 (diff) | |
Backport pull request #17930 from jellyfin/release-12.z
Fix playlist encoding recognition
Original-merge: e5d91342b78d46f1b678ce5d5516079281b716a8
Merged-by: crobibero <cody@robibe.ro>
Backported-by: Cody Robibero <cody@robibe.ro>
3 files changed, 167 insertions, 4 deletions
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 2b0f480b1c..7c3e1867ef 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -26,6 +26,7 @@ <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> <PackageReference Include="TMDbLib" /> + <PackageReference Include="UTF.Unknown" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 924fde4808..4952d03e8a 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; +using UtfUnknown; namespace MediaBrowser.Providers.Playlists; @@ -26,6 +28,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, IForcedProvider, IHasItemChangeMonitor { + /// <summary> + /// Minimum confidence required before a detected encoding is preferred over UTF-8. + /// </summary> + private const float MinimumEncodingConfidence = 0.5f; + private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly ILogger<PlaylistItemsProvider> _logger; @@ -136,23 +143,38 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new PlsContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } - private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) + internal IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } + private Encoding DetectEncoding(Stream stream, string playlistPath) + { + var detected = CharsetDetector.DetectFromStream(stream).Detected; + stream.Seek(0, SeekOrigin.Begin); + + if (detected?.Encoding is null || detected.Confidence < MinimumEncodingConfidence) + { + _logger.LogDebug("Could not detect the encoding of playlist {Path}, assuming UTF-8", playlistPath); + return Encoding.UTF8; + } + + _logger.LogDebug("Detected encoding {Encoding} for playlist {Path}", detected.Encoding.WebName, playlistPath); + return detected.Encoding; + } + private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new ZplContent(); @@ -191,7 +213,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); - if (!File.Exists(pathToCheck)) + if (!File.Exists(pathToCheck) && !TryNormalizePath(ref pathToCheck)) { return false; } @@ -208,6 +230,36 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return false; } + private static bool TryNormalizePath(ref string path) + { + foreach (var form in new[] { NormalizationForm.FormC, NormalizationForm.FormD }) + { + string normalized; + try + { + if (path.IsNormalized(form)) + { + continue; + } + + normalized = path.Normalize(form); + } + catch (ArgumentException) + { + // The path is not valid Unicode, there is nothing to normalize. + return false; + } + + if (File.Exists(normalized)) + { + path = normalized; + return true; + } + } + + return false; + } + /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) { diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs new file mode 100644 index 0000000000..a73ed61a75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Playlists; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Playlists; + +public sealed class PlaylistItemsProviderTests : IDisposable +{ + private const string AccentedFolder = "Música épica"; + private const string AccentedSong = "Canción.mp3"; + private const string AsciiSong = "Song.mp3"; + + private readonly string _libraryRoot; + private readonly PlaylistItemsProvider _sut; + + public PlaylistItemsProviderTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N")); + var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder); + Directory.CreateDirectory(mediaFolder); + File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty); + File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>())) + .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path }); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>())) + .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath))); + + _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object); + } + + [Fact] + public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// Playlists written by Windows media players default to the local codepage rather than UTF-8. + /// Decoding those as UTF-8 mangles every accented character and loses the entry. + /// </summary> + [Fact] + public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD). + /// </summary> + [Fact] + public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1); + + [Fact] + public void GetM3uItems_AsciiEntries_ResolvesPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1); + + [Fact] + public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1); + + [Fact] + public void GetM3uItems_MixedEntries_ResolvesEveryEntry() + => AssertResolved( + Encoding.GetEncoding(1252), + [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"], + 2); + + [Fact] + public void GetM3uItems_MissingFile_ResolvesNothing() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0); + + public void Dispose() + { + if (Directory.Exists(_libraryRoot)) + { + Directory.Delete(_libraryRoot, true); + } + } + + private void AssertResolved(Encoding encoding, string[] entries, int expected) + { + var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u"); + var content = new StringBuilder("#EXTM3U\n"); + foreach (var entry in entries) + { + content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n'); + } + + File.WriteAllBytes( + playlistPath, + [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]); + + using var stream = File.OpenRead(playlistPath); + var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList(); + + Assert.Equal(expected, resolved.Count); + } +} |
