diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-04-24 19:00:19 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-24 19:00:19 +0200 |
| commit | a183fce142a47db3b2b9faa1e0f2863f0d56e5a1 (patch) | |
| tree | 8637cf347d573917242a3c5546baee33028825a3 /src | |
| parent | d1f242bc097b1530de27d5e74f303ff06096c294 (diff) | |
| parent | b1e2419c6593a3aa4c8df3778831a3214ae5a1c0 (diff) | |
Merge branch 'master' into Preservation-of-Watched-Status-on-Re-watch
Diffstat (limited to 'src')
21 files changed, 225 insertions, 259 deletions
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj index 28c4972d21..0b29a71cbd 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 7bcc7eeca4..76ffa5a9ea 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1873 + using System; using System.Data.Common; using System.Linq; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs index 2d6bc69028..404292e8eb 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs @@ -1,5 +1,6 @@ #pragma warning disable MT1013 // Releasing lock without guarantee of execution #pragma warning disable MT1012 // Acquiring lock without guarantee of releasing +#pragma warning disable CA1873 using System; using System.Data; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj index 03e5fc4958..aeee527016 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index da63df8e29..2b52abcb5b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -61,7 +61,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var customOptions = databaseConfiguration.CustomProviderOptions?.Options; var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); - sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); + sqliteConnectionBuilder.DataSource = GetOption(customOptions, "path", e => e, () => Path.Combine(_applicationPaths.DataPath, "jellyfin.db")); sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default); sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true); sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30); diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index ba402dfe09..f7c20463f0 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <!-- TODO: Remove once we update SkiaSharp > 2.88.5 --> diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 503e2f941f..3f7ae4d2cd 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -24,61 +24,29 @@ public class SkiaEncoder : IImageEncoder private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; private readonly ILogger<SkiaEncoder> _logger; private readonly IApplicationPaths _appPaths; - private static readonly SKImageFilter _imageFilter; - private static readonly SKTypeface[] _typefaces; + private static readonly SKTypeface?[] _typefaces = InitializeTypefaces(); + private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution( + new SKSizeI(3, 3), + [ + 0, -.1f, 0, + -.1f, 1.4f, -.1f, + 0, -.1f, 0 + ], + 1f, + 0f, + new SKPointI(1, 1), + SKShaderTileMode.Clamp, + true); /// <summary> /// The default sampling options, equivalent to old high quality filter settings when upscaling. /// </summary> - public static readonly SKSamplingOptions UpscaleSamplingOptions; + public static readonly SKSamplingOptions UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell); /// <summary> /// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling. /// </summary> - public static readonly SKSamplingOptions DefaultSamplingOptions; - -#pragma warning disable CA1810 - static SkiaEncoder() -#pragma warning restore CA1810 - { - var kernel = new[] - { - 0, -.1f, 0, - -.1f, 1.4f, -.1f, - 0, -.1f, 0, - }; - - var kernelSize = new SKSizeI(3, 3); - var kernelOffset = new SKPointI(1, 1); - _imageFilter = SKImageFilter.CreateMatrixConvolution( - kernelSize, - kernel, - 1f, - 0f, - kernelOffset, - SKShaderTileMode.Clamp, - true); - - // Initialize the list of typefaces - // We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point - // But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F) - _typefaces = - [ - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '鸡'), // CJK Simplified Chinese - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '雞'), // CJK Traditional Chinese - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ノ'), // CJK Japanese - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '각'), // CJK Korean - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 128169), // Emojis, 128169 is the 💩emoji - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ז'), // Hebrew - SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic - SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font - ]; - - // use cubic for upscaling - UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell); - // use bilinear for everything else - DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); - } + public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); /// <summary> /// Initializes a new instance of the <see cref="SkiaEncoder"/> class. @@ -132,7 +100,7 @@ public class SkiaEncoder : IImageEncoder /// <summary> /// Gets the default typeface to use. /// </summary> - public static SKTypeface DefaultTypeFace => _typefaces.Last(); + public static SKTypeface? DefaultTypeFace => _typefaces.Last(); /// <summary> /// Check if the native lib is available. @@ -153,6 +121,40 @@ public class SkiaEncoder : IImageEncoder } /// <summary> + /// Initialize the list of typefaces + /// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point + /// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F). + /// </summary> + /// <returns>The list of typefaces.</returns> + private static SKTypeface?[] InitializeTypefaces() + { + int[] chars = [ + '鸡', // CJK Simplified Chinese + '雞', // CJK Traditional Chinese + 'ノ', // CJK Japanese + '각', // CJK Korean + 128169, // Emojis, 128169 is the Pile of Poo (💩) emoji + 'ז', // Hebrew + 'ي' // Arabic + ]; + var fonts = new List<SKTypeface>(chars.Length + 1); + foreach (var ch in chars) + { + var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, ch); + if (font is not null) + { + fonts.Add(font); + } + } + + // Default font + fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) + ?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'a')); + + return fonts.ToArray(); + } + + /// <summary> /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>. /// </summary> /// <param name="selectedFormat">The format to convert.</param> @@ -209,39 +211,69 @@ public class SkiaEncoder : IImageEncoder return default; } - using var codec = SKCodec.Create(safePath, out var result); - - switch (result) + SKCodec? codec = null; + bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase); + try { - case SKCodecResult.Success: - // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel - // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.) - // `SKCodec.Create` returns a *non‑null* codec together with - // SKCodecResult.InternalError. The header still contains valid dimensions, - // which is all we need here – so we fall back to them instead of aborting. - // See e.g. Skia bugs #4139, #6092. - case SKCodecResult.InternalError when codec is not null: - var info = codec.Info; - return new ImageDimensions(info.Width, info.Height); + codec = SKCodec.Create(safePath, out var result); + switch (result) + { + case SKCodecResult.Success: + // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel + // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.) + // `SKCodec.Create` returns a *non‑null* codec together with + // SKCodecResult.InternalError. The header still contains valid dimensions, + // which is all we need here – so we fall back to them instead of aborting. + // See e.g. Skia bugs #4139, #6092. + case SKCodecResult.InternalError when codec is not null: + var info = codec.Info; + return new ImageDimensions(info.Width, info.Height); + + case SKCodecResult.Unimplemented: + _logger.LogDebug("Image format not supported: {FilePath}", path); + return default; - case SKCodecResult.Unimplemented: - _logger.LogDebug("Image format not supported: {FilePath}", path); - return default; + default: + { + var boundsInfo = SKBitmap.DecodeBounds(safePath); + if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + { + return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + } - default: + _logger.LogWarning( + "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", + path, + result); + + return default; + } + } + } + finally + { + try + { + codec?.Dispose(); + } + catch (Exception ex) { - var boundsInfo = SKBitmap.DecodeBounds(safePath); + _logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath); + } - if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + if (isSafePathTemp) + { + try { - return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + if (File.Exists(safePath)) + { + File.Delete(safePath); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath); } - - _logger.LogWarning( - "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", - path, - result); - return default; } } } @@ -779,7 +811,7 @@ public class SkiaEncoder : IImageEncoder { foreach (var typeface in _typefaces) { - if (typeface.ContainsGlyphs(c)) + if (typeface is not null && typeface.ContainsGlyphs(c)) { return typeface; } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 46e5213a8c..6ffb022842 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -85,7 +85,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable "jpeg", "jpg", "png", - "aiff", "cr2", "crw", "nef", diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 5f4b3fe8d4..a442f74576 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs deleted file mode 100644 index 299e2f94ae..0000000000 --- a/src/Jellyfin.Extensions/AlphanumericComparator.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Jellyfin.Extensions -{ - /// <summary> - /// Alphanumeric <see cref="IComparer{T}" />. - /// </summary> - public class AlphanumericComparator : IComparer<string?> - { - /// <summary> - /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other. - /// </summary> - /// <param name="s1">The first object to compare.</param> - /// <param name="s2">The second object to compare.</param> - /// <returns>A signed integer that indicates the relative values of <c>x</c> and <c>y</c>.</returns> - public static int CompareValues(string? s1, string? s2) - { - if (s1 is null && s2 is null) - { - return 0; - } - - if (s1 is null) - { - return -1; - } - - if (s2 is null) - { - return 1; - } - - int len1 = s1.Length; - int len2 = s2.Length; - - // Early return for empty strings - if (len1 == 0 && len2 == 0) - { - return 0; - } - - if (len1 == 0) - { - return -1; - } - - if (len2 == 0) - { - return 1; - } - - int pos1 = 0; - int pos2 = 0; - - do - { - int start1 = pos1; - int start2 = pos2; - - bool isNum1 = char.IsDigit(s1[pos1++]); - bool isNum2 = char.IsDigit(s2[pos2++]); - - while (pos1 < len1 && char.IsDigit(s1[pos1]) == isNum1) - { - pos1++; - } - - while (pos2 < len2 && char.IsDigit(s2[pos2]) == isNum2) - { - pos2++; - } - - var span1 = s1.AsSpan(start1, pos1 - start1); - var span2 = s2.AsSpan(start2, pos2 - start2); - - if (isNum1 && isNum2) - { - // Trim leading zeros so we can compare the length - // of the strings to find the largest number - span1 = span1.TrimStart('0'); - span2 = span2.TrimStart('0'); - var span1Len = span1.Length; - var span2Len = span2.Length; - if (span1Len < span2Len) - { - return -1; - } - - if (span1Len > span2Len) - { - return 1; - } - } - - int result = span1.CompareTo(span2, StringComparison.InvariantCulture); - if (result != 0) - { - return result; - } - } while (pos1 < len1 && pos2 < len2); - - return len1 - len2; - } - - /// <inheritdoc /> - public int Compare(string? x, string? y) - { - return CompareValues(x, y); - } - } -} diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs index 3eb9da01f2..0c78756236 100644 --- a/src/Jellyfin.Extensions/EnumerableExtensions.cs +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -64,13 +64,13 @@ public static class EnumerableExtensions /// <typeparam name="T">The type of item.</typeparam> /// <returns>The IEnumerable{Enum}.</returns> public static IEnumerable<T> GetUniqueFlags<T>(this T flags) - where T : Enum + where T : struct, Enum { - foreach (Enum value in Enum.GetValues(flags.GetType())) + foreach (T value in Enum.GetValues<T>()) { if (flags.HasFlag(value)) { - yield return (T)value; + yield return value; } } } diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index f52fd014da..9a7cf4aabe 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 60df47113a..c7e8319f59 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -131,7 +131,7 @@ namespace Jellyfin.Extensions /// </summary> /// <param name="values">The enumerable of strings to trim.</param> /// <returns>The enumeration of trimmed strings.</returns> - public static IEnumerable<string> Trimmed(this IEnumerable<string> values) + public static IEnumerable<string> Trimmed(this IEnumerable<string?> values) { return values.Select(i => (i ?? string.Empty).Trim()); } diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index be7ff52977..d877a0d124 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -156,6 +156,13 @@ namespace Jellyfin.LiveTv.IO if (mediaSource.ReadAtNativeFramerate) { inputModifier += " -re"; + + // Set a larger catchup value to revert to the old behavior, + // otherwise, remuxing might stall due to this new option + if (_mediaEncoder.EncoderVersion >= new Version(8, 0)) + { + inputModifier += " -readrate_catchup 100"; + } } if (mediaSource.RequiresLooping) diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index f04c02504c..575441de92 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -13,7 +13,6 @@ <ItemGroup> <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="Jellyfin.XmlTv" /> - <PackageReference Include="System.Linq.Async" /> </ItemGroup> <ItemGroup> diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7938b7a6e4..318c3a2d36 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -62,21 +60,21 @@ namespace Jellyfin.LiveTv.Listings _logger.LogInformation("xmltv path: {Path}", info.Path); string cacheFilename = info.Id + ".xml"; - string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); - - if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) - { - return cacheFile; - } + string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv"); + string cacheFile = Path.Join(cacheDir, cacheFilename); - // Must check if file exists as parent directory may not exist. if (File.Exists(cacheFile)) { + if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + { + return cacheFile; + } + File.Delete(cacheFile); } else { - Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); + Directory.CreateDirectory(cacheDir); } if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) @@ -154,33 +152,37 @@ namespace Jellyfin.LiveTv.Listings private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) { - string episodeTitle = program.Episode.Title; + string? episodeTitle = program.Episode?.Title; var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); + var imageUrl = program.Icons.FirstOrDefault()?.Source; + var rating = program.Ratings.FirstOrDefault()?.Value; + var starRating = program.StarRatings?.FirstOrDefault()?.StarRating; var programInfo = new ProgramInfo { ChannelId = program.ChannelId, EndDate = program.EndDate.UtcDateTime, - EpisodeNumber = program.Episode.Episode, + EpisodeNumber = program.Episode?.Episode, EpisodeTitle = episodeTitle, Genres = programCategories, StartDate = program.StartDate.UtcDateTime, Name = program.Title, Overview = program.Description, ProductionYear = program.CopyrightDate?.Year, - SeasonNumber = program.Episode.Series, - IsSeries = program.Episode.Episode is not null, + SeasonNumber = program.Episode?.Series, + IsSeries = program.Episode?.Episode is not null, IsRepeat = program.IsPreviouslyShown && !program.IsNew, IsPremiere = program.Premiere is not null, + IsLive = program.IsLive, IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, - HasImage = !string.IsNullOrEmpty(program.Icon?.Source), - OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, - CommunityRating = program.StarRating, - SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) + ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl, + HasImage = !string.IsNullOrEmpty(imageUrl), + OfficialRating = string.IsNullOrEmpty(rating) ? null : rating, + CommunityRating = starRating is null ? null : (float)starRating.Value, + SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) }; if (string.IsNullOrWhiteSpace(program.ProgramId)) @@ -261,7 +263,7 @@ namespace Jellyfin.LiveTv.Listings { Id = c.Id, Name = c.DisplayName, - ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, + ImageUrl = string.IsNullOrEmpty(c.Icons.FirstOrDefault()?.Source) ? null : c.Icons.FirstOrDefault()!.Source, Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number }).ToList(); } diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs index 2270758454..5da7762f6f 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs @@ -93,6 +93,13 @@ namespace Jellyfin.LiveTv.TunerHosts } else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { + if (!IsValidChannelUrl(trimmedLine)) + { + _logger.LogWarning("Skipping M3U channel entry with non-HTTP path: {Path}", trimmedLine); + extInf = string.Empty; + continue; + } + var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine); channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); @@ -247,6 +254,16 @@ namespace Jellyfin.LiveTv.TunerHosts return numberString; } + private static bool IsValidChannelUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "udp", StringComparison.OrdinalIgnoreCase)); + } + private static bool IsValidChannelNumber(string numberString) { if (string.IsNullOrWhiteSpace(numberString) diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index 80b5aa84e4..902f513768 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -12,9 +12,6 @@ <ProjectReference Include="../Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" /> </ItemGroup> - <ItemGroup> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> - </ItemGroup> <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index cc8d942ebb..5e7e2090cd 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -23,10 +23,6 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> - </ItemGroup> - - <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <_Parameter1>Jellyfin.MediaEncoding.Keyframes.Tests</_Parameter1> </AssemblyAttribute> diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj index 1a146549de..36b9581a7b 100644 --- a/src/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/src/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 126d9f15cf..6a8a91fa51 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -16,7 +16,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Jellyfin.Networking.Manager; @@ -115,7 +114,7 @@ public class NetworkManager : INetworkManager, IDisposable public static string MockNetworkSettings { get; set; } = string.Empty; /// <summary> - /// Gets a value indicating whether IP4 is enabled. + /// Gets a value indicating whether IPv4 is enabled. /// </summary> public bool IsIPv4Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv4; @@ -341,12 +340,12 @@ public class NetworkManager : INetworkManager, IDisposable } else { - _lanSubnets = lanSubnets; + _lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray(); } _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true) - ? excludedSubnets - : new List<IPNetwork>(); + ? excludedSubnets.Select(x => x.Subnet).ToArray() + : Array.Empty<IPNetwork>(); } } @@ -362,7 +361,7 @@ public class NetworkManager : INetworkManager, IDisposable } /// <summary> - /// Filteres a list of bind addresses and exclusions on available interfaces. + /// Filters a list of bind addresses and exclusions on available interfaces. /// </summary> /// <param name="config">The network config to be filtered by.</param> /// <param name="interfaces">A list of possible interfaces to be filtered.</param> @@ -376,7 +375,7 @@ public class NetworkManager : INetworkManager, IDisposable if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) { var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network) - ? network.Prefix + ? network.Address : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) .Select(x => x.Address) .FirstOrDefault() ?? IPAddress.None)) @@ -445,7 +444,7 @@ public class NetworkManager : INetworkManager, IDisposable var remoteFilteredSubnets = remoteIPFilter.Where(x => x.Contains('/', StringComparison.OrdinalIgnoreCase)).ToArray(); if (NetworkUtils.TryParseToSubnets(remoteFilteredSubnets, out var remoteAddressFilterResult, false)) { - remoteAddressFilter = remoteAddressFilterResult.ToList(); + remoteAddressFilter = remoteAddressFilterResult.Select(x => x.Subnet).ToList(); } // Parse everything else as an IP and construct subnet with a single IP @@ -545,7 +544,7 @@ public class NetworkManager : INetworkManager, IDisposable { foreach (var lan in _lanSubnets) { - var lanPrefix = lan.Prefix; + var lanPrefix = lan.BaseAddress; publishedServerUrls.Add( new PublishedServerUriOverride( new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)), @@ -554,12 +553,11 @@ public class NetworkManager : INetworkManager, IDisposable false)); } } - else if (NetworkUtils.TryParseToSubnet(identifier, out var result) && result is not null) + else if (NetworkUtils.TryParseToSubnet(identifier, out var result)) { - var data = new IPData(result.Prefix, result); publishedServerUrls.Add( new PublishedServerUriOverride( - data, + result, replacement, true, true)); @@ -621,16 +619,12 @@ public class NetworkManager : INetworkManager, IDisposable foreach (var details in interfaceList) { var parts = details.Split(','); - if (NetworkUtils.TryParseToSubnet(parts[0], out var subnet)) + if (NetworkUtils.TryParseToSubnet(parts[0], out var data)) { - var address = subnet.Prefix; - var index = int.Parse(parts[1], CultureInfo.InvariantCulture); - if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6) + data.Index = int.Parse(parts[1], CultureInfo.InvariantCulture); + if (data.AddressFamily == AddressFamily.InterNetwork || data.AddressFamily == AddressFamily.InterNetworkV6) { - var data = new IPData(address, subnet, parts[2]) - { - Index = index - }; + data.Name = parts[2]; interfaces.Add(data); } } @@ -753,12 +747,13 @@ public class NetworkManager : INetworkManager, IDisposable /// <inheritdoc/> public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false) { - return NetworkManager.GetAllBindInterfaces(individualInterfaces, _configurationManager, _interfaces, IsIPv4Enabled, IsIPv6Enabled); + return NetworkManager.GetAllBindInterfaces(_logger, individualInterfaces, _configurationManager, _interfaces, IsIPv4Enabled, IsIPv6Enabled); } /// <summary> /// Reads the jellyfin configuration of the configuration manager and produces a list of interfaces that should be bound. /// </summary> + /// <param name="logger">Logger to use for messages.</param> /// <param name="individualInterfaces">Defines that only known interfaces should be used.</param> /// <param name="configurationManager">The ConfigurationManager.</param> /// <param name="knownInterfaces">The known interfaces that gets returned if possible or instructed.</param> @@ -766,6 +761,7 @@ public class NetworkManager : INetworkManager, IDisposable /// <param name="readIpv6">Include IPV6 type interfaces.</param> /// <returns>A list of ip address of which jellyfin should bind to.</returns> public static IReadOnlyList<IPData> GetAllBindInterfaces( + ILogger<NetworkManager> logger, bool individualInterfaces, IConfigurationManager configurationManager, IReadOnlyList<IPData> knownInterfaces, @@ -779,6 +775,13 @@ public class NetworkManager : INetworkManager, IDisposable return knownInterfaces; } + // TODO: remove when upgrade to dotnet 11 is done + if (readIpv6 && !Socket.OSSupportsIPv6) + { + logger.LogWarning("IPv6 Unsupported by OS, not listening on IPv6"); + readIpv6 = false; + } + // No bind address and no exclusions, so listen on all interfaces. var result = new List<IPData>(); if (readIpv4 && readIpv6) @@ -875,7 +878,20 @@ public class NetworkManager : INetworkManager, IDisposable if (availableInterfaces.Count == 0) { // There isn't any others, so we'll use the loopback. - result = IsIPv4Enabled && !IsIPv6Enabled ? "127.0.0.1" : "::1"; + // Prefer loopback address matching the source's address family + if (source is not null && source.AddressFamily == AddressFamily.InterNetwork && IsIPv4Enabled) + { + result = "127.0.0.1"; + } + else if (source is not null && source.AddressFamily == AddressFamily.InterNetworkV6 && IsIPv6Enabled) + { + result = "::1"; + } + else + { + result = IsIPv4Enabled ? "127.0.0.1" : "::1"; + } + _logger.LogWarning("{Source}: Only loopback {Result} returned, using that as bind address.", source, result); return result; } @@ -900,9 +916,19 @@ public class NetworkManager : INetworkManager, IDisposable } } - // Fallback to first available interface + // Fallback to an interface matching the source's address family, or first available + var preferredInterface = availableInterfaces + .FirstOrDefault(x => x.Address.AddressFamily == source.AddressFamily); + + if (preferredInterface is not null) + { + result = NetworkUtils.FormatIPString(preferredInterface.Address); + _logger.LogDebug("{Source}: No matching subnet found, using interface with matching address family: {Result}", source, result); + return result; + } + result = NetworkUtils.FormatIPString(availableInterfaces[0].Address); - _logger.LogDebug("{Source}: No matching interfaces found, using preferred interface as bind address: {Result}", source, result); + _logger.LogDebug("{Source}: No matching interfaces found, using first available interface as bind address: {Result}", source, result); return result; } @@ -920,7 +946,7 @@ public class NetworkManager : INetworkManager, IDisposable { if (NetworkUtils.TryParseToSubnet(address, out var subnet)) { - return IsInLocalNetwork(subnet.Prefix); + return IsInLocalNetwork(subnet.Address); } return NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled) @@ -1171,13 +1197,13 @@ public class NetworkManager : INetworkManager, IDisposable var logLevel = debug ? LogLevel.Debug : LogLevel.Information; if (_logger.IsEnabled(logLevel)) { - _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.BaseAddress + "/" + s.PrefixLength)); _logger.Log(logLevel, "Filtered interface addresses: {Addresses}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); _logger.Log(logLevel, "Bind Addresses {Addresses}", GetAllBindInterfaces(false).OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); _logger.Log(logLevel, "Remote IP filter is {Type}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); - _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.BaseAddress + "/" + s.PrefixLength)); } } } |
