diff options
Diffstat (limited to 'src')
26 files changed, 419 insertions, 508 deletions
diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index 0590ded32..ba402dfe0 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>net8.0</TargetFramework> + <TargetFramework>net9.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 ede93aaa5..2dac5598f 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -195,8 +195,10 @@ public class SkiaEncoder : IImageEncoder return string.Empty; } + // Use FileStream with FileShare.Read instead of having Skia open the file to allow concurrent read access + using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); // Any larger than 128x128 is too slow and there's no visually discernible difference - return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); + return BlurHashEncoder.Encode(xComp, yComp, fileStream, 128, 128); } private bool RequiresSpecialCharacterHack(string path) @@ -269,14 +271,24 @@ public class SkiaEncoder : IImageEncoder } // create the bitmap - var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); + SKBitmap? bitmap = null; + try + { + bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); - // decode - _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + // decode + _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); - origin = codec.EncodedOrigin; + origin = codec.EncodedOrigin; - return bitmap; + return bitmap!; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error decoding image {0}", path); + bitmap?.Dispose(); + throw; + } } var resultBitmap = SKBitmap.Decode(NormalizePath(path)); @@ -286,17 +298,26 @@ public class SkiaEncoder : IImageEncoder return Decode(path, true, orientation, out origin); } - // If we have to resize these they often end up distorted - if (resultBitmap.ColorType == SKColorType.Gray8) + try { - using (resultBitmap) + // If we have to resize these they often end up distorted + if (resultBitmap.ColorType == SKColorType.Gray8) { - return Decode(path, true, orientation, out origin); + using (resultBitmap) + { + return Decode(path, true, orientation, out origin); + } } - } - origin = SKEncodedOrigin.TopLeft; - return resultBitmap; + origin = SKEncodedOrigin.TopLeft; + return resultBitmap; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error decoding image {0}", path); + resultBitmap?.Dispose(); + throw; + } } private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation) @@ -335,58 +356,78 @@ public class SkiaEncoder : IImageEncoder var width = (int)Math.Round(svg.Drawable.Bounds.Width); var height = (int)Math.Round(svg.Drawable.Bounds.Height); - var bitmap = new SKBitmap(width, height); - using var canvas = new SKCanvas(bitmap); - canvas.DrawPicture(svg.Picture); - canvas.Flush(); - canvas.Save(); + SKBitmap? bitmap = null; + try + { + bitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(bitmap); + canvas.DrawPicture(svg.Picture); + canvas.Flush(); + canvas.Save(); - return bitmap; + return bitmap!; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error extracting image {0}", path); + bitmap?.Dispose(); + throw; + } } private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) { var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.RightTop; - var rotated = needsFlip - ? new SKBitmap(bitmap.Height, bitmap.Width) - : new SKBitmap(bitmap.Width, bitmap.Height); - using var surface = new SKCanvas(rotated); - var midX = (float)rotated.Width / 2; - var midY = (float)rotated.Height / 2; - - switch (origin) - { - case SKEncodedOrigin.TopRight: - surface.Scale(-1, 1, midX, midY); - break; - case SKEncodedOrigin.BottomRight: - surface.RotateDegrees(180, midX, midY); - break; - case SKEncodedOrigin.BottomLeft: - surface.Scale(1, -1, midX, midY); - break; - case SKEncodedOrigin.LeftTop: - surface.Translate(0, -rotated.Height); - surface.Scale(1, -1, midX, midY); - surface.RotateDegrees(-90); - break; - case SKEncodedOrigin.RightTop: - surface.Translate(rotated.Width, 0); - surface.RotateDegrees(90); - break; - case SKEncodedOrigin.RightBottom: - surface.Translate(rotated.Width, 0); - surface.Scale(1, -1, midX, midY); - surface.RotateDegrees(90); - break; - case SKEncodedOrigin.LeftBottom: - surface.Translate(0, rotated.Height); - surface.RotateDegrees(-90); - break; - } - - surface.DrawBitmap(bitmap, 0, 0); - return rotated; + SKBitmap? rotated = null; + try + { + rotated = needsFlip + ? new SKBitmap(bitmap.Height, bitmap.Width) + : new SKBitmap(bitmap.Width, bitmap.Height); + using var surface = new SKCanvas(rotated); + var midX = (float)rotated.Width / 2; + var midY = (float)rotated.Height / 2; + + switch (origin) + { + case SKEncodedOrigin.TopRight: + surface.Scale(-1, 1, midX, midY); + break; + case SKEncodedOrigin.BottomRight: + surface.RotateDegrees(180, midX, midY); + break; + case SKEncodedOrigin.BottomLeft: + surface.Scale(1, -1, midX, midY); + break; + case SKEncodedOrigin.LeftTop: + surface.Translate(0, -rotated.Height); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(-90); + break; + case SKEncodedOrigin.RightTop: + surface.Translate(rotated.Width, 0); + surface.RotateDegrees(90); + break; + case SKEncodedOrigin.RightBottom: + surface.Translate(rotated.Width, 0); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(90); + break; + case SKEncodedOrigin.LeftBottom: + surface.Translate(0, rotated.Height); + surface.RotateDegrees(-90); + break; + } + + surface.DrawBitmap(bitmap, 0, 0); + return rotated; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error rotating image"); + rotated?.Dispose(); + throw; + } } /// <summary> @@ -562,7 +603,7 @@ public class SkiaEncoder : IImageEncoder // Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail. if (posters.Count > 0 && backdrops.Count > 0) { - var splashBuilder = new SplashscreenBuilder(this); + var splashBuilder = new SplashscreenBuilder(this, _logger); var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); splashBuilder.GenerateSplash(posters, backdrops, outputPath); } diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs index 990556623..03733d4f8 100644 --- a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Microsoft.Extensions.Logging; using SkiaSharp; namespace Jellyfin.Drawing.Skia; @@ -11,21 +12,24 @@ public class SplashscreenBuilder { private const int FinalWidth = 1920; private const int FinalHeight = 1080; - // generated collage resolution should be higher than the final resolution + // generated collage resolution should be greater than the final resolution private const int WallWidth = FinalWidth * 3; private const int WallHeight = FinalHeight * 2; private const int Rows = 6; private const int Spacing = 20; private readonly SkiaEncoder _skiaEncoder; + private readonly ILogger _logger; /// <summary> /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class. /// </summary> /// <param name="skiaEncoder">The SkiaEncoder.</param> - public SplashscreenBuilder(SkiaEncoder skiaEncoder) + /// <param name="logger">The logger.</param> + public SplashscreenBuilder(SkiaEncoder skiaEncoder, ILogger logger) { _skiaEncoder = skiaEncoder; + _logger = logger; } /// <summary> @@ -55,65 +59,76 @@ public class SplashscreenBuilder var posterIndex = 0; var backdropIndex = 0; - var bitmap = new SKBitmap(WallWidth, WallHeight); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Black); - - int posterHeight = WallHeight / 6; - - for (int i = 0; i < Rows; i++) + SKBitmap? bitmap = null; + try { - int imageCounter = Random.Shared.Next(0, 5); - int currentWidthPos = i * 75; - int currentHeight = i * (posterHeight + Spacing); - - while (currentWidthPos < WallWidth) - { - SKBitmap? currentImage; - - switch (imageCounter) - { - case 0: - case 2: - case 3: - currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex); - posterIndex = newPosterIndex; - break; - default: - currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex); - backdropIndex = newBackdropIndex; - break; - } - - if (currentImage is null) - { - throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!"); - } - - // resize to the same aspect as the original - var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); - using var resizedBitmap = new SKBitmap(imageWidth, posterHeight); - currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High); - - // draw on canvas - canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight); + bitmap = new SKBitmap(WallWidth, WallHeight); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); - currentWidthPos += imageWidth + Spacing; + int posterHeight = WallHeight / 6; - currentImage.Dispose(); + for (int i = 0; i < Rows; i++) + { + int imageCounter = Random.Shared.Next(0, 5); + int currentWidthPos = i * 75; + int currentHeight = i * (posterHeight + Spacing); - if (imageCounter >= 4) - { - imageCounter = 0; - } - else + while (currentWidthPos < WallWidth) { - imageCounter++; + SKBitmap? currentImage; + + switch (imageCounter) + { + case 0: + case 2: + case 3: + currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex); + posterIndex = newPosterIndex; + break; + default: + currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex); + backdropIndex = newBackdropIndex; + break; + } + + if (currentImage is null) + { + throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!"); + } + + using (currentImage) + { + var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); + using var resizedBitmap = new SKBitmap(imageWidth, posterHeight); + currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High); + + // draw on canvas + canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight); + + // resize to the same aspect as the original + currentWidthPos += imageWidth + Spacing; + } + + if (imageCounter >= 4) + { + imageCounter = 0; + } + else + { + imageCounter++; + } } } - } - return bitmap; + return bitmap; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error creating splashscreen image"); + bitmap?.Dispose(); + throw; + } } /// <summary> @@ -123,25 +138,35 @@ public class SplashscreenBuilder /// <returns>The transformed image.</returns> private SKBitmap Transform3D(SKBitmap input) { - var bitmap = new SKBitmap(FinalWidth, FinalHeight); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Black); - var matrix = new SKMatrix + SKBitmap? bitmap = null; + try { - ScaleX = 0.324108899f, - ScaleY = 0.563934922f, - SkewX = -0.244337708f, - SkewY = 0.0377609022f, - TransX = 42.0407715f, - TransY = -198.104706f, - Persp0 = -9.08959337E-05f, - Persp1 = 6.85242048E-05f, - Persp2 = 0.988209724f - }; - - canvas.SetMatrix(matrix); - canvas.DrawBitmap(input, 0, 0); - - return bitmap; + bitmap = new SKBitmap(FinalWidth, FinalHeight); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); + var matrix = new SKMatrix + { + ScaleX = 0.324108899f, + ScaleY = 0.563934922f, + SkewX = -0.244337708f, + SkewY = 0.0377609022f, + TransX = 42.0407715f, + TransY = -198.104706f, + Persp0 = -9.08959337E-05f, + Persp1 = 6.85242048E-05f, + Persp2 = 0.988209724f + }; + + canvas.SetMatrix(matrix); + canvas.DrawBitmap(input, 0, 0); + + return bitmap; + } + catch (Exception e) + { + _logger.LogError(e, "Detected intermediary error creating splashscreen image transforming the image"); + bitmap?.Dispose(); + throw; + } } } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 5d4732234..0bd3b8920 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; +using System.Reflection.Metadata.Ecma335; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; @@ -404,8 +406,27 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable } /// <inheritdoc /> + public string GetImageCacheTag(string baseItemPath, DateTime imageDateModified) + => (baseItemPath + imageDateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + + /// <inheritdoc /> public string GetImageCacheTag(BaseItem item, ItemImageInfo image) - => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + => GetImageCacheTag(item.Path, image.DateModified); + + /// <inheritdoc /> + public string GetImageCacheTag(BaseItemDto item, ItemImageInfo image) + => GetImageCacheTag(item.Path, image.DateModified); + + /// <inheritdoc /> + public string? GetImageCacheTag(BaseItemDto item, ChapterInfo chapter) + { + if (chapter.ImagePath is null) + { + return null; + } + + return GetImageCacheTag(item.Path, chapter.ImageDateModified); + } /// <inheritdoc /> public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter) @@ -431,8 +452,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable return null; } - return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() - .ToString("N", CultureInfo.InvariantCulture); + return GetImageCacheTag(user.ProfileImage.Path, user.ProfileImage.LastModified); } private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 4a02f90f9..5f4b3fe8d 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index f786cc3b4..1613d83bc 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>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> @@ -15,7 +15,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Extensions</PackageId> - <VersionPrefix>10.10.0</VersionPrefix> + <VersionPrefix>10.11.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs index 1466d3a71..7472f9c66 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; @@ -35,38 +36,27 @@ namespace Jellyfin.Extensions.Json.Converters var stringEntries = reader.GetString()!.Split(Delimiter, StringSplitOptions.RemoveEmptyEntries); if (stringEntries.Length == 0) { - return Array.Empty<T>(); + return []; } - var parsedValues = new object[stringEntries.Length]; - var convertedCount = 0; + var typedValues = new List<T>(); for (var i = 0; i < stringEntries.Length; i++) { try { - parsedValues[i] = _typeConverter.ConvertFromInvariantString(stringEntries[i].Trim()) ?? throw new FormatException(); - convertedCount++; + var parsedValue = _typeConverter.ConvertFromInvariantString(stringEntries[i].Trim()); + if (parsedValue is not null) + { + typedValues.Add((T)parsedValue); + } } catch (FormatException) { - // TODO log when upgraded to .Net6 - // https://github.com/dotnet/runtime/issues/42975 - // _logger.LogDebug(e, "Error converting value."); + // Ignore unconvertible inputs } } - var typedValues = new T[convertedCount]; - var typedValueIndex = 0; - for (var i = 0; i < stringEntries.Length; i++) - { - if (parsedValues[i] is not null) - { - typedValues.SetValue(parsedValues[i], typedValueIndex); - typedValueIndex++; - } - } - - return typedValues; + return typedValues.ToArray(); } return JsonSerializer.Deserialize<T[]>(ref reader, options); @@ -75,7 +65,26 @@ namespace Jellyfin.Extensions.Json.Converters /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, T[]? value, JsonSerializerOptions options) { - throw new NotImplementedException(); + if (value is not null) + { + writer.WriteStartArray(); + if (value.Length > 0) + { + foreach (var it in value) + { + if (it is not null) + { + writer.WriteStringValue(it.ToString()); + } + } + } + + writer.WriteEndArray(); + } + else + { + writer.WriteNullValue(); + } } } } diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 79c5873d5..71e46764a 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Channels // Every so often new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index f657422a0..b75cc0fb2 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities.Libraries; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; @@ -40,6 +41,11 @@ public class GuideManager : IGuideManager private readonly LiveTvDtoService _tvDtoService; /// <summary> + /// Amount of days images are pre-cached from external sources. + /// </summary> + public const int MaxCacheDays = 2; + + /// <summary> /// Initializes a new instance of the <see cref="GuideManager"/> class. /// </summary> /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param> @@ -204,14 +210,14 @@ public class GuideManager : IGuideManager progress.Report(15); numComplete = 0; - var programs = new List<Guid>(); + var programs = new List<LiveTvProgram>(); var channels = new List<Guid>(); var guideDays = GetGuideDays(); - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + _logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays); - var maxCacheDate = DateTime.UtcNow.AddDays(2); + var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays); foreach (var currentChannel in list) { cancellationToken.ThrowIfCancellationRequested(); @@ -237,22 +243,23 @@ public class GuideManager : IGuideManager DtoOptions = new DtoOptions(true) }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); - var newPrograms = new List<LiveTvProgram>(); - var updatedPrograms = new List<BaseItem>(); + var newPrograms = new List<Guid>(); + var updatedPrograms = new List<Guid>(); foreach (var program in channelPrograms) { var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel); + var id = programItem.Id; if (isNew) { - newPrograms.Add(programItem); + newPrograms.Add(id); } else if (isUpdated) { - updatedPrograms.Add(programItem); + updatedPrograms.Add(id); } - programs.Add(programItem.Id); + programs.Add(programItem); isMovie |= program.IsMovie; isSeries |= program.IsSeries; @@ -261,24 +268,30 @@ public class GuideManager : IGuideManager isKids |= program.IsKids; } - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + _logger.LogDebug( + "Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs", + currentChannel.Name, + newPrograms.Count, + updatedPrograms.Count); if (newPrograms.Count > 0) { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false); + var newProgramDtos = programs.Where(b => newPrograms.Contains(b.Id)).ToList(); + _libraryManager.CreateOrUpdateItems(newProgramDtos, null, cancellationToken); } if (updatedPrograms.Count > 0) { + var updatedProgramDtos = programs.Where(b => updatedPrograms.Contains(b.Id)).ToList(); await _libraryManager.UpdateItemsAsync( - updatedPrograms, + updatedProgramDtos, currentChannel, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false); } + await PreCacheImages(programs, maxCacheDate).ConfigureAwait(false); + currentChannel.IsMovie = isMovie; currentChannel.IsNews = isNews; currentChannel.IsSports = isSports; @@ -313,7 +326,8 @@ public class GuideManager : IGuideManager } progress.Report(100); - return new Tuple<List<Guid>, List<Guid>>(channels, programs); + var programIds = programs.Select(p => p.Id).ToList(); + return new Tuple<List<Guid>, List<Guid>>(channels, programIds); } private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) @@ -618,77 +632,17 @@ public class GuideManager : IGuideManager item.IndexNumber = info.EpisodeNumber; item.ParentIndexNumber = info.SeasonNumber; - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } + forceUpdate = forceUpdate || UpdateImages(item, info); - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) + if (isNew) { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } + item.OnMetadataChanged(); - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } + return (item, isNew, false); } var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) { isUpdated = true; } @@ -703,7 +657,7 @@ public class GuideManager : IGuideManager } } - if (isNew || isUpdated) + if (isUpdated) { item.OnMetadataChanged(); } @@ -711,7 +665,80 @@ public class GuideManager : IGuideManager return (item, isNew, isUpdated); } - private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate) + private static bool UpdateImages(BaseItem item, ProgramInfo info) + { + var updated = false; + + // Primary + updated |= UpdateImage(ImageType.Primary, item, info); + + // Thumbnail + updated |= UpdateImage(ImageType.Thumb, item, info); + + // Logo + updated |= UpdateImage(ImageType.Logo, item, info); + + // Backdrop + return updated || UpdateImage(ImageType.Backdrop, item, info); + } + + private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info) + { + var image = item.GetImages(imageType).FirstOrDefault(); + var currentImagePath = image?.Path; + var newImagePath = imageType switch + { + ImageType.Primary => info.ImagePath, + _ => string.Empty + }; + var newImageUrl = imageType switch + { + ImageType.Backdrop => info.BackdropImageUrl, + ImageType.Logo => info.LogoImageUrl, + ImageType.Primary => info.ImageUrl, + ImageType.Thumb => info.ThumbImageUrl, + _ => string.Empty + }; + + var differentImage = newImageUrl?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false + || newImagePath?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false; + if (!differentImage) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(newImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = newImagePath, + Type = imageType + }, + 0); + + return true; + } + + if (!string.IsNullOrWhiteSpace(newImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = newImageUrl, + Type = imageType + }, + 0); + + return true; + } + + item.RemoveImage(image); + + return false; + } + + private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate) { await Parallel.ForEachAsync( programs @@ -741,7 +768,7 @@ public class GuideManager : IGuideManager } catch (Exception ex) { - _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path); + _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); } } } diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs index a9fde0850..5164d695f 100644 --- a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -66,7 +66,7 @@ public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledT { new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index ff00c8999..c04954207 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -124,22 +124,7 @@ namespace Jellyfin.LiveTv.IO private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) { - string videoArgs; - if (EncodeVideo(mediaSource)) - { - const int MaxBitrate = 25000000; - videoArgs = string.Format( - CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", - GetOutputSizeParam(), - MaxBitrate); - } - else - { - videoArgs = "-codec:v:0 copy"; - } - - videoArgs += " -fflags +genpts"; + string videoArgs = "-codec:v:0 copy -fflags +genpts"; var flags = new List<string>(); if (mediaSource.IgnoreDts) @@ -157,7 +142,7 @@ namespace Jellyfin.LiveTv.IO flags.Add("+genpts"); } - var inputModifier = "-async 1 -vsync -1"; + var inputModifier = "-async 1"; if (flags.Count > 0) { @@ -205,19 +190,6 @@ namespace Jellyfin.LiveTv.IO private static string GetAudioArgs(MediaSourceInfo mediaSource) { return "-codec:a:0 copy"; - - // var audioChannels = 2; - // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - // if (audioStream is not null) - // { - // audioChannels = audioStream.Channels ?? audioChannels; - // } - // return "-codec:a:0 aac -strict experimental -ab 320000"; - } - - private static bool EncodeVideo(MediaSourceInfo mediaSource) - { - return false; } protected string GetOutputSizeParam() diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index c58889740..f04c02504 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>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 3df2d0d2c..39c2bd375 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -230,10 +230,15 @@ public class ListingsManager : IListingsManager var listingsProviderInfo = config.ListingProviders .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + var channelMappingExists = listingsProviderInfo.ChannelMappings + .Any(pair => string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, providerChannelNumber, StringComparison.OrdinalIgnoreCase)); + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); - if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase) + && !channelMappingExists) { var newItem = new NameValuePair { diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index c7a57859e..d6f15906e 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -19,6 +19,7 @@ using System.Threading.Tasks; using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; @@ -38,7 +39,7 @@ namespace Jellyfin.LiveTv.Listings private readonly IHttpClientFactory _httpClientFactory; private readonly AsyncNonKeyedLocker _tokenLock = new(1); - private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); + private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private DateTime _lastErrorResponse; private bool _disposed = false; @@ -86,7 +87,7 @@ namespace Jellyfin.LiveTv.Listings { _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); - return Enumerable.Empty<ProgramInfo>(); + return []; } var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); @@ -94,7 +95,7 @@ namespace Jellyfin.LiveTv.Listings _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); var requestList = new List<RequestScheduleForChannelDto>() { - new RequestScheduleForChannelDto() + new() { StationId = channelId, Date = dates @@ -109,7 +110,7 @@ namespace Jellyfin.LiveTv.Listings var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureAwait(false); if (dailySchedules is null) { - return Array.Empty<ProgramInfo>(); + return []; } _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); @@ -120,17 +121,17 @@ namespace Jellyfin.LiveTv.Listings var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken) - .ConfigureAwait(false); + var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); if (programDetails is null) { - return Array.Empty<ProgramInfo>(); + return []; } var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); var programIdsWithImages = programDetails - .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .Where(p => p.HasImageArtwork) + .Select(p => p.ProgramId) .ToList(); var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); @@ -138,17 +139,15 @@ namespace Jellyfin.LiveTv.Listings var programsInfo = new List<ProgramInfo>(); foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) { - // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.ProgramId + " which says it has images? " + - // programDict[schedule.ProgramId].hasImageArtwork); - if (string.IsNullOrEmpty(schedule.ProgramId)) { continue; } - if (images is not null) + // Only add images which will be pre-cached until we can implement dynamic token fetching + var endDate = schedule.AirDateTime?.AddSeconds(schedule.Duration); + var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays); + if (willBeCached && images is not null) { var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); if (imageIndex > -1) @@ -456,7 +455,7 @@ namespace Jellyfin.LiveTv.Listings if (programIds.Count == 0) { - return Array.Empty<ShowImagesDto>(); + return []; } StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); @@ -483,7 +482,7 @@ namespace Jellyfin.LiveTv.Listings { _logger.LogError(ex, "Error getting image info from schedules direct"); - return Array.Empty<ShowImagesDto>(); + return []; } } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs index 856b7a89b..79bcbe649 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -8,7 +8,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public class LineupDto { /// <summary> - /// Gets or sets the linup. + /// Gets or sets the lineup. /// </summary> [JsonPropertyName("lineup")] public string? Lineup { get; set; } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs index ea583a1ce..89c4ee5a8 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -23,7 +23,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the provider callsign. /// </summary> [JsonPropertyName("providerCallsign")] - public string? ProvderCallsign { get; set; } + public string? ProviderCallsign { get; set; } /// <summary> /// Gets or sets the logical channel number. diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs index cafc8e273..7998a7a92 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -8,7 +8,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public class MetadataDto { /// <summary> - /// Gets or sets the linup. + /// Gets or sets the lineup. /// </summary> [JsonPropertyName("lineup")] public string? Lineup { get; set; } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs index 8c3906f86..7bfc4bc8b 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -64,7 +64,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>(); /// <summary> - /// Gets or sets the list of content raitings. + /// Gets or sets the list of content ratings. /// </summary> [JsonPropertyName("contentRating")] public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>(); diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7dc30f727..7938b7a6e 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -84,10 +84,11 @@ namespace Jellyfin.LiveTv.Listings _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); } } else @@ -112,7 +113,8 @@ namespace Jellyfin.LiveTv.Listings await using (fileStream.ConfigureAwait(false)) { - if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase) || + Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gzip", StringComparison.OrdinalIgnoreCase)) { try { diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs index 9e7323f5b..6a68b8c25 100644 --- a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs +++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using Jellyfin.Extensions.Json; using Microsoft.Extensions.Logging; @@ -15,7 +16,7 @@ namespace Jellyfin.LiveTv.Timers where T : class { private readonly string _dataPath; - private readonly object _fileDataLock = new object(); + private readonly Lock _fileDataLock = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private T[]? _items; diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs index c8d678e2f..e3afe1513 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs @@ -93,7 +93,7 @@ namespace Jellyfin.LiveTv.TunerHosts } else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { - var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine); + var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine); channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); channel.Path = trimmedLine; @@ -106,7 +106,7 @@ namespace Jellyfin.LiveTv.TunerHosts return channels; } - private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl) + private ChannelInfo GetChannelInfo(string extInf, string tunerHostId, string mediaUrl) { var channel = new ChannelInfo() { diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index ee79802a1..dc581724a 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>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index c79dcee3c..c826d3d9c 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>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj index 24b3ecaab..1a146549d 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>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -14,7 +14,4 @@ <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> </ItemGroup> - <ItemGroup> - <PackageReference Include="Mono.Nat" /> - </ItemGroup> </Project> diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index b285b836b..dd01e9533 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -27,7 +27,7 @@ public class NetworkManager : INetworkManager, IDisposable /// <summary> /// Threading lock for network properties. /// </summary> - private readonly object _initLock; + private readonly Lock _initLock; private readonly ILogger<NetworkManager> _logger; @@ -35,7 +35,7 @@ public class NetworkManager : INetworkManager, IDisposable private readonly IConfiguration _startupConfig; - private readonly object _networkEventLock; + private readonly Lock _networkEventLock; /// <summary> /// Holds the published server URLs and the IPs to use them on. @@ -57,7 +57,7 @@ public class NetworkManager : INetworkManager, IDisposable /// <summary> /// Dictionary containing interface addresses and their subnets. /// </summary> - private IReadOnlyList<IPData> _interfaces; + private List<IPData> _interfaces; /// <summary> /// Unfiltered user defined LAN subnets (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>) @@ -81,7 +81,6 @@ public class NetworkManager : INetworkManager, IDisposable /// <param name="configurationManager">The <see cref="IConfigurationManager"/> instance.</param> /// <param name="startupConfig">The <see cref="IConfiguration"/> instance holding startup parameters.</param> /// <param name="logger">Logger to use for messages.</param> -#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this. public NetworkManager(IConfigurationManager configurationManager, IConfiguration startupConfig, ILogger<NetworkManager> logger) { ArgumentNullException.ThrowIfNull(logger); @@ -94,7 +93,7 @@ public class NetworkManager : INetworkManager, IDisposable _interfaces = new List<IPData>(); _macAddresses = new List<PhysicalAddress>(); _publishedServerUrls = new List<PublishedServerUriOverride>(); - _networkEventLock = new object(); + _networkEventLock = new(); _remoteAddressFilter = new List<IPNetwork>(); _ = bool.TryParse(startupConfig[DetectNetworkChangeKey], out var detectNetworkChange); @@ -109,7 +108,6 @@ public class NetworkManager : INetworkManager, IDisposable _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated; } -#pragma warning restore CS8618 // Non-nullable field is uninitialized. /// <summary> /// Event triggered on network changes. @@ -312,6 +310,7 @@ public class NetworkManager : INetworkManager, IDisposable /// <summary> /// Initializes internal LAN cache. /// </summary> + [MemberNotNull(nameof(_lanSubnets), nameof(_excludedSubnets))] private void InitializeLan(NetworkConfiguration config) { lock (_initLock) @@ -591,6 +590,7 @@ public class NetworkManager : INetworkManager, IDisposable /// Reloads all settings and re-Initializes the instance. /// </summary> /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param> + [MemberNotNull(nameof(_lanSubnets), nameof(_excludedSubnets))] public void UpdateSettings(object configuration) { ArgumentNullException.ThrowIfNull(configuration); @@ -702,7 +702,7 @@ public class NetworkManager : INetworkManager, IDisposable return false; } } - else if (!_lanSubnets.Any(x => x.Contains(remoteIP))) + else if (!IsInLocalNetwork(remoteIP)) { // Remote not enabled. So everyone should be LAN. return false; @@ -973,7 +973,7 @@ public class NetworkManager : INetworkManager, IDisposable bindPreference = string.Empty; int? port = null; - // Only consider subnets including the source IP, prefering specific overrides + // Only consider subnets including the source IP, preferring specific overrides List<PublishedServerUriOverride> validPublishedServerUrls; if (!isInExternalSubnet) { @@ -997,7 +997,9 @@ public class NetworkManager : INetworkManager, IDisposable // Get interface matching override subnet var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address)); - if (intf?.Address is not null) + if (intf?.Address is not null + || (data.Data.AddressFamily == AddressFamily.InterNetwork && data.Data.Address.Equals(IPAddress.Any)) + || (data.Data.AddressFamily == AddressFamily.InterNetworkV6 && data.Data.Address.Equals(IPAddress.IPv6Any))) { // If matching interface is found, use override bindPreference = data.OverrideUri; @@ -1025,6 +1027,7 @@ public class NetworkManager : INetworkManager, IDisposable } _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); + return true; } @@ -1063,6 +1066,7 @@ public class NetworkManager : INetworkManager, IDisposable // If none exists, this will select the first external interface if there is one. bindAddress = externalInterfaces .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenByDescending(x => x.Subnet.PrefixLength) .ThenBy(x => x.Index) .Select(x => x.Address) .First(); @@ -1080,6 +1084,7 @@ public class NetworkManager : INetworkManager, IDisposable // If none exists, this will select the first internal interface if there is one. bindAddress = _interfaces.Where(x => IsInLocalNetwork(x.Address)) .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenByDescending(x => x.Subnet.PrefixLength) .ThenBy(x => x.Index) .Select(x => x.Address) .FirstOrDefault(); diff --git a/src/Jellyfin.Networking/PortForwardingHost.cs b/src/Jellyfin.Networking/PortForwardingHost.cs deleted file mode 100644 index d01343624..000000000 --- a/src/Jellyfin.Networking/PortForwardingHost.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Mono.Nat; - -namespace Jellyfin.Networking; - -/// <summary> -/// <see cref="IHostedService"/> responsible for UPnP port forwarding. -/// </summary> -public sealed class PortForwardingHost : IHostedService, IDisposable -{ - private readonly IServerApplicationHost _appHost; - private readonly ILogger<PortForwardingHost> _logger; - private readonly IServerConfigurationManager _config; - private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new(); - - private Timer? _timer; - private string? _configIdentifier; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="PortForwardingHost"/> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="appHost">The application host.</param> - /// <param name="config">The configuration manager.</param> - public PortForwardingHost( - ILogger<PortForwardingHost> logger, - IServerApplicationHost appHost, - IServerConfigurationManager config) - { - _logger = logger; - _appHost = appHost; - _config = config; - } - - private string GetConfigIdentifier() - { - const char Separator = '|'; - var config = _config.GetNetworkConfiguration(); - - return new StringBuilder(32) - .Append(config.EnableUPnP).Append(Separator) - .Append(config.PublicHttpPort).Append(Separator) - .Append(config.PublicHttpsPort).Append(Separator) - .Append(_appHost.HttpPort).Append(Separator) - .Append(_appHost.HttpsPort).Append(Separator) - .Append(_appHost.ListenWithHttps).Append(Separator) - .Append(config.EnableRemoteAccess).Append(Separator) - .ToString(); - } - - private void OnConfigurationUpdated(object? sender, EventArgs e) - { - var oldConfigIdentifier = _configIdentifier; - _configIdentifier = GetConfigIdentifier(); - - if (!string.Equals(_configIdentifier, oldConfigIdentifier, StringComparison.OrdinalIgnoreCase)) - { - Stop(); - Start(); - } - } - - /// <inheritdoc /> - public Task StartAsync(CancellationToken cancellationToken) - { - Start(); - - _config.ConfigurationUpdated += OnConfigurationUpdated; - - return Task.CompletedTask; - } - - /// <inheritdoc /> - public Task StopAsync(CancellationToken cancellationToken) - { - Stop(); - - return Task.CompletedTask; - } - - private void Start() - { - var config = _config.GetNetworkConfiguration(); - if (!config.EnableUPnP || !config.EnableRemoteAccess) - { - return; - } - - _logger.LogInformation("Starting NAT discovery"); - - NatUtility.DeviceFound += OnNatUtilityDeviceFound; - NatUtility.StartDiscovery(); - - _timer?.Dispose(); - _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); - } - - private void Stop() - { - _logger.LogInformation("Stopping NAT discovery"); - - NatUtility.StopDiscovery(); - NatUtility.DeviceFound -= OnNatUtilityDeviceFound; - - _timer?.Dispose(); - _timer = null; - } - - private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - try - { - // On some systems the device discovered event seems to fire repeatedly - // This check will help ensure we're not trying to port map the same device over and over - if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0)) - { - return; - } - - await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating port forwarding rules"); - } - } - - private IEnumerable<Task> CreatePortMaps(INatDevice device) - { - var config = _config.GetNetworkConfiguration(); - yield return CreatePortMap(device, _appHost.HttpPort, config.PublicHttpPort); - - if (_appHost.ListenWithHttps) - { - yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); - } - } - - private async Task CreatePortMap(INatDevice device, int privatePort, int publicPort) - { - _logger.LogDebug( - "Creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}", - privatePort, - publicPort, - device.DeviceEndpoint); - - try - { - var mapping = new Mapping(Protocol.Tcp, privatePort, publicPort, 0, _appHost.Name); - await device.CreatePortMapAsync(mapping).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Error creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}.", - privatePort, - publicPort, - device.DeviceEndpoint); - } - } - - /// <inheritdoc /> - public void Dispose() - { - if (_disposed) - { - return; - } - - _config.ConfigurationUpdated -= OnConfigurationUpdated; - - _timer?.Dispose(); - _timer = null; - - _disposed = true; - } -} |
