Browse Source

Merge remote-tracking branch 'upstream/release-10.9.z' into fix-season-backdrops

Shadowghost 11 months ago
parent
commit
afeff31dca
29 changed files with 376 additions and 326 deletions
  1. 1 1
      Emby.Naming/ExternalFiles/ExternalPathParser.cs
  2. 3 3
      Emby.Server.Implementations/IO/ManagedFileSystem.cs
  3. 1 1
      Emby.Server.Implementations/Library/LibraryManager.cs
  4. 1 1
      Emby.Server.Implementations/Localization/Ratings/au.csv
  5. 9 4
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  6. 1 8
      Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
  7. 3 41
      Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
  8. 3 42
      Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
  9. 2 1
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  10. 5 0
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  11. 4 3
      MediaBrowser.Controller/Entities/BaseItem.cs
  12. 11 1
      MediaBrowser.Controller/Entities/Folder.cs
  13. 2 9
      MediaBrowser.Controller/Entities/TV/Series.cs
  14. 64 0
      MediaBrowser.Controller/IO/FileSystemHelper.cs
  15. 2 2
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  16. 4 4
      MediaBrowser.Controller/Playlists/Playlist.cs
  17. 5 7
      MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
  18. 3 3
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  19. 15 12
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  20. 8 8
      MediaBrowser.Model/Entities/MediaStream.cs
  21. 21 4
      MediaBrowser.Providers/Manager/ImageSaver.cs
  22. 13 13
      MediaBrowser.Providers/Manager/ItemImageProvider.cs
  23. 6 7
      MediaBrowser.Providers/Manager/MetadataService.cs
  24. 164 136
      MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
  25. 1 1
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
  26. 13 10
      MediaBrowser.Providers/TV/SeriesMetadataService.cs
  27. 3 1
      MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
  28. 7 3
      src/Jellyfin.Networking/Manager/NetworkManager.cs
  29. 1 0
      tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs

+ 1 - 1
Emby.Naming/ExternalFiles/ExternalPathParser.cs

@@ -107,7 +107,7 @@ namespace Emby.Naming.ExternalFiles
                         pathInfo.Language = culture.ThreeLetterISOLanguageName;
                         extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
                     }
-                    else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
+                    else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
                     {
                         pathInfo.IsHearingImpaired = true;
                         extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);

+ 3 - 3
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -389,7 +389,7 @@ namespace Emby.Server.Implementations.IO
             var info = new FileInfo(path);
 
             if (info.Exists &&
-                ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden)
+                (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden != isHidden)
             {
                 if (isHidden)
                 {
@@ -417,8 +417,8 @@ namespace Emby.Server.Implementations.IO
                 return;
             }
 
-            if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly
-                && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden)
+            if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly == readOnly
+                && (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden == isHidden)
             {
                 return;
             }

+ 1 - 1
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -1884,7 +1884,7 @@ namespace Emby.Server.Implementations.Library
                     try
                     {
                         var index = item.GetImageIndex(img);
-                        image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false);
+                        image = await ConvertImageToLocal(item, img, index, true).ConfigureAwait(false);
                     }
                     catch (ArgumentException)
                     {

+ 1 - 1
Emby.Server.Implementations/Localization/Ratings/au.csv

@@ -1,11 +1,11 @@
 Exempt,0
 G,0
 7+,7
+PG,15
 M,15
 MA,15
 MA15+,15
 MA 15+,15
-PG,16
 16+,16
 R,18
 R18+,18

+ 9 - 4
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -170,8 +170,13 @@ namespace Emby.Server.Implementations.Playlists
         private List<Playlist> GetUserPlaylists(Guid userId)
         {
             var user = _userManager.GetUserById(userId);
+            var playlistsFolder = GetPlaylistsFolder(userId);
+            if (playlistsFolder is null)
+            {
+                return [];
+            }
 
-            return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>().ToList();
+            return playlistsFolder.GetChildren(user, true).OfType<Playlist>().ToList();
         }
 
         private static string GetTargetPath(string path)
@@ -184,11 +189,11 @@ namespace Emby.Server.Implementations.Playlists
             return path;
         }
 
-        private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
+        private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, User user, DtoOptions options)
         {
             var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null);
 
-            return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
+            return Playlist.GetPlaylistItems(items, user, options);
         }
 
         public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
@@ -208,7 +213,7 @@ namespace Emby.Server.Implementations.Playlists
                 ?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
 
             // Retrieve all the items to be added to the playlist
-            var newItems = GetPlaylistItems(newItemIds, playlist.MediaType, user, options)
+            var newItems = GetPlaylistItems(newItemIds, user, options)
                 .Where(i => i.SupportsAddingToPlaylist);
 
             // Filter out duplicate items, if necessary

+ 1 - 8
Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs

@@ -127,15 +127,8 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
         {
             _logger.LogDebug("Updating {FolderName}", folder.Name);
             folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray();
+            _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit);
             folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken);
-
-            _providerManager.QueueRefresh(
-                folder.Id,
-                new MetadataRefreshOptions(new DirectoryService(_fileSystem))
-                {
-                    ForceSave = true
-                },
-                RefreshPriority.High);
         }
     }
 

+ 3 - 41
Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Tasks;
@@ -133,53 +134,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
 
                 cancellationToken.ThrowIfCancellationRequested();
 
-                DeleteFile(file.FullName);
+                FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
 
                 index++;
             }
 
-            DeleteEmptyFolders(directory);
+            FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
 
             progress.Report(100);
         }
-
-        private void DeleteEmptyFolders(string parent)
-        {
-            foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
-            {
-                DeleteEmptyFolders(directory);
-                if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
-                {
-                    try
-                    {
-                        Directory.Delete(directory, false);
-                    }
-                    catch (UnauthorizedAccessException ex)
-                    {
-                        _logger.LogError(ex, "Error deleting directory {Path}", directory);
-                    }
-                    catch (IOException ex)
-                    {
-                        _logger.LogError(ex, "Error deleting directory {Path}", directory);
-                    }
-                }
-            }
-        }
-
-        private void DeleteFile(string path)
-        {
-            try
-            {
-                _fileSystem.DeleteFile(path);
-            }
-            catch (UnauthorizedAccessException ex)
-            {
-                _logger.LogError(ex, "Error deleting file {Path}", path);
-            }
-            catch (IOException ex)
-            {
-                _logger.LogError(ex, "Error deleting file {Path}", path);
-            }
-        }
     }
 }

+ 3 - 42
Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs

@@ -1,10 +1,10 @@
 using System;
 using System.Collections.Generic;
-using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Tasks;
@@ -113,53 +113,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
 
                 cancellationToken.ThrowIfCancellationRequested();
 
-                DeleteFile(file.FullName);
+                FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
 
                 index++;
             }
 
-            DeleteEmptyFolders(directory);
+            FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
 
             progress.Report(100);
         }
-
-        private void DeleteEmptyFolders(string parent)
-        {
-            foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
-            {
-                DeleteEmptyFolders(directory);
-                if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
-                {
-                    try
-                    {
-                        Directory.Delete(directory, false);
-                    }
-                    catch (UnauthorizedAccessException ex)
-                    {
-                        _logger.LogError(ex, "Error deleting directory {Path}", directory);
-                    }
-                    catch (IOException ex)
-                    {
-                        _logger.LogError(ex, "Error deleting directory {Path}", directory);
-                    }
-                }
-            }
-        }
-
-        private void DeleteFile(string path)
-        {
-            try
-            {
-                _fileSystem.DeleteFile(path);
-            }
-            catch (UnauthorizedAccessException ex)
-            {
-                _logger.LogError(ex, "Error deleting file {Path}", path);
-            }
-            catch (IOException ex)
-            {
-                _logger.LogError(ex, "Error deleting file {Path}", path);
-            }
-        }
     }
 }

+ 2 - 1
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -80,7 +80,8 @@ public class ItemRefreshController : BaseJellyfinApiController
                 || imageRefreshMode == MetadataRefreshMode.FullRefresh
                 || replaceAllImages
                 || replaceAllMetadata,
-            IsAutomated = false
+            IsAutomated = false,
+            RemoveOldMetadata = replaceAllMetadata
         };
 
         _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);

+ 5 - 0
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -154,6 +154,11 @@ public static class StreamingHelpers
                 // Some channels from HDHomerun will experience A/V sync issues
                 streamingRequest.SegmentContainer = "ts";
                 streamingRequest.VideoCodec = "h264";
+                streamingRequest.AudioCodec = "aac";
+                state.SupportedVideoCodecs = ["h264"];
+                state.Request.VideoCodec = "h264";
+                state.SupportedAudioCodecs = ["aac"];
+                state.Request.AudioCodec = "aac";
             }
 
             var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);

+ 4 - 3
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1949,14 +1949,15 @@ namespace MediaBrowser.Controller.Entities
                 return;
             }
 
-            // Remove it from the item
-            RemoveImage(info);
-
+            // Remove from file system
             if (info.IsLocalFile)
             {
                 FileSystem.DeleteFile(info.Path);
             }
 
+            // Remove from item
+            RemoveImage(info);
+
             await UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
         }
 

+ 11 - 1
MediaBrowser.Controller/Entities/Folder.cs

@@ -6,6 +6,7 @@ using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using System.Security;
 using System.Text.Json.Serialization;
 using System.Threading;
 using System.Threading.Tasks;
@@ -370,9 +371,18 @@ namespace MediaBrowser.Controller.Entities
                 {
                     nonCachedChildren = GetNonCachedChildren(directoryService);
                 }
+                catch (IOException ex)
+                {
+                    Logger.LogError(ex, "Error retrieving children from file system");
+                }
+                catch (SecurityException ex)
+                {
+                    Logger.LogError(ex, "Error retrieving children from file system");
+                }
                 catch (Exception ex)
                 {
-                    Logger.LogError(ex, "Error retrieving children folder");
+                    Logger.LogError(ex, "Error retrieving children");
+                    return;
                 }
 
                 progress.Report(ProgressHelpers.RetrievedChildren);

+ 2 - 9
MediaBrowser.Controller/Entities/TV/Series.cs

@@ -350,17 +350,10 @@ namespace MediaBrowser.Controller.Entities.TV
 
         public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
         {
-            var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons;
-
-            // add optimization when this setting is not enabled
-            var seriesKey = queryFromSeries ?
-                GetUniqueSeriesKey(this) :
-                GetUniqueSeriesKey(parentSeason);
-
             var query = new InternalItemsQuery(user)
             {
-                AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey,
-                SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null,
+                AncestorWithPresentationUniqueKey = null,
+                SeriesPresentationUniqueKey = GetUniqueSeriesKey(this),
                 IncludeItemTypes = new[] { BaseItemKind.Episode },
                 OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
                 DtoOptions = options

+ 64 - 0
MediaBrowser.Controller/IO/FileSystemHelper.cs

@@ -0,0 +1,64 @@
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.IO;
+
+/// <summary>
+/// Helper methods for file system management.
+/// </summary>
+public static class FileSystemHelper
+{
+    /// <summary>
+    /// Deletes the file.
+    /// </summary>
+    /// <param name="fileSystem">The fileSystem.</param>
+    /// <param name="path">The path.</param>
+    /// <param name="logger">The logger.</param>
+    public static void DeleteFile(IFileSystem fileSystem, string path, ILogger logger)
+    {
+        try
+        {
+            fileSystem.DeleteFile(path);
+        }
+        catch (UnauthorizedAccessException ex)
+        {
+            logger.LogError(ex, "Error deleting file {Path}", path);
+        }
+        catch (IOException ex)
+        {
+            logger.LogError(ex, "Error deleting file {Path}", path);
+        }
+    }
+
+    /// <summary>
+    /// Recursively delete empty folders.
+    /// </summary>
+    /// <param name="fileSystem">The fileSystem.</param>
+    /// <param name="path">The path.</param>
+    /// <param name="logger">The logger.</param>
+    public static void DeleteEmptyFolders(IFileSystem fileSystem, string path, ILogger logger)
+    {
+        foreach (var directory in fileSystem.GetDirectoryPaths(path))
+        {
+            DeleteEmptyFolders(fileSystem, directory, logger);
+            if (!fileSystem.GetFileSystemEntryPaths(directory).Any())
+            {
+                try
+                {
+                    Directory.Delete(directory, false);
+                }
+                catch (UnauthorizedAccessException ex)
+                {
+                    logger.LogError(ex, "Error deleting directory {Path}", directory);
+                }
+                catch (IOException ex)
+                {
+                    logger.LogError(ex, "Error deleting directory {Path}", directory);
+                }
+            }
+        }
+    }
+}

+ 2 - 2
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -1208,8 +1208,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 var subtitlePath = state.SubtitleStream.Path;
                 var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
 
-                if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
-                    || subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase))
+                // dvdsub/vobsub graphical subtitles use .sub+.idx pairs
+                if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
                 {
                     var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
                     if (File.Exists(idxFile))

+ 4 - 4
MediaBrowser.Controller/Playlists/Playlist.cs

@@ -166,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists
             return base.GetChildren(user, true, query);
         }
 
-        public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
+        public static IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
         {
             if (user is not null)
             {
@@ -177,14 +177,14 @@ namespace MediaBrowser.Controller.Playlists
 
             foreach (var item in inputItems)
             {
-                var playlistItems = GetPlaylistItems(item, user, playlistMediaType, options);
+                var playlistItems = GetPlaylistItems(item, user, options);
                 list.AddRange(playlistItems);
             }
 
             return list;
         }
 
-        private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, MediaType mediaType, DtoOptions options)
+        private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, DtoOptions options)
         {
             if (item is MusicGenre musicGenre)
             {
@@ -216,7 +216,7 @@ namespace MediaBrowser.Controller.Playlists
                 {
                     Recursive = true,
                     IsFolder = false,
-                    MediaTypes = [mediaType],
+                    MediaTypes = [MediaType.Audio, MediaType.Video],
                     EnableTotalRecordCount = false,
                     DtoOptions = options
                 };

+ 5 - 7
MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs

@@ -40,13 +40,12 @@ namespace MediaBrowser.LocalMetadata.Images
             var parentPathFiles = directoryService.GetFiles(parentPath);
             var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()).ToString();
 
-            var thumbName = string.Concat(nameWithoutExtension, "-thumb");
-            var images = GetImageFilesFromFolder(thumbName, parentPathFiles);
+            var images = GetImageFilesFromFolder(nameWithoutExtension, parentPathFiles);
 
-            var metadataSubPath = directoryService.GetDirectories(parentPath).Where(d => d.Name.EndsWith("metadata", StringComparison.OrdinalIgnoreCase)).ToList();
-            foreach (var path in metadataSubPath)
+            var metadataSubDir = directoryService.GetDirectories(parentPath).FirstOrDefault(d => d.Name.Equals("metadata", StringComparison.Ordinal));
+            if (metadataSubDir is not null)
             {
-                var files = directoryService.GetFiles(path.FullName);
+                var files = directoryService.GetFiles(metadataSubDir.FullName);
                 images.AddRange(GetImageFilesFromFolder(nameWithoutExtension, files));
             }
 
@@ -55,9 +54,8 @@ namespace MediaBrowser.LocalMetadata.Images
 
         private List<LocalImageInfo> GetImageFilesFromFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> filePaths)
         {
-            var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
-
             var list = new List<LocalImageInfo>(1);
+            var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
 
             foreach (var i in filePaths)
             {

+ 3 - 3
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -1155,10 +1155,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
 
             // Get all files from the BDMV/STREAMING directory
             // Only return playable local .m2ts files
+            var files = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")).ToList();
             return validPlaybackFiles
-                .Select(f => _fileSystem.GetFileInfo(Path.Join(path, "BDMV", "STREAM", f)))
-                .Where(f => f.Exists)
-                .Select(f => f.FullName)
+                .Select(validFile => files.FirstOrDefault(f => Path.GetFileName(f.FullName.AsSpan()).Equals(validFile, StringComparison.OrdinalIgnoreCase))?.FullName)
+                .Where(f => f is not null)
                 .ToList();
         }
 

+ 15 - 12
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -280,8 +280,8 @@ namespace MediaBrowser.MediaEncoding.Probing
                     splitFormat[i] = "mpeg";
                 }
 
-                // Handle MPEG-2 container
-                else if (string.Equals(splitFormat[i], "mpeg", StringComparison.OrdinalIgnoreCase))
+                // Handle MPEG-TS container
+                else if (string.Equals(splitFormat[i], "mpegts", StringComparison.OrdinalIgnoreCase))
                 {
                     splitFormat[i] = "ts";
                 }
@@ -624,15 +624,19 @@ namespace MediaBrowser.MediaEncoding.Probing
         {
             if (string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase))
             {
-                codec = "dvbsub";
+                codec = "DVBSUB";
             }
-            else if ((codec ?? string.Empty).Contains("PGS", StringComparison.OrdinalIgnoreCase))
+            else if (string.Equals(codec, "dvb_teletext", StringComparison.OrdinalIgnoreCase))
             {
-                codec = "PGSSUB";
+                codec = "DVBTXT";
             }
-            else if ((codec ?? string.Empty).Contains("DVD", StringComparison.OrdinalIgnoreCase))
+            else if (string.Equals(codec, "dvd_subtitle", StringComparison.OrdinalIgnoreCase))
             {
-                codec = "DVDSUB";
+                codec = "DVDSUB"; // .sub+.idx
+            }
+            else if (string.Equals(codec, "hdmv_pgs_subtitle", StringComparison.OrdinalIgnoreCase))
+            {
+                codec = "PGSSUB"; // .sup
             }
 
             return codec;
@@ -779,11 +783,10 @@ namespace MediaBrowser.MediaEncoding.Probing
                     && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase);
 
                 if (isAudio
-                    && (string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
-                        || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase)))
+                    || string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase))
                 {
                     stream.Type = MediaStreamType.EmbeddedImage;
                 }

+ 8 - 8
MediaBrowser.Model/Entities/MediaStream.cs

@@ -656,14 +656,14 @@ namespace MediaBrowser.Model.Entities
         {
             string codec = format ?? string.Empty;
 
-            // sub = external .sub file
-
-            return !codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
-                   && !codec.Contains("dvd", StringComparison.OrdinalIgnoreCase)
-                   && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
-                   && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)
-                   && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
-                   && !string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase);
+            // microdvd and dvdsub/vobsub share the ".sub" file extension, but it's text-based.
+
+            return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase)
+                   || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
+                       && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase)
+                       && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
+                       && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
+                       && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase));
         }
 
         public bool SupportsSubtitleConversionTo(string toCodec)

+ 21 - 4
MediaBrowser.Providers/Manager/ImageSaver.cs

@@ -14,6 +14,7 @@ using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Entities;
@@ -188,11 +189,27 @@ namespace MediaBrowser.Providers.Manager
                 {
                     _fileSystem.DeleteFile(currentPath);
 
-                    // Remove containing directory if empty
-                    var folder = Path.GetDirectoryName(currentPath);
-                    if (!_fileSystem.GetFiles(folder).Any())
+                    // Remove local episode metadata directory if it exists and is empty
+                    var directory = Path.GetDirectoryName(currentPath);
+                    if (item is Episode && directory.Equals("metadata", StringComparison.Ordinal))
                     {
-                        Directory.Delete(folder);
+                        var parentDirectoryPath = Directory.GetParent(currentPath).FullName;
+                        if (_fileSystem.DirectoryExists(parentDirectoryPath) && !_fileSystem.GetFiles(parentDirectoryPath).Any())
+                        {
+                            try
+                            {
+                                _logger.LogInformation("Deleting empty local metadata folder {Folder}", parentDirectoryPath);
+                                Directory.Delete(parentDirectoryPath);
+                            }
+                            catch (UnauthorizedAccessException ex)
+                            {
+                                _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
+                            }
+                            catch (IOException ex)
+                            {
+                                _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
+                            }
+                        }
                     }
                 }
                 catch (FileNotFoundException)

+ 13 - 13
MediaBrowser.Providers/Manager/ItemImageProvider.cs

@@ -10,6 +10,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Controller.Providers;
@@ -96,7 +97,7 @@ namespace MediaBrowser.Providers.Manager
         public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions)
         {
             var hasChanges = false;
-            IDirectoryService directoryService = refreshOptions?.DirectoryService;
+            var directoryService = refreshOptions?.DirectoryService;
 
             if (item is not Photo)
             {
@@ -359,10 +360,8 @@ namespace MediaBrowser.Providers.Manager
 
         private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images)
         {
-            for (var i = 0; i < images.Count; i++)
+            foreach (var image in images)
             {
-                var image = images[i];
-
                 if (image.IsLocalFile)
                 {
                     try
@@ -377,19 +376,20 @@ namespace MediaBrowser.Providers.Manager
                     {
                         _logger.LogWarning(ex, "Unable to delete {Image}", image.Path);
                     }
-                    finally
-                    {
-                        // Always remove empty parent folder
-                        var folder = Path.GetDirectoryName(image.Path);
-                        if (Directory.Exists(folder) && !_fileSystem.GetFiles(folder).Any())
-                        {
-                            Directory.Delete(folder);
-                        }
-                    }
                 }
             }
 
             item.RemoveImages(images);
+
+            // Cleanup old metadata directory for episodes if empty
+            if (item is Episode)
+            {
+                var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata");
+                if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any())
+                {
+                    Directory.Delete(oldLocalMetadataDirectory);
+                }
+            }
         }
 
         /// <summary>

+ 6 - 7
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -675,6 +675,8 @@ namespace MediaBrowser.Providers.Manager
             };
             temp.Item.Path = item.Path;
             temp.Item.Id = item.Id;
+            temp.Item.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode;
+            temp.Item.PreferredMetadataLanguage = item.PreferredMetadataLanguage;
 
             var foundImageTypes = new List<ImageType>();
 
@@ -817,19 +819,16 @@ namespace MediaBrowser.Providers.Manager
         {
             var refreshResult = new RefreshResult();
 
-            var tmpDataMerged = false;
+            if (id is not null)
+            {
+                MergeNewData(temp.Item, id);
+            }
 
             foreach (var provider in providers)
             {
                 var providerName = provider.GetType().Name;
                 Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
 
-                if (id is not null && !tmpDataMerged)
-                {
-                    MergeNewData(temp.Item, id);
-                    tmpDataMerged = true;
-                }
-
                 try
                 {
                     var result = await provider.GetMetadata(id, cancellationToken).ConfigureAwait(false);

+ 164 - 136
MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs

@@ -1,7 +1,5 @@
 #nullable disable
 
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.IO;
@@ -18,182 +16,212 @@ using MediaBrowser.Model.IO;
 using Microsoft.Extensions.Logging;
 using PlaylistsNET.Content;
 
-namespace MediaBrowser.Providers.Playlists
+namespace MediaBrowser.Providers.Playlists;
+
+/// <summary>
+/// Local playlist provider.
+/// </summary>
+public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
+    IHasOrder,
+    IForcedProvider,
+    IHasItemChangeMonitor
 {
-    public class PlaylistItemsProvider : ICustomMetadataProvider<Playlist>,
-        IHasOrder,
-        IForcedProvider,
-        IPreRefreshProvider,
-        IHasItemChangeMonitor
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<PlaylistItemsProvider> _logger;
+    private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists];
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PlaylistItemsProvider"/> class.
+    /// </summary>
+    /// <param name="logger">Instance of the <see cref="ILogger{PlaylistItemsProvider}"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem)
     {
-        private readonly IFileSystem _fileSystem;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ILogger<PlaylistItemsProvider> _logger;
-        private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists];
+        _logger = logger;
+        _libraryManager = libraryManager;
+        _fileSystem = fileSystem;
+    }
+
+    /// <inheritdoc />
+    public string Name => "Playlist Item Provider";
+
+    /// <inheritdoc />
+    public int Order => 100;
 
-        public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem)
+    /// <inheritdoc />
+    public Task<MetadataResult<Playlist>> GetMetadata(
+        ItemInfo info,
+        IDirectoryService directoryService,
+        CancellationToken cancellationToken)
+    {
+        var result = new MetadataResult<Playlist>()
         {
-            _logger = logger;
-            _libraryManager = libraryManager;
-            _fileSystem = fileSystem;
+            Item = new Playlist
+            {
+                Path = info.Path
+            }
+        };
+        Fetch(result);
+
+        return Task.FromResult(result);
+    }
+
+    private void Fetch(MetadataResult<Playlist> result)
+    {
+        var item = result.Item;
+        var path = item.Path;
+        if (!Playlist.IsPlaylistFile(path))
+        {
+            return;
         }
 
-        public string Name => "Playlist Reader";
+        var extension = Path.GetExtension(path);
+        if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+        {
+            return;
+        }
+
+        var items = GetItems(path, extension).ToArray();
+        if (items.Length > 0)
+        {
+            result.HasMetadata = true;
+            item.LinkedChildren = items;
+        }
 
-        // Run last
-        public int Order => 100;
+        return;
+    }
 
-        public Task<ItemUpdateType> FetchAsync(Playlist item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+    private IEnumerable<LinkedChild> GetItems(string path, string extension)
+    {
+        var libraryRoots = _libraryManager.GetUserRootFolder().Children
+                            .OfType<CollectionFolder>()
+                            .Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
+                            .SelectMany(f => f.PhysicalLocations)
+                            .Distinct(StringComparer.OrdinalIgnoreCase)
+                            .ToList();
+
+        using (var stream = File.OpenRead(path))
         {
-            var path = item.Path;
-            if (!Playlist.IsPlaylistFile(path))
+            if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
             {
-                return Task.FromResult(ItemUpdateType.None);
+                return GetWplItems(stream, path, libraryRoots);
             }
 
-            var extension = Path.GetExtension(path);
-            if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+            if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
             {
-                return Task.FromResult(ItemUpdateType.None);
+                return GetZplItems(stream, path, libraryRoots);
             }
 
-            var items = GetItems(path, extension).ToArray();
-
-            item.LinkedChildren = items;
-
-            return Task.FromResult(ItemUpdateType.MetadataImport);
-        }
+            if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
+            {
+                return GetM3uItems(stream, path, libraryRoots);
+            }
 
-        private IEnumerable<LinkedChild> GetItems(string path, string extension)
-        {
-            var libraryRoots = _libraryManager.GetUserRootFolder().Children
-                                .OfType<CollectionFolder>()
-                                .Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
-                                .SelectMany(f => f.PhysicalLocations)
-                                .Distinct(StringComparer.OrdinalIgnoreCase)
-                                .ToList();
-
-            using (var stream = File.OpenRead(path))
+            if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
             {
-                if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
-                {
-                    return GetWplItems(stream, path, libraryRoots);
-                }
-
-                if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
-                {
-                    return GetZplItems(stream, path, libraryRoots);
-                }
-
-                if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
-                {
-                    return GetM3uItems(stream, path, libraryRoots);
-                }
-
-                if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
-                {
-                    return GetM3uItems(stream, path, libraryRoots);
-                }
-
-                if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
-                {
-                    return GetPlsItems(stream, path, libraryRoots);
-                }
+                return GetM3uItems(stream, path, libraryRoots);
             }
 
-            return Enumerable.Empty<LinkedChild>();
+            if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
+            {
+                return GetPlsItems(stream, path, libraryRoots);
+            }
         }
 
-        private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
-        {
-            var content = new PlsContent();
-            var playlist = content.GetFromStream(stream);
+        return Enumerable.Empty<LinkedChild>();
+    }
 
-            return playlist.PlaylistEntries
-                    .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
-                    .Where(i => i is not null);
-        }
+    private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
+    {
+        var content = new PlsContent();
+        var playlist = content.GetFromStream(stream);
 
-        private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
-        {
-            var content = new M3uContent();
-            var playlist = content.GetFromStream(stream);
+        return playlist.PlaylistEntries
+                .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+                .Where(i => i is not null);
+    }
 
-            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)
+    {
+        var content = new M3uContent();
+        var playlist = content.GetFromStream(stream);
 
-        private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
-        {
-            var content = new ZplContent();
-            var playlist = content.GetFromStream(stream);
+        return playlist.PlaylistEntries
+                .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+                .Where(i => i is not null);
+    }
 
-            return playlist.PlaylistEntries
-                    .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
-                    .Where(i => i is not null);
-        }
+    private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
+    {
+        var content = new ZplContent();
+        var playlist = content.GetFromStream(stream);
 
-        private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots)
-        {
-            var content = new WplContent();
-            var playlist = content.GetFromStream(stream);
+        return playlist.PlaylistEntries
+                .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+                .Where(i => i is not null);
+    }
 
-            return playlist.PlaylistEntries
-                    .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
-                    .Where(i => i is not null);
-        }
+    private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots)
+    {
+        var content = new WplContent();
+        var playlist = content.GetFromStream(stream);
+
+        return playlist.PlaylistEntries
+                .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+                .Where(i => i is not null);
+    }
 
-        private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
+    private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
+    {
+        if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
         {
-            if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
+            return new LinkedChild
             {
-                return new LinkedChild
-                {
-                    Path = parsedPath,
-                    Type = LinkedChildType.Manual
-                };
-            }
-
-            return null;
+                Path = parsedPath,
+                Type = LinkedChildType.Manual
+            };
         }
 
-        private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
+        return null;
+    }
+
+    private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
+    {
+        path = null;
+        string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
+        if (!File.Exists(pathToCheck))
         {
-            path = null;
-            string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
-            if (!File.Exists(pathToCheck))
-            {
-                return false;
-            }
+            return false;
+        }
 
-            foreach (var libraryPath in libraryPaths)
+        foreach (var libraryPath in libraryPaths)
+        {
+            if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
             {
-                if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
-                {
-                    path = pathToCheck;
-                    return true;
-                }
+                path = pathToCheck;
+                return true;
             }
-
-            return false;
         }
 
-        public bool HasChanged(BaseItem item, IDirectoryService directoryService)
-        {
-            var path = item.Path;
+        return false;
+    }
 
-            if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
+    /// <inheritdoc />
+    public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+    {
+        var path = item.Path;
+        if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
+        {
+            var file = directoryService.GetFile(path);
+            if (file is not null && file.LastWriteTimeUtc != item.DateModified)
             {
-                var file = directoryService.GetFile(path);
-                if (file is not null && file.LastWriteTimeUtc != item.DateModified)
-                {
-                    _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
-                    return true;
-                }
+                _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
+                return true;
             }
-
-            return false;
         }
+
+        return false;
     }
 }

+ 1 - 1
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs

@@ -250,7 +250,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
         // If we have a release ID but not a release group ID, lookup the release group
         if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
         {
-            var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false);
+            var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
             releaseGroupId = release.ReleaseGroup?.Id.ToString();
             result.HasMetadata = true;
         }

+ 13 - 10
MediaBrowser.Providers/TV/SeriesMetadataService.cs

@@ -61,8 +61,8 @@ namespace MediaBrowser.Providers.TV
             await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 
             RemoveObsoleteEpisodes(item);
-            RemoveObsoleteSeasons(item);
             await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+            RemoveObsoleteSeasons(item);
         }
 
         /// <inheritdoc />
@@ -91,7 +91,7 @@ namespace MediaBrowser.Providers.TV
 
         private void RemoveObsoleteSeasons(Series series)
         {
-            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync.
+            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in CreateSeasonsAsync.
             var physicalSeasonNumbers = new HashSet<int>();
             var virtualSeasons = new List<Season>();
             foreach (var existingSeason in series.Children.OfType<Season>())
@@ -203,11 +203,16 @@ namespace MediaBrowser.Providers.TV
             foreach (var seasonNumber in uniqueSeasonNumbers)
             {
                 // Null season numbers will have a 'dummy' season created because seasons are always required.
-                if (!seasons.Any(i => i.IndexNumber == seasonNumber))
+                var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
+                if (existingSeason is null)
                 {
                     var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
-                    var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
-                    series.AddChild(season);
+                    await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
+                }
+                else if (existingSeason.IsVirtualItem)
+                {
+                    existingSeason.IsVirtualItem = false;
+                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
                 }
             }
         }
@@ -220,7 +225,7 @@ namespace MediaBrowser.Providers.TV
         /// <param name="seasonNumber">The season number.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The newly created season.</returns>
-        private async Task<Season> CreateSeasonAsync(
+        private async Task CreateSeasonAsync(
             Series series,
             string? seasonName,
             int? seasonNumber,
@@ -237,14 +242,12 @@ namespace MediaBrowser.Providers.TV
                     typeof(Season)),
                 IsVirtualItem = false,
                 SeriesId = series.Id,
-                SeriesName = series.Name
+                SeriesName = series.Name,
+                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
             };
 
             series.AddChild(season);
-
             await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
-
-            return season;
         }
 
         private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)

+ 3 - 1
MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs

@@ -519,7 +519,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     if (reader.TryReadDateTimeExact(nfoConfiguration.ReleaseDateFormat, out var releaseDate))
                     {
                         item.PremiereDate = releaseDate;
-                        item.ProductionYear = releaseDate.Year;
+
+                        // Production year can already be set by the year tag
+                        item.ProductionYear ??= releaseDate.Year;
                     }
 
                     break;

+ 7 - 3
src/Jellyfin.Networking/Manager/NetworkManager.cs

@@ -919,10 +919,14 @@ public class NetworkManager : INetworkManager, IDisposable
     {
         ArgumentNullException.ThrowIfNull(address);
 
-        // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+        // Map IPv6 mapped IPv4 back to IPv4 (happens if Kestrel runs in dual-socket mode)
+        if (address.IsIPv4MappedToIPv6)
+        {
+            address = address.MapToIPv4();
+        }
+
         if ((TrustAllIPv6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
-            || address.Equals(IPAddress.Loopback)
-            || address.Equals(IPAddress.IPv6Loopback))
+            || IPAddress.IsLoopback(address))
         {
             return true;
         }

+ 1 - 0
tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs

@@ -104,6 +104,7 @@ public class ExternalPathParserTests
     [InlineData(".en.cc.title", "title", "eng", false, false, true)]
     [InlineData(".hi.en.title", "title", "eng", false, false, true)]
     [InlineData(".en.hi.title", "title", "eng", false, false, true)]
+    [InlineData(".Subs for Chinese Audio.eng", "Subs for Chinese Audio", "eng", false, false, false)]
     public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false)
     {
         var path = "My.Video" + tokens + ".srt";