diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml
index 069d7e60..18a0b9d2 100644
--- a/.github/workflows/artifacts.yml
+++ b/.github/workflows/artifacts.yml
@@ -71,8 +71,8 @@ jobs:
         shell: bash
         run: |
           sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
-          dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
-          dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
+          dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
+          dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
 
       - name: Bundle
         shell: bash
@@ -190,8 +190,8 @@ jobs:
 
           # Build everything
           sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
-          dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
-          dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
+          dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
+          dotnet publish ErsatzTV/ErsatzTV.csproj --framework net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
           mkdir "$release_name"
           mv scanner/* "$release_name/"
           mv main/* "$release_name/"
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj
index 12998780..ace50f56 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <NoWarn>VSTHRD200</NoWarn>
         <ImplicitUsings>enable</ImplicitUsings>
         <AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -10,18 +10,18 @@
 
     <ItemGroup>
       <PackageReference Include="Bugsnag" Version="4.0.0" />
-      <PackageReference Include="CliWrap" Version="3.8.2" />
+      <PackageReference Include="CliWrap" Version="3.9.0" />
       <PackageReference Include="Humanizer.Core" Version="2.14.1" />
       <PackageReference Include="MediatR" Version="12.5.0" />
-      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
-      <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
-      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
+      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
       <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
       <PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
-      <PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
+      <PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
       <PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
     </ItemGroup>
 
diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
index 67da0774..5fe97e5b 100644
--- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
+++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
@@ -532,7 +532,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
 
         byte[] textData = Encoding.UTF8.GetBytes(text);
         byte[] hash = MD5.HashData(textData);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath);
diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
index b714f940..5f0464a0 100644
--- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
+++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
@@ -1,30 +1,30 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <NoWarn>VSTHRD200</NoWarn>
         <ImplicitUsings>enable</ImplicitUsings>
     </PropertyGroup>
 
     <ItemGroup>
       <PackageReference Include="Bugsnag" Version="4.0.0" />
-      <PackageReference Include="CliWrap" Version="3.8.2" />
+      <PackageReference Include="CliWrap" Version="3.9.0" />
       <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
-      <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
-      <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
-      <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
-      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
-      <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
-      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
-      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
+      <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.6" />
+      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
+      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
       <PackageReference Include="NSubstitute" Version="5.3.0" />
       <PackageReference Include="NUnit" Version="4.3.2" />
       <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
-      <PackageReference Include="Serilog" Version="4.2.0" />
-      <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
+      <PackageReference Include="Serilog" Version="4.3.0" />
+      <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
       <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
       <PackageReference Include="Shouldly" Version="4.3.0" />
     </ItemGroup>
diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj
index 8655a936..07d5dec3 100644
--- a/ErsatzTV.Core/ErsatzTV.Core.csproj
+++ b/ErsatzTV.Core/ErsatzTV.Core.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <NoWarn>VSTHRD200</NoWarn>
         <ImplicitUsings>enable</ImplicitUsings>
         <AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -15,17 +15,17 @@
       <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
       <PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
       <PackageReference Include="MediatR" Version="12.5.0" />
-      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
-      <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
-      <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
-      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
+      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
       <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
-      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
+      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
       <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
-      <PackageReference Include="Serilog" Version="4.2.0" />
+      <PackageReference Include="Serilog" Version="4.3.0" />
       <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
       <PackageReference Include="SkiaSharp" Version="2.88.9" />
       <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
index 44edfaed..ba08fe1d 100644
--- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
+++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
@@ -442,7 +442,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
             channel.FFmpegProfile,
             hlsRealtime);
 
-        IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
+        Resolution desiredResolution = channel.FFmpegProfile.Resolution;
 
         var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
         var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
index 463fdffc..8f48593b 100644
--- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
+++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
@@ -89,7 +89,7 @@ public static class FFmpegPlaybackSettingsCalculator
 
                 if (NeedToScale(ffmpegProfile, videoVersion) && videoVersion.SampleAspectRatio != "0:0")
                 {
-                    IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
+                    DisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
                     if (!scaledSize.IsSameSizeAs(videoVersion))
                     {
                         int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
@@ -234,14 +234,14 @@ public static class FFmpegPlaybackSettingsCalculator
         IsOddSize(version) ||
         TooSmallToCrop(ffmpegProfile, version);
 
-    private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaVersion version) =>
+    private static bool IsIncorrectSize(Resolution desiredResolution, MediaVersion version) =>
         IsAnamorphic(version) ||
         version.Width != desiredResolution.Width ||
         version.Height != desiredResolution.Height;
 
-    private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize displaySize) =>
-        displaySize.Height > desiredResolution.Height ||
-        displaySize.Width > desiredResolution.Width;
+    private static bool IsTooLarge(Resolution desiredResolution, MediaVersion version) =>
+        version.Height > desiredResolution.Height ||
+        version.Width > desiredResolution.Width;
 
     private static bool IsOddSize(MediaVersion version) =>
         version.Height % 2 == 1 || version.Width % 2 == 1;
@@ -258,13 +258,13 @@ public static class FFmpegPlaybackSettingsCalculator
 
     private static DisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
     {
-        IDisplaySize sarSize = SARSize(version);
+        DisplaySize sarSize = SARSize(version);
         int p = version.Width * sarSize.Width;
         int q = version.Height * sarSize.Height;
         int g = Gcd(q, p);
         p = p / g;
         q = q / g;
-        IDisplaySize targetSize = ffmpegProfile.Resolution;
+        Resolution targetSize = ffmpegProfile.Resolution;
         int hw1 = targetSize.Width;
         int hh1 = hw1 * q / p;
         int hh2 = targetSize.Height;
diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
index e346e016..57876c9a 100644
--- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
+++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
@@ -151,7 +151,7 @@ public class FFmpegProcessService
         }
     }
 
-    private static bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
+    private static bool NeedToPad(Resolution target, IDisplaySize displaySize) =>
         displaySize.Width != target.Width || displaySize.Height != target.Height;
 
     internal async Task<WatermarkOptions> GetWatermarkOptions(
diff --git a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
index b631eb0d..0d600e33 100644
--- a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
@@ -7,7 +7,7 @@ namespace ErsatzTV.Core.Scheduling;
 public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator
 {
     private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
-    private readonly IList<MediaItem> _sortedMediaItems;
+    private readonly List<MediaItem> _sortedMediaItems;
 
     public ChronologicalMediaCollectionEnumerator(
         IEnumerable<MediaItem> mediaItems,
@@ -39,7 +39,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
 
     public CollectionEnumeratorState State { get; }
 
-    public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
+    public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
     public Option<bool> CurrentIncludeInProgramGuide { get; }
 
     public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
diff --git a/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
index 4b3ab4fe..05c289c0 100644
--- a/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
@@ -8,7 +8,7 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
 {
     private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
 
-    private readonly IList<MediaItem> _sortedMediaItems;
+    private readonly List<MediaItem> _sortedMediaItems;
 
     public CustomOrderCollectionEnumerator(
         Collection collection,
@@ -38,7 +38,7 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
 
     public CollectionEnumeratorState State { get; }
 
-    public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
+    public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
     public Option<bool> CurrentIncludeInProgramGuide { get; }
 
     public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
diff --git a/ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs b/ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs
index 5b179b0c..d8c2dab5 100644
--- a/ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs
@@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Scheduling;
 
 public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator
 {
-    private readonly IList<ProgramScheduleItem> _sortedScheduleItems;
+    private readonly List<ProgramScheduleItem> _sortedScheduleItems;
 
     public OrderedScheduleItemsEnumerator(
         IEnumerable<ProgramScheduleItem> scheduleItems,
diff --git a/ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs
index efe29917..1d0c7d3f 100644
--- a/ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs
@@ -65,7 +65,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
 
     public void MoveNext()
     {
-        IList<int> groups = _groupMedia.Keys.ToList();
+        var groups = _groupMedia.Keys.ToList();
         int nextRandom = _random.Next();
 
         int groupNumber = nextRandom % groups.Count;
diff --git a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
index 88b1e00b..cb381a6f 100644
--- a/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
@@ -7,7 +7,7 @@ namespace ErsatzTV.Core.Scheduling;
 public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator
 {
     private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
-    private readonly IList<MediaItem> _sortedMediaItems;
+    private readonly List<MediaItem> _sortedMediaItems;
 
     public SeasonEpisodeMediaCollectionEnumerator(
         IEnumerable<MediaItem> mediaItems,
@@ -39,7 +39,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
 
     public CollectionEnumeratorState State { get; }
 
-    public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
+    public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
     public Option<bool> CurrentIncludeInProgramGuide { get; }
 
     public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
diff --git a/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
index 894f28b3..8c1ab3b5 100644
--- a/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
@@ -12,7 +12,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
     private readonly int _mediaItemCount;
     private readonly bool _randomStartPoint;
     private Random _random;
-    private IList<MediaItem> _shuffled;
+    private MediaItem[] _shuffled;
 
     public ShuffleInOrderCollectionEnumerator(
         IList<CollectionWithItems> collections,
@@ -60,12 +60,12 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
 
     public CollectionEnumeratorState State { get; }
 
-    public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
+    public Option<MediaItem> Current => _shuffled.Length != 0 ? _shuffled[State.Index % _mediaItemCount] : None;
     public Option<bool> CurrentIncludeInProgramGuide { get; }
 
     public void MoveNext()
     {
-        if ((State.Index + 1) % _shuffled.Count == 0)
+        if ((State.Index + 1) % _shuffled.Length == 0)
         {
             Option<MediaItem> tail = Current;
 
@@ -83,12 +83,12 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
             State.Index++;
         }
 
-        State.Index %= _shuffled.Count;
+        State.Index %= _shuffled.Length;
     }
 
     public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
 
-    public int Count => _shuffled.Count;
+    public int Count => _shuffled.Length;
 
     private MediaItem[] Shuffle(IList<CollectionWithItems> collections, Random random)
     {
diff --git a/ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs b/ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs
index 027d1c9f..28595409 100644
--- a/ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs
@@ -8,7 +8,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
     private readonly IList<ProgramScheduleItem> _scheduleItems;
     private readonly int _scheduleItemsCount;
     private CloneableRandom _random;
-    private IList<ProgramScheduleItem> _shuffled;
+    private ProgramScheduleItem[] _shuffled;
 
     public ShuffledScheduleItemsEnumerator(
         IList<ProgramScheduleItem> scheduleItems,
@@ -68,7 +68,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
 
         if ((State.Index + offset) % _scheduleItemsCount == 0)
         {
-            IList<ProgramScheduleItem> shuffled;
+            ProgramScheduleItem[] shuffled;
             ProgramScheduleItem tail = Current;
 
             // clone the random
diff --git a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
index d2ec859c..f2354248 100644
--- a/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
+++ b/ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
@@ -1,15 +1,15 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <Nullable>enable</Nullable>
 
         <IsPackable>false</IsPackable>
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
         <PackageReference Include="NSubstitute" Version="5.3.0" />
         <PackageReference Include="NUnit" Version="4.3.2" />
         <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
diff --git a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
index e3aaa32e..e9e8f937 100644
--- a/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
+++ b/ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
         <AnalysisLevel>latest-Recommended</AnalysisLevel>
@@ -9,10 +9,10 @@
     </PropertyGroup>
 
     <ItemGroup>
-      <PackageReference Include="CliWrap" Version="3.8.2" />
+      <PackageReference Include="CliWrap" Version="3.9.0" />
       <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
-      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
-      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
+      <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
     </ItemGroup>
 
 
diff --git a/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj b/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
index d21f3191..8d288a13 100644
--- a/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
+++ b/ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
     </PropertyGroup>
@@ -16,8 +16,8 @@
     </ItemGroup>
 
     <ItemGroup>
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
-      <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.6" />
+      <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
     </ItemGroup>
 
 </Project>
diff --git a/ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj b/ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj
index e6df2f62..d016f619 100644
--- a/ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj
+++ b/ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
     </PropertyGroup>
@@ -13,8 +13,8 @@
 
     <ItemGroup>
       <PackageReference Include="Dapper" Version="2.1.66" />
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.15" />
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.6" />
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
     </ItemGroup>
 
 
diff --git a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
index 3ffb2191..f81d2be5 100644
--- a/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
+++ b/ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
 
@@ -9,11 +9,11 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
         <PackageReference Include="NSubstitute" Version="5.3.0" />
         <PackageReference Include="NUnit" Version="4.3.2" />
         <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
-        <PackageReference Include="NUnit.Analyzers" Version="4.7.0">
+        <PackageReference Include="NUnit.Analyzers" Version="4.9.2">
           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
           <PrivateAssets>all</PrivateAssets>
         </PackageReference>
diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
index 3e3cd1f5..1133f70d 100644
--- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
+++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
         <NoWarn>VSTHRD200</NoWarn>
         <ImplicitUsings>enable</ImplicitUsings>
@@ -11,20 +11,20 @@
 
     <ItemGroup>
       <PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" />
-      <PackageReference Include="CliWrap" Version="3.8.2" />
+      <PackageReference Include="CliWrap" Version="3.9.0" />
       <PackageReference Include="Dapper" Version="2.1.66" />
-      <PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.0.0" />
+      <PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.0.7" />
       <PackageReference Include="Jint" Version="4.2.2" />
       <PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" />
       <PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
       <PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
-      <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.15" />
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
+      <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
-      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.6" />
+      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
@@ -35,7 +35,7 @@
       <PackageReference Include="TagLibSharp" Version="2.3.0" />
 
       <!-- transitive; upgrading due to vuln -->
-      <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
+      <PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
       <PackageReference Include="System.Net.Http" Version="4.3.4" />
     </ItemGroup>
 
diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs
index d07f14fa..1b858247 100644
--- a/ErsatzTV.Infrastructure/Images/ImageCache.cs
+++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs
@@ -48,7 +48,7 @@ public class ImageCache : IImageCache
             }
 
             byte[] hash = await ComputeFileHash(tempFileName);
-            string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
+            string hex = Convert.ToHexString(hash);
             string subfolder = hex[..2];
             string baseFolder = artworkKind switch
             {
@@ -82,7 +82,7 @@ public class ImageCache : IImageCache
         {
             var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
             byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
-            string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
+            string hex = Convert.ToHexString(hash);
             string subfolder = hex[..2];
             string baseFolder = artworkKind switch
             {
diff --git a/ErsatzTV.Infrastructure/Metadata/CollectionEtag.cs b/ErsatzTV.Infrastructure/Metadata/CollectionEtag.cs
index 04328b57..ad0f08cc 100644
--- a/ErsatzTV.Infrastructure/Metadata/CollectionEtag.cs
+++ b/ErsatzTV.Infrastructure/Metadata/CollectionEtag.cs
@@ -26,6 +26,6 @@ public class CollectionEtag : ICollectionEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 }
diff --git a/ErsatzTV.Infrastructure/Plex/PlexEtag.cs b/ErsatzTV.Infrastructure/Plex/PlexEtag.cs
index ec0d5681..3eae993b 100644
--- a/ErsatzTV.Infrastructure/Plex/PlexEtag.cs
+++ b/ErsatzTV.Infrastructure/Plex/PlexEtag.cs
@@ -106,7 +106,7 @@ public class PlexEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     public string ForShow(PlexMetadataResponse response)
@@ -153,7 +153,7 @@ public class PlexEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     public string ForSeason(PlexXmlMetadataResponse response)
@@ -191,7 +191,7 @@ public class PlexEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     public string ForEpisode(PlexXmlMetadataResponse response)
@@ -287,7 +287,7 @@ public class PlexEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     public string ForCollection(PlexCollectionMetadataResponse response)
@@ -312,7 +312,7 @@ public class PlexEtag
 
         ms.Position = 0;
         byte[] hash = SHA1.Create().ComputeHash(ms);
-        return BitConverter.ToString(hash).Replace("-", string.Empty);
+        return Convert.ToHexString(hash);
     }
 
     private enum FieldKey : byte
diff --git a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs
index 3780d999..f609e1e7 100644
--- a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs
+++ b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs
@@ -67,11 +67,13 @@ public partial class SearchQueryParser(IDbContextFactory<TvContext> dbContextFac
 
         using Analyzer analyzerWrapper = AnalyzerWrapper();
 
-        QueryParser parser = new CustomMultiFieldQueryParser(
+        var parser = new CustomMultiFieldQueryParser(
             LuceneSearchIndex.AppLuceneVersion,
             [LuceneSearchIndex.TitleField],
-            analyzerWrapper);
-        parser.AllowLeadingWildcard = true;
+            analyzerWrapper)
+        {
+            AllowLeadingWildcard = true
+        };
         Query result = ParseQuery(parsedQuery, parser);
 
         Log.Logger.Debug("Search query parsed from [{Query}] to [{ParsedQuery}]", query, result.ToString());
diff --git a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
index 9dfe7ddb..d6bac833 100644
--- a/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
+++ b/ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
 
@@ -10,11 +10,11 @@
 
     <ItemGroup>
         <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
         <PackageReference Include="NSubstitute" Version="5.3.0" />
         <PackageReference Include="NUnit" Version="4.3.2" />
         <PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
-        <PackageReference Include="NUnit.Analyzers" Version="4.7.0">
+        <PackageReference Include="NUnit.Analyzers" Version="4.9.2">
           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
           <PrivateAssets>all</PrivateAssets>
         </PackageReference>
diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
index d6650efe..74cce9b1 100644
--- a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
+++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
@@ -73,7 +73,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
         CancellationToken cancellationToken)
     {
         var incomingItemIds = new List<string>();
-        IReadOnlyDictionary<string, TEtag> existingMovies = (await movieRepository.GetExistingMovies(library))
+        var existingMovies = (await movieRepository.GetExistingMovies(library))
             .ToImmutableDictionary(e => e.MediaServerItemId, e => e);
 
         await foreach ((TMovie incoming, int totalMovieCount) in movieEntries.WithCancellation(cancellationToken))
@@ -253,7 +253,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
     private async Task<bool> ShouldScanItem(
         IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
         TLibrary library,
-        IReadOnlyDictionary<string, TEtag> existingMovies,
+        ImmutableDictionary<string, TEtag> existingMovies,
         TMovie incoming,
         string localPath,
         bool deepScan)
diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
index dd3664c1..e25e2999 100644
--- a/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
+++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
@@ -73,7 +73,7 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
         CancellationToken cancellationToken)
     {
         var incomingItemIds = new List<string>();
-        IReadOnlyDictionary<string, TEtag> existingOtherVideos = (await otherVideoRepository.GetExistingOtherVideos(library))
+        var existingOtherVideos = (await otherVideoRepository.GetExistingOtherVideos(library))
             .ToImmutableDictionary(e => e.MediaServerItemId, e => e);
 
         await foreach ((TOtherVideo incoming, int totalOtherVideoCount) in otherVideoEntries.WithCancellation(cancellationToken))
@@ -253,7 +253,7 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
     private async Task<bool> ShouldScanItem(
         IMediaServerOtherVideoRepository<TLibrary, TOtherVideo, TEtag> otherVideoRepository,
         TLibrary library,
-        IReadOnlyDictionary<string, TEtag> existingOtherVideos,
+        ImmutableDictionary<string, TEtag> existingOtherVideos,
         TOtherVideo incoming,
         string localPath,
         bool deepScan)
diff --git a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj
index 8a6287d4..6b2670a5 100644
--- a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj
+++ b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj
@@ -2,7 +2,7 @@
 
     <PropertyGroup>
         <OutputType>Exe</OutputType>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
         <Configurations>Debug;Release;Debug No Sync</Configurations>
@@ -20,16 +20,16 @@
     </ItemGroup>
 
     <ItemGroup>
-      <PackageReference Include="CliWrap" Version="3.8.2" />
+      <PackageReference Include="CliWrap" Version="3.9.0" />
       <PackageReference Include="Humanizer.Core" Version="2.14.1" />
       <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
       <PackageReference Include="MediatR" Version="12.5.0" />
-      <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
-      <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
-      <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
-      <PackageReference Include="Serilog" Version="4.2.0" />
-      <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
-      <PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
+      <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.6" />
+      <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
+      <PackageReference Include="Serilog" Version="4.3.0" />
+      <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
+      <PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
       <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
       <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
       <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj
index b6ca2049..3dd9b659 100644
--- a/ErsatzTV/ErsatzTV.csproj
+++ b/ErsatzTV/ErsatzTV.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
         <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
         <IsPackable>false</IsPackable>
@@ -18,31 +18,31 @@
     <ItemGroup>
       <PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
       <PackageReference Include="Bugsnag.AspNet.Core" Version="4.0.0" />
-      <PackageReference Include="FluentValidation" Version="11.11.0" />
-      <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
-      <PackageReference Include="Heron.MudCalendar" Version="3.0.0" />
-      <PackageReference Include="HtmlSanitizer" Version="8.1.870" />
+      <PackageReference Include="FluentValidation" Version="12.0.0" />
+      <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
+      <PackageReference Include="Heron.MudCalendar" Version="3.2.0" />
+      <PackageReference Include="HtmlSanitizer" Version="9.0.886" />
       <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
-      <PackageReference Include="Markdig" Version="0.41.1" />
+      <PackageReference Include="Markdig" Version="0.41.2" />
       <PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
-      <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
-      <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.15" />
-      <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.15" />
-      <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.15" />
-      <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
+      <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
+      <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.6" />
+      <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.6" />
+      <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="9.0.6" />
+      <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
-      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
+      <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
         <PrivateAssets>all</PrivateAssets>
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
       </PackageReference>
-      <PackageReference Include="MudBlazor" Version="8.5.1" />
+      <PackageReference Include="MudBlazor" Version="8.8.0" />
       <PackageReference Include="NaturalSort.Extension" Version="4.3.0" />
       <PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
-      <PackageReference Include="Serilog" Version="4.2.0" />
-      <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
-      <PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
+      <PackageReference Include="Serilog" Version="4.3.0" />
+      <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
+      <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
       <PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
       <PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
       <PackageReference Include="System.Runtime.Handles" Version="4.3.0" />
@@ -50,7 +50,7 @@
 
       <!-- transitive; upgrading due to vuln -->
       <PackageReference Include="Microsoft.AspnetCore.Http" Version="2.3.0" />
-      <PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
+      <PackageReference Include="System.Text.Encodings.Web" Version="9.0.6" />
       <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
     </ItemGroup>
 
diff --git a/ErsatzTV/Pages/DecoTemplateEditor.razor b/ErsatzTV/Pages/DecoTemplateEditor.razor
index 07cd464f..1fc0bc93 100644
--- a/ErsatzTV/Pages/DecoTemplateEditor.razor
+++ b/ErsatzTV/Pages/DecoTemplateEditor.razor
@@ -111,7 +111,8 @@
             </div>
         </MudItem>
         <MudItem xs="8">
-            <MudCalendar Class="mt-4"
+            <MudCalendar T="CalendarItem"
+                         Class="mt-4"
                          Items="@_decoTemplate.Items"
                          ShowMonth="false"
                          ShowWeek="false"
diff --git a/ErsatzTV/Pages/TemplateEditor.razor b/ErsatzTV/Pages/TemplateEditor.razor
index d7549e3e..10d2b9ab 100644
--- a/ErsatzTV/Pages/TemplateEditor.razor
+++ b/ErsatzTV/Pages/TemplateEditor.razor
@@ -86,7 +86,8 @@
             </div>
         </MudItem>
         <MudItem xs="8">
-            <MudCalendar Class="mt-4"
+            <MudCalendar T="CalendarItem"
+                         Class="mt-4"
                          Items="@_template.Items"
                          ShowMonth="false"
                          ShowWeek="false"
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 07987c50..b5d5cb9f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,10 +1,10 @@
-FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-amd64 AS dotnet-runtime
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-amd64 AS dotnet-runtime
 
 FROM jasongdove/ersatztv-ffmpeg:7.1.1 AS runtime-base
 COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
 
 # https://hub.docker.com/_/microsoft-dotnet
-FROM mcr.microsoft.com/dotnet/sdk:8.0-noble-amd64 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build
 RUN apt-get update && apt-get install -y ca-certificates gnupg
 WORKDIR /source
 
diff --git a/docker/arm32v7/Dockerfile b/docker/arm32v7/Dockerfile
index 4448cc45..0ed74d5e 100644
--- a/docker/arm32v7/Dockerfile
+++ b/docker/arm32v7/Dockerfile
@@ -1,10 +1,10 @@
-FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-arm32v7 AS dotnet-runtime
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-arm32v7 AS dotnet-runtime
 
 FROM jasongdove/ersatztv-ffmpeg:7.1.1-arm AS runtime-base
 COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
 
 # https://hub.docker.com/_/microsoft-dotnet
-FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy-amd64 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build
 RUN apt-get update && apt-get install -y ca-certificates gnupg
 WORKDIR /source
 
@@ -32,10 +32,10 @@ COPY ErsatzTV.Infrastructure.MySql/. ./ErsatzTV.Infrastructure.MySql/
 COPY ErsatzTV.Scanner/. ./ErsatzTV.Scanner/
 WORKDIR /source/ErsatzTV.Scanner
 ARG INFO_VERSION="unknown"
-RUN dotnet publish ErsatzTV.Scanner.csproj --framework net8.0 -c release -o /app --runtime linux-arm --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
+RUN dotnet publish ErsatzTV.Scanner.csproj --framework net9.0 -c release -o /app --runtime linux-arm --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
 WORKDIR /source/ErsatzTV
 RUN sed -i '/Scanner/d' ErsatzTV.csproj
-RUN dotnet publish ErsatzTV.csproj --framework net8.0 -c release -o /app --runtime linux-arm --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
+RUN dotnet publish ErsatzTV.csproj --framework net9.0 -c release -o /app --runtime linux-arm --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
 
 # final stage/image
 FROM runtime-base
diff --git a/docker/arm64/Dockerfile b/docker/arm64/Dockerfile
index 42138241..ed298d3e 100644
--- a/docker/arm64/Dockerfile
+++ b/docker/arm64/Dockerfile
@@ -1,10 +1,10 @@
-FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-arm64v8 AS dotnet-runtime
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-arm64v8 AS dotnet-runtime
 
 FROM jasongdove/ersatztv-ffmpeg:7.1.1-arm64 AS runtime-base
 COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
 
 # https://hub.docker.com/_/microsoft-dotnet
-FROM mcr.microsoft.com/dotnet/sdk:8.0-noble-amd64 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build
 RUN apt-get update && apt-get install -y ca-certificates gnupg
 WORKDIR /source
 
@@ -32,10 +32,10 @@ COPY ErsatzTV.Infrastructure.MySql/. ./ErsatzTV.Infrastructure.MySql/
 COPY ErsatzTV.Scanner/. ./ErsatzTV.Scanner/
 WORKDIR /source/ErsatzTV.Scanner
 ARG INFO_VERSION="unknown"
-RUN dotnet publish ErsatzTV.Scanner.csproj --framework net8.0 -c release -o /app --runtime linux-arm64 --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
+RUN dotnet publish ErsatzTV.Scanner.csproj --framework net9.0 -c release -o /app --runtime linux-arm64 --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
 WORKDIR /source/ErsatzTV
 RUN sed -i '/Scanner/d' ErsatzTV.csproj
-RUN dotnet publish ErsatzTV.csproj --framework net8.0 -c release -o /app --runtime linux-arm64 --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
+RUN dotnet publish ErsatzTV.csproj --framework net9.0 -c release -o /app --runtime linux-arm64 --no-self-contained --no-restore -p:DebugType=Embedded -p:PublishSingleFile=false -p:PublishTrimmed=false -p:InformationalVersion=${INFO_VERSION}
 
 # final stage/image
 FROM runtime-base
diff --git a/docker/ffmpeg-tests/Dockerfile b/docker/ffmpeg-tests/Dockerfile
index 36b78491..5a97828f 100644
--- a/docker/ffmpeg-tests/Dockerfile
+++ b/docker/ffmpeg-tests/Dockerfile
@@ -1,5 +1,5 @@
 FROM jasongdove/ersatztv-ffmpeg:7.1.1
-RUN apt-get update && apt-get install -y ca-certificates gnupg dotnet8 dotnet-sdk-8.0 aspnetcore-runtime-8.0 mkvtoolnix
+RUN apt-get update && apt-get install -y ca-certificates gnupg dotnet9 dotnet-sdk-9.0 aspnetcore-runtime-9.0 mkvtoolnix
 WORKDIR /source
 
 # copy csproj and restore as distinct layers
diff --git a/docker/nvidia/Dockerfile b/docker/nvidia/Dockerfile
index 07987c50..b5d5cb9f 100644
--- a/docker/nvidia/Dockerfile
+++ b/docker/nvidia/Dockerfile
@@ -1,10 +1,10 @@
-FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-amd64 AS dotnet-runtime
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-amd64 AS dotnet-runtime
 
 FROM jasongdove/ersatztv-ffmpeg:7.1.1 AS runtime-base
 COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
 
 # https://hub.docker.com/_/microsoft-dotnet
-FROM mcr.microsoft.com/dotnet/sdk:8.0-noble-amd64 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build
 RUN apt-get update && apt-get install -y ca-certificates gnupg
 WORKDIR /source
 
diff --git a/docker/vaapi/Dockerfile b/docker/vaapi/Dockerfile
index ce022efd..6a191668 100644
--- a/docker/vaapi/Dockerfile
+++ b/docker/vaapi/Dockerfile
@@ -1,10 +1,10 @@
-FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-amd64 AS dotnet-runtime
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-amd64 AS dotnet-runtime
 
 FROM jasongdove/ersatztv-ffmpeg:7.1.1 AS runtime-base
 COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
 
 # https://hub.docker.com/_/microsoft-dotnet
-FROM mcr.microsoft.com/dotnet/sdk:8.0-noble-amd64 AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build
 RUN apt-get update && apt-get install -y ca-certificates gnupg
 WORKDIR /source
 
diff --git a/global.json b/global.json
index 60b4c025..4f85ebc6 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
 {
   "sdk": {
-    "version": "8.0.100",
+    "version": "9.0.100",
     "rollForward": "latestMinor",
     "allowPrerelease": false
   }