From a0d1e05696e61cef0b060c51c52d27c8c71c7d84 Mon Sep 17 00:00:00 2001 From: dkanada Date: Sat, 21 Mar 2026 12:02:22 +0900 Subject: [PATCH 001/119] migrate local comic providers to server codebase --- Directory.Packages.props | 1 + .../ComicBookInfo/ComicBookInfoProvider.cs | 246 ++++++++++++++++++ .../Models/ComicBookInfoCredit.cs | 21 ++ .../Models/ComicBookInfoFormat.cs | 27 ++ .../Models/ComicBookInfoMetadata.cs | 107 ++++++++ .../Books/ComicImageProvider.cs | 146 +++++++++++ .../Books/ComicInfo/ComicInfoReader.cs | 218 ++++++++++++++++ .../ComicInfo/ExternalComicInfoProvider.cs | 100 +++++++ .../ComicInfo/InternalComicInfoProvider.cs | 121 +++++++++ MediaBrowser.Providers/Books/ComicProvider.cs | 59 +++++ .../Books/ComicServiceRegistrator.cs | 23 ++ .../Books/IComicProvider.cs | 28 ++ .../MediaBrowser.Providers.csproj | 1 + 13 files changed, 1098 insertions(+) create mode 100644 MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs create mode 100644 MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs create mode 100644 MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs create mode 100644 MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs create mode 100644 MediaBrowser.Providers/Books/ComicImageProvider.cs create mode 100644 MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs create mode 100644 MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs create mode 100644 MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs create mode 100644 MediaBrowser.Providers/Books/ComicProvider.cs create mode 100644 MediaBrowser.Providers/Books/ComicServiceRegistrator.cs create mode 100644 MediaBrowser.Providers/Books/IComicProvider.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 09524549ef..2131a1484e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,6 +67,7 @@ + diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs new file mode 100644 index 0000000000..4ef618b0eb --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -0,0 +1,246 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Books.ComicBookInfo.Models; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives.Zip; + +namespace MediaBrowser.Providers.Books.ComicBookInfo; + +/// +/// ComicBookInfo provider. +/// +public class ComicBookInfoProvider : IComicProvider +{ + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ComicBookInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + _logger.LogError("could not load comic: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + try + { + Stream stream = File.OpenRead(path); + + // not yet async: https://github.com/adamhathcock/sharpcompress/pull/565 + await using (stream.ConfigureAwait(false)) + using (var archive = ZipArchive.Open(stream)) + { + if (!archive.IsComplete) + { + _logger.LogError("incomplete comic archive: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var volume = archive.Volumes.First(); + + if (volume.Comment is null) + { + _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var comicBookMetadata = JsonSerializer.Deserialize(volume.Comment, JsonDefaults.Options); + + if (comicBookMetadata is null) + { + _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + return SaveMetadata(comicBookMetadata); + } + } + catch (Exception) + { + _logger.LogError("failed to load ComicBookInfo metadata: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private MetadataResult SaveMetadata(ComicBookInfoFormat comic) + { + if (comic.Metadata is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var book = ReadComicBookMetadata(comic.Metadata); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + if (comic.Metadata.Language is not null) + { + metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language); + } + + if (comic.Metadata.Credits.Count > 0) + { + ReadPeopleMetadata(comic.Metadata, metadataResult); + } + + return metadataResult; + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have ComicBookInfo metadata + return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null; + } + + private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic) + { + var book = new Book(); + var hasFoundMetadata = false; + + hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title); + hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series); + hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre)); + hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview); + hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher])); + + if (comic.PublicationYear is not null) + { + book.ProductionYear = comic.PublicationYear; + hasFoundMetadata = true; + } + + if (comic.Issue is not null) + { + book.IndexNumber = comic.Issue; + hasFoundMetadata = true; + } + + if (comic.Tags.Count > 0) + { + book.Tags = comic.Tags.ToArray(); + hasFoundMetadata = true; + } + + if (comic.PublicationYear is not null && comic.PublicationMonth is not null) + { + book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value); + hasFoundMetadata = true; + } + + return hasFoundMetadata ? book : null; + } + + private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult metadataResult) + { + foreach (var person in comic.Credits) + { + if (person.Person is null || person.Role is null) + { + continue; + } + + if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase)) + { + var name = person.Person.Split(','); + person.Person = name[1].Trim(' ') + " " + name[0].Trim(' '); + } + + if (!Enum.TryParse(person.Role, out PersonKind personKind)) + { + personKind = PersonKind.Unknown; + } + + if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase)) + { + personKind = PersonKind.Colorist; + } + + metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind }); + } + } + + private static string? ReadCultureInfoInto(string language) + { + try + { + return CultureInfo.GetCultureInfo(language).DisplayName; + } + catch (CultureNotFoundException) + { + return null; + } + } + + private static bool ReadStringInto(string? data, Action commitResult) + { + if (!string.IsNullOrWhiteSpace(data)) + { + commitResult(data); + return true; + } + + return false; + } + + private static DateTime? ReadTwoPartDateInto(int year, int month) + { + try + { + // use first day of the month because this format doesn't include a day + return new DateTime(year, month, 1); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs new file mode 100644 index 0000000000..fe7aa40456 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo credit. +/// +public class ComicBookInfoCredit +{ + /// + /// Gets or sets the person name. + /// + [JsonPropertyName("person")] + public string? Person { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs new file mode 100644 index 0000000000..5c4e3d948f --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo format. +/// +public class ComicBookInfoFormat +{ + /// + /// Gets or sets the app ID. + /// + [JsonPropertyName("appID")] + public string? AppId { get; set; } + + /// + /// Gets or sets the last modified timestamp. + /// + [JsonPropertyName("lastModified")] + public string? LastModified { get; set; } + + /// + /// Gets or sets the metadata. + /// + [JsonPropertyName("ComicBookInfo/1.0")] + public ComicBookInfoMetadata? Metadata { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs new file mode 100644 index 0000000000..42e1b3d4f6 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo metadata. +/// +public class ComicBookInfoMetadata +{ + /// + /// Gets or sets the series. + /// + [JsonPropertyName("series")] + public string? Series { get; set; } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the publisher. + /// + [JsonPropertyName("publisher")] + public string? Publisher { get; set; } + + /// + /// Gets or sets the publication month. + /// + [JsonPropertyName("publicationMonth")] + public int? PublicationMonth { get; set; } + + /// + /// Gets or sets the publication year. + /// + [JsonPropertyName("publicationYear")] + public int? PublicationYear { get; set; } + + /// + /// Gets or sets the issue number. + /// + [JsonPropertyName("issue")] + public int? Issue { get; set; } + + /// + /// Gets or sets the number of issues. + /// + [JsonPropertyName("numberOfIssues")] + public int? NumberOfIssues { get; set; } + + /// + /// Gets or sets the volume number. + /// + [JsonPropertyName("volume")] + public int? Volume { get; set; } + + /// + /// Gets or sets the number of volumes. + /// + [JsonPropertyName("numberOfVolumes")] + public int? NumberOfVolumes { get; set; } + + /// + /// Gets or sets the rating. + /// + [JsonPropertyName("rating")] + public int? Rating { get; set; } + + /// + /// Gets or sets the genre. + /// + [JsonPropertyName("genre")] + public string? Genre { get; set; } + + /// + /// Gets or sets the language. + /// + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + + /// + /// Gets or sets the list of credits. + /// + [JsonPropertyName("credits")] + public IReadOnlyList Credits { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of tags. + /// + [JsonPropertyName("tags")] + public IReadOnlyList Tags { get; set; } = Array.Empty(); + + /// + /// Gets or sets the comments. + /// + [JsonPropertyName("comments")] + public string? Comments { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs new file mode 100644 index 0000000000..01ab22a520 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +namespace MediaBrowser.Providers.Books; + +/// +/// The ComicImageProvider tries to find either an image named "cover" or, in case that +/// fails, just takes the first image inside the archive, hoping that it is the cover. +/// +public class ComicImageProvider : IDynamicImageProvider +{ + private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"]; + private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public ComicImageProvider(ILogger logger) + { + _logger = logger; + } + + /// + public string Name => "Comic Book Archive Cover Extractor"; + + /// + public Task GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(item.Path); + + if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + return LoadCover(item); + } + + return Task.FromResult(new DynamicImageResponse { HasImage = false }); + } + + /// + public IEnumerable GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// + /// Tries to load a cover from the CBZ archive. Returns a response + /// with no image if nothing is found. + /// + /// Item to check for covers. + private async Task LoadCover(BaseItem item) + { + var memoryStream = new MemoryStream(); + + try + { + ImageFormat imageFormat; + + using (Stream stream = File.OpenRead(item.Path)) + using (var archive = ArchiveFactory.Open(stream)) + { + // throw exception to log results if no cover is found + (var cover, imageFormat) = FindCoverEntryInArchive(archive) ?? throw new InvalidOperationException("no supported cover found"); + + // copy the cover to memory stream + await cover.OpenEntryStream().CopyToAsync(memoryStream).ConfigureAwait(false); + } + + // reset stream position after copying + memoryStream.Position = 0; + + return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat }; + } + catch (Exception e) + { + _logger.LogError(e, "failed to load cover from {Path}", item.Path); + return new DynamicImageResponse { HasImage = false }; + } + } + + /// + /// Tries to find the entry containing the cover. + /// + /// The archive to search. + /// The search result. + private (IArchiveEntry CoverEntry, ImageFormat ImageFormat)? FindCoverEntryInArchive(IArchive archive) + { + IArchiveEntry? cover; + + // only some comics will explicitly name their cover file + // in many cases the cover will simply be the first image in the archive + foreach (var extension in _coverExtensions) + { + cover = archive.Entries.FirstOrDefault(e => e.Key == "cover" + extension); + + if (cover is not null) + { + var imageFormat = GetImageFormat(extension); + + return (cover, imageFormat); + } + } + + cover = archive.Entries.OrderBy(x => x.Key).FirstOrDefault(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase)); + + if (cover is not null) + { + var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty)); + + return (cover, imageFormat); + } + + return null; + } + + private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch + { + ".jpg" => ImageFormat.Jpg, + ".jpeg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".svg" => ImageFormat.Svg, + _ => throw new ArgumentException($"unsupported extension: {extension}"), + }; +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs new file mode 100644 index 0000000000..429a2cf6d5 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using SharpCompress; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// ComicInfo reader. +/// +public class ComicInfoReader +{ + /// + /// Filename to check for comic metadata either next to the comic file or inside the archive. + /// + public const string ComicRackMetaFile = "ComicInfo.xml"; + + /// + /// Read comic book metadata. + /// + /// The XDocument to read for comic metadata. + /// The resulting book. + public Book? ReadComicBookMetadata(XDocument xml) + { + var book = new Book(); + var hasFoundMetadata = false; + + // this value is only used internally since Jellyfin has no manga flag + var isManga = false; + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase)); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year); + hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime); + hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres => genres.ForEach(genre => book.AddGenre(genre))); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher])); + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title => + { + if (isManga) + { + // Software like ComicTagger (https://github.com/comictagger/comictagger) will use + // this field for the series name in the original language when tagging manga. + book.OriginalTitle = title; + } + else + { + // Some US comics can be part of cross-over story arcs. This field is then used to + // specify an alternate series. + } + }); + + return hasFoundMetadata ? book : null; + } + + /// + /// Read people metadata. + /// + /// The XDocument to read for people metadata. + /// The metadata result to update. + public void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult) + { + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors => + { + authors.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author })); + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers => + { + pencillers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller })); + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers => + { + inkers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker })); + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers => + { + letterers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer })); + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists => + { + artists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist })); + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists => + { + colorists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist })); + }); + } + + /// + /// Read culture information. + /// + /// the XDocument to read for metadata. + /// The path to search. + /// The action to take after parsing all metadata. + public void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult) + { + string? culture = null; + + if (!ReadStringInto(xml, xPath, value => culture = value)) + { + return; + } + + try + { + // culture cannot be null here as the method would have returned earlier + commitResult(new CultureInfo(culture!)); + } + catch (CultureNotFoundException) + { + } + } + + private static bool ReadStringInto(XDocument xml, string xPath, Action commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + commitResult(resultElement.Value); + return true; + } + + return false; + } + + private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action> commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value)) + { + return false; + } + + try + { + var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray(); + if (splits.Length < 1) + { + return false; + } + + commitResult(splits); + return true; + } + catch (ArgumentNullException) + { + return false; + } + } + + private static bool ReadIntInto(XDocument xml, string xPath, Action commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + return ParseInt(resultElement.Value, commitResult); + } + + return false; + } + + private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action commitResult) + { + int year = 0; + int month = 0; + int day = 0; + var parsed = false; + + parsed |= ReadIntInto(xml, yearXPath, num => year = num); + parsed |= ReadIntInto(xml, monthXPath, num => month = num); + parsed |= ReadIntInto(xml, dayXPath, num => day = num); + + if (!parsed) + { + return false; + } + + try + { + var dateTime = new DateTime(year, month, day); + + commitResult(dateTime); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool ParseInt(string input, Action commitResult) + { + if (int.TryParse(input, out var parsed)) + { + commitResult(parsed); + return true; + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs new file mode 100644 index 0000000000..62fca925c8 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// Handles metadata for comics which is saved as an XML document. This XML document is not part +/// of the comic itself but an external file. +/// +public class ExternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + private readonly ComicInfoReader _utilities = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var book = _utilities.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult); + _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetXmlFilePath(item.Path); + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var path = GetXmlFilePath(info.Path).FullName; + + if (path is null) + { + return null; + } + + try + { + using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true }); + var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken); + + return await comicInfoXml.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogInformation(e, "Could not load external xml from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path); + return null; + } + } + + private FileSystemMetadata GetXmlFilePath(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!); + var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml")); + + return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile)); + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs new file mode 100644 index 0000000000..eff248b8d4 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -0,0 +1,121 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// Handles metadata for comics which is saved as an XML document inside the comic itself. +/// +public class InternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + private readonly ComicInfoReader _utilities = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public InternalComicInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var book = _utilities.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult); + _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + return null; + } + + try + { + // open the comic archive and try to get the ComicInfo.xml entry + using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false); + var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile); + + if (container is null) + { + return null; + } + + using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false); + var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken); + + return await comicInfoXml.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "could not load internal XML from {Path}", path); + return null; + } + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have internal metadata + if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileInfo; + } +} diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs new file mode 100644 index 0000000000..d59c58c330 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicProvider.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// +/// Comic provider. +/// +public class ComicProvider : ILocalMetadataProvider, IHasItemChangeMonitor +{ + private readonly IEnumerable _comicProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The list of comic providers. + public ComicProvider(IEnumerable comicProviders) + { + _comicProviders = comicProviders; + } + + /// + public string Name => "Comic Provider"; + + /// + public async Task> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + foreach (IComicProvider comicProvider in _comicProviders) + { + var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false); + + if (metadata.HasMetadata) + { + return metadata; + } + } + + return new MetadataResult { HasMetadata = false }; + } + + /// + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + foreach (IComicProvider iComicFileProvider in _comicProviders) + { + var fileChanged = iComicFileProvider.HasItemChanged(item); + + if (fileChanged) + { + return fileChanged; + } + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs new file mode 100644 index 0000000000..0d096241d6 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Providers.Books.ComicBookInfo; +using MediaBrowser.Providers.Books.ComicInfo; +using Microsoft.Extensions.DependencyInjection; + +namespace MediaBrowser.Providers.Books; + +/// +public class ComicServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + // register the generic local metadata provider for comic files + serviceCollection.AddSingleton(); + + // register the actual implementations of the local metadata provider for comic files + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + } +} diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs new file mode 100644 index 0000000000..06c8bd1136 --- /dev/null +++ b/MediaBrowser.Providers/Books/IComicProvider.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// +/// Comic provider interface. +/// +public interface IComicProvider +{ + /// + /// Read the item metadata. + /// + /// The item information. + /// Instance of the interface. + /// The cancellation token. + /// The metadata result. + ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken); + + /// + /// Determine whether the item has changed. + /// + /// The item. + /// Item change status. + bool HasItemChanged(BaseItem item); +} diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index ed0c63b97f..cbf050c5df 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -22,6 +22,7 @@ + From 8ceb8c23cead6afbeb79605c4ef53b7d4372cbd8 Mon Sep 17 00:00:00 2001 From: Beatriz Teixeira Date: Thu, 26 Mar 2026 13:02:54 +0000 Subject: [PATCH 002/119] fix(dto): prefer PlaylistsFolder primary image for playlists tiles This patch fixes issue #16032 where the Playlists media folder ignored a user-uploaded Primary image and kept showing the generated collage. The root cause was DTO image precedence on UserView items for CollectionType.playlists. We now prefer the display parent (PlaylistsFolder) Primary image when available by clearing the UserView Primary tag and setting ParentPrimaryImageItemId/ParentPrimaryImageTag. Added tests cover both paths: parent custom image preferred, and fallback to existing UserView Primary when parent has none. --- Emby.Server.Implementations/Dto/DtoService.cs | 15 ++ .../Dto/DtoServiceImageInheritanceTests.cs | 137 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index b392340f71..ac21c1c6d3 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1363,6 +1363,21 @@ namespace Emby.Server.Implementations.Dto private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) { + if (item is UserView { ViewType: CollectionType.playlists } playlistsView + && options.GetImageLimit(ImageType.Primary) > 0 + && !playlistsView.DisplayParentId.IsEmpty()) + { + var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId); + var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0); + + if (displayParentPrimaryImage is not null) + { + dto.ImageTags?.Remove(ImageType.Primary); + dto.ParentPrimaryImageItemId = displayParent!.Id; + dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage); + } + } + if (!item.SupportsInheritedParentImages) { return; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs new file mode 100644 index 0000000000..96625ae670 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -0,0 +1,137 @@ +using System; +using Emby.Server.Implementations.Dto; +using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceImageInheritanceTests +{ + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/playlists-custom.jpg", + DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.NotNull(dto.ParentPrimaryImageItemId); + Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag); + Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary)); + } + + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = [] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.Null(dto.ParentPrimaryImageItemId); + Assert.Null(dto.ParentPrimaryImageTag); + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); + } + + private static DtoService BuildDtoService(BaseItem displayParent) + { + var libraryManager = new Mock(); + var userDataManager = new Mock(); + var imageProcessor = new Mock(); + var providerManager = new Mock(); + var recordingsManager = new Mock(); + var appHost = new Mock(); + var mediaSourceManager = new Mock(); + var liveTvManager = new Mock(); + var trickplayManager = new Mock(); + var chapterManager = new Mock(); + var logger = new Mock>(); + + libraryManager + .Setup(x => x.GetItemById(displayParent.Id)) + .Returns(displayParent); + + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny(), It.IsAny())) + .Returns((_, image) => image.Path); + + return new DtoService( + logger.Object, + libraryManager.Object, + userDataManager.Object, + imageProcessor.Object, + providerManager.Object, + recordingsManager.Object, + appHost.Object, + mediaSourceManager.Object, + new Lazy(() => liveTvManager.Object), + trickplayManager.Object, + chapterManager.Object); + } +} From 07a802d8fa93460c9f2a7f42da7a1f14a893a322 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:33:56 +0200 Subject: [PATCH 003/119] Implement search providers --- .../ApplicationHost.cs | 5 +- .../Library/Search/SearchManager.cs | 443 ++++++++++++++++++ .../Library/Search/SqlSearchProvider.cs | 200 ++++++++ .../Library/SearchEngine.cs | 200 -------- Jellyfin.Api/Controllers/ItemsController.cs | 76 ++- Jellyfin.Api/Controllers/SearchController.cs | 15 +- .../Item/BaseItemRepository.ByName.cs | 59 ++- .../Item/BaseItemRepository.TranslateQuery.cs | 19 +- .../Entities/InternalItemsQuery.cs | 2 + .../Library/IExternalSearchProvider.cs | 20 + .../Library/IInternalSearchProvider.cs | 8 + .../Library/ISearchEngine.cs | 18 - .../Library/ISearchManager.cs | 48 ++ .../Library/ISearchProvider.cs | 44 ++ .../Library/SearchProviderQuery.cs | 45 ++ .../Library/SearchResult.cs | 60 +++ .../Configuration/MetadataPluginType.cs | 3 +- .../JellyfinQueryHelperExtensions.cs | 100 +++- 18 files changed, 1093 insertions(+), 272 deletions(-) create mode 100644 Emby.Server.Implementations/Library/Search/SearchManager.cs create mode 100644 Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs delete mode 100644 Emby.Server.Implementations/Library/SearchEngine.cs create mode 100644 MediaBrowser.Controller/Library/IExternalSearchProvider.cs create mode 100644 MediaBrowser.Controller/Library/IInternalSearchProvider.cs delete mode 100644 MediaBrowser.Controller/Library/ISearchEngine.cs create mode 100644 MediaBrowser.Controller/Library/ISearchManager.cs create mode 100644 MediaBrowser.Controller/Library/ISearchProvider.cs create mode 100644 MediaBrowser.Controller/Library/SearchProviderQuery.cs create mode 100644 MediaBrowser.Controller/Library/SearchResult.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index e8cab6ea8c..c97821f094 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -25,6 +25,7 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Library.Search; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -537,7 +538,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -694,6 +696,7 @@ namespace Emby.Server.Implementations GetExports()); Resolve().AddParts(GetExports()); + Resolve().AddParts(GetExports()); } /// diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs new file mode 100644 index 0000000000..d4c3302239 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Search; + +/// +/// Manages search providers and orchestrates search operations. +/// +public class SearchManager : ISearchManager +{ + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDbContextFactory _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private readonly ILogger _logger; + private IExternalSearchProvider[] _externalProviders = []; + private IInternalSearchProvider[] _internalProviders = []; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The user manager. + /// The database context factory. + /// The shared item query helpers. + /// The logger. + public SearchManager( + ILibraryManager libraryManager, + IUserManager userManager, + IDbContextFactory dbProvider, + IItemQueryHelpers queryHelpers, + ILogger logger) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _logger = logger; + } + + /// + public void AddParts(IEnumerable providers) + { + var allProviders = providers.OrderBy(p => p.Priority).ToArray(); + + _externalProviders = allProviders.OfType().ToArray(); + _internalProviders = allProviders.OfType().ToArray(); + + _logger.LogInformation( + "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}", + _externalProviders.Length, + string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")), + string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})"))); + } + + /// + public IReadOnlyList GetProviders() + { + return [.. _externalProviders, .. _internalProviders]; + } + + /// + public async Task> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + + var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + if (results.Count == 0 && _internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, falling back to internal providers"); + results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + } + + // External providers don't know about user permissions, so they may return IDs from + // hidden libraries or items the user is otherwise blocked from. Filter the candidate + // set to only items this user can access (top-parent libraries, parental rating, + // blocked/allowed tags, owned-item rules) before returning. The Items controller's + // second roundtrip via folder.GetItems applies most of these again, but it does not + // restrict by TopParentIds when ItemIds is set, leaving a gap that this closes. + if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + { + var user = _userManager.GetUserById(query.UserId.Value); + if (user is not null) + { + results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + } + } + + return results; + } + + private async Task> FilterByUserAccessAsync( + IReadOnlyList candidates, + User user, + CancellationToken cancellationToken) + { + // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates + // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds + // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty. + var accessFilter = new InternalItemsQuery(user); + _libraryManager.ConfigureUserAccess(accessFilter, user); + + var candidateIds = new Guid[candidates.Count]; + for (var i = 0; i < candidates.Count; i++) + { + candidateIds[i] = candidates[i].ItemId; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => candidateIds.Contains(e.Id)); + + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + + var allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + if (allowedIds.Count == candidates.Count) + { + return candidates; + } + + var filtered = new List(allowedIds.Count); + foreach (var c in candidates) + { + if (allowedIds.Contains(c.ItemId)) + { + filtered.Add(c); + } + } + + if (filtered.Count < candidates.Count) + { + _logger.LogDebug( + "Dropped {Dropped} of {Total} search candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + } + + return filtered; + } + } + + /// + public async Task> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var providerQuery = BuildProviderQuery(query); + var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false); + if (candidates.Count == 0) + { + return new QueryResult(); + } + + var candidateScores = BuildScoreLookup(candidates); + var user = !query.UserId.IsEmpty() ? _userManager.GetUserById(query.UserId) : null; + + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + var internalQuery = new InternalItemsQuery(user) + { + ItemIds = candidateScores.Keys.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [], + MediaTypes = query.MediaTypes.ToArray(), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, + Recursive = true, + IsKids = query.IsKids, + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + DtoOptions = new DtoOptions + { + Fields = + [ + ItemFields.AirTime, + ItemFields.DateCreated, + ItemFields.ChannelInfo, + ItemFields.ParentId + ] + } + }; + + // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name + // rather than being stored as regular library items. They require special handling: + // 1. Convert ParentId to AncestorIds (to filter by library folder) + // 2. Set IncludeItemsByName = true (to include these virtual items in results) + // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally) + // 4. Use GetAllArtists() instead of GetItemList() to query the artist index + IReadOnlyList items; + if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) + { + if (!internalQuery.ParentId.IsEmpty()) + { + internalQuery.AncestorIds = [internalQuery.ParentId]; + internalQuery.ParentId = Guid.Empty; + } + + internalQuery.IncludeItemsByName = true; + internalQuery.IncludeItemTypes = []; + items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList(); + } + else + { + items = _libraryManager.GetItemList(internalQuery); + } + + var orderedResults = items + .Select(item => new SearchHintInfo { Item = item }) + .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f)) + .ToList(); + + var totalCount = orderedResults.Count; + + if (query.StartIndex.HasValue) + { + orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList(); + } + + if (query.Limit.HasValue) + { + orderedResults = orderedResults.Take(query.Limit.Value).ToList(); + } + + return new QueryResult(query.StartIndex, totalCount, orderedResults); + } + + private async Task> CollectFromProvidersAsync( + IEnumerable providers, + SearchProviderQuery providerQuery, + string searchTerm, + CancellationToken cancellationToken) + { + var bestScores = new Dictionary(); + var requestedLimit = providerQuery.Limit ?? 100; + + foreach (var provider in providers.Where(p => p.CanSearch(providerQuery))) + { + if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is IExternalSearchProvider externalProvider) + { + var count = 0; + await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + UpdateBestScore(bestScores, result); + count++; + if (bestScores.Count >= requestedLimit) + { + break; + } + } + + _logger.LogDebug( + "External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + count, + searchTerm); + } + else + { + var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + foreach (var result in candidates) + { + UpdateBestScore(bestScores, result); + } + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + candidates.Count, + searchTerm); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + } + } + + return bestScores + .Select(kvp => new SearchResult(kvp.Key, kvp.Value)) + .OrderByDescending(r => r.Score) + .Take(requestedLimit) + .ToList(); + } + + private static void UpdateBestScore(Dictionary bestScores, SearchResult result) + { + if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) + { + bestScores[result.ItemId] = result.Score; + } + } + + private static Dictionary BuildScoreLookup(IReadOnlyList results) + { + var lookup = new Dictionary(results.Count); + foreach (var result in results) + { + lookup[result.ItemId] = result.Score; + } + + return lookup; + } + + private static SearchProviderQuery BuildProviderQuery(SearchQuery query) + { + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + // Remove any excluded types from includes + if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0) + { + includeItemTypes.RemoveAll(excludeItemTypes.Contains); + } + + return new SearchProviderQuery + { + SearchTerm = query.SearchTerm, + UserId = query.UserId.IsEmpty() ? null : query.UserId, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + MediaTypes = query.MediaTypes.ToArray(), + Limit = query.Limit, + ParentId = query.ParentId + }; + } + + private static List BuildExcludeItemTypes(SearchQuery query) + { + var excludeItemTypes = query.ExcludeItemTypes.ToList(); + + excludeItemTypes.Add(BaseItemKind.Year); + excludeItemTypes.Add(BaseItemKind.Folder); + excludeItemTypes.Add(BaseItemKind.CollectionFolder); + + if (!query.IncludeGenres) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Genre); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); + } + + if (!query.IncludePeople) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Person); + } + + if (!query.IncludeStudios) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Studio); + } + + if (!query.IncludeArtists) + { + AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); + } + + return excludeItemTypes; + } + + private static List BuildIncludeItemTypes(SearchQuery query) + { + var includeItemTypes = query.IncludeItemTypes.ToList(); + if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); + } + } + + if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Person); + } + } + + if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + } + + if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) + { + if (!query.IncludeMedia) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); + } + } + + return includeItemTypes; + } + + private static void AddIfMissing(List list, BaseItemKind value) + { + if (!list.Contains(value)) + { + list.Add(value); + } + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs new file mode 100644 index 0000000000..53c1cbbb79 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -0,0 +1,200 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Emby.Server.Implementations.Library.Search; + +/// +/// Built-in SQL-based search provider that queries the library database directly. +/// +public class SqlSearchProvider : IInternalSearchProvider +{ + private const int DefaultSearchLimit = 100; + private const float ExactMatchScore = 100f; + private const float PrefixMatchScore = 80f; + private const float WordPrefixMatchScore = 75f; + private const float ContainsMatchScore = 50f; + + private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly IDbContextFactory _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + + /// + /// Initializes a new instance of the class. + /// + /// The database context factory. + /// The item type lookup. + public SqlSearchProvider(IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + } + + /// + public string Name => "Database"; + + /// + public MetadataPluginType Type => MetadataPluginType.SearchProvider; + + /// + public int Priority => 100; // Low priority - runs as fallback + + /// + public bool CanSearch(SearchProviderQuery query) + { + // SQL search can always handle any query + return true; + } + + /// + public async Task> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + if (string.IsNullOrEmpty(rawSearchTerm)) + { + return []; + } + + var cleanSearchTerm = rawSearchTerm.GetCleanValue(); + if (string.IsNullOrEmpty(cleanSearchTerm)) + { + return []; + } + + var cleanPrefix = cleanSearchTerm + " "; + // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName, + // so match it via a case-insensitive LIKE rather than a per-row case conversion + // that may not translate to SQL on every provider. + var likeOriginal = $"%{rawSearchTerm}%"; + var limit = query.Limit ?? DefaultSearchLimit; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // Lightweight projection: select only what's needed to score and identify items. + var dbQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => e.Id != _placeholderId) + .Where(e => !e.IsVirtualItem) + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + + dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); + dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); + dbQuery = ApplyParentFilter(dbQuery, query.ParentId); + + // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is + // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it + // directly without any per-row case conversion. Items that match only via + // OriginalTitle fall through to the Contains tier. + // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take + // satisfies EF Core's row-limiting-with-OrderBy requirement. + var scored = dbQuery.Select(e => new + { + e.Id, + Score = + (e.CleanName == cleanSearchTerm) ? ExactMatchScore + : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore + : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore + : ContainsMatchScore + }); + + var top = await scored + .OrderByDescending(x => x.Score) + .ThenBy(x => x.Id) + .Take(limit) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var results = new List(top.Count); + foreach (var row in top) + { + results.Add(new SearchResult(row.Id, row.Score)); + } + + return results; + } + } + + private IQueryable ApplyTypeFilter( + IQueryable query, + BaseItemKind[] includeItemTypes, + BaseItemKind[] excludeItemTypes) + { + if (includeItemTypes.Length > 0) + { + var includeTypeNames = MapKindsToTypeNames(includeItemTypes); + if (includeTypeNames.Count > 0) + { + query = query.Where(e => includeTypeNames.Contains(e.Type)); + } + } + else if (excludeItemTypes.Length > 0) + { + var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes); + if (excludeTypeNames.Count > 0) + { + query = query.Where(e => !excludeTypeNames.Contains(e.Type)); + } + } + + return query; + } + + private static IQueryable ApplyMediaTypeFilter( + IQueryable query, + MediaType[] mediaTypes) + { + if (mediaTypes.Length == 0) + { + return query; + } + + var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray(); + return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType)); + } + + private static IQueryable ApplyParentFilter( + IQueryable query, + Guid? parentId) + { + if (!parentId.HasValue || parentId.Value.IsEmpty()) + { + return query; + } + + var pid = parentId.Value; + return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); + } + + private List MapKindsToTypeNames(BaseItemKind[] kinds) + { + var list = new List(kinds.Length); + foreach (var kind in kinds) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null) + { + list.Add(name); + } + } + + return list; + } +} diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs deleted file mode 100644 index c682118597..0000000000 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ /dev/null @@ -1,200 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; -using Jellyfin.Extensions; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace Emby.Server.Implementations.Library -{ - public class SearchEngine : ISearchEngine - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - public SearchEngine(ILibraryManager libraryManager, IUserManager userManager) - { - _libraryManager = libraryManager; - _userManager = userManager; - } - - public QueryResult GetSearchHints(SearchQuery query) - { - User? user = null; - if (!query.UserId.IsEmpty()) - { - user = _userManager.GetUserById(query.UserId); - } - - var results = GetSearchHints(query, user); - var totalRecordCount = results.Count; - - if (query.StartIndex.HasValue) - { - results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value); - } - - if (query.Limit.HasValue && query.Limit.Value > 0) - { - results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); - } - - return new QueryResult( - query.StartIndex, - totalRecordCount, - results); - } - - private static void AddIfMissing(List list, BaseItemKind value) - { - if (!list.Contains(value)) - { - list.Add(value); - } - } - - /// - /// Gets the search hints. - /// - /// The query. - /// The user. - /// IEnumerable{SearchHintResult}. - /// query.SearchTerm is null or empty. - private List GetSearchHints(SearchQuery query, User? user) - { - var searchTerm = query.SearchTerm; - - ArgumentException.ThrowIfNullOrEmpty(searchTerm); - - searchTerm = searchTerm.Trim().RemoveDiacritics(); - - var excludeItemTypes = query.ExcludeItemTypes.ToList(); - var includeItemTypes = query.IncludeItemTypes.ToList(); - - excludeItemTypes.Add(BaseItemKind.Year); - excludeItemTypes.Add(BaseItemKind.Folder); - - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Genre); - AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); - } - - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Person); - } - - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Studio); - } - - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); - } - - AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder); - AddIfMissing(excludeItemTypes, BaseItemKind.Folder); - var mediaTypes = query.MediaTypes.ToList(); - - if (includeItemTypes.Count > 0) - { - excludeItemTypes.Clear(); - mediaTypes.Clear(); - } - - var searchQuery = new InternalItemsQuery(user) - { - SearchTerm = searchTerm, - ExcludeItemTypes = excludeItemTypes.ToArray(), - IncludeItemTypes = includeItemTypes.ToArray(), - Limit = query.Limit, - IncludeItemsByName = !query.ParentId.HasValue, - ParentId = query.ParentId ?? Guid.Empty, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - Recursive = true, - - IsKids = query.IsKids, - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - MediaTypes = mediaTypes.ToArray(), - - DtoOptions = new DtoOptions - { - Fields = new ItemFields[] - { - ItemFields.AirTime, - ItemFields.DateCreated, - ItemFields.ChannelInfo, - ItemFields.ParentId - } - } - }; - - IReadOnlyList mediaItems; - - if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) - { - if (!searchQuery.ParentId.IsEmpty()) - { - searchQuery.AncestorIds = [searchQuery.ParentId]; - searchQuery.ParentId = Guid.Empty; - } - - searchQuery.IncludeItemsByName = true; - searchQuery.IncludeItemTypes = Array.Empty(); - mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList(); - } - else - { - mediaItems = _libraryManager.GetItemList(searchQuery); - } - - return mediaItems.Select(i => new SearchHintInfo - { - Item = i - }).ToList(); - } - } -} diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 53656186c8..133104680f 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -41,6 +42,7 @@ public class ItemsController : BaseJellyfinApiController private readonly ILogger _logger; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataRepository; + private readonly ISearchManager _searchManager; /// /// Initializes a new instance of the class. @@ -52,6 +54,7 @@ public class ItemsController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public ItemsController( IUserManager userManager, ILibraryManager libraryManager, @@ -59,7 +62,8 @@ public class ItemsController : BaseJellyfinApiController IDtoService dtoService, ILogger logger, ISessionManager sessionManager, - IUserDataManager userDataRepository) + IUserDataManager userDataRepository, + ISearchManager searchManager) { _userManager = userManager; _libraryManager = libraryManager; @@ -68,6 +72,7 @@ public class ItemsController : BaseJellyfinApiController _logger = logger; _sessionManager = sessionManager; _userDataRepository = userDataRepository; + _searchManager = searchManager; } /// @@ -298,7 +303,7 @@ public class ItemsController : BaseJellyfinApiController if (collectionType == CollectionType.playlists) { recursive = true; - includeItemTypes = new[] { BaseItemKind.Playlist }; + includeItemTypes = [BaseItemKind.Playlist]; } else if (folder is ICollectionFolder) { @@ -328,6 +333,34 @@ public class ItemsController : BaseJellyfinApiController if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) { + // Use search providers when searchTerm is provided. Providers return only IDs and scores; + // items are loaded server-side via folder.GetItems below, which applies user-access filtering. + Dictionary? searchResultScores = null; + Guid[] itemIds = ids; + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var searchProviderQuery = new SearchProviderQuery + { + SearchTerm = searchTerm, + UserId = userId, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + MediaTypes = mediaTypes, + Limit = limit.HasValue ? limit.Value * 3 : null, + ParentId = parentId + }; + + var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); + if (searchResults.Count > 0) + { + searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); + itemIds = ids.Length > 0 + ? ids.Concat(searchResultScores.Keys).Distinct().ToArray() + : searchResultScores.Keys.ToArray(); + } + } + var query = new InternalItemsQuery(user) { IsPlayed = isPlayed, @@ -337,8 +370,8 @@ public class ItemsController : BaseJellyfinApiController Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, - Limit = limit, - StartIndex = startIndex, + Limit = searchResultScores is not null ? null : limit, + StartIndex = searchResultScores is not null ? null : startIndex, IsMissing = isMissing, IsUnaired = isUnaired, CollapseBoxSetItems = collapseBoxSetItems, @@ -385,7 +418,7 @@ public class ItemsController : BaseJellyfinApiController ImageTypes = imageTypes, VideoTypes = videoTypes, AdjacentTo = adjacentTo, - ItemIds = ids, + ItemIds = itemIds, MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, ParentId = parentId ?? Guid.Empty, @@ -394,7 +427,7 @@ public class ItemsController : BaseJellyfinApiController EnableTotalRecordCount = enableTotalRecordCount, ExcludeItemIds = excludeItemIds, DtoOptions = dtoOptions, - SearchTerm = searchTerm, + SearchTerm = searchResultScores is null ? searchTerm : null, MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), MinPremiereDate = minPremiereDate?.ToUniversalTime(), @@ -476,7 +509,7 @@ public class ItemsController : BaseJellyfinApiController { query.AlbumIds = albums.SelectMany(i => { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); }).ToArray(); } @@ -502,12 +535,37 @@ public class ItemsController : BaseJellyfinApiController // Albums by artist if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; } } query.Parent = null; + + // folder.GetItems applies user-access filtering via the InternalItemsQuery's User. result = folder.GetItems(query); + if (searchResultScores is not null && searchResultScores.Count > 0) + { + var orderedItems = result.Items + .OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f)) + .ThenBy(item => item.SortName) + .ToArray(); + + var totalCount = orderedItems.Length; + if (startIndex.HasValue && startIndex.Value > 0) + { + orderedItems = orderedItems.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + orderedItems = orderedItems.Take(limit.Value).ToArray(); + } + + return new QueryResult( + startIndex, + totalCount, + _dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user)); + } } else { @@ -861,7 +919,7 @@ public class ItemsController : BaseJellyfinApiController var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)], IsResumable = true, StartIndex = startIndex, Limit = limit, diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index ecf2335ba0..b03cb88e75 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers; [Authorize] public class SearchController : BaseJellyfinApiController { - private readonly ISearchEngine _searchEngine; + private readonly ISearchManager _searchManager; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IImageProcessor _imageProcessor; @@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController /// /// Initializes a new instance of the class. /// - /// Instance of interface. + /// Instance of interface. /// Instance of interface. /// Instance of interface. /// Instance of interface. public SearchController( - ISearchEngine searchEngine, + ISearchManager searchManager, ILibraryManager libraryManager, IDtoService dtoService, IImageProcessor imageProcessor) { - _searchEngine = searchEngine; + _searchManager = searchManager; _libraryManager = libraryManager; _dtoService = dtoService; _imageProcessor = imageProcessor; @@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController [HttpGet] [Description("Gets search hints based on a search term")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetSearchHints( + public async Task> GetSearchHints( [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] Guid? userId, @@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController [FromQuery] bool includeArtists = true) { userId = RequestHelpers.GetUserId(User, userId); - var result = _searchEngine.GetSearchHints(new SearchQuery + var result = await _searchManager.GetSearchHintsAsync(new SearchQuery { Limit = limit, SearchTerm = searchTerm, @@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController IsNews = isNews, IsSeries = isSeries, IsSports = isSports - }); + }).ConfigureAwait(false); return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 380c6e582c..f557e3732a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -133,21 +133,15 @@ public sealed partial class BaseItemRepository IsSeries = filter.IsSeries }); - // Keep this as an IQueryable sub-select. Materializing to a list would inline one - // bound parameter per CleanValue and hit SQLite's variable cap on libraries with - // high-cardinality value types (e.g. tens of thousands of artists). - var matchingCleanValues = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Join( - innerQueryFilter, - ivm => ivm.ItemId, - g => g.Id, - (ivm, g) => ivm.ItemValue.CleanValue) - .Distinct(); - + // Use a correlated EXISTS rather than `IN (SELECT DISTINCT CleanValue ...)`. The + // IN-form would force materialization of the full set of artist CleanValues across the + // entire library before filtering. var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) - .Where(e => matchingCleanValues.Contains(e.CleanName!)); + .Where(e => context.ItemValuesMap.Any(ivm => + itemValueTypes.Contains(ivm.ItemValue.Type) + && ivm.ItemValue.CleanValue == e.CleanName + && innerQueryFilter.Any(g => g.Id == ivm.ItemId))); var outerQueryFilter = new InternalItemsQuery(filter.User) { @@ -174,9 +168,42 @@ public sealed partial class BaseItemRepository // (e.g. alternate versions) by picking the lowest Id per group. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - var orderedMasterQuery = ApplyOrder(masterQuery, filter, context) - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + IQueryable orderedMasterQuery; + if (!string.IsNullOrEmpty(filter.SearchTerm)) + { + var cleanSearchTerm = filter.SearchTerm.GetCleanValue(); + var cleanSearchPrefix = cleanSearchTerm + " "; + + orderedMasterQuery = masterQuery + .Select(e => new + { + e.Id, + e.PresentationUniqueKey, + e.SortName, + Score = (e.CleanName == cleanSearchTerm) ? 0 + : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 + : e.CleanName!.Contains(cleanSearchPrefix) ? 2 + : 3 + }) + .GroupBy(x => x.PresentationUniqueKey) + .Select(g => new + { + Id = g.Min(x => x.Id), + Score = g.Min(x => x.Score), + SortName = g.Min(x => x.SortName) + }) + .OrderBy(x => x.Score) + .ThenBy(x => x.SortName) + .Select(x => x.Id); + } + else + { + orderedMasterQuery = masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) + .OrderBy(x => x.SortName) + .Select(x => x.Id); + } var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 0abe981af8..d3e49b58da 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -932,24 +932,17 @@ public sealed partial class BaseItemRepository if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) { - var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); + baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds); } if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - // Allow setting a null or empty value to get all items that have the specified provider set. - var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); - } + baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId); + } - var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); - if (includeSelected.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); - } + if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) + { + baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); } if (filter.HasImdbId.HasValue) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..8ae578b228 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -351,6 +351,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary? HasAnyProviderId { get; set; } + public Dictionary? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs new file mode 100644 index 0000000000..bded8ba3a3 --- /dev/null +++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for external search providers that offer enhanced search capabilities. +/// +public interface IExternalSearchProvider : ISearchProvider +{ + /// + /// Searches for items matching the query. + /// + /// The search query. + /// Cancellation token. + /// Async enumerable of search results with relevance scores. + new IAsyncEnumerable SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs new file mode 100644 index 0000000000..f87931395d --- /dev/null +++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs @@ -0,0 +1,8 @@ +namespace MediaBrowser.Controller.Library; + +/// +/// Marker interface for internal search providers that typically query the local database directly. +/// +public interface IInternalSearchProvider : ISearchProvider +{ +} diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs deleted file mode 100644 index 31dcbba5bd..0000000000 --- a/MediaBrowser.Controller/Library/ISearchEngine.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace MediaBrowser.Controller.Library -{ - /// - /// Interface ILibrarySearchEngine. - /// - public interface ISearchEngine - { - /// - /// Gets the search hints. - /// - /// The query. - /// Task{IEnumerable{SearchHintInfo}}. - QueryResult GetSearchHints(SearchQuery query); - } -} diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs new file mode 100644 index 0000000000..4f763829a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchManager.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; + +namespace MediaBrowser.Controller.Library; + +/// +/// Orchestrates search operations across registered search providers. +/// +public interface ISearchManager +{ + /// + /// Searches for items and returns hints suitable for autocomplete/typeahead UI. + /// Results are ordered by relevance score from search providers. + /// + /// The search query including filters and pagination. + /// Cancellation token. + /// Paginated search hints with item metadata for display. + Task> GetSearchHintsAsync( + SearchQuery query, + CancellationToken cancellationToken = default); + + /// + /// Gets ranked search results from registered providers. Returns only item IDs and + /// relevance scores; callers are responsible for loading items and applying user-access filtering. + /// + /// The search provider query with type/media filters. + /// Cancellation token. + /// Search results containing item IDs and relevance scores. + Task> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default); + + /// + /// Registers search providers discovered through dependency injection. + /// Called during application startup. + /// + /// The search providers to register. + void AddParts(IEnumerable providers); + + /// + /// Gets all registered search providers ordered by priority. + /// + /// The list of search providers including the SQL fallback provider. + IReadOnlyList GetProviders(); +} diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs new file mode 100644 index 0000000000..3b300ed38b --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for search providers. +/// +public interface ISearchProvider +{ + /// + /// Gets the name of the provider. + /// + string Name { get; } + + /// + /// Gets the type of the provider. + /// + MetadataPluginType Type { get; } + + /// + /// Gets the priority of the provider. Lower values execute first. + /// + int Priority { get; } + + /// + /// Searches for items matching the query. + /// + /// The search query. + /// Cancellation token. + /// Ranked list of candidate item IDs with scores. + Task> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); + + /// + /// Determines whether this provider can handle the given query. + /// + /// The search query to evaluate. + /// True if this provider can search for the query; otherwise, false. + bool CanSearch(SearchProviderQuery query); +} diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs new file mode 100644 index 0000000000..845588c872 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -0,0 +1,45 @@ +using System; +using Jellyfin.Data.Enums; + +namespace MediaBrowser.Controller.Library; + +/// +/// Query object for search providers. +/// +public class SearchProviderQuery +{ + /// + /// Gets the search term. + /// + public required string SearchTerm { get; init; } + + /// + /// Gets the user ID for user-specific searches. + /// + public Guid? UserId { get; init; } + + /// + /// Gets the item types to include in the search. + /// + public BaseItemKind[] IncludeItemTypes { get; init; } = []; + + /// + /// Gets the item types to exclude from the search. + /// + public BaseItemKind[] ExcludeItemTypes { get; init; } = []; + + /// + /// Gets the media types to include in the search. + /// + public MediaType[] MediaTypes { get; init; } = []; + + /// + /// Gets the maximum number of results to return. + /// + public int? Limit { get; init; } + + /// + /// Gets the parent ID to scope the search. + /// + public Guid? ParentId { get; init; } +} diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs new file mode 100644 index 0000000000..e6f145e979 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchResult.cs @@ -0,0 +1,60 @@ +using System; + +namespace MediaBrowser.Controller.Library; + +/// +/// Represents an item matched by a search query with its relevance score. +/// +public readonly struct SearchResult : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The item ID. + /// The relevance score. + public SearchResult(Guid itemId, float score) + { + ItemId = itemId; + Score = score; + } + + /// + /// Gets the ID of the matching item. + /// + public Guid ItemId { get; init; } + + /// + /// Gets the relevance score. Higher values indicate more relevant results. + /// + public float Score { get; init; } + + /// + /// Compares two instances for equality. + /// + /// The left operand. + /// The right operand. + /// True if the instances are equal; otherwise, false. + public static bool operator ==(SearchResult left, SearchResult right) + => left.Equals(right); + + /// + /// Compares two instances for inequality. + /// + /// The left operand. + /// The right operand. + /// True if the instances are not equal; otherwise, false. + public static bool operator !=(SearchResult left, SearchResult right) + => !left.Equals(right); + + /// + public override bool Equals(object? obj) + => obj is SearchResult other && Equals(other); + + /// + public bool Equals(SearchResult other) + => ItemId.Equals(other.ItemId) && Score.Equals(other.Score); + + /// + public override int GetHashCode() + => HashCode.Combine(ItemId, Score); +} diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 670d6e3837..d53a924ea0 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -15,6 +15,7 @@ namespace MediaBrowser.Model.Configuration MetadataSaver, SubtitleFetcher, LyricFetcher, - MediaSegmentProvider + MediaSegmentProvider, + SearchProvider } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index f386e882e2..e366bdb095 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -111,6 +111,92 @@ public static class JellyfinQueryHelperExtensions && val.map.ItemId == item.Id) == EF.Constant(!invert); } + /// + /// Filters items that match any of the specified (provider name, value) pairs. + /// + /// The source query. + /// Dictionary mapping provider names to arrays of values to match. + /// A filtered query. + public static IQueryable WhereHasAnyProviderIds( + this IQueryable baseQuery, + IReadOnlyDictionary providerIds) + { + var providerKeys = providerIds + .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) + .ToList(); + + if (providerKeys.Count == 0) + { + return baseQuery; + } + + return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + /// + /// Filters items that have any of the specified providers. Empty/null values match any value for that provider. + /// + /// The source query. + /// Dictionary mapping provider names to optional values. + /// A filtered query. + public static IQueryable WhereHasAnyProviderId( + this IQueryable baseQuery, + IReadOnlyDictionary providerIds) + { + var existenceOnly = providerIds + .Where(e => string.IsNullOrEmpty(e.Value)) + .Select(e => e.Key) + .ToList(); + + var specificValues = providerIds + .Where(e => !string.IsNullOrEmpty(e.Value)) + .Select(e => $"{e.Key}:{e.Value}") + .ToList(); + + if (existenceOnly.Count == 0 && specificValues.Count == 0) + { + return baseQuery; + } + + if (existenceOnly.Count == 0) + { + return baseQuery.Where(e => e.Provider!.Any(p => + specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + if (specificValues.Count == 0) + { + return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId))); + } + + // Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries. + return baseQuery.Where(e => e.Provider!.Any(p => + existenceOnly.Contains(p.ProviderId) || + specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + /// + /// Excludes items that match any of the specified (provider name, value) pairs. + /// + /// The source query. + /// Dictionary mapping provider names to values to exclude. + /// A filtered query. + public static IQueryable WhereExcludeProviderIds( + this IQueryable baseQuery, + IReadOnlyDictionary providerIds) + { + var excludeKeys = providerIds + .Select(e => $"{e.Key}:{e.Value}") + .ToList(); + + if (excludeKeys.Count == 0) + { + return baseQuery; + } + + return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + /// /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. /// @@ -138,13 +224,13 @@ public static class JellyfinQueryHelperExtensions var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - if (oneOf.Count < 4) // arbitrary value choosen. - { - // if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup - return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); - } - - return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); + return Expression.Lambda>( + Expression.Call( + null, + containsMethodInfo, + Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), + property.Body), + parameter); } internal static class ParameterReplacer From ea7000a4d6bec1cd289eb947b1ad8b7a756d41b7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 02:20:48 +0200 Subject: [PATCH 004/119] Fix Sonar complaints --- .../Library/Search/SearchManager.cs | 151 +++++++------- .../Item/BaseItemRepository.ByName.cs | 187 +++++++++--------- 2 files changed, 178 insertions(+), 160 deletions(-) diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index d4c3302239..af916ec9a7 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -145,15 +145,7 @@ public class SearchManager : ISearchManager return candidates; } - var filtered = new List(allowedIds.Count); - foreach (var c in candidates) - { - if (allowedIds.Contains(c.ItemId)) - { - filtered.Add(c); - } - } - + var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); if (filtered.Count < candidates.Count) { _logger.LogDebug( @@ -271,46 +263,7 @@ public class SearchManager : ISearchManager break; } - try - { - if (provider is IExternalSearchProvider externalProvider) - { - var count = 0; - await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) - { - UpdateBestScore(bestScores, result); - count++; - if (bestScores.Count >= requestedLimit) - { - break; - } - } - - _logger.LogDebug( - "External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", - provider.Name, - count, - searchTerm); - } - else - { - var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); - foreach (var result in candidates) - { - UpdateBestScore(bestScores, result); - } - - _logger.LogDebug( - "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", - provider.Name, - candidates.Count, - searchTerm); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); - } + await CollectFromProviderAsync(provider, providerQuery, searchTerm, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false); } return bestScores @@ -320,6 +273,68 @@ public class SearchManager : ISearchManager .ToList(); } + private async Task CollectFromProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + string searchTerm, + Dictionary bestScores, + int requestedLimit, + CancellationToken cancellationToken) + { + try + { + var count = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false) + : await CollectFromInternalProviderAsync(provider, providerQuery, bestScores, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + count, + searchTerm); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + } + } + + private static async Task CollectFromExternalProviderAsync( + IExternalSearchProvider provider, + SearchProviderQuery providerQuery, + Dictionary bestScores, + int requestedLimit, + CancellationToken cancellationToken) + { + var count = 0; + await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + UpdateBestScore(bestScores, result); + count++; + if (bestScores.Count >= requestedLimit) + { + break; + } + } + + return count; + } + + private static async Task CollectFromInternalProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + Dictionary bestScores, + CancellationToken cancellationToken) + { + var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + foreach (var result in candidates) + { + UpdateBestScore(bestScores, result); + } + + return candidates.Count; + } + private static void UpdateBestScore(Dictionary bestScores, SearchResult result) { if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) @@ -397,42 +412,38 @@ public class SearchManager : ISearchManager private static List BuildIncludeItemTypes(SearchQuery query) { var includeItemTypes = query.IncludeItemTypes.ToList(); - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) + if (query.IncludeMedia) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } + return includeItemTypes; } - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) + if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); } - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) + if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } + AddIfMissing(includeItemTypes, BaseItemKind.Person); } - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) + if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio)) { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + + if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist)) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); } return includeItemTypes; } + private static bool IsEmptyOrContains(List list, BaseItemKind value) + => list.Count == 0 || list.Contains(value); + private static void AddIfMissing(List list, BaseItemKind value) { if (!list.Contains(value)) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index f557e3732a..7c64d9854d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -167,43 +167,7 @@ public sealed partial class BaseItemRepository // Build the master query and collapse rows that share a PresentationUniqueKey // (e.g. alternate versions) by picking the lowest Id per group. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - - IQueryable orderedMasterQuery; - if (!string.IsNullOrEmpty(filter.SearchTerm)) - { - var cleanSearchTerm = filter.SearchTerm.GetCleanValue(); - var cleanSearchPrefix = cleanSearchTerm + " "; - - orderedMasterQuery = masterQuery - .Select(e => new - { - e.Id, - e.PresentationUniqueKey, - e.SortName, - Score = (e.CleanName == cleanSearchTerm) ? 0 - : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 - : e.CleanName!.Contains(cleanSearchPrefix) ? 2 - : 3 - }) - .GroupBy(x => x.PresentationUniqueKey) - .Select(g => new - { - Id = g.Min(x => x.Id), - Score = g.Min(x => x.Score), - SortName = g.Min(x => x.SortName) - }) - .OrderBy(x => x.Score) - .ThenBy(x => x.SortName) - .Select(x => x.Id); - } - else - { - orderedMasterQuery = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) - .OrderBy(x => x.SortName) - .Select(x => x.Id); - } + var orderedMasterQuery = BuildOrderedMasterQuery(masterQuery, filter.SearchTerm); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) @@ -229,60 +193,10 @@ public sealed partial class BaseItemRepository query = ApplyOrder(query, filter, context); + result.StartIndex = filter.StartIndex ?? 0; if (filter.IncludeItemTypes.Length > 0) { - var typeSubQuery = new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ExcludeItemIds = filter.ExcludeItemIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsPlayed = filter.IsPlayed - }; - - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); - - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); - - // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) - // Instead, start from ItemValueMaps and join with BaseItems - var countsByCleanName = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) - .Join( - context.BaseItems, - ivm => ivm.ItemId, - e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .GroupBy(x => x.CleanName) - .ToDictionary( - g => g.Key, - g => new ItemCounts - { - SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), - EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), - MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), - AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), - ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), - SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), - TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), - }); - - result.StartIndex = filter.StartIndex ?? 0; + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); result.Items = [ .. query @@ -300,7 +214,6 @@ public sealed partial class BaseItemRepository } else { - result.StartIndex = filter.StartIndex ?? 0; result.Items = [ .. query @@ -314,4 +227,98 @@ public sealed partial class BaseItemRepository return result; } + + private static IQueryable BuildOrderedMasterQuery(IQueryable masterQuery, string? searchTerm) + { + if (string.IsNullOrEmpty(searchTerm)) + { + return masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) + .OrderBy(x => x.SortName) + .Select(x => x.Id); + } + + var cleanSearchTerm = searchTerm.GetCleanValue(); + var cleanSearchPrefix = cleanSearchTerm + " "; + + return masterQuery + .Select(e => new + { + e.Id, + e.PresentationUniqueKey, + e.SortName, + Score = (e.CleanName == cleanSearchTerm) ? 0 + : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 + : e.CleanName!.Contains(cleanSearchPrefix) ? 2 + : 3 + }) + .GroupBy(x => x.PresentationUniqueKey) + .Select(g => new + { + Id = g.Min(x => x.Id), + Score = g.Min(x => x.Score), + SortName = g.Min(x => x.SortName) + }) + .OrderBy(x => x.Score) + .ThenBy(x => x.SortName) + .Select(x => x.Id); + } + + private Dictionary BuildItemCountsByCleanName( + Database.Implementations.JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList itemValueTypes) + { + var typeSubQuery = new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; + + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + var itemIds = itemCountQuery.Select(e => e.Id); + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems + return context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) + .GroupBy(x => new { x.CleanName, x.Type }) + .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) + .GroupBy(x => x.CleanName) + .ToDictionary( + g => g.Key, + g => new ItemCounts + { + SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), + EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), + MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), + AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), + ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), + SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), + TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), + }); + } } From 5e82b61bab8c9461624fd2095fc9ccd11e33ce8d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 4 May 2026 23:40:07 +0200 Subject: [PATCH 005/119] Apply review suggestions --- .../Library/Search/SearchManager.cs | 97 ++++++++----------- .../Library/Search/SqlSearchProvider.cs | 52 +++++++--- .../JellyfinQueryHelperExtensions.cs | 15 +-- 3 files changed, 92 insertions(+), 72 deletions(-) diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index af916ec9a7..39fff42d9b 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -85,19 +85,20 @@ public class SearchManager : ISearchManager var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + var fromExternal = results.Count > 0; if (results.Count == 0 && _internalProviders.Length > 0) { _logger.LogDebug("No results from external providers, falling back to internal providers"); results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); } - // External providers don't know about user permissions, so they may return IDs from - // hidden libraries or items the user is otherwise blocked from. Filter the candidate - // set to only items this user can access (top-parent libraries, parental rating, - // blocked/allowed tags, owned-item rules) before returning. The Items controller's - // second roundtrip via folder.GetItems applies most of these again, but it does not - // restrict by TopParentIds when ItemIds is set, leaving a gap that this closes. - if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + // Internal providers apply user-access filtering inline in their queries. External + // providers don't know about user permissions, so they may return IDs from hidden + // libraries or items the user is otherwise blocked from. Run the post-filter only + // when results came from externals to close that gap. The Items controller's second + // roundtrip via folder.GetItems applies most of these again, but it does not restrict + // by TopParentIds when ItemIds is set. + if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) { var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) @@ -120,31 +121,28 @@ public class SearchManager : ISearchManager var accessFilter = new InternalItemsQuery(user); _libraryManager.ConfigureUserAccess(accessFilter, user); - var candidateIds = new Guid[candidates.Count]; - for (var i = 0; i < candidates.Count; i++) - { - candidateIds[i] = candidates[i].ItemId; - } + Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { var baseQuery = dbContext.BaseItems .AsNoTracking() - .Where(e => candidateIds.Contains(e.Id)); + .WhereOneOrMany(candidateIds, e => e.Id); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false); + if (allowedCount == candidates.Count) + { + return candidates; + } + var allowedIds = await baseQuery .Select(e => e.Id) .ToHashSetAsync(cancellationToken) .ConfigureAwait(false); - if (allowedIds.Count == candidates.Count) - { - return candidates; - } - var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); if (filtered.Count < candidates.Count) { @@ -253,17 +251,24 @@ public class SearchManager : ISearchManager string searchTerm, CancellationToken cancellationToken) { - var bestScores = new Dictionary(); var requestedLimit = providerQuery.Limit ?? 100; - - foreach (var provider in providers.Where(p => p.CanSearch(providerQuery))) + var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray(); + if (applicable.Length == 0) { - if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested) - { - break; - } + return []; + } - await CollectFromProviderAsync(provider, providerQuery, searchTerm, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false); + var perProvider = await Task.WhenAll( + applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken))) + .ConfigureAwait(false); + + var bestScores = new Dictionary(); + foreach (var providerResults in perProvider) + { + foreach (var result in providerResults) + { + UpdateBestScore(bestScores, result); + } } return bestScores @@ -273,66 +278,50 @@ public class SearchManager : ISearchManager .ToList(); } - private async Task CollectFromProviderAsync( + private async Task> CollectFromProviderAsync( ISearchProvider provider, SearchProviderQuery providerQuery, string searchTerm, - Dictionary bestScores, int requestedLimit, CancellationToken cancellationToken) { try { - var count = provider is IExternalSearchProvider externalProvider - ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, bestScores, requestedLimit, cancellationToken).ConfigureAwait(false) - : await CollectFromInternalProviderAsync(provider, providerQuery, bestScores, cancellationToken).ConfigureAwait(false); + var results = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false) + : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); _logger.LogDebug( "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", provider.Name, - count, + results.Count, searchTerm); + return results; } catch (Exception ex) { _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + return []; } } - private static async Task CollectFromExternalProviderAsync( + private static async Task> CollectFromExternalProviderAsync( IExternalSearchProvider provider, SearchProviderQuery providerQuery, - Dictionary bestScores, int requestedLimit, CancellationToken cancellationToken) { - var count = 0; + var results = new List(); await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) { - UpdateBestScore(bestScores, result); - count++; - if (bestScores.Count >= requestedLimit) + results.Add(result); + if (results.Count >= requestedLimit) { break; } } - return count; - } - - private static async Task CollectFromInternalProviderAsync( - ISearchProvider provider, - SearchProviderQuery providerQuery, - Dictionary bestScores, - CancellationToken cancellationToken) - { - var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); - foreach (var result in candidates) - { - UpdateBestScore(bestScores, result); - } - - return candidates.Count; + return results; } private static void UpdateBestScore(Dictionary bestScores, SearchResult result) diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index 53c1cbbb79..bc766f1c8c 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -10,6 +10,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; @@ -32,16 +33,30 @@ public class SqlSearchProvider : IInternalSearchProvider private readonly IDbContextFactory _dbProvider; private readonly IItemTypeLookup _itemTypeLookup; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IItemQueryHelpers _queryHelpers; /// /// Initializes a new instance of the class. /// /// The database context factory. /// The item type lookup. - public SqlSearchProvider(IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup) + /// The library manager. + /// The user manager. + /// The shared item query helpers. + public SqlSearchProvider( + IDbContextFactory dbProvider, + IItemTypeLookup itemTypeLookup, + ILibraryManager libraryManager, + IUserManager userManager, + IItemQueryHelpers queryHelpers) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; + _libraryManager = libraryManager; + _userManager = userManager; + _queryHelpers = queryHelpers; } /// @@ -99,6 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId); // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it @@ -116,20 +132,13 @@ public class SqlSearchProvider : IInternalSearchProvider : ContainsMatchScore }); - var top = await scored + return await scored .OrderByDescending(x => x.Score) .ThenBy(x => x.Id) .Take(limit) - .ToListAsync(cancellationToken) + .Select(x => new SearchResult(x.Id, x.Score)) + .ToArrayAsync(cancellationToken) .ConfigureAwait(false); - - var results = new List(top.Count); - foreach (var row in top) - { - results.Add(new SearchResult(row.Id, row.Score)); - } - - return results; } } @@ -184,6 +193,27 @@ public class SqlSearchProvider : IInternalSearchProvider return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); } + private IQueryable ApplyUserAccessFilter( + JellyfinDbContext dbContext, + IQueryable query, + Guid? userId) + { + if (!userId.HasValue || userId.Value.IsEmpty()) + { + return query; + } + + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + return query; + } + + var accessFilter = new InternalItemsQuery(user); + _libraryManager.ConfigureUserAccess(accessFilter, user); + return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); + } + private List MapKindsToTypeNames(BaseItemKind[] kinds) { var list = new List(kinds.Length); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index e366bdb095..1af7460540 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -224,13 +224,14 @@ public static class JellyfinQueryHelperExtensions var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - return Expression.Lambda>( - Expression.Call( - null, - containsMethodInfo, - Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), - property.Body), - parameter); + // Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a + // parameterized array lookup by ~5-10% up to ~32 elements. + if (oneOf.Count <= 32) + { + return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); + } + + return Expression.Lambda>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); } internal static class ParameterReplacer From f6ac1d9f3837c9fda7bf101ed98128e13427d390 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Thu, 19 Feb 2026 11:25:00 -0500 Subject: [PATCH 006/119] Fix folders being identified as seasons in mixed libraries --- Emby.Naming/TV/SeasonPathParser.cs | 14 ++++++++++++ .../TV/SeasonPathParserTests.cs | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index ea4875e00a..58c000ede3 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -21,6 +21,9 @@ namespace Emby.Naming.TV [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] private static partial Regex SeasonPrefix(); + [GeneratedRegex(@"(season|staffel|stagione|sæson|temporada|series|kausi|säsong|seizoen|seasong|sezon|sezona|sezóna|sezonul|시즌|シーズン|сезон)", RegexOptions.IgnoreCase)] + private static partial Regex SeasonKeyword(); + /// /// Attempts to parse season number from path. /// @@ -91,14 +94,25 @@ namespace Emby.Naming.TV return (val, true); } + bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases; var preMatch = ProcessPre().Match(filename); if (preMatch.Success) { + if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(preMatch); } else { var postMatch = ProcessPost().Match(filename); + if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(postMatch); } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 4dbe769bf4..2035140f00 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -83,4 +83,26 @@ public class SeasonPathParserTests Assert.Equal(seasonNumber, result.SeasonNumber); Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } + + [Theory] + [InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)] + [InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)] + + public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) + { + var result = SeasonPathParser.Parse(path, parentPath, false, false); + + Assert.Equal(result.SeasonNumber is not null, result.Success); + Assert.Equal(seasonNumber, result.SeasonNumber); + Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); + } } From 0028104ed980feccd893af6f86db45306dfeb9b9 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Wed, 6 May 2026 20:53:51 -0400 Subject: [PATCH 007/119] Consolidate kewords --- Emby.Naming/TV/SeasonPathParser.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 58c000ede3..9caebaf7ac 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,18 +10,23 @@ namespace Emby.Naming.TV /// public static partial class SeasonPathParser { + private const string SeasonKeywordPattern = + @"시즌|シーズン|сезон" + + @"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" + + @"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi"; + private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled); - [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPre(); - [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] private static partial Regex SeasonPrefix(); - [GeneratedRegex(@"(season|staffel|stagione|sæson|temporada|series|kausi|säsong|seizoen|seasong|sezon|sezona|sezóna|sezonul|시즌|シーズン|сезон)", RegexOptions.IgnoreCase)] + [GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)] private static partial Regex SeasonKeyword(); /// From df751af19409f056d6c9b1dd3442a251c3252c2d Mon Sep 17 00:00:00 2001 From: dkanada Date: Fri, 8 May 2026 12:51:34 +0900 Subject: [PATCH 008/119] fix reported SonarQube issues --- .../ComicBookInfo/ComicBookInfoProvider.cs | 2 +- .../Books/ComicInfo/ComicInfoReader.cs | 20 +++++++------------ .../ComicInfo/ExternalComicInfoProvider.cs | 9 ++++----- .../ComicInfo/InternalComicInfoProvider.cs | 7 +++---- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index 4ef618b0eb..990d452fb1 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -236,7 +236,7 @@ public class ComicBookInfoProvider : IComicProvider try { // use first day of the month because this format doesn't include a day - return new DateTime(year, month, 1); + return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified); } catch (ArgumentOutOfRangeException) { diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs index 429a2cf6d5..b8329e7805 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs @@ -14,7 +14,7 @@ namespace MediaBrowser.Providers.Books.ComicInfo; /// /// ComicInfo reader. /// -public class ComicInfoReader +public static class ComicInfoReader { /// /// Filename to check for comic metadata either next to the comic file or inside the archive. @@ -26,7 +26,7 @@ public class ComicInfoReader /// /// The XDocument to read for comic metadata. /// The resulting book. - public Book? ReadComicBookMetadata(XDocument xml) + public static Book? ReadComicBookMetadata(XDocument xml) { var book = new Book(); var hasFoundMetadata = false; @@ -67,7 +67,7 @@ public class ComicInfoReader /// /// The XDocument to read for people metadata. /// The metadata result to update. - public void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult) + public static void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult) { ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors => { @@ -106,7 +106,7 @@ public class ComicInfoReader /// the XDocument to read for metadata. /// The path to search. /// The action to take after parsing all metadata. - public void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult) + public static void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult) { string? culture = null; @@ -115,14 +115,8 @@ public class ComicInfoReader return; } - try - { - // culture cannot be null here as the method would have returned earlier - commitResult(new CultureInfo(culture!)); - } - catch (CultureNotFoundException) - { - } + // culture cannot be null here as the method would have returned earlier + commitResult(new CultureInfo(culture!)); } private static bool ReadStringInto(XDocument xml, string xPath, Action commitResult) @@ -194,7 +188,7 @@ public class ComicInfoReader try { - var dateTime = new DateTime(year, month, day); + var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified); commitResult(dateTime); return true; diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs index 62fca925c8..8dd76d8b15 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -19,7 +19,6 @@ public class ExternalComicInfoProvider : IComicProvider { private readonly IFileSystem _fileSystem; private readonly ILogger _logger; - private readonly ComicInfoReader _utilities = new(); /// /// Initializes a new instance of the class. @@ -43,7 +42,7 @@ public class ExternalComicInfoProvider : IComicProvider return new MetadataResult { HasMetadata = false }; } - var book = _utilities.ReadComicBookMetadata(comicInfoXml); + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); if (book is null) { @@ -52,8 +51,8 @@ public class ExternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; - _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult); - _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); return metadataResult; } @@ -84,7 +83,7 @@ public class ExternalComicInfoProvider : IComicProvider } catch (Exception e) { - _logger.LogInformation(e, "Could not load external xml from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path); + _logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path); return null; } } diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs index eff248b8d4..98a6aba7d6 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -17,7 +17,6 @@ public class InternalComicInfoProvider : IComicProvider { private readonly IFileSystem _fileSystem; private readonly ILogger _logger; - private readonly ComicInfoReader _utilities = new(); /// /// Initializes a new instance of the class. @@ -41,7 +40,7 @@ public class InternalComicInfoProvider : IComicProvider return new MetadataResult { HasMetadata = false }; } - var book = _utilities.ReadComicBookMetadata(comicInfoXml); + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); if (book is null) { @@ -50,8 +49,8 @@ public class InternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; - _utilities.ReadPeopleMetadata(comicInfoXml, metadataResult); - _utilities.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); return metadataResult; } From 65710a4e4ffa849d820b79b16711b136f7d10112 Mon Sep 17 00:00:00 2001 From: dkanada Date: Sun, 10 May 2026 12:49:35 +0900 Subject: [PATCH 009/119] add missing exception information to error log --- .../Books/ComicBookInfo/ComicBookInfoProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index 990d452fb1..a372b90212 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -79,9 +79,9 @@ public class ComicBookInfoProvider : IComicProvider return SaveMetadata(comicBookMetadata); } } - catch (Exception) + catch (Exception ex) { - _logger.LogError("failed to load ComicBookInfo metadata: {Path}", info.Path); + _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); return new MetadataResult { HasMetadata = false }; } } From 9e794e80c29f51264b98594488ed714de5537921 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 12 May 2026 23:11:34 +0200 Subject: [PATCH 010/119] Fix master merge --- .../Item/BaseItemRepository.ByName.cs | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index cda970aa81..7c64d9854d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -163,36 +164,35 @@ public sealed partial class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking - // the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER - // ApplyOrder runs the caller's actual sort. + // Build the master query and collapse rows that share a PresentationUniqueKey + // (e.g. alternate versions) by picking the lowest Id per group. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - var representativeIds = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + var orderedMasterQuery = BuildOrderedMasterQuery(masterQuery, filter.SearchTerm); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) { - result.TotalRecordCount = representativeIds.Count(); + result.TotalRecordCount = orderedMasterQuery.Count(); } - var query = ApplyNavigations( - context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)), - filter); - - query = ApplyOrder(query, filter, context); - if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) { - query = query.Skip(filter.StartIndex.Value); + orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value); } if (filter.Limit.HasValue) { - query = query.Take(filter.Limit.Value); + orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value); } + var masterIds = orderedMasterQuery.ToList(); + + var query = ApplyNavigations( + context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)), + filter); + + query = ApplyOrder(query, filter, context); + result.StartIndex = filter.StartIndex ?? 0; if (filter.IncludeItemTypes.Length > 0) { @@ -228,6 +228,43 @@ public sealed partial class BaseItemRepository return result; } + private static IQueryable BuildOrderedMasterQuery(IQueryable masterQuery, string? searchTerm) + { + if (string.IsNullOrEmpty(searchTerm)) + { + return masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) }) + .OrderBy(x => x.SortName) + .Select(x => x.Id); + } + + var cleanSearchTerm = searchTerm.GetCleanValue(); + var cleanSearchPrefix = cleanSearchTerm + " "; + + return masterQuery + .Select(e => new + { + e.Id, + e.PresentationUniqueKey, + e.SortName, + Score = (e.CleanName == cleanSearchTerm) ? 0 + : e.CleanName!.StartsWith(cleanSearchTerm) ? 1 + : e.CleanName!.Contains(cleanSearchPrefix) ? 2 + : 3 + }) + .GroupBy(x => x.PresentationUniqueKey) + .Select(g => new + { + Id = g.Min(x => x.Id), + Score = g.Min(x => x.Score), + SortName = g.Min(x => x.SortName) + }) + .OrderBy(x => x.Score) + .ThenBy(x => x.SortName) + .Select(x => x.Id); + } + private Dictionary BuildItemCountsByCleanName( Database.Implementations.JellyfinDbContext context, InternalItemsQuery filter, From 97c20e6ac5bf89aa0a29f950b9308036e589de12 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 15 May 2026 14:37:01 +0200 Subject: [PATCH 011/119] Fix movie recommendations --- .../Library/LibraryManager.cs | 6 + .../SimilarItems/MovieSimilarItemsProvider.cs | 274 ++++++++++++++++-- .../SimilarItems/SimilarItemsManager.cs | 17 ++ Jellyfin.Api/Controllers/MoviesController.cs | 248 ++++++++-------- .../Item/PeopleRepository.cs | 27 +- .../IBatchLocalSimilarItemsProvider.cs | 23 ++ .../Library/ILibraryManager.cs | 9 + .../Library/ISimilarItemsManager.cs | 10 + .../Persistence/IPeopleRepository.cs | 9 + 9 files changed, 475 insertions(+), 148 deletions(-) create mode 100644 MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index f2480679d9..c20a4442eb 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3389,6 +3389,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 93aa0574c0..5660cf30a8 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,36 +1,65 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Emby.Server.Implementations.Library.SimilarItems; /// -/// Provides similar items for movies and trailers. +/// Provides similar items for movies and trailers using weighted scoring. /// -public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider, IBatchLocalSimilarItemsProvider { - private readonly ILibraryManager _libraryManager; + private const int GenreWeight = 10; + private const int TagWeight = 5; + private const int StudioWeight = 5; + private const int DirectorWeight = 50; + private const int ActorWeight = 15; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly (string[] PersonTypes, int Weight)[] _peopleDimensions = + [ + ([nameof(PersonKind.Director)], DirectorWeight), + ([nameof(PersonKind.Actor), nameof(PersonKind.GuestStar)], ActorWeight) + ]; + + private readonly IDbContextFactory _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private readonly IServerConfigurationManager _serverConfigurationManager; /// /// Initializes a new instance of the class. /// - /// The library manager. + /// The database context factory. + /// The shared query helpers. /// The server configuration manager. public MovieSimilarItemsProvider( - ILibraryManager libraryManager, + IDbContextFactory dbProvider, + IItemQueryHelpers queryHelpers, IServerConfigurationManager serverConfigurationManager) { - _libraryManager = libraryManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; } @@ -41,15 +70,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider MetadataPluginType.LocalSimilarityProvider; /// - public Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } /// - public Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } bool ILocalSimilarItemsProvider.Supports(Type itemType) @@ -63,29 +94,230 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) }; - private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) { var includeItemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { includeItemTypes.Add(BaseItemKind.Trailer); includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - var internalQuery = new InternalItemsQuery(query.User) + var limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); + + using var context = _dbProvider.CreateDbContext(); + + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = ComputeBatchScores(sourceIds, context); + + var allCandidateIds = new HashSet(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } + + var result = new Dictionary>(); + if (allCandidateIds.Count == 0) + { + return Task.FromResult(result); + } + + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) { - Genres = item.Genres, - Tags = item.Tags, - Limit = query.Limit, - DtoOptions = query.DtoOptions ?? new DtoOptions(), - ExcludeItemIds = [.. query.ExcludeItemIds], IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, EnableGroupByMetadataKey = true, EnableTotalRecordCount = false, - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + IsMovie = true, + IsPlayed = false }; - return _libraryManager.GetItemList(internalQuery); + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = baseQuery + .Where(e => allCandidateIdsList.Contains(e.Id)) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToList(); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet(); + var perSourceOrderedIds = new Dictionary>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); + + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + if (allOrderedIds.Count == 0) + { + return Task.FromResult(result); + } + + // Phase 4: One entity load for all results + var allOrderedIdsList = allOrderedIds.ToList(); + var entitiesById = _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => allOrderedIdsList.Contains(e.Id)), + filter) + .AsSplitQuery() + .AsEnumerable() + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return Task.FromResult(result); + } + + private Dictionary> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + { + var result = new Dictionary>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + // Score item-value dimensions (genre, tags, studios) + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceMap = context.ItemValuesMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) + .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) + .ToList() + .GroupBy(m => m.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.CleanValue).ToHashSet()); + + var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allValues.Count == 0) + { + continue; + } + + var valueToCandidates = context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allValues.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) + .ToList() + .GroupBy(m => m.CleanValue) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceValues)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var value in sourceValues) + { + if (valueToCandidates.TryGetValue(value, out var candidates)) + { + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } + } + + // Score people dimensions (directors, actors) + foreach (var (personTypes, weight) in _peopleDimensions) + { + var sourceMap = context.PeopleBaseItemMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToList() + .GroupBy(m => m.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + + var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allPeopleIds.Count == 0) + { + continue; + } + + var personToCandidates = context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allPeopleIds.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToList() + .GroupBy(m => m.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourcePeopleIds)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var peopleId in sourcePeopleIds) + { + if (personToCandidates.TryGetValue(peopleId, out var candidates)) + { + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } + } + + // Remove self-references and empty entries + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index b56779cf3f..d4ee643f95 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -225,6 +225,23 @@ public class SimilarItemsManager : ISimilarItemsManager .ToList(); } + /// + public Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query) + { + var batchProvider = _similarItemsProviders + .OfType() + .FirstOrDefault(); + + if (batchProvider is null) + { + return Task.FromResult(new Dictionary>()); + } + + return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + } + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( IReadOnlyList references, int providerOrder, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 50d34d0656..9c0e216f12 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -2,6 +2,9 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -33,6 +36,7 @@ public class MoviesController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. @@ -41,16 +45,19 @@ public class MoviesController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public MoviesController( IUserManager userManager, ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + ISimilarItemsManager similarItemsManager) { _userManager = userManager; _libraryManager = libraryManager; _dtoService = dtoService; _serverConfigurationManager = serverConfigurationManager; + _similarItemsManager = similarItemsManager; } /// @@ -61,15 +68,17 @@ public class MoviesController : BaseJellyfinApiController /// Optional. The fields to return. /// The max number of categories to return. /// The max number of items to return per category. + /// The cancellation token. /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public ActionResult> GetMovieRecommendations( + public Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + [FromQuery] int itemLimit = 8, + CancellationToken cancellationToken = default) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -86,15 +95,13 @@ public class MoviesController : BaseJellyfinApiController IncludeItemTypes = new[] { BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) }, - // IsMovie = true OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, Limit = 7, ParentId = parentIdGuid, Recursive = true, IsPlayed = true, + EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }; @@ -122,31 +129,52 @@ public class MoviesController : BaseJellyfinApiController }); var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); + var recentDirectors = GetDirectors(mostRecentMovies).ToList(); + var recentActors = GetActors(mostRecentMovies).ToList(); - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + var similarToRecentlyPlayed = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed); - var categoryTypes = new List> + var similarToLiked = BuildPendingFromBatch( + _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), + likedBaseline, + RecommendationType.SimilarToLikedItem); + + var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); + var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List> { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() }; while (categories.Count < categoryLimit) @@ -157,7 +185,17 @@ public class MoviesController : BaseJellyfinApiController { if (category.MoveNext()) { - categories.Add(category.Current); + var pending = category.Current; + var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); + + categories.Add(new RecommendationDto + { + BaselineItemName = pending.BaselineItemName, + CategoryId = pending.CategoryId, + RecommendationType = pending.RecommendationType, + Items = returnItems + }); + allEmpty = false; if (categories.Count >= categoryLimit) @@ -173,10 +211,36 @@ public class MoviesController : BaseJellyfinApiController } } - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + return Task.FromResult>>( + Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); } - private IEnumerable GetWithDirector( + private static List BuildPendingFromBatch( + Task>> batchTask, + IReadOnlyList baselineItems, + RecommendationType type) + { + var batchResults = batchTask.GetAwaiter().GetResult(); + var results = new List(); + + foreach (var item in baselineItems) + { + if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) + { + results.Add(new PendingRecommendation + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = similar + }); + } + } + + return results; + } + + private IEnumerable GetWithPerson( User? user, IEnumerable names, int itemLimit, @@ -190,17 +254,21 @@ public class MoviesController : BaseJellyfinApiController itemTypes.Add(BaseItemKind.LiveTvProgram); } + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + foreach (var name in names) { var items = _libraryManager.GetItemList( new InternalItemsQuery(user) { Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, + PersonTypes = personTypes, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, + IsPlayed = false, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) @@ -209,119 +277,47 @@ public class MoviesController : BaseJellyfinApiController if (items.Count > 0) { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto + yield return new PendingRecommendation { BaselineItemName = name, CategoryId = name.GetMD5(), RecommendationType = type, - Items = returnItems + Items = items }; } } } - private IEnumerable GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetActors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + new[] { PersonType.Actor, PersonType.GuestStar }, + limit: 0); } - private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + private IReadOnlyList GetDirectors(IReadOnlyList items) { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems( + itemIds, + [PersonType.Director], + limit: 0); } - private IEnumerable GetActors(IEnumerable items) + /// + /// Holds a recommendation category's BaseItems before DTO conversion. + /// DTO conversion is deferred until the round-robin actually selects the category. + /// + private sealed class PendingRecommendation { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); + public required string BaselineItemName { get; init; } - var itemIds = items.Select(i => i.Id).ToList(); + public required Guid CategoryId { get; init; } - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } + public required RecommendationType RecommendationType { get; init; } - private IEnumerable GetDirectors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty())); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + public required IReadOnlyList Items { get; init; } } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 8f8741d00f..88bf6fa1bb 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,31 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit) + { + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var names = query + .Select(m => m.People.Name) + .Distinct(); + + if (limit > 0) + { + names = names.Take(limit); + } + + return names.ToArray(); + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -239,7 +264,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..fe2ce7d394 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// A local similar items provider that supports batch queries across multiple source items. +/// Implementations share access filtering and entity loading across all sources for better performance. +/// +public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..365f078652 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,15 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets distinct people names for multiple items. + /// + /// The item IDs. + /// The person types to include. + /// Maximum number of names. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 0ced6f71ee..1c826ea780 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -47,4 +47,14 @@ public interface ISimilarItemsManager int? limit, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + + /// + /// Gets similar items for multiple source items in a single batch. + /// + /// The source items to find similar items for. + /// The query options. + /// Per-source-item results keyed by source item ID. + Task>> GetBatchSimilarItemsAsync( + IReadOnlyList sourceItems, + SimilarItemsQuery query); } diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..7474130ec4 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,13 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets distinct people names for multiple items efficiently by querying from the mapping table. + /// + /// The item IDs to get people for. + /// The person types to include (e.g. "Actor", "Director"). + /// Maximum number of names to return. + /// The distinct people names. + IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit); } From 1fdf58e40f7c8f58377be3716368720923d8d8c0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 09:44:36 +0200 Subject: [PATCH 012/119] Address review comments --- .../SimilarItems/MovieSimilarItemsProvider.cs | 1 - .../SimilarItems/SimilarItemsManager.cs | 205 +++++++++++++- Jellyfin.Api/Controllers/MoviesController.cs | 257 +----------------- .../Library/ISimilarItemsManager.cs | 24 +- .../Library/SimilarItemsRecommendation.cs | 32 +++ 5 files changed, 258 insertions(+), 261 deletions(-) create mode 100644 MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 5660cf30a8..54466a6ad9 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -188,7 +188,6 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider allOrderedIdsList.Contains(e.Id)), filter) - .AsSplitQuery() .AsEnumerable() .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(dto => dto is not null) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d4ee643f95..fc83817015 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -8,12 +8,16 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; @@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; private ISimilarItemsProvider[] _similarItemsProviders = []; /// @@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager /// The server application paths. /// The library manager. /// The file system. + /// The server configuration manager. public SimilarItemsManager( ILogger logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } /// @@ -226,20 +234,205 @@ public class SimilarItemsManager : ISimilarItemsManager } /// - public Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, + public async Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dtoOptions); + + var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Movie], + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)], + Limit = 7, + ParentId = parentId, + Recursive = true, + IsPlayed = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Descending)], + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentId, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); + var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]); + var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed, + batchQuery).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery).ConfigureAwait(false); + + var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); + var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List> + { + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() + }; + + var categories = new List(); + while (categories.Count < categoryLimit) + { + var allEmpty = true; + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return [.. categories.OrderBy(i => i.RecommendationType)]; + } + + private async Task> GetSimilarItemsRecommendationsAsync( + IReadOnlyList baselineItems, + RecommendationType recommendationType, SimilarItemsQuery query) { var batchProvider = _similarItemsProviders .OfType() .FirstOrDefault(); - if (batchProvider is null) + if (batchProvider is null || baselineItems.Count == 0) { - return Task.FromResult(new Dictionary>()); + return []; } - return batchProvider.GetBatchSimilarItemsAsync(sourceItems, query); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + + var recommendations = new List(baselineItems.Count); + foreach (var baseline in baselineItems) + { + if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + { + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); + } + } + + return recommendations; + } + + private IEnumerable GetPersonRecommendations( + User? user, + IReadOnlyList names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type, + IReadOnlyList itemTypes) + { + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty(); + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + Limit = itemLimit + 2, + PersonTypes = personTypes, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + IsPlayed = false, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }) + .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + yield return new SimilarItemsRecommendation + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = items + }; + } + } + } + + private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes) + { + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0); } private List<(BaseItem Item, float Score)> ResolveRemoteReferences( diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 9c0e216f12..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,20 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -33,30 +26,22 @@ namespace Jellyfin.Api.Controllers; public class MoviesController : BaseJellyfinApiController { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ISimilarItemsManager _similarItemsManager; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. public MoviesController( IUserManager userManager, - ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager, ISimilarItemsManager similarItemsManager) { _userManager = userManager; - _libraryManager = libraryManager; _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; _similarItemsManager = similarItemsManager; } @@ -72,7 +57,7 @@ public class MoviesController : BaseJellyfinApiController /// Movie recommendations returned. /// The list of movie recommendations. [HttpGet("Recommendations")] - public Task>> GetMovieRecommendations( + public async Task>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, @@ -86,238 +71,16 @@ public class MoviesController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields }; - var categories = new List(); + var recommendations = await _similarItemsManager + .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken) + .ConfigureAwait(false); - var parentIdGuid = parentId ?? Guid.Empty; - - var query = new InternalItemsQuery(user) + return Ok(recommendations.Select(r => new RecommendationDto { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - }, - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - var recentDirectors = GetDirectors(mostRecentMovies).ToList(); - var recentActors = GetActors(mostRecentMovies).ToList(); - - // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. - var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit - ? recentlyPlayedMovies.Take(categoryLimit).ToList() - : recentlyPlayedMovies; - var likedBaseline = likedMovies.Count > categoryLimit - ? likedMovies.Take(categoryLimit).ToList() - : likedMovies; - - var batchQuery = new SimilarItemsQuery - { - User = user, - Limit = itemLimit, - DtoOptions = dtoOptions - }; - - var similarToRecentlyPlayed = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(recentlyPlayedBaseline, batchQuery), - recentlyPlayedBaseline, - RecommendationType.SimilarToRecentlyPlayed); - - var similarToLiked = BuildPendingFromBatch( - _similarItemsManager.GetBatchSimilarItemsAsync(likedBaseline, batchQuery), - likedBaseline, - RecommendationType.SimilarToLikedItem); - - var hasDirectorFromRecentlyPlayed = GetWithPerson(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed); - var hasActorFromRecentlyPlayed = GetWithPerson(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed); - - // Use a single enumerator per list, listed twice so MoveNext advances it - // twice per round-robin pass (giving these categories double weight). - // IMPORTANT: Declare as IEnumerator to box the List.Enumerator struct once; - // using var would box separately per list insertion, creating independent copies. - IEnumerator similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); - IEnumerator similarToLikedEnum = similarToLiked.GetEnumerator(); - - var categoryTypes = new List> - { - similarToRecentlyPlayedEnum, - similarToRecentlyPlayedEnum, - similarToLikedEnum, - similarToLikedEnum, - hasDirectorFromRecentlyPlayed.GetEnumerator(), - hasActorFromRecentlyPlayed.GetEnumerator() - }; - - while (categories.Count < categoryLimit) - { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - var pending = category.Current; - var returnItems = _dtoService.GetBaseItemDtos(pending.Items, dtoOptions, user); - - categories.Add(new RecommendationDto - { - BaselineItemName = pending.BaselineItemName, - CategoryId = pending.CategoryId, - RecommendationType = pending.RecommendationType, - Items = returnItems - }); - - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return Task.FromResult>>( - Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable())); - } - - private static List BuildPendingFromBatch( - Task>> batchTask, - IReadOnlyList baselineItems, - RecommendationType type) - { - var batchResults = batchTask.GetAwaiter().GetResult(); - var results = new List(); - - foreach (var item in baselineItems) - { - if (batchResults.TryGetValue(item.Id, out var similar) && similar.Count > 0) - { - results.Add(new PendingRecommendation - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = similar - }); - } - } - - return results; - } - - private IEnumerable GetWithPerson( - User? user, - IEnumerable names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed - ? [PersonType.Director] - : Array.Empty(); - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - Limit = itemLimit + 2, - PersonTypes = personTypes, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - IsPlayed = false, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - yield return new PendingRecommendation - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = items - }; - } - } - } - - private IReadOnlyList GetActors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - new[] { PersonType.Actor, PersonType.GuestStar }, - limit: 0); - } - - private IReadOnlyList GetDirectors(IReadOnlyList items) - { - var itemIds = items.Select(i => i.Id).ToArray(); - return _libraryManager.GetPeopleNamesByItems( - itemIds, - [PersonType.Director], - limit: 0); - } - - /// - /// Holds a recommendation category's BaseItems before DTO conversion. - /// DTO conversion is deferred until the round-robin actually selects the category. - /// - private sealed class PendingRecommendation - { - public required string BaselineItemName { get; init; } - - public required Guid CategoryId { get; init; } - - public required RecommendationType RecommendationType { get; init; } - - public required IReadOnlyList Items { get; init; } + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 1c826ea780..36fa547eeb 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library; @@ -49,12 +50,21 @@ public interface ISimilarItemsManager CancellationToken cancellationToken); /// - /// Gets similar items for multiple source items in a single batch. + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to . /// - /// The source items to find similar items for. - /// The query options. - /// Per-source-item results keyed by source item ID. - Task>> GetBatchSimilarItemsAsync( - IReadOnlyList sourceItems, - SimilarItemsQuery query); + /// The user the recommendations are for. May be for anonymous access. + /// The library/folder to localize the search to. Pass to use the root. + /// Maximum number of recommendation categories to return. + /// Maximum number of items per category. + /// DTO options used when querying the library. + /// The cancellation token. + /// The list of recommendation categories, ordered by . + Task> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs new file mode 100644 index 0000000000..71346fcadf --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion. +/// +public sealed class SimilarItemsRecommendation +{ + /// + /// Gets the display name of the baseline item the recommendation is based on. + /// + public required string BaselineItemName { get; init; } + + /// + /// Gets an identifier for the recommendation category. + /// + public required Guid CategoryId { get; init; } + + /// + /// Gets the recommendation type. + /// + public required RecommendationType RecommendationType { get; init; } + + /// + /// Gets the similar items for the baseline, ordered by relevance. + /// + public required IReadOnlyList Items { get; init; } +} From d71194aa8cb07d998c0ed15df964c7c1259e7f17 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 09:50:33 +0200 Subject: [PATCH 013/119] Parallelize internal and external calls --- .../Library/Search/SearchManager.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index 39fff42d9b..ff14a1db3a 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -84,12 +84,27 @@ public class SearchManager : ISearchManager var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); - var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); - var fromExternal = results.Count > 0; - if (results.Count == 0 && _internalProviders.Length > 0) + var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken); + var internalTask = _internalProviders.Length > 0 + ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken) + : Task.FromResult>([]); + + await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false); + + var externalResults = await externalTask.ConfigureAwait(false); + var fromExternal = externalResults.Count > 0; + IReadOnlyList results; + if (fromExternal) { - _logger.LogDebug("No results from external providers, falling back to internal providers"); - results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); + results = externalResults; + } + else + { + results = await internalTask.ConfigureAwait(false); + if (_internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, using internal provider results"); + } } // Internal providers apply user-access filtering inline in their queries. External From 3655b4b09449e572826fa2f91a88f3b6dd4e63c4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 16:11:13 +0200 Subject: [PATCH 014/119] Apply review and sonar suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 320 +++++++++--------- .../SimilarItems/SimilarItemsManager.cs | 11 +- .../IBatchLocalSimilarItemsProvider.cs | 5 +- 3 files changed, 173 insertions(+), 163 deletions(-) diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 54466a6ad9..29cde6a570 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -30,6 +30,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider public async Task> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } /// public async Task> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - var results = await GetBatchSimilarItemsAsync([item], query).ConfigureAwait(false); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); return results.TryGetValue(item.Id, out var items) ? items : []; } @@ -95,9 +99,10 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider - public Task>> GetBatchSimilarItemsAsync( + public async Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) @@ -109,108 +114,119 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider i.Id).ToList(); - var perSourceScores = ComputeBatchScores(sourceIds, context); - - var allCandidateIds = new HashSet(); - foreach (var (_, scores) in perSourceScores) + if (sourceItems.Count > MaxBatchSourceItems) { - allCandidateIds.UnionWith( - scores.OrderByDescending(kvp => kvp.Value) - .Take(limit * 3) - .Select(kvp => kvp.Key)); + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); } - var result = new Dictionary>(); - if (allCandidateIds.Count == 0) + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - return Task.FromResult(result); - } + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false); - // Phase 2: One access filter for all candidates - var filter = new InternalItemsQuery(query.User) - { - IncludeItemTypes = [.. includeItemTypes], - ExcludeItemIds = [.. query.ExcludeItemIds], - DtoOptions = dtoOptions, - EnableGroupByMetadataKey = true, - EnableTotalRecordCount = false, - IsMovie = true, - IsPlayed = false - }; - - _queryHelpers.PrepareFilterQuery(filter); - var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); - baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); - - var allCandidateIdsList = allCandidateIds.ToList(); - var accessibleItems = baseQuery - .Where(e => allCandidateIdsList.Contains(e.Id)) - .Select(e => new { e.Id, e.PresentationUniqueKey }) - .ToList(); - - // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey - var allOrderedIds = new HashSet(); - var perSourceOrderedIds = new Dictionary>(); - - foreach (var item in sourceItems) - { - if (!perSourceScores.TryGetValue(item.Id, out var scores)) + var allCandidateIds = new HashSet(); + foreach (var (_, scores) in perSourceScores) { - continue; + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); } - var orderedIds = accessibleItems - .Where(x => scores.ContainsKey(x.Id)) - .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) - .DistinctBy(x => x.PresentationUniqueKey) - .Take(limit) - .Select(x => x.Id) - .ToList(); - - if (orderedIds.Count > 0) + var result = new Dictionary>(); + if (allCandidateIds.Count == 0) { - perSourceOrderedIds[item.Id] = orderedIds; - allOrderedIds.UnionWith(orderedIds); + return result; } - } - if (allOrderedIds.Count == 0) - { - return Task.FromResult(result); - } - - // Phase 4: One entity load for all results - var allOrderedIdsList = allOrderedIds.ToList(); - var entitiesById = _queryHelpers.ApplyNavigations( - context.BaseItems.AsNoTracking().Where(e => allOrderedIdsList.Contains(e.Id)), - filter) - .AsEnumerable() - .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) - .Where(dto => dto is not null) - .ToDictionary(i => i!.Id); - - // Phase 5: Split by source, preserving score order - foreach (var (sourceId, orderedIds) in perSourceOrderedIds) - { - var items = orderedIds - .Where(entitiesById.ContainsKey) - .Select(id => entitiesById[id]!) - .ToList(); - - if (items.Count > 0) + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) { - result[sourceId] = items; - } - } + IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + IsMovie = true, + IsPlayed = false + }; - return Task.FromResult(result); + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = await baseQuery + .WhereOneOrMany(allCandidateIdsList, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet(); + var perSourceOrderedIds = new Dictionary>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); + + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + if (allOrderedIds.Count == 0) + { + return result; + } + + // Phase 4: One entity load for all results. AsSplitQuery avoids a SQL Cartesian + // product across the multiple collection Includes added by ApplyNavigations. + var allOrderedIdsList = allOrderedIds.ToList(); + var entities = await _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id), + filter) + .AsSplitQuery() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var entitiesById = entities + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return result; + } } - private Dictionary> ComputeBatchScores(List sourceIds, JellyfinDbContext context) + private static async Task>> ComputeBatchScoresAsync(List sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) { var result = new Dictionary>(); foreach (var id in sourceIds) @@ -218,95 +234,52 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.CleanValue).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allValues = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allValues.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var valueToCandidates = context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allValues.Contains(m.ItemValue.CleanValue)) - .Select(m => new { m.ItemId, m.ItemValue.CleanValue }) - .ToList() - .GroupBy(m => m.CleanValue) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourceValues)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var value in sourceValues) - { - if (valueToCandidates.TryGetValue(value, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Score people dimensions (directors, actors) foreach (var (personTypes, weight) in _peopleDimensions) { - var sourceMap = context.PeopleBaseItemMap.AsNoTracking() + var sourceRows = await context.PeopleBaseItemMap.AsNoTracking() .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.ItemId) - .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var allPeopleIds = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allPeopleIds.Count == 0) + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) { continue; } - var personToCandidates = context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allPeopleIds.Contains(m.PeopleId)) - .Select(m => new { m.ItemId, m.PeopleId }) - .ToList() - .GroupBy(m => m.PeopleId) - .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + var candidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allKeys.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, Key = m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var sourceId in sourceIds) - { - if (!sourceMap.TryGetValue(sourceId, out var sourcePeopleIds)) - { - continue; - } - - var scoreMap = result[sourceId]; - foreach (var peopleId in sourcePeopleIds) - { - if (personToCandidates.TryGetValue(peopleId, out var candidates)) - { - foreach (var candidateId in candidates) - { - scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; - } - } - } - } + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } - // Remove self-references and empty entries foreach (var sourceId in sourceIds) { var scoreMap = result[sourceId]; @@ -319,4 +292,35 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider( + List sourceIds, + Dictionary> sourceMap, + Dictionary> keyToCandidates, + int weight, + Dictionary> result) + where TKey : notnull + { + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceKeys)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var key in sourceKeys) + { + if (!keyToCandidates.TryGetValue(key, out var candidates)) + { + continue; + } + + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index fc83817015..358c170db2 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -299,12 +299,14 @@ public class SimilarItemsManager : ISimilarItemsManager var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( recentlyPlayedBaseline, RecommendationType.SimilarToRecentlyPlayed, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var similarToLiked = await GetSimilarItemsRecommendationsAsync( likedBaseline, RecommendationType.SimilarToLikedItem, - batchQuery).ConfigureAwait(false); + batchQuery, + cancellationToken).ConfigureAwait(false); var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); @@ -356,7 +358,8 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task> GetSimilarItemsRecommendationsAsync( IReadOnlyList baselineItems, RecommendationType recommendationType, - SimilarItemsQuery query) + SimilarItemsQuery query, + CancellationToken cancellationToken) { var batchProvider = _similarItemsProviders .OfType() @@ -367,7 +370,7 @@ public class SimilarItemsManager : ISimilarItemsManager return []; } - var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query).ConfigureAwait(false); + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); var recommendations = new List(baselineItems.Count); foreach (var baseline in baselineItems) diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs index fe2ce7d394..af49711606 100644 --- a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -16,8 +17,10 @@ public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider /// /// The source items to find similar items for. /// The query options. + /// The cancellation token. /// Per-source-item results keyed by source item ID. Task>> GetBatchSimilarItemsAsync( IReadOnlyList sourceItems, - SimilarItemsQuery query); + SimilarItemsQuery query, + CancellationToken cancellationToken); } From d6240bfa88b3e6acbd9eda5089d957e08ea17e88 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 16 May 2026 18:19:40 +0200 Subject: [PATCH 015/119] Apply review suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 29cde6a570..d73120e691 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -41,11 +41,14 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider _personTypeWeights = new(StringComparer.Ordinal) + { + [nameof(PersonKind.Director)] = DirectorWeight, + [nameof(PersonKind.Actor)] = ActorWeight, + [nameof(PersonKind.GuestStar)] = ActorWeight, + }; + + private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys]; private readonly IDbContextFactory _dbProvider; private readonly IItemQueryHelpers _queryHelpers; @@ -194,8 +197,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider e.Id), @@ -257,27 +259,31 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (personSourceRows.Count > 0) { - var sourceRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => sourceIds.Contains(m.ItemId) && personTypes.Contains(m.People.PersonType)) - .Select(m => new { m.ItemId, Key = m.PeopleId }) + var allPersonIds = personSourceRows.Select(r => r.PeopleId).Distinct().ToList(); + + var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => allPersonIds.Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) .ToListAsync(cancellationToken).ConfigureAwait(false); - var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); - var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); - if (allKeys.Count == 0) + var personToCandidates = personCandidateRows + .GroupBy(r => r.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!])) { - continue; + var sourceMap = weightGroup + .GroupBy(r => r.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result); } - - var candidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allKeys.Contains(m.PeopleId)) - .Select(m => new { m.ItemId, Key = m.PeopleId }) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); - ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); } foreach (var sourceId in sourceIds) From 3d8bcf1ffd1c56efaffe9ff004c2ec44afbb9818 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 15 Mar 2024 14:30:55 +0100 Subject: [PATCH 016/119] Alternate solution to #7843 without extra prop --- Emby.Server.Implementations/Library/LibraryManager.cs | 10 ++++++++-- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..1745d711b4 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2432,8 +2432,14 @@ namespace Emby.Server.Implementations.Library var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path is not null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); - // Skip image processing if current or live tv source - if (outdated.Length == 0 || item.SourceType != SourceType.Library) + + var parentItem = item.GetParent(); + var isLiveTvShow = item.SourceType != SourceType.Library && + parentItem is not null && + parentItem.SourceType != SourceType.Library; // not a channel + + // Skip image processing if current or live tv show + if (outdated.Length == 0 || isLiveTvShow) { RegisterItem(item); return; diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..c3cc70381e 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -448,14 +448,19 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); + var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); + + // Update channel image if image URL has changed + if (currentPrimary is null + || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) { if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) { item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); forceUpdate = true; } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + else if (!imageUrlIsNull) { item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); forceUpdate = true; From b141b893eb2ef759eff8f9ba3d70ac31f22c8acc Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Mon, 2 Feb 2026 23:17:16 -0500 Subject: [PATCH 017/119] Add new viewtypes --- .../Enums/ViewType.cs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs index b2bcbf2bb6..34810b9199 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs @@ -108,5 +108,50 @@ public enum ViewType /// /// Shows upcoming. /// - Upcoming = 20 + Upcoming = 20, + + /// + /// Shows authors. + /// + Authors = 21, + + /// + /// Shows books. + /// + Books = 22, + + /// + /// Shows folders. + /// + Folders = 23, + + /// + /// Shows mixed media. + /// + Mixed = 24, + + /// + /// Shows photos. + /// + Photos = 25, + + /// + /// Shows photo albums. + /// + PhotoAlbums = 26, + + /// + /// Shows series timers. + /// + SeriesTimers = 27, + + /// + /// Shows studios. + /// + Studios = 28, + + /// + /// Shows videos. + /// + Videos = 29 } From 5ed3ffdb42cde2ddb45d0ca55d84d885d877d2a9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 22 May 2026 00:10:41 +0200 Subject: [PATCH 018/119] Use embedded query --- .../Library/SimilarItems/MovieSimilarItemsProvider.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index d73120e691..b4ed12a20c 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -266,10 +266,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider 0) { - var allPersonIds = personSourceRows.Select(r => r.PeopleId).Distinct().ToList(); - var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() - .Where(m => allPersonIds.Contains(m.PeopleId)) + .Where(m => context.PeopleBaseItemMap + .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) + .Select(s => s.PeopleId) + .Contains(m.PeopleId)) .Select(m => new { m.ItemId, m.PeopleId }) .ToListAsync(cancellationToken).ConfigureAwait(false); From 69b0b63a950a7a076a682fb81d3a0ff32239c1a0 Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sun, 24 May 2026 03:31:56 +0800 Subject: [PATCH 019/119] Fix inconsistent extradata generated by hevc_vaapi on AMD driver This change is required for upstream ffmpeg 8+, because its mp4 muxer will drop in-band PS when using codec tag hvc1. Signed-off-by: nyanmisaka --- MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 8f6e36bce4..8688ea4b6c 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2014,11 +2014,15 @@ namespace MediaBrowser.Controller.MediaEncoding args += keyFrameArg + gopArg; } - // global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS + // The in-band Parameter Sets generated by the AMD HEVC VA-API encoder is inconsistent + // with the extradata generated by ffmpeg, causing decoding failures when using hvc1. if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) && _mediaEncoder.IsVaapiDeviceAmd) { - args += " -flags:v -global_header"; + // Extracting the extradata from the in-band PS to bypass the issue. + // This can be removed once the issue is resolved in libva or Mesa. + // Transcoding is unavoidable here, so using BSF will not conflict with BSF in remuxing. + args += " -flags:v -global_header -bsf:v extract_extradata=remove=0"; } return args; From e627c723e29804e8f6f682bb61032908961f8699 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 23 May 2026 22:41:44 +0200 Subject: [PATCH 020/119] Extract attachments in one ffmpeg command when dumping --- .../Attachments/AttachmentExtractor.cs | 147 +++++++++++++++++- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index d9cb7a450f..9dd3dcecba 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - foreach (var attachment in mediaSource.MediaAttachments) - { - if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) - { - await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false); - } - } + await ExtractAllAttachmentsIndividuallyInternal( + inputFile, + mediaSource, + cancellationToken).ConfigureAwait(false); } else { @@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments } } + private async Task ExtractAllAttachmentsIndividuallyInternal( + string inputFile, + MediaSourceInfo mediaSource, + CancellationToken cancellationToken) + { + var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource); + + ArgumentException.ThrowIfNullOrEmpty(inputPath); + + var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (outputFolder is null) + { + _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile); + return; + } + + using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false)) + { + Directory.CreateDirectory(outputFolder); + + var dumpArgs = new StringBuilder(); + var missingPaths = new List(); + foreach (var attachment in mediaSource.MediaAttachments) + { + if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; + if (File.Exists(attachmentPath)) + { + continue; + } + + dumpArgs.AppendFormat( + CultureInfo.InvariantCulture, + "-dump_attachment:{0} \"{1}\" ", + attachment.Index, + EncodingUtils.NormalizePath(attachmentPath)); + missingPaths.Add(attachmentPath); + } + + if (missingPaths.Count == 0) + { + // Skip extraction if all files already exist + return; + } + + var hasVideoOrAudioStream = mediaSource.MediaStreams + .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio); + var processArgs = string.Format( + CultureInfo.InvariantCulture, + "{0}{1} -i {2} {3}", + dumpArgs, + inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, + inputPath, + hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); + + int exitCode; + + using (var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) + { + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Start(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; + } + } + + var failed = false; + + if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1)) + { + failed = true; + + foreach (var path in missingPaths) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachment {Path}", path); + } + } + } + + if (!failed && missingPaths.Exists(p => !File.Exists(p))) + { + failed = true; + } + + if (failed) + { + _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder); + + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder)); + } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder); + } + } + private async Task ExtractAllAttachmentsInternal( string inputFile, MediaSourceInfo mediaSource, From 317f57803d1e8851028edb087ca1cadacfb086c4 Mon Sep 17 00:00:00 2001 From: Bas <44002186+854562@users.noreply.github.com> Date: Sat, 23 May 2026 12:58:44 -0400 Subject: [PATCH 021/119] Translated using Weblate (Dutch) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/ --- Emby.Server.Implementations/Localization/Core/nl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 898f5892c9..9aea3adc22 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -8,7 +8,7 @@ "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", - "HeaderContinueWatching": "Verder kijken", + "HeaderContinueWatching": "Verderkijken", "HeaderFavoriteEpisodes": "Favoriete afleveringen", "HeaderFavoriteShows": "Favoriete series", "HeaderLiveTV": "Live-tv", From 93b82ecb419a3fffd2b8d55fc6e75a9937d8db7b Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Sun, 24 May 2026 05:55:23 -0400 Subject: [PATCH 022/119] Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- .../Localization/Core/ka.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 5245d89948..5587301fe4 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -20,7 +20,7 @@ "External": "გარე", "HeaderFavoriteEpisodes": "რჩეული ეპიზოდები", "HearingImpaired": "სმენადაქვეითებული", - "LabelRunningTimeValue": "ხანგრძლივობა: {0}", + "LabelRunningTimeValue": "გაშვების დრო: {0}", "MixedContent": "შერეული შემცველობა", "MusicVideos": "მუსიკის ვიდეოები", "NotificationOptionInstallationFailed": "დაყენების შეცდომა", @@ -31,7 +31,7 @@ "PluginUninstalledWithName": "{0} წაიშალა", "VersionNumber": "ვერსია {0}", "TasksChannelsCategory": "ინტერნეტ-არხები", - "TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.", + "TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.", "Collections": "კოლექციები", "Default": "ნაგულისხმევი", "Favorites": "რჩეულები", @@ -53,30 +53,30 @@ "TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია", "TaskKeyframeExtractor": "საკვანძო კადრის გამომღები", "LabelIpAddressValue": "IP მისამართი: {0}", - "NameInstallFailed": "{0}-ის დაყენების შეცდომა", + "NameInstallFailed": "{0}-ის დაყენების ჩავარდა", "NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება", "NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია", "NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია", - "NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია", + "NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია", "NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა", - "NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა", + "NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა", "NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა", "NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია", "PluginInstalledWithName": "{0} დაყენებულია", "PluginUpdatedWithName": "{0} განახლდა", "TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება", - "TaskCleanCache": "ქეშის საქაღალდის გასუფთავება", - "TaskRefreshChapterImages": "თავის სურათების გაშლა", + "TaskCleanCache": "კეშის საქაღალდის გასუფთავება", + "TaskRefreshChapterImages": "თავის სურათების ამოღება", "TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება", "TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება", "TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება", - "TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა", - "UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს", + "TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა", + "UserDownloadingItemWithValues": "{0} იწერს {1}-ს", "FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან", "UserCreatedWithName": "მომხმარებელი {0} შეიქმნა", - "UserDeletedWithName": "მომხმარებელი {0} წაშლილია", - "UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან", - "UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა", + "UserDeletedWithName": "მომხმარებელი {0} წაიშალა", + "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", + "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", @@ -96,16 +96,16 @@ "TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.", "TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.", - "TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება", - "TaskAudioNormalization": "აუდიოს ნორმალიზება", + "TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია", + "TaskAudioNormalization": "აუდიოს ნორმალიზაცია", "TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.", "TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა", - "TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის", - "TaskExtractMediaSegments": "მედია სეგმენტების სკანირება", + "TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკას სიმღერებისთვის", + "TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება", "TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.", - "TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია", + "TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია", "TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.", - "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება", + "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა", "CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.", "LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა", "Original": "ორიგინალი" From 0b25c1483cca6fc90eee4b7c9d5e47b923bf8a7c Mon Sep 17 00:00:00 2001 From: Nitzan Selwyn Date: Sun, 24 May 2026 06:26:17 -0400 Subject: [PATCH 023/119] Translated using Weblate (Hebrew (Israel)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he_IL/ --- .../Localization/Core/he_IL.json | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index dedbc56a74..69e765f85f 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -16,5 +16,50 @@ "HeaderLiveTV": "טלוויזיה בשידור חי", "HeaderNextUp": "הבא", "HearingImpaired": "ללקויי שמיעה", - "HomeVideos": "סרטונים ביתיים" + "HomeVideos": "סרטונים ביתיים", + "AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}", + "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", + "Default": "בררת מחדל", + "FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}", + "Forced": "בכוח", + "Inherit": "ירש", + "LabelIpAddressValue": "כתובת IP: {0}", + "LabelRunningTimeValue": "זמן ריצה: {0}", + "Latest": "הכי חדש", + "LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}", + "MixedContent": "תוכן מעורב", + "MusicVideos": "סרטוני מוזיקה", + "NameInstallFailed": "{0} התכנות כושלות", + "NameSeasonUnknown": "עונה לא ידוע", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה", + "NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן", + "NotificationOptionAudioPlayback": "החלה השמעת אודיו", + "NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן", + "NotificationOptionInstallationFailed": "התקנה נכשלה", + "NotificationOptionNewLibraryContent": "תוכן חדש נוסף", + "NotificationOptionPluginError": "תוסף נכשל", + "NotificationOptionPluginInstalled": "תוסף הותקן", + "NotificationOptionPluginUninstalled": "תוסף נמחק", + "NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן", + "NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת", + "NotificationOptionTaskFailed": "כשל במשימה מתוכננת", + "NotificationOptionUserLockedOut": "המשתמש ננעל", + "NotificationOptionVideoPlayback": "החלה הפעלת וידאו", + "NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה", + "Original": "מקורי", + "Photos": "תמונות", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} נמחק", + "PluginUpdatedWithName": "{0} עודכן", + "ScheduledTaskFailedWithName": "{0} נכשל", + "Shows": "סדרות", + "StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.", + "SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה", + "TvShows": "תוכניות טלויזיה", + "Undefined": "לא מוגדר", + "UserCreatedWithName": "המשתמש {0} נוצר", + "UserDeletedWithName": "המשתמש {0} נמחק", + "UserDownloadingItemWithValues": "{0} מוריד את {1}" } From a784733a53bec21a39a58ea1eb36216ff0563c2b Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Sun, 24 May 2026 08:09:41 -0400 Subject: [PATCH 024/119] Translated using Weblate (Georgian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ka/ --- Emby.Server.Implementations/Localization/Core/ka.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 5587301fe4..f7ca19d7f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -78,7 +78,7 @@ "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", - "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", + "UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", "UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე", "TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.", From 4322ee4391ea864b892b9dbca76af80cb5a46663 Mon Sep 17 00:00:00 2001 From: Nitzan Selwyn Date: Sun, 24 May 2026 08:46:26 -0400 Subject: [PATCH 025/119] Translated using Weblate (Hebrew (Israel)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he_IL/ --- .../Localization/Core/he_IL.json | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index 69e765f85f..b551608fd0 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -61,5 +61,52 @@ "Undefined": "לא מוגדר", "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} נמחק", - "UserDownloadingItemWithValues": "{0} מוריד את {1}" + "UserDownloadingItemWithValues": "{0} מוריד את {1}", + "UserLockedOutWithName": "המשתמש {0} ננעל בחוץ", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}", + "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}", + "VersionNumber": "גרסה {0}", + "TasksMaintenanceCategory": "תחזוקה", + "TasksLibraryCategory": "ספריה", + "TasksApplicationCategory": "אפליקציה", + "TasksChannelsCategory": "ערוצי אינטרנט", + "TaskCleanActivityLog": "נקה יומן פעילות", + "TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.", + "TaskCleanCache": "נקה ספריית מטמון", + "TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.", + "TaskRefreshChapterImages": "חלץ תמונות פרק", + "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.", + "TaskAudioNormalization": "נורמליזציה של שמע", + "TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.", + "TaskRefreshLibrary": "סרוק ספריית מדיה", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.", + "TaskCleanLogs": "נקה ספריית יומן", + "TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.", + "TaskRefreshPeople": "רענן אנשים", + "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshTrickplayImages": "צור תמונות Trickplay", + "TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.", + "TaskUpdatePlugins": "עדכן פלאגינים", + "TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.", + "TaskCleanTranscode": "נקה ספריית קידוד", + "TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.", + "TaskRefreshChannels": "רענן ערוצים", + "TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.", + "TaskDownloadMissingLyrics": "הורד מילות שיר חסרות", + "TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", + "TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.", + "TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים", + "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.", + "TaskKeyframeExtractor": "מחלץ פריים מרכזי", + "TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.", + "TaskExtractMediaSegments": "סריקת מקטעי מדיה", + "TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.", + "TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay", + "TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.", + "CleanupUserDataTask": "משימת ניקוי נתוני משתמש", + "CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות." } From 498d265208673c323cc8401b45e0f17382fde7ae Mon Sep 17 00:00:00 2001 From: joanluc Date: Sun, 24 May 2026 07:22:28 -0400 Subject: [PATCH 026/119] Translated using Weblate (Occitan) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/oc/ --- Emby.Server.Implementations/Localization/Core/oc.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index 0967ef424b..cad5640763 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1 +1,3 @@ -{} +{ + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" +} From cb9d6e9884d3b952321736392801743198b0ccd9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 24 May 2026 18:26:21 +0200 Subject: [PATCH 027/119] Add batch method for people names --- .../Library/LibraryManager.cs | 6 ++ .../Item/PeopleRepository.cs | 58 +++++++++++++++++++ .../Library/ILibraryManager.cs | 8 +++ .../Persistence/IPeopleRepository.cs | 10 ++++ 4 files changed, 82 insertions(+) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..662e28ec1d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3394,6 +3394,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + return _peopleRepository.GetPeopleNamesByItem(itemIds, personTypes); + } + public void UpdatePeople(BaseItem item, List people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index b612112d49..d84a59850d 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,64 @@ public class PeopleRepository(IDbContextFactory dbProvider, I transaction.Commit(); } + /// + public IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes) + { + if (itemIds.Count == 0) + { + return new Dictionary>(); + } + + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + // One round-trip: pull (ItemId, ListOrder, Name) sorted by ItemId+ListOrder, group in memory. + var rows = query + .OrderBy(m => m.ItemId) + .ThenBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToArray(); + + var result = new Dictionary>(); + List? current = null; + var currentId = Guid.Empty; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (row.ItemId != currentId) + { + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + currentId = row.ItemId; + current = new List(); + seen.Clear(); + } + + if (!string.IsNullOrWhiteSpace(row.Name) && seen.Add(row.Name)) + { + current!.Add(row.Name); + } + } + + if (current is { Count: > 0 }) + { + result[currentId] = current; + } + + return result; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f4c2196400..d794205f00 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -597,6 +597,14 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Gets the people names per item for a batch of item IDs in a single DB round-trip. + /// + /// The item IDs to look up. + /// Optional person types to include. Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. Items with no people are absent. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); + /// /// Queries the items. /// diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..3a3b2bfb1f 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,14 @@ public interface IPeopleRepository /// The query. /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + + /// + /// Gets the people names per item for a batch of item IDs, preserving per-item list order. + /// One database round-trip for the whole batch; grouped by item id in memory. + /// Items with no people are omitted from the returned dictionary. + /// + /// The item IDs to get people for. + /// Optional person types to include (e.g. "Actor", "Director"). Empty for all. + /// Dictionary keyed by item id; values are the per-item people names. + IReadOnlyDictionary> GetPeopleNamesByItem(IReadOnlyList itemIds, IReadOnlyList personTypes); } From f9eb550cbb992c32f0560ad5894a56b276a74d7f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 11:02:16 +0000 Subject: [PATCH 028/119] Update skiasharp monorepo --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f568f7e781..8d97774348 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + @@ -69,9 +69,9 @@ - - - + + + From 148cdef1800fafa6f9892b319b5dce8bf545a4ce Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Mon, 25 May 2026 10:08:02 -0400 Subject: [PATCH 029/119] Update issue template version to 10.11.10 --- .github/ISSUE_TEMPLATE/issue report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index a7e644a55f..0689db7a87 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,6 +87,7 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 10.11.10 - 10.11.9 - 10.11.8 - 10.11.7 From 9fafc87bf6de9c742154191b650bad22dec35c47 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 25 May 2026 16:55:58 +0000 Subject: [PATCH 030/119] Deleted translation using Weblate (English (Middle)) --- Emby.Server.Implementations/Localization/Core/enm.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Emby.Server.Implementations/Localization/Core/enm.json diff --git a/Emby.Server.Implementations/Localization/Core/enm.json b/Emby.Server.Implementations/Localization/Core/enm.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/Emby.Server.Implementations/Localization/Core/enm.json +++ /dev/null @@ -1 +0,0 @@ -{} From d0f1df13b2660ec8a20083929a731226bfbd7c9d Mon Sep 17 00:00:00 2001 From: Antonio Toledo Date: Mon, 25 May 2026 02:34:48 -0400 Subject: [PATCH 031/119] Translated using Weblate (Spanish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es/ --- Emby.Server.Implementations/Localization/Core/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 35efcf74d3..563dce8fe6 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -108,5 +108,5 @@ "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", "Original": "Original", - "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}." + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" } From dd3aa04422338a2e77f703f9184666422e025993 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 07:28:33 +0000 Subject: [PATCH 032/119] Update dependency z440.atl.core to 7.14.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f568f7e781..66846f5629 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,7 +79,7 @@ - + From a05bde53d46d1a074b5f17181c59086837dfb04c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 25 May 2026 10:49:01 +0200 Subject: [PATCH 033/119] Fix external data pruning on item deletion --- .../ApplicationHost.cs | 1 + .../Library/ExternalDataManager.cs | 40 ++-- .../Library/LibraryManager.cs | 19 +- ...60524220000_CleanupOrphanedExternalData.cs | 182 ++++++++++++++++++ .../IO/IExternalDataManager.cs | 7 + 5 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c81829688f..7cbff0c67e 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -539,6 +539,7 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index 4ad0f999bf..2c18e56df7 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Chapters; @@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager /// public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) { - var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList(); - var itemId = item.Id; - if (validPaths.Count > 0) - { - foreach (var path in validPaths) - { - try - { - Directory.Delete(path, true); - } - catch (Exception ex) - { - _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); - } - } - } + DeleteExternalItemFiles(item); + var itemId = item.Id; await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } + + /// + public void DeleteExternalItemFiles(BaseItem item) + { + foreach (var path in _pathManager.GetExtractedDataPaths(item)) + { + if (!Directory.Exists(path)) + { + continue; + } + + try + { + Directory.Delete(path, true); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); + } + } + } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..2d89f763d3 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library private readonly FastConcurrentLru _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly Lazy _externalDataManagerFactory; /// /// The _root folder sync lock. @@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library /// The path manager. /// The .ignore rule handler. /// The media stream repository. + /// The external data manager (lazy, to break the DI cycle through ChapterManager). public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, - IMediaStreamRepository mediaStreamRepository) + IMediaStreamRepository mediaStreamRepository, + Lazy externalDataManagerFactory) { _appHost = appHost; _logger = loggerFactory.CreateLogger(); @@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; _mediaStreamRepository = mediaStreamRepository; + _externalDataManagerFactory = externalDataManagerFactory; RecordConfigurationValues(_configurationManager.Configuration); } @@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library } } + var externalDataManager = _externalDataManagerFactory.Value; + foreach (var (item, _, _) in pathMaps) + { + externalDataManager.DeleteExternalItemFiles(item); + } + _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); } @@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); + var externalDataManager = _externalDataManagerFactory.Value; + externalDataManager.DeleteExternalItemFiles(item); + foreach (var child in children) + { + externalDataManager.DeleteExternalItemFiles(child); + } + _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) diff --git a/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260524220000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the BaseItems table. The database side is cleaned up synchronously by +/// IItemPersistenceService.DeleteItem, so the leftover orphans live on the filesystem. +/// +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger _logger; + private readonly IDbContextFactory _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The database context factory. + /// The application paths. + /// The server application paths. + public CleanupOrphanedExternalData( + IStartupLogger logger, + IDbContextFactory dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs index f69f4586c6..b2eb8fc3f1 100644 --- a/MediaBrowser.Controller/IO/IExternalDataManager.cs +++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs @@ -16,4 +16,11 @@ public interface IExternalDataManager /// The cancellation token. /// Task. Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// + /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images). + /// Use this when DB-side cleanup is already handled by another code path (e.g. IItemPersistenceService.DeleteItem). + /// + /// The item. + void DeleteExternalItemFiles(BaseItem item); } From 11130030d25101e4ca42e2215d8343155a529b79 Mon Sep 17 00:00:00 2001 From: Bond-009 Date: Tue, 26 May 2026 20:59:20 +0200 Subject: [PATCH 034/119] Backport: Fix/user manager collation (#16919) Backport: Fix/user manager collation --- .gitignore | 4 + Jellyfin.Api/Controllers/StartupController.cs | 12 +- .../Users/UserManager.cs | 20 +- .../Migrations/JellyfinMigrationService.cs | 143 +- ...20260522092304_UpdateNormalizedUsername.cs | 44 + .../Entities/User.cs | 11 + .../ModelConfiguration/UserConfiguration.cs | 4 + ...22092303_AddNormalizedUsername.Designer.cs | 1804 ++++++++++++++++ .../20260522092303_AddNormalizedUsername.cs | 32 + ...dUniqueNormalizedUsernameIndex.Designer.cs | 1807 +++++++++++++++++ ...120336_AddUniqueNormalizedUsernameIndex.cs | 28 + .../Migrations/JellyfinDbModelSnapshot.cs | 10 +- .../UserManagerNormalizedUsernameTests.cs | 240 +++ 13 files changed, 4068 insertions(+), 91 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs diff --git a/.gitignore b/.gitignore index e399f1fc47..381c15909d 100644 --- a/.gitignore +++ b/.gitignore @@ -278,3 +278,7 @@ apiclient/generated # Omnisharp crash logs mono_crash.*.json + +# Devcontainer temp files +.devcontainer/devcontainer-lock.json +dotnet/ diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 4373a46adc..fa6d9efe36 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -145,13 +145,15 @@ public class StartupController : BaseJellyfinApiController return BadRequest("Password must not be empty"); } - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); +#pragma warning disable CA1309 // Use ordinal string comparison + if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase)) + { + await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false); + } +#pragma warning restore CA1309 // Use ordinal string comparison + if (!string.IsNullOrEmpty(startupUserDto.Password)) { await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 8c0cbbd448..37c4106496 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,4 +1,3 @@ -#pragma warning disable CA1307 #pragma warning disable RS0030 // Do not use banned APIs using System; @@ -161,12 +160,8 @@ namespace Jellyfin.Server.Implementations.Users using var dbContext = _dbProvider.CreateDbContext(); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings return UserQuery(dbContext) - .FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper()); -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } @@ -187,10 +182,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId) + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -198,8 +191,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", newName)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user = await UserQuery(dbContext) @@ -208,6 +199,7 @@ namespace Jellyfin.Server.Implementations.Users .ConfigureAwait(false) ?? throw new ResourceNotFoundException(nameof(userId)); user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); } } @@ -257,10 +249,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == name.ToUpper()) + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -268,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", name)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index d664b718bc..9bf927bb95 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -193,84 +193,89 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService(); var migrationsAssembly = dbContext.GetService(); - var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - var pendingCodeMigrations = migrationStage - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) - .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) - .ToArray(); + (string Key, IInternalMigration Migration)[] migrations = []; - (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; - if (stage is JellyfinMigrationStageTypes.CoreInitialisation) - { - pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) - .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) - .ToArray(); - } + do + { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply. + var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + var pendingCodeMigrations = migrationStage + .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) + .ToArray(); - (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); - - foreach (var item in migrations) - { - var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); - try + (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; + if (stage is JellyfinMigrationStageTypes.CoreInitialisation) { - migrationLogger.LogInformation("Perform migration {Name}", item.Key); - await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); - migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); + pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) + .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) + .ToArray(); } - catch (Exception ex) - { - migrationLogger.LogCritical("Error: {Error}", ex.Message); - migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); - if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); + migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + + foreach (var item in migrations) + { + var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); + try { - if (_backupKey.LibraryDb is not null) - { - migrationLogger.LogInformation("Attempt to rollback librarydb."); - try - { - var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); - File.Move(_backupKey.LibraryDb, libraryDbPath, true); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); - } - } - - if (_backupKey.JellyfinDb is not null) - { - migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); - try - { - await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); - } - } - - if (_backupKey.FullBackup is not null) - { - migrationLogger.LogInformation("Attempt to rollback from backup."); - try - { - await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); - } - } + migrationLogger.LogInformation("Perform migration {Name}", item.Key); + await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); + migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); } + catch (Exception ex) + { + migrationLogger.LogCritical("Error: {Error}", ex.Message); + migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); - throw; + if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + { + if (_backupKey.LibraryDb is not null) + { + migrationLogger.LogInformation("Attempt to rollback librarydb."); + try + { + var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + File.Move(_backupKey.LibraryDb, libraryDbPath, true); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); + } + } + + if (_backupKey.JellyfinDb is not null) + { + migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); + try + { + await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); + } + } + + if (_backupKey.FullBackup is not null) + { + migrationLogger.LogInformation("Attempt to rollback from backup."); + try + { + await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); + } + } + } + + throw; + } } - } + } while (migrations.Length != 0); } } diff --git a/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs new file mode 100644 index 0000000000..8100d4759e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Part 2 Migration for NormalisedUsername. +/// +[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +#pragma warning disable SA1649 // File name should match first type name +public class UpdateNormalizedUsername : IAsyncMigrationRoutine +#pragma warning restore SA1649 // File name should match first type name +{ + private readonly IDbContextFactory _contextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// Db Context factory. + public UpdateNormalizedUsername(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var user in users) + { + user.NormalizedUsername = user.Username.ToUpperInvariant(); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 6c81fa729c..b10e210e5d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId); Username = username; + NormalizedUsername = username.ToUpperInvariant(); AuthenticationProviderId = authenticationProviderId; PasswordResetProviderId = passwordResetProviderId; @@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities [StringLength(255)] public string Username { get; set; } + /// + /// Gets or sets the user's normalized name. + /// + /// + /// Required, Max length = 255. + /// + [MaxLength(255)] + [StringLength(255)] + public string NormalizedUsername { get; set; } + /// /// Gets or sets the user's password, or null if none is set. /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs index 61b5e06e8a..ed4138680d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs @@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration builder .HasIndex(entity => entity.Username) .IsUnique(); + + builder + .HasIndex(entity => entity.NormalizedUsername) + .IsUnique(); } } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs new file mode 100644 index 0000000000..63f858bc98 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs @@ -0,0 +1,1804 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260522092303_AddNormalizedUsername")] + partial class AddNormalizedUsername + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs new file mode 100644 index 0000000000..670f59ba7a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddNormalizedUsername : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NormalizedUsername", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + defaultValue: string.Empty); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("ALTER TABLE Users DROP COLUMN NormalizedUsername;"); + + migrationBuilder.Sql( + @"DELETE FROM __EFMigrationsHistory + WHERE MigrationId = '20260522092304_UpdateNormalizedUsername'"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs new file mode 100644 index 0000000000..a1f555a59b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs @@ -0,0 +1,1807 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260524120336_AddUniqueNormalizedUsernameIndex")] + partial class AddUniqueNormalizedUsernameIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs new file mode 100644 index 0000000000..6c17775d16 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddUniqueNormalizedUsernameIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_NormalizedUsername", + table: "Users", + column: "NormalizedUsername", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUsername", + table: "Users"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs index 86b838d64e..fd18c035e6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -1348,6 +1348,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property("MustUpdatePassword") .HasColumnType("INTEGER"); + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property("Password") .HasMaxLength(65535) .HasColumnType("TEXT"); @@ -1390,6 +1395,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); + b.HasIndex("NormalizedUsername") + .IsUnique(); + b.HasIndex("Username") .IsUnique(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs new file mode 100644 index 0000000000..596bf58fb1 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public sealed class UserManagerNormalizedUsernameTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + private readonly UserManager _userManager; + + public UserManagerNormalizedUsernameTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock(); + var configManager = new Mock(); + var appPaths = new Mock(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock().Object, + appHost.Object, + new Mock().Object, + NullLogger.Instance, + configManager.Object, + new IPasswordResetProvider[] { defaultPasswordResetProvider }, + new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider }); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + } + + // ----- GetUserByName tests ----- + + [Theory] + // German umlauts + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n + [InlineData("Ñoño", "ÑOÑO")] + // ASCII, invariant uppercase lookup + [InlineData("jellyfin", "JELLYFIN")] + // Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130) + [InlineData("Çelebi", "ÇELEBI")] + public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName( + string username, string normalizedLookup) + { + await _userManager.CreateUserAsync(username); + + var found = _userManager.GetUserByName(normalizedLookup); + + Assert.NotNull(found); + Assert.Equal(username, found.Username); + } + + [Theory] + // German umlaut, look up by both upper and lower case + [InlineData("münchen")] + // Spanish tilde-n + [InlineData("Ñoño")] + // lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ' + [InlineData("ali")] + // mixed ASCII + umlaut + [InlineData("testüser")] + public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username) + { + await _userManager.CreateUserAsync(username); + + var upperFound = _userManager.GetUserByName(username.ToUpperInvariant()); + var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant()); + var exactFound = _userManager.GetUserByName(username); + + Assert.NotNull(upperFound); + Assert.NotNull(lowerFound); + Assert.NotNull(exactFound); + } + + [Theory] + [InlineData("nonexistent")] + // No user with NormalizedUsername = "MÜNCHEN" has been created + [InlineData("MÜNCHEN")] + public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName) + { + var result = _userManager.GetUserByName(lookupName); + + Assert.Null(result); + } + + // ----- CreateUserAsync duplicate detection tests ----- + + [Theory] + // German umlaut, case-swapped duplicate + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n, lowercase duplicate + [InlineData("Ñoño", "ñoño")] + // ASCII, uppercase duplicate + [InlineData("alice", "ALICE")] + // Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant() + [InlineData("çelebi", "ÇELEBI")] + public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException( + string existingUsername, string duplicateUsername) + { + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync( + () => _userManager.CreateUserAsync(duplicateUsername)); + } + + [Theory] + // Different non-ASCII names that do not collide after normalization + [InlineData("münchen", "münchen2")] + [InlineData("ali", "ali2")] + // Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E) + [InlineData("noño", "nono")] + public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers( + string firstUsername, string secondUsername) + { + var first = await _userManager.CreateUserAsync(firstUsername); + var second = await _userManager.CreateUserAsync(secondUsername); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotEqual(first.Id, second.Id); + } + + // ----- RenameUser tests ----- + + [Theory] + // Rename to non-ASCII name + [InlineData("alice", "münchen")] + // Rename between similar non-ASCII and ASCII + [InlineData("müller", "mueller")] + // Contains 'i': invariant uppercase is always 'I', never Turkish 'İ' + [InlineData("ali", "ALI2")] + // Rename to Spanish tilde-n name + [InlineData("testuser", "Ñoño")] + public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant( + string originalName, string newName) + { + var user = await _userManager.CreateUserAsync(originalName); + + await _userManager.RenameUser(user.Id, originalName, newName); + + var renamed = _userManager.GetUserById(user.Id); + Assert.NotNull(renamed); + Assert.Equal(newName, renamed.Username); + Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername); + } + + [Theory] + // Same name different case: NormalizedUsername already taken + [InlineData("münchen", "MÜNCHEN")] + // Spanish, lowercase conflicts with existing uppercase-normalised entry + [InlineData("Ñoño", "ñoño")] + // ASCII, capitalised conflict + [InlineData("alice", "Alice")] + // Mixed ASCII + umlaut + [InlineData("testüser", "TESTÜSER")] + public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException( + string existingUsername, string conflictingNewName) + { + var targetUser = await _userManager.CreateUserAsync("renametarget"); + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync( + () => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName)); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } + } +} From fdfdf34cdb0b54d7fb12c02ac0d75cf8ae75543b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 18:59:55 +0000 Subject: [PATCH 035/119] Update dependency Microsoft.NET.Test.Sdk to 18.6.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f568f7e781..9e46327bd4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -47,7 +47,7 @@ - + From 4af66c4e1ad0f8c0105dc3a48c2bfaf29cd11750 Mon Sep 17 00:00:00 2001 From: Erik W <22211983+Lampan-git@users.noreply.github.com> Date: Tue, 26 May 2026 21:02:43 +0200 Subject: [PATCH 036/119] Improve OriginalLanguage normalization and inheritance (#16829) Improve OriginalLanguage normalization and inheritance --- .../Library/MediaSourceManager.cs | 16 +--------------- MediaBrowser.Controller/Entities/BaseItem.cs | 19 +++++++++++++++++-- .../Entities/TV/Episode.cs | 6 ++++++ MediaBrowser.Controller/Entities/TV/Season.cs | 6 ++++++ MediaBrowser.Controller/Entities/Video.cs | 11 +++++++++++ .../Plugins/Omdb/OmdbProvider.cs | 2 +- 6 files changed, 42 insertions(+), 18 deletions(-) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index fdb4c7328b..66614c6725 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -440,10 +440,6 @@ namespace Emby.Server.Implementations.Library if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) { - originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage) - ? originalLanguage.Split(',').FirstOrDefault() - : null; - if (user.PlayDefaultAudioTrack) { source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( @@ -498,17 +494,7 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - var originalLanguage = item?.OriginalLanguage ?? item switch - { - Episode episode => episode.Series.OriginalLanguage, - Video video => video.GetOwner() switch - { - Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage, - BaseItem owner => owner.OriginalLanguage, - null => null - }, - _ => null - }; + var originalLanguage = item?.GetInheritedOriginalLanguage(); SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 4cdcaabbb1..e24b60f69f 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -94,6 +94,8 @@ namespace MediaBrowser.Controller.Entities private string _name; + private string _originalLanguage; + public const char SlugChar = '-'; protected BaseItem() @@ -217,7 +219,11 @@ namespace MediaBrowser.Controller.Entities public string OriginalTitle { get; set; } [JsonIgnore] - public string OriginalLanguage { get; set; } + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } /// /// Gets or sets the id. @@ -1564,7 +1570,7 @@ namespace MediaBrowser.Controller.Entities } /// - /// Gets the preferred metadata language. + /// Gets the preferred metadata country code. /// /// System.String. public string GetPreferredMetadataCountryCode() @@ -1598,6 +1604,15 @@ namespace MediaBrowser.Controller.Entities return lang; } + /// + /// Gets the original language of the item, inheriting from parent items if necessary. + /// + /// System.String. + public virtual string GetInheritedOriginalLanguage() + { + return OriginalLanguage; + } + public virtual bool IsSaveLocalMetadataEnabled() { if (SourceType == SourceType.Channel) diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index dbe6f94dfd..42e4f79942 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV return 16.0 / 9; } + /// + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override List GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index f70f7dfb4c..e96ed05a5e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV return result; } + /// + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 80bcd62dcd..44cae5197a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -278,6 +278,17 @@ namespace MediaBrowser.Controller.Entities return linkedVersionCount + localVersionCount + 1; } + /// + public override string GetInheritedOriginalLanguage() + { + if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer) + { + return GetOwner()?.GetInheritedOriginalLanguage(); + } + + return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage(); + } + public override List GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 4882822766..f562d64ddd 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -413,7 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } item.Overview = result.Plot; - item.OriginalLanguage = result.Language; + item.OriginalLanguage = result.Language?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); if (!Plugin.Instance.Configuration.CastAndCrew) { From f0e01e33c4f301ee8dd7d4975e142c5efc6888a8 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Tue, 26 May 2026 21:37:53 +0200 Subject: [PATCH 037/119] Unpin SkiaSharp --- Directory.Packages.props | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8d97774348..b337c078f1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,10 +68,9 @@ - - - - + + + From 9f350171c6cd5fc61f96407a53f447353f431c4f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 27 May 2026 00:33:31 +0200 Subject: [PATCH 038/119] Discover existing trickplay files on scan --- .../Trickplay/TrickplayManager.cs | 175 +++++++++++++++++- 1 file changed, 173 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 0791e04e85..58b9f7f822 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay; /// /// ITrickplayManager implementation. /// -public class TrickplayManager : ITrickplayManager +public partial class TrickplayManager : ITrickplayManager { private readonly ILogger _logger; private readonly IMediaEncoder _mediaEncoder; @@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager } } + private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken) + { + var options = _config.Configuration.TrickplayOptions; + var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false); + + // Remove DB rows whose on-disk folder no longer exists in either possible location. + // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration. + var orphanedWidths = new List(); + foreach (var (width, info) in existing) + { + cancellationToken.ThrowIfCancellationRequested(); + var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false); + var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true); + if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir)) + { + orphanedWidths.Add(width); + } + } + + if (orphanedWidths.Count > 0) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.TrickplayInfos + .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + foreach (var width in orphanedWidths) + { + _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path); + existing.Remove(width); + } + } + + var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + if (!Directory.Exists(trickplayDirectory)) + { + return; + } + + foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var match = TrickplaySubdirRegex().Match(subdir.Name); + if (!match.Success) + { + continue; + } + + var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + if (existing.ContainsKey(width)) + { + continue; + } + + var tiles = subdir.GetFiles("*.jpg") + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToArray(); + if (tiles.Length == 0) + { + continue; + } + + // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real + // thumbnail count cannot be read from tile dimensions. Instead, bound the count from + // the tile count and per-tile capacity, then pick an interval consistent with the + // video runtime - snapping to the server's configured interval when it fits. + var thumbsPerTile = tileWidth * tileHeight; + var maxThumbs = tiles.Length * thumbsPerTile; + var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1; + + int interval; + int thumbnailCount; + if (video.RunTimeTicks is long ticks) + { + var runtimeMs = ticks / TimeSpan.TicksPerMillisecond; + var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs)); + var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs)); + + if (options.Interval >= minInterval && options.Interval <= maxInterval) + { + interval = options.Interval; + } + else + { + var midpoint = (minInterval + maxInterval) / 2.0; + var snapped = (long)Math.Round(midpoint / 1000d) * 1000L; + interval = (int)Math.Clamp(snapped, minInterval, maxInterval); + } + + thumbnailCount = Math.Clamp( + (int)Math.Round(runtimeMs / (double)interval), + minThumbs, + maxThumbs); + } + else + { + interval = Math.Max(1000, options.Interval); + thumbnailCount = maxThumbs; + } + + var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName); + var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight)); + + var info = new TrickplayInfo + { + ItemId = video.Id, + Width = width, + Interval = interval, + TileWidth = tileWidth, + TileHeight = tileHeight, + ThumbnailCount = thumbnailCount, + Height = thumbPxH, + Bandwidth = 0, + }; + + foreach (var tile in tiles) + { + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m)); + info.Bandwidth = Math.Max(info.Bandwidth, bitrate); + } + + await SaveTrickplayInfo(info).ConfigureAwait(false); + _logger.LogInformation( + "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}", + width, + tileWidth, + tileHeight, + thumbnailCount, + interval, + video.Path); + } + } + /// public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) { @@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager return; } + var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; + + // Catalog any existing trickplay folders on disk before any prune/generate. This picks up + // user-placed files even when their (width, tile dims) don't match the server's configured values. + if (!replace) + { + await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false); + } + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + + // When extraction is disabled and files live next to media, treat them as user-managed: + // discovery above already catalogued whatever is on disk, leave it alone. + if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia) + { + return; + } + if (!libraryOptions.EnableTrickplayImageExtraction || replace) { // Prune existing data @@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager return Path.Combine(path, subdirectory); } + [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")] + private static partial Regex TrickplaySubdirRegex(); + + private static bool HasTrickplayTiles(string directory) + { + if (!Directory.Exists(directory)) + { + return false; + } + + return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any(); + } + private async Task HasTrickplayResolutionAsync(Guid itemId, int width) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); From 961c6d3d547a1e3c6352129b45fffd1158571d15 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Fri, 22 May 2026 16:53:58 -0500 Subject: [PATCH 039/119] Compare old file byte-by-byte to new stream Don't overwrite if identical. --- .../Savers/BaseNfoSaver.cs | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index ed32e6c76a..64daac68e3 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -198,15 +198,23 @@ namespace MediaBrowser.XbmcMetadata.Savers cancellationToken.ThrowIfCancellationRequested(); - await SaveToFileAsync(memoryStream, path).ConfigureAwait(false); + await SaveToFileAsync(memoryStream, path, cancellationToken).ConfigureAwait(false); } } - private async Task SaveToFileAsync(Stream stream, string path) + private async Task SaveToFileAsync(Stream stream, string path, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); Directory.CreateDirectory(directory); + // Compare byte-for-byte before proceeding. + if (File.Exists(path) && await IsFileIdenticalAsync(stream, path, cancellationToken).ConfigureAwait(false)) + { + return; // Don't save since .nfo is unchanged. + } + + stream.Position = 0; + // On Windows, saving the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); @@ -222,7 +230,7 @@ namespace MediaBrowser.XbmcMetadata.Savers var filestream = new FileStream(path, fileStreamOptions); await using (filestream.ConfigureAwait(false)) { - await stream.CopyToAsync(filestream).ConfigureAwait(false); + await stream.CopyToAsync(filestream, cancellationToken).ConfigureAwait(false); } if (ConfigurationManager.Configuration.SaveMetadataHidden) @@ -231,6 +239,68 @@ namespace MediaBrowser.XbmcMetadata.Savers } } + private static async Task IsFileIdenticalAsync(Stream stream, string path, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrEmpty(path); + + if (!stream.CanSeek) + { + return false; + } + + const int BufferSize = 81920; + var originalPosition = stream.Position; + + try + { + stream.Position = 0; + + using var existingFileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: BufferSize, + FileOptions.Asynchronous); + + if (existingFileStream.Length != stream.Length) + { + return false; + } + + var streamBuffer = new byte[BufferSize]; + var existingBuffer = new byte[BufferSize]; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var streamBytesRead = await stream.ReadAsync(streamBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); + var existingBytesRead = await existingFileStream.ReadAsync(existingBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); + + if (streamBytesRead != existingBytesRead) + { + return false; + } + + if (streamBytesRead == 0) + { + return true; + } + + if (!streamBuffer.AsSpan(0, streamBytesRead).SequenceEqual(existingBuffer.AsSpan(0, existingBytesRead))) + { + return false; + } + } + } + finally + { + stream.Position = originalPosition; + } + } + private void SetHidden(string path, bool hidden) { try From 02ca63cd13779dbff9971e10a7afd62d2634337b Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 26 May 2026 22:37:17 +0000 Subject: [PATCH 040/119] Moved IsFileIdenticalAsync & IsStreamIdenticalAsync to StreamExtensions. --- .../Savers/BaseNfoSaver.cs | 64 +------------ src/Jellyfin.Extensions/StreamExtensions.cs | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 63 deletions(-) diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 64daac68e3..78907a5e68 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -208,7 +208,7 @@ namespace MediaBrowser.XbmcMetadata.Savers Directory.CreateDirectory(directory); // Compare byte-for-byte before proceeding. - if (File.Exists(path) && await IsFileIdenticalAsync(stream, path, cancellationToken).ConfigureAwait(false)) + if (File.Exists(path) && await stream.IsFileIdenticalAsync(path, cancellationToken).ConfigureAwait(false)) { return; // Don't save since .nfo is unchanged. } @@ -239,68 +239,6 @@ namespace MediaBrowser.XbmcMetadata.Savers } } - private static async Task IsFileIdenticalAsync(Stream stream, string path, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(stream); - ArgumentException.ThrowIfNullOrEmpty(path); - - if (!stream.CanSeek) - { - return false; - } - - const int BufferSize = 81920; - var originalPosition = stream.Position; - - try - { - stream.Position = 0; - - using var existingFileStream = new FileStream( - path, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: BufferSize, - FileOptions.Asynchronous); - - if (existingFileStream.Length != stream.Length) - { - return false; - } - - var streamBuffer = new byte[BufferSize]; - var existingBuffer = new byte[BufferSize]; - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - var streamBytesRead = await stream.ReadAsync(streamBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); - var existingBytesRead = await existingFileStream.ReadAsync(existingBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); - - if (streamBytesRead != existingBytesRead) - { - return false; - } - - if (streamBytesRead == 0) - { - return true; - } - - if (!streamBuffer.AsSpan(0, streamBytesRead).SequenceEqual(existingBuffer.AsSpan(0, existingBytesRead))) - { - return false; - } - } - } - finally - { - stream.Position = originalPosition; - } - } - private void SetHidden(string path, bool hidden) { try diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 0cfac384e3..fa019b0059 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -1,9 +1,12 @@ +using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace Jellyfin.Extensions { @@ -12,6 +15,8 @@ namespace Jellyfin.Extensions /// public static class StreamExtensions { + private const int StreamComparisonBufferSize = 65536; + /// /// Reads all lines in the . /// @@ -60,5 +65,96 @@ namespace Jellyfin.Extensions yield return line; } } + + /// + /// Determines whether a stream is identical to a file on disk. + /// + /// The stream to compare. + /// The file path to compare against. + /// The token to monitor for cancellation requests. + /// True if the stream and file are identical; otherwise false. + public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrEmpty(path); + + if (!stream.CanSeek) + { + return false; + } + + var originalPosition = stream.Position; + try + { + stream.Position = 0; + + var existingFileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: StreamComparisonBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (existingFileStream.ConfigureAwait(false)) + { + return await stream.IsStreamIdenticalAsync(existingFileStream, cancellationToken).ConfigureAwait(false); + } + } + finally + { + stream.Position = originalPosition; + } + } + + /// + /// Determines whether two streams are identical. + /// + /// The first stream to compare. + /// The second stream to compare. + /// The token to monitor for cancellation requests. + /// True if the streams are identical; otherwise false. + public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + if (b.Length != a.Length) + { + return false; + } + + var bufferA = ArrayPool.Shared.Rent(StreamComparisonBufferSize); + var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var bytesReadA = await a.ReadAsync(bufferA.AsMemory(), cancellationToken).ConfigureAwait(false); + var bytesReadB = await b.ReadAsync(bufferB.AsMemory(), cancellationToken).ConfigureAwait(false); + + if (bytesReadA != bytesReadB) + { + return false; + } + + if (bytesReadA == 0) + { + return true; + } + + if (!bufferA.AsSpan(0, bytesReadA).SequenceEqual(bufferB.AsSpan(0, bytesReadB))) + { + return false; + } + } + } + finally + { + ArrayPool.Shared.Return(bufferA); + ArrayPool.Shared.Return(bufferB); + } + } } } From c449a933722980625640e56bfe5dbd746214b5a8 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 26 May 2026 23:11:01 +0000 Subject: [PATCH 041/119] Explicitly handle MemoryStream(s) --- src/Jellyfin.Extensions/StreamExtensions.cs | 91 ++++++++++++++++----- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index fa019b0059..ed3f6e665d 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -123,37 +123,84 @@ namespace Jellyfin.Extensions return false; } - var bufferA = ArrayPool.Shared.Rent(StreamComparisonBufferSize); - var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize); - try + // If b is MemoryStream but a is not, swap them to use fast path B + if (b is MemoryStream && a is not MemoryStream) { - while (true) + (a, b) = (b, a); + } + + if (a is MemoryStream ms_a) + { + var bufferA = ms_a.GetBuffer(); + + // Fast path A: if both streams are MemoryStreams, compare directly against each other + if (b is MemoryStream ms_b) { - cancellationToken.ThrowIfCancellationRequested(); + return bufferA.AsSpan(0, (int)ms_a.Length).SequenceEqual(ms_b.GetBuffer().AsSpan(0, (int)ms_b.Length)); + } - var bytesReadA = await a.ReadAsync(bufferA.AsMemory(), cancellationToken).ConfigureAwait(false); - var bytesReadB = await b.ReadAsync(bufferB.AsMemory(), cancellationToken).ConfigureAwait(false); - - if (bytesReadA != bytesReadB) + // Fast path B: only first stream is a MemoryStream, compare against second stream chunk-by-chunk + var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize); + try + { + var memoryB = bufferB.AsMemory(); + int offset = 0; + int bytesRead; + while ((bytesRead = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false)) > 0) { - return false; + cancellationToken.ThrowIfCancellationRequested(); + + if (!bufferA.AsSpan(offset, bytesRead).SequenceEqual(bufferB.AsSpan(0, bytesRead))) + { + return false; + } + + offset += bytesRead; } - if (bytesReadA == 0) - { - return true; - } - - if (!bufferA.AsSpan(0, bytesReadA).SequenceEqual(bufferB.AsSpan(0, bytesReadB))) - { - return false; - } + return offset == ms_a.Length; + } + finally + { + ArrayPool.Shared.Return(bufferB); } } - finally + else { - ArrayPool.Shared.Return(bufferA); - ArrayPool.Shared.Return(bufferB); + var bufferA = ArrayPool.Shared.Rent(StreamComparisonBufferSize); + var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize); + try + { + var memoryA = bufferA.AsMemory(); + var memoryB = bufferB.AsMemory(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var bytesReadA = await a.ReadAsync(memoryA, cancellationToken).ConfigureAwait(false); + var bytesReadB = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false); + + if (bytesReadA != bytesReadB) + { + return false; + } + + if (bytesReadA == 0) + { + return true; + } + + if (!bufferA.AsSpan(0, bytesReadA).SequenceEqual(bufferB.AsSpan(0, bytesReadB))) + { + return false; + } + } + } + finally + { + ArrayPool.Shared.Return(bufferA); + ArrayPool.Shared.Return(bufferB); + } } } } From b555de4ceab449dca3112a56e8eb5d81489e1faa Mon Sep 17 00:00:00 2001 From: Michael Jones Date: Tue, 26 May 2026 22:24:16 -0500 Subject: [PATCH 042/119] Fix CA2007 warnings in InstallationManager Wrap the downloaded stream in an explicit await using block with ConfigureAwait(false), matching the pattern already used in LiveStreamHelper and similar callers. Also add ConfigureAwait(false) to the ZipFile.ExtractToDirectoryAsync call. Part of #2149 --- .../Updates/InstallationManager.cs | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 67b77a112d..ef53e3b326 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -527,42 +527,44 @@ namespace Emby.Server.Implementations.Updates using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - // CA5351: Do Not Use Broken Cryptographic Algorithms + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); - if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError( - "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", - package.Name, - package.Checksum, - hash); - throw new InvalidDataException("The checksum of the received data doesn't match."); - } - - // Version folder as they cannot be overwritten in Windows. - targetDir += "_" + package.Version; - - if (Directory.Exists(targetDir)) - { - try + var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) { - Directory.Delete(targetDir, true); + _logger.LogError( + "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + package.Name, + package.Checksum, + hash); + throw new InvalidDataException("The checksum of the received data doesn't match."); } + + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; + + if (Directory.Exists(targetDir)) + { + try + { + Directory.Delete(targetDir, true); + } #pragma warning disable CA1031 // Do not catch general exception types - catch + catch #pragma warning restore CA1031 // Do not catch general exception types - { - // Ignore any exceptions. + { + // Ignore any exceptions. + } } - } - stream.Position = 0; - await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken); + stream.Position = 0; + await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false); + } // Ensure we create one or populate existing ones with missing data. await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); From d085eb9d8719c0f3797593067ddf7715e19188d2 Mon Sep 17 00:00:00 2001 From: PCEWLKR Date: Wed, 27 May 2026 19:20:21 -0400 Subject: [PATCH 043/119] Use ConfigureAwait(false) in CollectionController.cs to maintain consistency with the existing async pattern --- Jellyfin.Api/Controllers/CollectionController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 227487b390..aa2b24c1e7 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -88,7 +88,7 @@ public class CollectionController : BaseJellyfinApiController [FromRoute, Required] Guid collectionId, [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } From aa2370e0212333d93ee250e9f2236f9d5bcb3d93 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 27 May 2026 19:53:31 -0500 Subject: [PATCH 044/119] Use TryGetBuffer() on MemoryStreams Also now throws if the streams are no CanSeek. --- src/Jellyfin.Extensions/StreamExtensions.cs | 32 +++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index ed3f6e665d..56a66b885a 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -73,6 +73,7 @@ namespace Jellyfin.Extensions /// The file path to compare against. /// The token to monitor for cancellation requests. /// True if the stream and file are identical; otherwise false. + /// does not support seeking. public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(stream); @@ -80,7 +81,7 @@ namespace Jellyfin.Extensions if (!stream.CanSeek) { - return false; + throw new ArgumentException("Stream must support seeking.", nameof(stream)); } var originalPosition = stream.Position; @@ -113,30 +114,39 @@ namespace Jellyfin.Extensions /// The second stream to compare. /// The token to monitor for cancellation requests. /// True if the streams are identical; otherwise false. + /// or does not support seeking. public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(a); ArgumentNullException.ThrowIfNull(b); + if (!a.CanSeek) + { + throw new ArgumentException("Stream must support seeking.", nameof(a)); + } + + if (!b.CanSeek) + { + throw new ArgumentException("Stream must support seeking.", nameof(b)); + } + if (b.Length != a.Length) { return false; } - // If b is MemoryStream but a is not, swap them to use fast path B + // If b is MemoryStream but a is not, swap them to enable fast path B if (b is MemoryStream && a is not MemoryStream) { (a, b) = (b, a); } - if (a is MemoryStream ms_a) + if (a is MemoryStream streamA && streamA.TryGetBuffer(out var segmentA)) { - var bufferA = ms_a.GetBuffer(); - // Fast path A: if both streams are MemoryStreams, compare directly against each other - if (b is MemoryStream ms_b) + if (b is MemoryStream streamB && streamB.TryGetBuffer(out var segmentB)) { - return bufferA.AsSpan(0, (int)ms_a.Length).SequenceEqual(ms_b.GetBuffer().AsSpan(0, (int)ms_b.Length)); + return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan()); } // Fast path B: only first stream is a MemoryStream, compare against second stream chunk-by-chunk @@ -148,9 +158,7 @@ namespace Jellyfin.Extensions int bytesRead; while ((bytesRead = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false)) > 0) { - cancellationToken.ThrowIfCancellationRequested(); - - if (!bufferA.AsSpan(offset, bytesRead).SequenceEqual(bufferB.AsSpan(0, bytesRead))) + if (!segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) { return false; } @@ -158,7 +166,7 @@ namespace Jellyfin.Extensions offset += bytesRead; } - return offset == ms_a.Length; + return offset == segmentA.Count; } finally { @@ -190,7 +198,7 @@ namespace Jellyfin.Extensions return true; } - if (!bufferA.AsSpan(0, bytesReadA).SequenceEqual(bufferB.AsSpan(0, bytesReadB))) + if (!memoryA.Span[..bytesReadA].SequenceEqual(memoryB.Span[..bytesReadB])) { return false; } From f12b666cbb1658fb9b98abe59270ee18a9e67085 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 27 May 2026 20:13:52 -0500 Subject: [PATCH 045/119] Remove IsStreamIdenticalAsync CanSeek requirement Now only uses for the Length mismatch. --- src/Jellyfin.Extensions/StreamExtensions.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 56a66b885a..fb3fd2eac1 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Extensions /// public static class StreamExtensions { - private const int StreamComparisonBufferSize = 65536; + private const int StreamComparisonBufferSize = 81920; /// /// Reads all lines in the . @@ -114,23 +114,12 @@ namespace Jellyfin.Extensions /// The second stream to compare. /// The token to monitor for cancellation requests. /// True if the streams are identical; otherwise false. - /// or does not support seeking. public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(a); ArgumentNullException.ThrowIfNull(b); - if (!a.CanSeek) - { - throw new ArgumentException("Stream must support seeking.", nameof(a)); - } - - if (!b.CanSeek) - { - throw new ArgumentException("Stream must support seeking.", nameof(b)); - } - - if (b.Length != a.Length) + if (a.CanSeek && b.CanSeek && b.Length != a.Length) { return false; } From 175232329612ea8bfc268519e21f6c372e79eea7 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 27 May 2026 20:18:18 -0500 Subject: [PATCH 046/119] Add unit tests for new public methods. --- .../StreamExtensionsTests.cs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs diff --git a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs new file mode 100644 index 0000000000..f7efee1e6c --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Xunit; + +namespace Jellyfin.Extensions.Tests; + +public class StreamExtensionsTests +{ + [Fact] + public async Task IsStreamIdenticalAsync_SeekableDifferentLengths_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new MemoryStream(new byte[] { 1, 2, 3 }); + await using var b = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableIdenticalStreams_ReturnsTrue() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableDifferentStreams_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 9, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task IsFileIdenticalAsync_NonSeekableStream_ThrowsArgumentException() + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3, 4 }, cancellationToken); + + try + { + await using var stream = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + + await Assert.ThrowsAsync(async () => + await stream.IsFileIdenticalAsync(path, cancellationToken)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch() + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var bytes = new byte[] { 10, 20, 30, 40, 50 }; + await File.WriteAllBytesAsync(path, bytes, cancellationToken); + + try + { + await using var stream = new MemoryStream(bytes); + stream.Position = 3; + + var result = await stream.IsFileIdenticalAsync(path, cancellationToken); + + Assert.True(result); + Assert.Equal(3, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch() + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + await File.WriteAllBytesAsync(path, new byte[] { 10, 20, 30, 40, 99 }, cancellationToken); + + try + { + await using var stream = new MemoryStream(new byte[] { 10, 20, 30, 40, 50 }); + stream.Position = 2; + + var result = await stream.IsFileIdenticalAsync(path, cancellationToken); + + Assert.False(result); + Assert.Equal(2, stream.Position); + } + finally + { + File.Delete(path); + } + } + + private sealed class NonSeekableReadStream : Stream + { + private readonly Stream _inner; + + public NonSeekableReadStream(byte[] data) + { + _inner = new MemoryStream(data, writable: false); + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } +} From fa3a33c04ead82a4eb0e1542369f1f87fa2a63da Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 03:41:28 +0000 Subject: [PATCH 047/119] Update CI dependencies --- .github/workflows/ci-codeql-analysis.yml | 2 +- .github/workflows/ci-compat.yml | 4 ++-- .github/workflows/ci-format.yml | 2 +- .github/workflows/ci-tests.yml | 2 +- .github/workflows/openapi-generate.yml | 2 +- .github/workflows/pull-request-conflict.yml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index cf4cc1c7f1..8d3e8fe6d5 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index dd48209a1f..99aa56f54f 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -17,7 +17,7 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: '10.0.x' @@ -47,7 +47,7 @@ jobs: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml index c2cca262bf..d6ba603fbb 100644 --- a/.github/workflows/ci-format.yml +++ b/.github/workflows/ci-format.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 3c7ba54acf..928249d93e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml index dbfaf9d30b..c2a5199c54 100644 --- a/.github/workflows/openapi-generate.yml +++ b/.github/workflows/openapi-generate.yml @@ -28,7 +28,7 @@ jobs: repository: ${{ inputs.repository }} - name: Configure .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index 32628ac912..7684743bef 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}} with: dirtyLabel: 'merge conflict' From 8d544e48424d9ddbb1f97d354ed6e6a3f749cbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Nie=C5=82acny?= Date: Thu, 28 May 2026 19:26:28 +0200 Subject: [PATCH 048/119] Fix A/V desync when resuming HLS with video transcode + audio copy (#16580) Fix A/V desync when resuming HLS with video transcode + audio copy --- .../MediaEncoding/EncodingHelper.cs | 69 ++++++++----- .../Configuration/EncodingOptions.cs | 4 +- .../Configuration/HlsAudioSeekStrategy.cs | 9 +- .../EncodingHelperAudioBitStreamTests.cs | 99 +++++++++++++++++++ 4 files changed, 153 insertions(+), 28 deletions(-) create mode 100644 tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 8688ea4b6c..ff8d84d45e 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -86,6 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0); + private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0); private static readonly string[] _videoProfilesH264 = [ @@ -1547,20 +1548,61 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) { - var bitStreamArgs = string.Empty; + var filters = new List(); + + var noiseFilter = GetCopiedAudioTrimBsf(state); + if (!string.IsNullOrEmpty(noiseFilter)) + { + filters.Add(noiseFilter); + } + var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); // Apply aac_adtstoasc bitstream filter when media source is in mpegts. if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase) && (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase) || string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) + || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)) + && IsAAC(state.AudioStream)) { - bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio); - bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + filters.Add("aac_adtstoasc"); } - return bitStreamArgs; + return filters.Count == 0 + ? string.Empty + : " -bsf:a " + string.Join(',', filters); + } + + // When video is transcoded, accurate_seek (the default) trims video to the + // exact seek point via decoder-side frame discard. But stream-copied audio + // bypasses the decoder, so it starts from the nearest keyframe — potentially + // seconds before the target. Use the noise bsf to drop copied audio packets + // before the seek target, achieving the same trim precision without + // re-encoding. The noise bsf's drop= parameter requires ffmpeg >= 5.0. + // Important: make sure not to use it with wtv because it breaks seeking + private string GetCopiedAudioTrimBsf(EncodingJobInfo state) + { + if (state.TranscodingType is not TranscodingJobType.Hls + || !state.IsVideoRequest + || IsCopyCodec(state.OutputVideoCodec) + || !IsCopyCodec(state.OutputAudioCodec) + || string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) + || _mediaEncoder.EncoderVersion < _minFFmpegNoiseBsfDrop) + { + return null; + } + + var startTicks = state.BaseRequest.StartTimeTicks ?? 0; + if (startTicks <= 0) + { + return null; + } + + var seekSeconds = startTicks / (double)TimeSpan.TicksPerSecond; + return string.Format( + CultureInfo.InvariantCulture, + "noise=drop='lt(pts*tb\\,{0:F3})'", + seekSeconds); } public static string GetSegmentFileExtension(string segmentContainer) @@ -3006,23 +3048,6 @@ namespace MediaBrowser.Controller.MediaEncoding } seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick)); - - if (state.IsVideoRequest) - { - // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest - // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to - // avoid A/V sync issues which cause playback issues on some devices. - // When remuxing video, the segment start times correspond to key frames in the source stream, so this - // option shouldn't change the seeked point that much. - // Important: make sure not to use it with wtv because it breaks seeking - if (state.TranscodingType is TranscodingJobType.Hls - && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase) - && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec)) - && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)) - { - seekParam += " -noaccurate_seek"; - } - } } return seekParam; diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 98fc2e632f..f5bb5330ed 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -61,7 +61,7 @@ public class EncodingOptions SubtitleExtractionTimeoutMinutes = 30; AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"]; HardwareDecodingCodecs = ["h264", "vc1"]; - HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek; + HlsAudioSeekStrategy = HlsAudioSeekStrategy.TrimCopiedAudio; } /// @@ -307,6 +307,6 @@ public class EncodingOptions /// /// Gets or sets the method used for audio seeking in HLS. /// - [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)] + [DefaultValue(HlsAudioSeekStrategy.TrimCopiedAudio)] public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; } } diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs index 49feeb435f..c9155faeb1 100644 --- a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs +++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs @@ -7,11 +7,12 @@ namespace MediaBrowser.Model.Configuration public enum HlsAudioSeekStrategy { /// - /// If the video stream is transcoded and the audio stream is copied, - /// seek the video stream to the same keyframe as the audio stream. The - /// resulting timestamps in the output streams may be inaccurate. + /// When video is transcoded and audio is copied, use a bitstream filter + /// to drop copied audio packets before the seek point, aligning them + /// with the accurately-seeked video. Timestamps are accurate and audio + /// remains stream-copied (no re-encoding overhead). /// - DisableAccurateSeek = 0, + TrimCopiedAudio = 0, /// /// Prevent audio streams from being copied if the video stream is transcoded. diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs new file mode 100644 index 0000000000..2dcb898051 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; +using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; + +namespace Jellyfin.Controller.Tests.MediaEncoding +{ + public class EncodingHelperAudioBitStreamTests + { + private const string BothFilters = " -bsf:a noise=drop='lt(pts*tb\\,63.063)',aac_adtstoasc"; + private const string NoiseOnly = " -bsf:a noise=drop='lt(pts*tb\\,63.063)'"; + private const string AdtsOnly = " -bsf:a aac_adtstoasc"; + private const long DefaultSeekTicks = 630_630_000L; + private const string DefaultFfmpegVersion = "5.0"; + + private static EncodingHelper CreateHelper(string ffmpegVersion) + { + var mediaEncoder = new Mock(); + mediaEncoder + .Setup(e => e.GetTimeParameter(It.IsAny())) + .Returns((long ticks) => TimeSpan.FromTicks(ticks).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture)); + mediaEncoder + .SetupGet(e => e.EncoderVersion) + .Returns(Version.Parse(ffmpegVersion)); + + return new EncodingHelper( + Mock.Of(), + mediaEncoder.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + } + + private static EncodingJobInfo CreateState( + TranscodingJobType jobType, + string outputVideoCodec, + string outputAudioCodec, + string audioStreamCodec, + string inputContainer, + long startTimeTicks) + { + return new EncodingJobInfo(jobType) + { + IsVideoRequest = true, + OutputVideoCodec = outputVideoCodec, + OutputAudioCodec = outputAudioCodec, + InputContainer = inputContainer, + RunTimeTicks = TimeSpan.FromMinutes(10).Ticks, + AudioStream = new MediaStream + { + Type = MediaStreamType.Audio, + Codec = audioStreamCodec + }, + BaseRequest = new BaseEncodingJobOptions + { + StartTimeTicks = startTimeTicks + } + }; + } + + [Theory] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", BothFilters)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "aac", BothFilters)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "hls", BothFilters)] + [InlineData(TranscodingJobType.Progressive, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "copy", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "aac", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "wtv", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", 0L, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, "4.4.6", "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "ts", "ts", NoiseOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "mkv", NoiseOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "ac3", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", NoiseOnly)] + public void AudioBitStreamArguments_AppliesGates( + TranscodingJobType jobType, + string outputVideoCodec, + string outputAudioCodec, + string audioStreamCodec, + string inputContainer, + long startTicks, + string ffmpegVersion, + string segmentContainer, + string mediaSourceContainer, + string expected) + { + var state = CreateState(jobType, outputVideoCodec, outputAudioCodec, audioStreamCodec, inputContainer, startTicks); + var result = CreateHelper(ffmpegVersion).GetAudioBitStreamArguments(state, segmentContainer, mediaSourceContainer); + Assert.Equal(expected, result); + } + } +} From 34fc8c270cf849ce6dab1f425448411291e1ca2e Mon Sep 17 00:00:00 2001 From: Bond-009 Date: Thu, 28 May 2026 19:37:06 +0200 Subject: [PATCH 049/119] Fix Merge Conflict Labeler #2 --- .github/workflows/pull-request-conflict.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index 7684743bef..3f6af02a48 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Apply label uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 - if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}} + if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' From 645ae6bb99671ec8bd87c6cb78e6fa3d77063c55 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Thu, 28 May 2026 13:31:13 -0500 Subject: [PATCH 050/119] Use ReadAtLeastAsync to handle short-reads. Seeks to beginning of streams if CanSeek is true. Added remarks about stream position. Add test coverage for short-reads. Fix fast-path tests to actually test the fast path. Also fix class comment. --- src/Jellyfin.Extensions/StreamExtensions.cs | 38 ++- .../StreamExtensionsTests.cs | 235 +++++++++++++++++- 2 files changed, 259 insertions(+), 14 deletions(-) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index fb3fd2eac1..15b44d8f40 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; namespace Jellyfin.Extensions { /// - /// Class BaseExtensions. + /// Extension methods for the class. /// public static class StreamExtensions { @@ -74,7 +74,11 @@ namespace Jellyfin.Extensions /// The token to monitor for cancellation requests. /// True if the stream and file are identical; otherwise false. /// does not support seeking. - public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken) + /// + /// The entire stream is compared against the file from the beginning (the position is reset to 0 on entry) + /// and restored to its original value after the call. + /// + public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); ArgumentException.ThrowIfNullOrEmpty(path); @@ -114,11 +118,31 @@ namespace Jellyfin.Extensions /// The second stream to compare. /// The token to monitor for cancellation requests. /// True if the streams are identical; otherwise false. - public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken) + /// + /// Seekable streams are compared from the beginning (their position is reset to 0 on entry). + /// Non-seekable streams are compared from their current read position. Stream positions are not + /// restored after the call. + /// + public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(a); ArgumentNullException.ThrowIfNull(b); + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a.CanSeek) + { + a.Position = 0; + } + + if (b.CanSeek) + { + b.Position = 0; + } + if (a.CanSeek && b.CanSeek && b.Length != a.Length) { return false; @@ -145,9 +169,9 @@ namespace Jellyfin.Extensions var memoryB = bufferB.AsMemory(); int offset = 0; int bytesRead; - while ((bytesRead = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false)) > 0) + while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0) { - if (!segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) + if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) { return false; } @@ -174,8 +198,8 @@ namespace Jellyfin.Extensions { cancellationToken.ThrowIfCancellationRequested(); - var bytesReadA = await a.ReadAsync(memoryA, cancellationToken).ConfigureAwait(false); - var bytesReadB = await b.ReadAsync(memoryB, cancellationToken).ConfigureAwait(false); + var bytesReadA = await a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); + var bytesReadB = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); if (bytesReadA != bytesReadB) { diff --git a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs index f7efee1e6c..cdbf2f8b1d 100644 --- a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Extensions; using Xunit; namespace Jellyfin.Extensions.Tests; @@ -65,8 +64,12 @@ public class StreamExtensionsTests } } - [Fact] - public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch() + // Both publiclyVisible values are exercised so the test runs once under the fast path + // (TryGetBuffer succeeds) and once under the slow path (TryGetBuffer returns false). + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch(bool publiclyVisible) { var cancellationToken = TestContext.Current.CancellationToken; var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); @@ -75,7 +78,7 @@ public class StreamExtensionsTests try { - await using var stream = new MemoryStream(bytes); + await using var stream = CreateMemoryStream(bytes, publiclyVisible); stream.Position = 3; var result = await stream.IsFileIdenticalAsync(path, cancellationToken); @@ -89,8 +92,10 @@ public class StreamExtensionsTests } } - [Fact] - public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch(bool publiclyVisible) { var cancellationToken = TestContext.Current.CancellationToken; var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); @@ -98,7 +103,7 @@ public class StreamExtensionsTests try { - await using var stream = new MemoryStream(new byte[] { 10, 20, 30, 40, 50 }); + await using var stream = CreateMemoryStream(new byte[] { 10, 20, 30, 40, 50 }, publiclyVisible); stream.Position = 2; var result = await stream.IsFileIdenticalAsync(path, cancellationToken); @@ -112,6 +117,96 @@ public class StreamExtensionsTests } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_BothMemoryStreams_NonZeroPositions_SeeksToStart(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible); + await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible); + a.Position = 3; + b.Position = 1; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_MemoryStreamPairedWithSeekableNonMemoryStream_NonZeroPositions_SeeksToStart(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible); + await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + a.Position = 2; + b.Position = 3; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_NonMemoryStreamPairedWithMemoryStream_Swaps_ReturnsTrue(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_BothSeekableNonMemoryStreams_NonZeroPositions_SeeksToStart() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + a.Position = 1; + b.Position = 2; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableShortReads_Identical_ReturnsTrue() + { + var cancellationToken = TestContext.Current.CancellationToken; + var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + await using var a = new ShortReadingNonSeekableStream(data, maxReadSize: 3); + await using var b = new ShortReadingNonSeekableStream(data, maxReadSize: 5); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableShortReads_DifferentLengths_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4 }, maxReadSize: 3); + await using var b = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4, 5 }, maxReadSize: 5); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + private static MemoryStream CreateMemoryStream(byte[] data, bool publiclyVisible) + => publiclyVisible + ? new MemoryStream(data, 0, data.Length, writable: false, publiclyVisible: true) + : new MemoryStream(data); + private sealed class NonSeekableReadStream : Stream { private readonly Stream _inner; @@ -173,4 +268,130 @@ public class StreamExtensionsTests await base.DisposeAsync(); } } + + private sealed class SeekableNonMemoryStream : Stream + { + private readonly MemoryStream _inner; + + public SeekableNonMemoryStream(byte[] data) + { + _inner = new MemoryStream(data, writable: false); + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => _inner.Length; + + public override long Position + { + get => _inner.Position; + set => _inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => _inner.Seek(offset, origin); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } + + private sealed class ShortReadingNonSeekableStream : Stream + { + private readonly Stream _inner; + private readonly int _maxReadSize; + + public ShortReadingNonSeekableStream(byte[] data, int maxReadSize) + { + _inner = new MemoryStream(data, writable: false); + _maxReadSize = maxReadSize; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, Math.Min(count, _maxReadSize)); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, Math.Min(count, _maxReadSize)), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } } From 5feb70f489670808be682e1f2f80c4780651c57b Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 29 May 2026 10:41:50 +0200 Subject: [PATCH 051/119] Fix recently added episode links and posters --- Emby.Server.Implementations/Dto/DtoService.cs | 35 +++++ .../Controllers/UserLibraryController.cs | 4 +- MediaBrowser.Controller/Dto/DtoOptions.cs | 56 +++++++- .../Dto/DtoServiceTests.cs | 131 ++++++++++++++++++ 4 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 321c7da1c4..f53328c7dd 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1366,6 +1366,41 @@ namespace Emby.Server.Implementations.Dto } } + if (options.PreferEpisodeParentPoster) + { + var episodeSeason = episode.Season; + var seasonPrimaryTag = episodeSeason is not null + ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) + : null; + + BaseItem? posterParent = null; + if (seasonPrimaryTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeason!.Id; + dto.ParentPrimaryImageTag = seasonPrimaryTag; + posterParent = episodeSeason; + } + else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeries.Id; + dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; + posterParent = episodeSeries; + } + + if (posterParent is not null) + { + if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag)) + { + // Only drop the episode's own primary blurhash; keep the poster parent's. + dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag); + } + + dto.SeriesPrimaryImageTag = null; + dto.PrimaryImageAspectRatio = null; + AttachPrimaryImageAspectRatio(dto, posterParent); + } + } + if (options.ContainsField(ItemFields.SeriesStudio)) { episodeSeries ??= episode.Series; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 779186942a..9e3933f2d4 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -557,6 +557,8 @@ public class UserLibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + dtoOptions.PreferEpisodeParentPoster = true; + var list = _userViewManager.GetLatestItems( new LatestItemsQuery { @@ -577,7 +579,7 @@ public class UserLibraryController : BaseJellyfinApiController var item = tuple.Item2[0]; var childCount = 0; - if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series)) + if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum)) { item = tuple.Item1; childCount = tuple.Item2.Count; diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index a71cdbd62c..d319feb6b2 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Dto { + /// + /// Options that control which fields and images are populated when building a . + /// public class DtoOptions { - private static readonly ItemFields[] DefaultExcludedFields = new[] - { + private static readonly ItemFields[] DefaultExcludedFields = + [ ItemFields.SeasonUserData, ItemFields.RefreshState - }; + ]; private static readonly ImageType[] AllImageTypes = Enum.GetValues(); @@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto .Except(DefaultExcludedFields) .ToArray(); + /// + /// Initializes a new instance of the class with all fields enabled. + /// public DtoOptions() : this(true) { } + /// + /// Initializes a new instance of the class. + /// + /// Whether to populate all available fields. public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -38,23 +46,61 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// + /// Gets or sets the fields to populate on the DTO. + /// public IReadOnlyList Fields { get; set; } + /// + /// Gets or sets the image types to populate on the DTO. + /// public IReadOnlyList ImageTypes { get; set; } + /// + /// Gets or sets the maximum number of images to return per image type. + /// public int ImageTypeLimit { get; set; } + /// + /// Gets or sets a value indicating whether image information is populated. + /// public bool EnableImages { get; set; } + /// + /// Gets or sets a value indicating whether program recording information is populated. + /// public bool AddProgramRecordingInfo { get; set; } + /// + /// Gets or sets a value indicating whether user data is populated. + /// public bool EnableUserData { get; set; } + /// + /// Gets or sets a value indicating whether the currently airing program is populated. + /// public bool AddCurrentProgram { get; set; } + /// + /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary + /// image, falling back to the series') should replace the episode's own (16:9) primary image. + /// Used by views that render episodes as poster cards, e.g. "Latest". + /// + public bool PreferEpisodeParentPoster { get; set; } + + /// + /// Gets a value indicating whether the specified field is populated. + /// + /// The field to check. + /// true if the field is populated; otherwise, false. public bool ContainsField(ItemFields field) => Fields.Contains(field); + /// + /// Gets the number of images to return for the specified image type. + /// + /// The image type. + /// The image limit for the type, or 0 if the type is not enabled. public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs new file mode 100644 index 0000000000..a5de0a4416 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -0,0 +1,131 @@ +using System; +using Emby.Server.Implementations.Dto; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceTests +{ + private readonly Mock _libraryManagerMock; + private readonly DtoService _dtoService; + + public DtoServiceTests() + { + _libraryManagerMock = new Mock(); + + var imageProcessor = new Mock(); + // Deterministic tag derived from the image so each item gets a distinct, assertable tag. + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny(), It.IsAny())) + .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path); + + var appHost = new Mock(); + appHost.Setup(x => x.SystemId).Returns("test-server"); + + // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE. + Video.RecordingsManager = new Mock().Object; + + _dtoService = new DtoService( + NullLogger.Instance, + _libraryManagerMock.Object, + new Mock().Object, + imageProcessor.Object, + new Mock().Object, + new Mock().Object, + appHost.Object, + new Mock().Object, + new Lazy(() => new Mock().Object), + new Mock().Object, + new Mock().Object); + + // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager. + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + [Fact] + public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries() + { + var (episode, season, series) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // The episode's own 16:9 primary is dropped in favor of the season's portrait poster. + Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Null(dto.SeriesPrimaryImageTag); + Assert.Equal(season.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image. + Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); + } + + [Fact] + public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster() + { + var (episode, _, series) = BuildEpisode(seasonHasPoster: false); + var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + + var dto = _dtoService.GetBaseItemDto(episode, options); + + Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Null(dto.SeriesPrimaryImageTag); + Assert.Equal(series.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + } + + [Fact] + public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary() + { + var (episode, _, _) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false); + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // Default behavior: the episode keeps its own primary and exposes the series poster as a tag. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); + Assert.Null(dto.ParentPrimaryImageItemId); + } + + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster) + { + // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the + // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode. + var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; + series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + + var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; + if (seasonHasPoster) + { + season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0); + } + + var episode = new Episode + { + Id = Guid.NewGuid(), + Name = "Episode", + SeasonId = season.Id, + SeriesId = series.Id + }; + episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0); + + _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season); + _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series); + + return (episode, season, series); + } +} From c9f71d8531d946f04d5395bd885f08128253559c Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Fri, 29 May 2026 11:00:34 -0700 Subject: [PATCH 052/119] Add a collection API for `Included In` feature (#15516) Add a collection API for `Included In` feature --- .../Collections/CollectionManager.cs | 25 ++++++- Jellyfin.Api/Controllers/LibraryController.cs | 71 +++++++++++++++++++ .../Item/LinkedChildrenService.cs | 23 ++++-- .../Collections/ICollectionManager.cs | 8 +++ .../Persistence/ILinkedChildrenService.cs | 4 +- .../Controllers/LibraryControllerTests.cs | 1 + 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 0ede5665f9..295efd456c 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,12 +4,15 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections private readonly ILibraryMonitor _iLibraryMonitor; private readonly ILogger _logger; private readonly IProviderManager _providerManager; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; @@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections /// The library monitor. /// The logger factory. /// The provider manager. + /// The linked children service. public CollectionManager( ILibraryManager libraryManager, IApplicationPaths appPaths, @@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILoggerFactory loggerFactory, - IProviderManager providerManager) + IProviderManager providerManager, + ILinkedChildrenService linkedChildrenService) { _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; _logger = loggerFactory.CreateLogger(); _providerManager = providerManager; + _linkedChildrenService = linkedChildrenService; _localizationManager = localizationManager; _appPaths = appPaths; } @@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } + /// + public IEnumerable GetCollectionsContainingItem(User user, Guid itemId) + { + ArgumentNullException.ThrowIfNull(user); + + if (itemId.IsEmpty()) + { + return Enumerable.Empty(); + } + + return _linkedChildrenService + .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet) + .Select(parentId => _libraryManager.GetItemById(parentId, user)) + .OfType(); + } + private IEnumerable GetCollections(User user) { var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index abf27b7702..6a30a80f1d 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController private readonly ISimilarItemsManager _similarItemsManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ICollectionManager _collectionManager; private readonly IDtoService _dtoService; private readonly IActivityManager _activityManager; private readonly ILocalizationManager _localization; @@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -75,6 +78,7 @@ public class LibraryController : BaseJellyfinApiController ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, + ICollectionManager collectionManager, IDtoService dtoService, IActivityManager activityManager, ILocalizationManager localization, @@ -86,6 +90,7 @@ public class LibraryController : BaseJellyfinApiController _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; + _collectionManager = collectionManager; _dtoService = dtoService; _activityManager = activityManager; _localization = localization; @@ -704,6 +709,72 @@ public class LibraryController : BaseJellyfinApiController return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true); } + /// + /// Gets the collections that include the specified item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The index of the first record in the output. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Collections returned. + /// User context missing. + /// Item not found. + /// The collections that contain the requested item. + [HttpGet("Items/{itemId}/Collections")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetItemCollections( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + + if (user is null) + { + return Unauthorized(); + } + + var item = _libraryManager.GetItemById(itemId, user); + if (item is null) + { + return NotFound(); + } + + var dtoOptions = new DtoOptions { Fields = fields }; + + var visibleCollections = _collectionManager + .GetCollectionsContainingItem(user, item.Id) + .OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + IEnumerable pagedCollections = visibleCollections; + if (startIndex.HasValue) + { + pagedCollections = pagedCollections.Skip(startIndex.Value); + } + + if (limit.HasValue) + { + pagedCollections = pagedCollections.Take(limit.Value); + } + + var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user); + + return new QueryResult( + startIndex, + visibleCollections.Count, + dtos); + } + /// /// Gets similar items. /// diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 9e11b6be62..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService } /// - public IReadOnlyList GetManualLinkedParentIds(Guid childId) + public IReadOnlyList GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null) { using var context = _dbProvider.CreateDbContext(); - return context.LinkedChildren - .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) - .Select(lc => lc.ParentId) - .Distinct() - .ToList(); + + var query = context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual); + + if (parentType.HasValue) + { + var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value]; + query = query.Join( + context.BaseItems + .Where(item => item.Type == parentTypeName), + lc => lc.ParentId, + item => item.Id, + (lc, _) => lc); + } + + return query.Select(lc => lc.ParentId).Distinct().ToList(); } /// diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 206b5ac426..8d5d54ffd9 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections /// IEnumerable{BaseItem}. IEnumerable CollapseItemsWithinBoxSets(IEnumerable items, User user); + /// + /// Gets the collections accessible to the supplied user that contain the provided item. + /// + /// The user. + /// The item identifier. + /// The collections containing the item. + IEnumerable GetCollectionsContainingItem(User user, Guid itemId); + /// /// Gets the folder where collections are stored. /// diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index d0cddf54a6..a4614fc125 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Audio; using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; @@ -29,8 +30,9 @@ public interface ILinkedChildrenService /// Gets parent IDs that reference the specified child with LinkedChildType.Manual. /// /// The child item ID. + /// Optional parent item type filter. /// List of parent IDs that reference the child. - IReadOnlyList GetManualLinkedParentIds(Guid childId); + IReadOnlyList GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null); /// /// Updates LinkedChildren references from one child to another. diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs index edbb46b34c..b9b2862c65 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture Date: Fri, 29 May 2026 15:54:58 -0500 Subject: [PATCH 053/119] Improved resilience for fast-paths Use fast paths only if we can TryGetBuffer on MemoryStream using segment's Array. Reduce swap overhead for fast path B. Avoid multiple virtcalls by memoizing the CanSeeks. Overlap slow path stream async reads. --- src/Jellyfin.Extensions/StreamExtensions.cs | 38 +++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 15b44d8f40..36361c58e8 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -133,36 +133,40 @@ namespace Jellyfin.Extensions return true; } - if (a.CanSeek) + if (a.CanSeek is var aCanSeek && aCanSeek) { a.Position = 0; } - if (b.CanSeek) + if (b.CanSeek is var bCanSeek && bCanSeek) { b.Position = 0; } - if (a.CanSeek && b.CanSeek && b.Length != a.Length) + if (aCanSeek && bCanSeek && b.Length != a.Length) { return false; } - // If b is MemoryStream but a is not, swap them to enable fast path B - if (b is MemoryStream && a is not MemoryStream) + // MemoryStreams only unlock a fast path if their underlying buffer is exposed via TryGetBuffer. + var segmentA = a is MemoryStream streamA && streamA.TryGetBuffer(out var bufA) ? bufA : default; + var segmentB = b is MemoryStream streamB && streamB.TryGetBuffer(out var bufB) ? bufB : default; + + // Fast path A: both streams expose buffers, compare segments directly + if (segmentA.Array is not null && segmentB.Array is not null) { - (a, b) = (b, a); + return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan()); } - if (a is MemoryStream streamA && streamA.TryGetBuffer(out var segmentA)) + if (segmentB.Array is not null) // && segmentA.Array is null guaranteed by previous check { - // Fast path A: if both streams are MemoryStreams, compare directly against each other - if (b is MemoryStream streamB && streamB.TryGetBuffer(out var segmentB)) - { - return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan()); - } + // swap so that segmentA is the non-null one, compared to b we need only one fast path B + (segmentA, b) = (segmentB, a); + } - // Fast path B: only first stream is a MemoryStream, compare against second stream chunk-by-chunk + if (segmentA.Array is not null) // either a was non-null, or b was non-null and was swapped there + { + // Fast path B: only one stream exposed a buffer, compare against the other chunk-by-chunk var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize); try { @@ -198,8 +202,12 @@ namespace Jellyfin.Extensions { cancellationToken.ThrowIfCancellationRequested(); - var bytesReadA = await a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); - var bytesReadB = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); + var taskA = a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).AsTask(); + var taskB = b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).AsTask(); + await Task.WhenAll(taskA, taskB).ConfigureAwait(false); + + var bytesReadA = await taskA.ConfigureAwait(false); + var bytesReadB = await taskB.ConfigureAwait(false); if (bytesReadA != bytesReadB) { From a3b2b9c07fb22f5de0bbe8cb67adbecbc344d9cf Mon Sep 17 00:00:00 2001 From: PCEWLKR Date: Fri, 29 May 2026 22:07:31 -0400 Subject: [PATCH 054/119] Remove the unused NowPlayingQueueFullItems session property from session DTOs and associated references --- CONTRIBUTORS.md | 1 + .../Session/SessionManager.cs | 13 ------------- MediaBrowser.Controller/Session/SessionInfo.cs | 9 +-------- MediaBrowser.Model/Dto/SessionInfoDto.cs | 8 +------- 4 files changed, 3 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 09a7198afe..d70ffddfd7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -114,6 +114,7 @@ - [oddstr13](https://github.com/oddstr13) - [olsh](https://github.com/olsh) - [orryverducci](https://github.com/orryverducci) + - [PCEWLKR](https://github.com/PCEWLKR) - [petermcneil](https://github.com/petermcneil) - [Phlogi](https://github.com/Phlogi) - [pjeanjean](https://github.com/pjeanjean) diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 5148b62655..18811ef3a9 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -453,18 +453,6 @@ namespace Emby.Server.Implementations.Session session.PlayState.RepeatMode = info.RepeatMode; session.PlayState.PlaybackOrder = info.PlaybackOrder; session.PlaylistItemId = info.PlaylistItemId; - - var nowPlayingQueue = info.NowPlayingQueue; - - if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue)) - { - session.NowPlayingQueue = nowPlayingQueue; - - var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id); - session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos( - _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }), - new DtoOptions(true)); - } } /// @@ -1217,7 +1205,6 @@ namespace Emby.Server.Implementations.Session SupportsMediaControl = sessionInfo.SupportsMediaControl, SupportsRemoteControl = sessionInfo.SupportsRemoteControl, NowPlayingQueue = sessionInfo.NowPlayingQueue, - NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems, HasCustomDeviceName = sessionInfo.HasCustomDeviceName, PlaylistItemId = sessionInfo.PlaylistItemId, ServerId = sessionInfo.ServerId, diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 96783f6073..fb68bfb770 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session PlayState = new PlayerStateInfo(); SessionControllers = []; NowPlayingQueue = []; - NowPlayingQueueFullItems = []; } /// @@ -271,16 +270,10 @@ namespace MediaBrowser.Controller.Session /// The now playing queue. public IReadOnlyList NowPlayingQueue { get; set; } - /// - /// Gets or sets the now playing queue full items. - /// - /// The now playing queue full items. - public IReadOnlyList NowPlayingQueueFullItems { get; set; } - /// /// Gets or sets a value indicating whether the session has a custom device name. /// - /// true if this session has a custom device name; otherwise, false. + /// true if the session has a custom device name; otherwise, false. public bool HasCustomDeviceName { get; set; } /// diff --git a/MediaBrowser.Model/Dto/SessionInfoDto.cs b/MediaBrowser.Model/Dto/SessionInfoDto.cs index d727cd8741..16b201de9d 100644 --- a/MediaBrowser.Model/Dto/SessionInfoDto.cs +++ b/MediaBrowser.Model/Dto/SessionInfoDto.cs @@ -149,13 +149,7 @@ public class SessionInfoDto public IReadOnlyList? NowPlayingQueue { get; set; } /// - /// Gets or sets the now playing queue full items. - /// - /// The now playing queue full items. - public IReadOnlyList? NowPlayingQueueFullItems { get; set; } - - /// - /// Gets or sets a value indicating whether the session has a custom device name. + /// Gets or sets a value indicating whether this session has a custom device name. /// /// true if this session has a custom device name; otherwise, false. public bool HasCustomDeviceName { get; set; } From ad4e884d18abb84598e5561f68613576c0d5648a Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Sat, 30 May 2026 00:35:42 -0400 Subject: [PATCH 055/119] Fix inaccessible artist when they exist in multiple libraries --- .../Item/BaseItemRepository.ByName.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index e4fd3204e1..c5b5fbf6d8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -170,12 +170,22 @@ public sealed partial class BaseItemRepository }; // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking - // the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER + // the lowest Id per group. For MusicArtist, prefer the entity from a library the user + // can actually access,since the same artist can have a folder in multiple libraries. + // Keep as an IQueryable sub-select so paging is applied AFTER // ApplyOrder runs the caller's actual sort. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - var representativeIds = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var representativeIds = isMusicArtist + ? masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g + .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .ThenBy(e => e.Id) + .First().Id) + : masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Min(e => e.Id)); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) From f178e3ebffd5a19a92eddbf386bff13859977e22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 18:49:39 +0000 Subject: [PATCH 056/119] Update swashbuckle-aspnetcore monorepo to 10.2.0 --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d0df007071..7c70b5a9e9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -74,8 +74,8 @@ - - + + From 941298ee8108d79bd2f9bc010415103fddf54b0e Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 8 May 2026 21:29:13 +0200 Subject: [PATCH 057/119] Write subtitles using SubtitleEdit We've been using SubtitleEdit to parse since 2021 https://github.com/jellyfin/jellyfin/pull/4984 I think it's time we start using it to write too --- .../Subtitles/AssWriter.cs | 57 ---- .../Subtitles/ISubtitleWriter.cs | 20 -- .../Subtitles/JsonWriter.cs | 44 --- .../Subtitles/SrtWriter.cs | 49 --- .../Subtitles/SsaWriter.cs | 57 ---- .../Subtitles/SubtitleEncoder.cs | 65 ++-- .../Subtitles/TtmlWriter.cs | 60 ---- .../Subtitles/VttWriter.cs | 53 ---- .../Subtitles/FilterEventsTests.cs | 282 ------------------ 9 files changed, 35 insertions(+), 652 deletions(-) delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs delete mode 100644 MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs delete mode 100644 tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs deleted file mode 100644 index 7d7b80e99d..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// ASS subtitle writer. - /// - public partial class AssWriter : ISubtitleWriter - { - [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineRegex(); - - /// - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - var timeFormat = @"hh\:mm\:ss\.ff"; - - // Write ASS header - writer.WriteLine("[Script Info]"); - writer.WriteLine("Title: Jellyfin transcoded ASS subtitle"); - writer.WriteLine("ScriptType: v4.00+"); - writer.WriteLine(); - writer.WriteLine("[V4+ Styles]"); - writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"); - writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1"); - writer.WriteLine(); - writer.WriteLine("[Events]"); - writer.WriteLine("Format: Layer, Start, End, Style, Text"); - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); - - writer.WriteLine( - "Dialogue: 0,{0},{1},Default,{2}", - startTime, - endTime, - text); - } - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs deleted file mode 100644 index dec714121d..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.IO; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// Interface ISubtitleWriter. - /// - public interface ISubtitleWriter - { - /// - /// Writes the specified information. - /// - /// The information. - /// The stream. - /// The cancellation token. - void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs deleted file mode 100644 index 1b452b0cec..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.IO; -using System.Text.Json; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// JSON subtitle writer. - /// - public class JsonWriter : ISubtitleWriter - { - /// - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new Utf8JsonWriter(stream)) - { - var trackevents = info.TrackEvents; - writer.WriteStartObject(); - writer.WriteStartArray("TrackEvents"); - - for (int i = 0; i < trackevents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var current = trackevents[i]; - writer.WriteStartObject(); - - writer.WriteString("Id", current.Id); - writer.WriteString("Text", current.Text); - writer.WriteNumber("StartPositionTicks", current.StartPositionTicks); - writer.WriteNumber("EndPositionTicks", current.EndPositionTicks); - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - writer.WriteEndObject(); - - writer.Flush(); - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs deleted file mode 100644 index 86f77aa067..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// SRT subtitle writer. - /// - public partial class SrtWriter : ISubtitleWriter - { - [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineEscapedRegex(); - - /// - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - - writer.WriteLine((i + 1).ToString(CultureInfo.InvariantCulture)); - writer.WriteLine( - @"{0:hh\:mm\:ss\,fff} --> {1:hh\:mm\:ss\,fff}", - TimeSpan.FromTicks(trackEvent.StartPositionTicks), - TimeSpan.FromTicks(trackEvent.EndPositionTicks)); - - var text = trackEvent.Text; - - // TODO: Not sure how to handle these - text = NewLineEscapedRegex().Replace(text, " "); - - writer.WriteLine(text); - writer.WriteLine(); - } - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs deleted file mode 100644 index b5fd1ed935..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// SSA subtitle writer. - /// - public partial class SsaWriter : ISubtitleWriter - { - [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineRegex(); - - /// - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - var timeFormat = @"hh\:mm\:ss\.ff"; - - // Write SSA header - writer.WriteLine("[Script Info]"); - writer.WriteLine("Title: Jellyfin transcoded SSA subtitle"); - writer.WriteLine("ScriptType: v4.00"); - writer.WriteLine(); - writer.WriteLine("[V4 Styles]"); - writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding"); - writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H19333333,0,0,0,1,0,2,10,10,10,0,1"); - writer.WriteLine(); - writer.WriteLine("[Events]"); - writer.WriteLine("Format: Layer, Start, End, Style, Text"); - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); - - writer.WriteLine( - "Dialogue: 0,{0},{1},Default,{2}", - startTime, - endTime, - text); - } - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index e0c5f3ad39..2dc71d08c4 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; using UtfUnknown; +using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -72,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles private MemoryStream ConvertSubtitles( Stream stream, - string inputFormat, + SubtitleInfo inputInfo, string outputFormat, long startTimeTicks, long endTimeTicks, @@ -83,13 +86,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { - var trackInfo = _subtitleParser.Parse(stream, inputFormat); + var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); - FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); + FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); - var writer = GetWriter(outputFormat); + var formatter = GetWriter(outputFormat); + + var text = formatter.ToText(subtitle, "untitled"); + using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + { + writer.Write(text); + } - writer.Write(trackInfo, ms, cancellationToken); ms.Position = 0; } catch @@ -101,26 +109,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles return ms; } - internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) + internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) { // Drop subs that have fully elapsed before the requested start position - track.TrackEvents = track.TrackEvents - .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0) - .ToArray(); + track.Paragraphs + .RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0); if (endTimeTicks > 0) { - track.TrackEvents = track.TrackEvents - .TakeWhile(i => i.StartPositionTicks <= endTimeTicks) - .ToArray(); + track.Paragraphs + .RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks); } if (!preserveTimestamps) { - foreach (var trackEvent in track.TrackEvents) + foreach (var trackEvent in track.Paragraphs) { - trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks); - trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks); + trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks))); + trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks))); } } } @@ -142,14 +148,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles var subtitleStream = mediaSource.MediaStreams .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex); - var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) + var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) .ConfigureAwait(false); // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream // ASS is a superset of SSA, skipping the conversion and preserving the styles - if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase) - || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) + if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase) + || (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) && string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))) { return stream; @@ -157,11 +163,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (stream) { - return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); + return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); } } - private async Task<(Stream Stream, string Format)> GetSubtitleStream( + private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) @@ -170,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false); - return (stream, fileInfo.Format); + return (stream, fileInfo); } private async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) @@ -267,43 +273,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) + private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value) { ArgumentException.ThrowIfNullOrEmpty(format); if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)) { - value = new AssWriter(); + value = new AdvancedSubStationAlpha(); return true; } if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { - value = new JsonWriter(); - return true; + throw new NotImplementedException(); } if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) { - value = new SrtWriter(); + value = new SubRip(); return true; } if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)) { - value = new SsaWriter(); + value = new SubStationAlpha(); return true; } if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { - value = new VttWriter(); + value = new WebVTT(); return true; } if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase)) { - value = new TtmlWriter(); + value = new TimedText10(); return true; } @@ -311,7 +316,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles return false; } - private ISubtitleWriter GetWriter(string format) + private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format) { if (TryGetWriter(format, out var writer)) { diff --git a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs deleted file mode 100644 index ea45f2070a..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// - /// TTML subtitle writer. - /// - public partial class TtmlWriter : ISubtitleWriter - { - [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineEscapeRegex(); - - /// - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - // Example: https://github.com/zmalltalker/ttml2vtt/blob/master/data/sample.xml - // Parser example: https://github.com/mozilla/popcorn-js/blob/master/parsers/parserTTML/popcorn.parserTTML.js - - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - writer.WriteLine(""); - writer.WriteLine(""); - - writer.WriteLine(""); - writer.WriteLine(""); - writer.WriteLine("