Browse Source

Merge branch 'main' into codex/add-select-all-on-page-feature

pull/2738/head
Jason Dove 7 months ago
parent
commit
4d00ed2d20
No known key found for this signature in database
  1. 14
      CHANGELOG.md
  2. 2
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  3. 10
      ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs
  4. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs
  5. 23
      ErsatzTV.Core/SystemEnvironment.cs
  6. 2
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  7. 15
      ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs
  8. 6889
      ErsatzTV.Infrastructure.MySql/Migrations/20251230132754_Fix_RemoteStreamMetadataTitle.Designer.cs
  9. 37
      ErsatzTV.Infrastructure.MySql/Migrations/20251230132754_Fix_RemoteStreamMetadataTitle.cs
  10. 6923
      ErsatzTV.Infrastructure.MySql/Migrations/20251231201959_Optimize_JellyfinItemIdEtagColumns.Designer.cs
  11. 358
      ErsatzTV.Infrastructure.MySql/Migrations/20251231201959_Optimize_JellyfinItemIdEtagColumns.cs
  12. 56
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  13. 6716
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251230132715_Fix_RemoteStreamMetadataTitle.Designer.cs
  14. 37
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251230132715_Fix_RemoteStreamMetadataTitle.cs
  15. 6750
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251231201920_Optimize_JellyfinItemIdEtagColumns.Designer.cs
  16. 72
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251231201920_Optimize_JellyfinItemIdEtagColumns.cs
  17. 34
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  18. 2
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  19. 15
      ErsatzTV.Infrastructure/Data/Configurations/Collection/JellyfinCollectionConfiguration.cs
  20. 6
      ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs
  21. 15
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinEpisodeConfiguration.cs
  22. 15
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinMovieConfiguration.cs
  23. 15
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinSeasonConfiguration.cs
  24. 15
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinShowConfiguration.cs
  25. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  26. 36
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  27. 2
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  28. 6
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  29. 2
      ErsatzTV/ErsatzTV.csproj
  30. 2
      ErsatzTV/Resources/sequential-schedule-import.schema.json
  31. 2
      ErsatzTV/Resources/sequential-schedule.schema.json
  32. 38
      ErsatzTV/SlowApiHandler.cs
  33. 24
      ErsatzTV/SlowQueryInterceptor.cs
  34. 20
      ErsatzTV/Startup.cs

14
CHANGELOG.md

@ -34,6 +34,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -34,6 +34,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This button will extract up to 30 seconds of the media item and zip it
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
- Add environment variables to help troubleshoot performance
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
- This is currently limited to *Jellyfin*
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
### Fixed
- Fix startup on systems unsupported by NvEncSharp
@ -55,11 +61,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -55,11 +61,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
- Fix playback of certain BT.2020 content
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
- NVIDIA: fix stream failure with certain content that should decode in hardware but falls back to software
- NVIDIA:
- Fix stream failure with certain content that should decode in hardware but falls back to software
- Fix stream failure with content that changes color metadata mid-stream
- Fix stream failure when configured fallback filler collection is empty
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
- Fix erroneous downgrade health check failure with some installations that use MariaDB
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
### Changed
- No longer round framerate to nearest integer when normalizing framerate
@ -69,6 +80,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -69,6 +80,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Items may still be cut as needed
- Hardware acceleration will now be used
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
- Optimize Jellyfin database fields and indexes
## [25.9.0] - 2025-11-29
### Added

2
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />

10
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

@ -40,9 +40,17 @@ public static partial class MultiPartEpisodeGrouper @@ -40,9 +40,17 @@ public static partial class MultiPartEpisodeGrouper
groups.Add(new GroupedMediaItem(item, null));
}
// when treating multiple shows as a single "show" - we need to
// first sort by release date before looking for part 1, part 2, etc. to group
// otherwise, we can simply sort by season, episode number
// which does not require release date metadata
IComparer<MediaItem> comparer = treatCollectionsAsShows
? new ChronologicalMediaComparer()
: new SeasonEpisodeMediaComparer();
IEnumerable<Episode> sortedEpisodes = showId.Match(
id => episodes.Filter(e => e.Season.ShowId == id),
() => episodes).OrderBy(identity, new ChronologicalMediaComparer());
() => episodes).OrderBy(identity, comparer);
foreach (Episode episode in sortedEpisodes)
{

2
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutCountHandler.cs

@ -43,7 +43,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay @@ -43,7 +43,7 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
e.Result = name switch
{
"count" => enumeratorCount,
"random" => random.Next() % enumeratorCount,
"random" => enumeratorCount > 0 ? random.Next() % enumeratorCount : 0,
_ => e.Result
};
};

23
ErsatzTV.Core/SystemEnvironment.cs

@ -36,6 +36,26 @@ public class SystemEnvironment @@ -36,6 +36,26 @@ public class SystemEnvironment
}
MaximumUploadMb = maximumUploadMb;
string slowDbMsVariable = Environment.GetEnvironmentVariable("ETV_SLOW_DB_MS");
if (int.TryParse(slowDbMsVariable, out int slowDbMs) && slowDbMs > 0)
{
SlowDbMs = slowDbMs;
}
string slowApiMsVariable = Environment.GetEnvironmentVariable("ETV_SLOW_API_MS");
if (int.TryParse(slowApiMsVariable, out int slowApiMs) && slowApiMs > 0)
{
SlowApiMs = slowApiMs;
}
string jellyfinPageSizeVariable = Environment.GetEnvironmentVariable("ETV_JF_PAGE_SIZE");
if (!int.TryParse(jellyfinPageSizeVariable, out int jellyfinPageSize) || jellyfinPageSize <= 0)
{
jellyfinPageSize = 10;
}
JellyfinPageSize = jellyfinPageSize;
}
public static string BaseUrl { get; }
@ -45,4 +65,7 @@ public class SystemEnvironment @@ -45,4 +65,7 @@ public class SystemEnvironment
public static int StreamingPort { get; }
public static bool AllowSharedPlexServers { get; }
public static int MaximumUploadMb { get; }
public static int? SlowDbMs { get; }
public static int? SlowApiMs { get; }
public static int JellyfinPageSize { get; }
}

2
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>

15
ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs

@ -364,6 +364,21 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder @@ -364,6 +364,21 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
desiredState.CroppedSize.IfNone(desiredState.PaddedSize)));
}
// unknown color params can *change* during transcoding,
// which can cause ffmpeg to insert an autoscale filter that
// cannot accept hardware frames.
// this filter isn't actually needed (the encoder accepts the frames just fine) so disable it
if (ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Nvenc &&
currentState.FrameDataLocation is FrameDataLocation.Hardware &&
videoStream.ColorParams.IsUnknown &&
(context.HasGraphicsEngine || context.HasSubtitleOverlay || context.HasWatermark))
{
if (pipelineSteps.All(p => p is not NoAutoScaleOutputOption))
{
pipelineSteps.Add(new NoAutoScaleOutputOption());
}
}
return new FilterChain(
videoInputFile.FilterSteps,
watermarkInputFile.Map(wm => wm.FilterSteps).IfNone([]),

6889
ErsatzTV.Infrastructure.MySql/Migrations/20251230132754_Fix_RemoteStreamMetadataTitle.Designer.cs generated

File diff suppressed because it is too large Load Diff

37
ErsatzTV.Infrastructure.MySql/Migrations/20251230132754_Fix_RemoteStreamMetadataTitle.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Fix_RemoteStreamMetadataTitle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// mark the file as needing an update
migrationBuilder.Sql(
"UPDATE `RemoteStreamMetadata` SET `DateUpdated` = '0001-01-01 00:00:00' WHERE `Title` IS NULL");
// mark the folder as needing an update
migrationBuilder.Sql(
"""
UPDATE `LibraryFolder` SET `Etag` = NULL WHERE `Id` IN (
SELECT `MF`.`LibraryFolderId`
FROM `MediaFile` `MF`
INNER JOIN `MediaVersion` `MV` ON `MV`.`Id` = `MF`.`MediaVersionId`
INNER JOIN `RemoteStream` `RS` ON `RS`.`Id` = `MV`.`RemoteStreamId`
INNER JOIN `RemoteStreamMetadata` `RSM` ON `RSM`.`RemoteStreamId` = `RS`.`Id`
WHERE `RSM`.`Title` IS NULL
)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6923
ErsatzTV.Infrastructure.MySql/Migrations/20251231201959_Optimize_JellyfinItemIdEtagColumns.Designer.cs generated

File diff suppressed because it is too large Load Diff

358
ErsatzTV.Infrastructure.MySql/Migrations/20251231201959_Optimize_JellyfinItemIdEtagColumns.cs

@ -0,0 +1,358 @@ @@ -0,0 +1,358 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Optimize_JellyfinItemIdEtagColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinShow",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinShow",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinSeason",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinSeason",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinMovie",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinMovie",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinLibrary",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinEpisode",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinEpisode",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinCollection",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinCollection",
type: "varchar(36)",
unicode: false,
maxLength: 36,
nullable: true,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_JellyfinShow_ItemId",
table: "JellyfinShow",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinSeason_ItemId",
table: "JellyfinSeason",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinMovie_ItemId",
table: "JellyfinMovie",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinLibrary_ItemId",
table: "JellyfinLibrary",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinEpisode_ItemId",
table: "JellyfinEpisode",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinCollection_ItemId",
table: "JellyfinCollection",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_JellyfinShow_ItemId",
table: "JellyfinShow");
migrationBuilder.DropIndex(
name: "IX_JellyfinSeason_ItemId",
table: "JellyfinSeason");
migrationBuilder.DropIndex(
name: "IX_JellyfinMovie_ItemId",
table: "JellyfinMovie");
migrationBuilder.DropIndex(
name: "IX_JellyfinLibrary_ItemId",
table: "JellyfinLibrary");
migrationBuilder.DropIndex(
name: "IX_JellyfinEpisode_ItemId",
table: "JellyfinEpisode");
migrationBuilder.DropIndex(
name: "IX_JellyfinCollection_ItemId",
table: "JellyfinCollection");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinShow",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinShow",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinSeason",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinSeason",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinMovie",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinMovie",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinLibrary",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinEpisode",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinEpisode",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "ItemId",
table: "JellyfinCollection",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
name: "Etag",
table: "JellyfinCollection",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(36)",
oldUnicode: false,
oldMaxLength: 36,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

56
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -1024,16 +1024,22 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1024,16 +1024,22 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Etag")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("Name")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("ItemId");
b.ToTable("JellyfinCollection", (string)null);
});
@ -3781,11 +3787,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3781,11 +3787,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Library");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<bool>("ShouldSyncItems")
.HasColumnType("tinyint(1)");
b.HasIndex("ItemId");
b.ToTable("JellyfinLibrary", (string)null);
});
@ -4053,10 +4063,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4053,10 +4063,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Episode");
b.Property<string>("Etag")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.HasIndex("ItemId");
b.ToTable("JellyfinEpisode", (string)null);
});
@ -4092,10 +4108,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4092,10 +4108,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Movie");
b.Property<string>("Etag")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.HasIndex("ItemId");
b.ToTable("JellyfinMovie", (string)null);
});
@ -4144,10 +4166,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4144,10 +4166,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Season");
b.Property<string>("Etag")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.HasIndex("ItemId");
b.ToTable("JellyfinSeason", (string)null);
});
@ -4183,10 +4211,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4183,10 +4211,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Show");
b.Property<string>("Etag")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("ItemId")
.HasColumnType("longtext");
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.HasIndex("ItemId");
b.ToTable("JellyfinShow", (string)null);
});

6716
ErsatzTV.Infrastructure.Sqlite/Migrations/20251230132715_Fix_RemoteStreamMetadataTitle.Designer.cs generated

File diff suppressed because it is too large Load Diff

37
ErsatzTV.Infrastructure.Sqlite/Migrations/20251230132715_Fix_RemoteStreamMetadataTitle.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Fix_RemoteStreamMetadataTitle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// mark the file as needing an update
migrationBuilder.Sql(
"UPDATE `RemoteStreamMetadata` SET `DateUpdated` = '0001-01-01 00:00:00' WHERE `Title` IS NULL");
// mark the folder as needing an update
migrationBuilder.Sql(
"""
UPDATE `LibraryFolder` SET `Etag` = NULL WHERE `Id` IN (
SELECT `MF`.`LibraryFolderId`
FROM `MediaFile` `MF`
INNER JOIN `MediaVersion` `MV` ON `MV`.`Id` = `MF`.`MediaVersionId`
INNER JOIN `RemoteStream` `RS` ON `RS`.`Id` = `MV`.`RemoteStreamId`
INNER JOIN `RemoteStreamMetadata` `RSM` ON `RSM`.`RemoteStreamId` = `RS`.`Id`
WHERE `RSM`.`Title` IS NULL
)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6750
ErsatzTV.Infrastructure.Sqlite/Migrations/20251231201920_Optimize_JellyfinItemIdEtagColumns.Designer.cs generated

File diff suppressed because it is too large Load Diff

72
ErsatzTV.Infrastructure.Sqlite/Migrations/20251231201920_Optimize_JellyfinItemIdEtagColumns.cs

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Optimize_JellyfinItemIdEtagColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_JellyfinShow_ItemId",
table: "JellyfinShow",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinSeason_ItemId",
table: "JellyfinSeason",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinMovie_ItemId",
table: "JellyfinMovie",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinLibrary_ItemId",
table: "JellyfinLibrary",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinEpisode_ItemId",
table: "JellyfinEpisode",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_JellyfinCollection_ItemId",
table: "JellyfinCollection",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_JellyfinShow_ItemId",
table: "JellyfinShow");
migrationBuilder.DropIndex(
name: "IX_JellyfinSeason_ItemId",
table: "JellyfinSeason");
migrationBuilder.DropIndex(
name: "IX_JellyfinMovie_ItemId",
table: "JellyfinMovie");
migrationBuilder.DropIndex(
name: "IX_JellyfinLibrary_ItemId",
table: "JellyfinLibrary");
migrationBuilder.DropIndex(
name: "IX_JellyfinEpisode_ItemId",
table: "JellyfinEpisode");
migrationBuilder.DropIndex(
name: "IX_JellyfinCollection_ItemId",
table: "JellyfinCollection");
}
}
}

34
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -981,9 +981,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -981,9 +981,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("Name")
@ -991,6 +995,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -991,6 +995,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasKey("Id");
b.HasIndex("ItemId");
b.ToTable("JellyfinCollection", (string)null);
});
@ -3608,11 +3614,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3608,11 +3614,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Library");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<bool>("ShouldSyncItems")
.HasColumnType("INTEGER");
b.HasIndex("ItemId");
b.ToTable("JellyfinLibrary", (string)null);
});
@ -3880,11 +3890,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3880,11 +3890,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Episode");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.HasIndex("ItemId");
b.ToTable("JellyfinEpisode", (string)null);
});
@ -3919,11 +3935,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3919,11 +3935,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Movie");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.HasIndex("ItemId");
b.ToTable("JellyfinMovie", (string)null);
});
@ -3971,11 +3993,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3971,11 +3993,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Season");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.HasIndex("ItemId");
b.ToTable("JellyfinSeason", (string)null);
});
@ -4010,11 +4038,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4010,11 +4038,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasBaseType("ErsatzTV.Core.Domain.Show");
b.Property<string>("Etag")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("TEXT");
b.HasIndex("ItemId");
b.ToTable("JellyfinShow", (string)null);
});

2
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

15
ErsatzTV.Infrastructure/Data/Configurations/Collection/JellyfinCollectionConfiguration.cs

@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinCollectionConfiguration : IEntityTypeConfiguration<JellyfinCollection>
{
public void Configure(EntityTypeBuilder<JellyfinCollection> builder) => builder.ToTable("JellyfinCollection");
public void Configure(EntityTypeBuilder<JellyfinCollection> builder)
{
builder.ToTable("JellyfinCollection");
builder.Property(c => c.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(c => c.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(c => c.ItemId);
}
}

6
ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs

@ -13,5 +13,11 @@ public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLib @@ -13,5 +13,11 @@ public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLib
builder.HasMany(l => l.PathInfos)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(l => l.ItemId);
}
}

15
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinEpisodeConfiguration.cs

@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinEpisodeConfiguration : IEntityTypeConfiguration<JellyfinEpisode>
{
public void Configure(EntityTypeBuilder<JellyfinEpisode> builder) => builder.ToTable("JellyfinEpisode");
public void Configure(EntityTypeBuilder<JellyfinEpisode> builder)
{
builder.ToTable("JellyfinEpisode");
builder.Property(e => e.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(e => e.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(e => e.ItemId);
}
}

15
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinMovieConfiguration.cs

@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinMovieConfiguration : IEntityTypeConfiguration<JellyfinMovie>
{
public void Configure(EntityTypeBuilder<JellyfinMovie> builder) => builder.ToTable("JellyfinMovie");
public void Configure(EntityTypeBuilder<JellyfinMovie> builder)
{
builder.ToTable("JellyfinMovie");
builder.Property(m => m.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(m => m.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(m => m.ItemId);
}
}

15
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinSeasonConfiguration.cs

@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinSeasonConfiguration : IEntityTypeConfiguration<JellyfinSeason>
{
public void Configure(EntityTypeBuilder<JellyfinSeason> builder) => builder.ToTable("JellyfinSeason");
public void Configure(EntityTypeBuilder<JellyfinSeason> builder)
{
builder.ToTable("JellyfinSeason");
builder.Property(s => s.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(s => s.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(s => s.ItemId);
}
}

15
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinShowConfiguration.cs

@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,5 +6,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinShowConfiguration : IEntityTypeConfiguration<JellyfinShow>
{
public void Configure(EntityTypeBuilder<JellyfinShow> builder) => builder.ToTable("JellyfinShow");
public void Configure(EntityTypeBuilder<JellyfinShow> builder)
{
builder.ToTable("JellyfinShow");
builder.Property(s => s.Etag)
.HasMaxLength(36)
.IsUnicode(false);
builder.Property(s => s.ItemId)
.HasMaxLength(36)
.IsUnicode(false);
builder.HasIndex(s => s.ItemId);
}
}

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.2.2" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="Jint" Version="4.4.2" />
<PackageReference Include="JsonSchema.Net" Version="8.0.4" />
<PackageReference Include="JsonSchema.Net" Version="8.0.5" />
<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" />

36
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin; @@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin;
public class JellyfinApiClient : IJellyfinApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<JellyfinApiClient> _logger;
private readonly IMemoryCache _memoryCache;
@ -23,11 +24,13 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -23,11 +24,13 @@ public class JellyfinApiClient : IJellyfinApiClient
IMemoryCache memoryCache,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IFallbackMetadataProvider fallbackMetadataProvider,
IHttpClientFactory httpClientFactory,
ILogger<JellyfinApiClient> logger)
{
_memoryCache = memoryCache;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_fallbackMetadataProvider = fallbackMetadataProvider;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
@ -37,7 +40,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -37,7 +40,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
IJellyfinApi service = ServiceForAddress(address);
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));
return await service.GetSystemInformation(apiKey, cts.Token)
@ -59,7 +62,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -59,7 +62,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
IJellyfinApi service = ServiceForAddress(address);
List<JellyfinLibraryResponse> libraries = await service.GetLibraries(apiKey);
return libraries
.Map(Project)
@ -189,7 +192,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -189,7 +192,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
IJellyfinApi service = ServiceForAddress(address);
JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, itemId);
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(playbackInfo);
return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics"));
@ -209,7 +212,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -209,7 +212,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
IJellyfinApi service = ServiceForAddress(address);
JellyfinLibraryItemsResponse itemsResponse = await service.GetShowLibraryItems(
apiKey,
parentId: library.ItemId,
@ -240,7 +243,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -240,7 +243,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
IJellyfinApi service = ServiceForAddress(address);
JellyfinSearchHintsResponse searchResponse = await service.SearchHints(
apiKey,
showTitle,
@ -281,7 +284,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -281,7 +284,7 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
private static async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
private async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
string address,
Option<JellyfinLibrary> maybeLibrary,
int mediaSourceId,
@ -289,19 +292,21 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -289,19 +292,21 @@ public class JellyfinApiClient : IJellyfinApiClient
Func<IJellyfinApi, string, int, int, Task<JellyfinLibraryItemsResponse>> getItems,
Func<Option<JellyfinLibrary>, JellyfinLibraryItemResponse, Option<TItem>> mapper)
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
const int PAGE_SIZE = 10;
IJellyfinApi service = ServiceForAddress(address);
int pages = int.MaxValue;
for (var i = 0; i < pages; i++)
{
int skip = i * PAGE_SIZE;
int skip = i * SystemEnvironment.JellyfinPageSize;
JellyfinLibraryItemsResponse result = await getItems(service, parentId, skip, PAGE_SIZE);
JellyfinLibraryItemsResponse result = await getItems(
service,
parentId,
skip,
SystemEnvironment.JellyfinPageSize);
// update page count
pages = Math.Min(pages, (result.TotalRecordCount - 1) / PAGE_SIZE + 1);
pages = Math.Min(pages, (result.TotalRecordCount - 1) / SystemEnvironment.JellyfinPageSize + 1);
foreach (TItem item in result.Items.Map(item => mapper(maybeLibrary, item)).Somes())
{
@ -1043,4 +1048,11 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -1043,4 +1048,11 @@ public class JellyfinApiClient : IJellyfinApiClient
return version;
});
}
private IJellyfinApi ServiceForAddress(string address)
{
var client = _httpClientFactory.CreateClient("RefitCustomClient");
client.BaseAddress = new Uri(address);
return RestService.For<IJellyfinApi>(client);
}
}

2
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

6
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -533,6 +533,12 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -533,6 +533,12 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata)
{
// title is not required in remote stream definition; use fallback title if missing
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging

2
ErsatzTV/ErsatzTV.csproj

@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.4.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="9.0.2" />
<PackageReference Include="Scalar.AspNetCore" Version="2.11.8" />
<PackageReference Include="Scalar.AspNetCore" Version="2.11.10" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />

2
ErsatzTV/Resources/sequential-schedule-import.schema.json

@ -191,7 +191,7 @@ @@ -191,7 +191,7 @@
"countInstruction": {
"type": "object",
"properties": {
"count": { "type": "integer" },
"count": { "type": ["integer", "string"] },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },

2
ErsatzTV/Resources/sequential-schedule.schema.json

@ -242,7 +242,7 @@ @@ -242,7 +242,7 @@
"countInstruction": {
"type": "object",
"properties": {
"count": { "type": "integer" },
"count": { "type": ["integer", "string"] },
"content": { "type": "string" },
"custom_title": { "type": "string" },
"filler_kind": { "$ref": "#/$defs/enums/filler_kind" },

38
ErsatzTV/SlowApiHandler.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using ErsatzTV.Core;
namespace ErsatzTV;
using System.Diagnostics;
public class SlowApiHandler(ILogger<SlowApiHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (SystemEnvironment.SlowApiMs > 0)
{
var stopwatch = Stopwatch.StartNew();
var response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > SystemEnvironment.SlowApiMs.Value)
{
string uri = request.RequestUri?.ToString() ?? "Unknown URI";
string method = request.Method.Method;
logger.LogDebug(
"[SLOW API] {Method} {Uri} took {Milliseconds}ms",
method,
uri,
stopwatch.ElapsedMilliseconds);
}
return response;
}
return await base.SendAsync(request, cancellationToken);
}
}

24
ErsatzTV/SlowQueryInterceptor.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace ErsatzTV;
public class SlowQueryInterceptor(int threshold) : DbCommandInterceptor
{
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Duration.TotalMilliseconds > threshold)
{
Serilog.Log.Logger.Debug(
"[SLOW QUERY] ({Milliseconds}ms): {Command}",
eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}

20
ErsatzTV/Startup.cs

@ -408,6 +408,12 @@ public class Startup @@ -408,6 +408,12 @@ public class Startup
var sqliteConnectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;";
string mySqlConnectionString = Configuration.GetValue<string>("MySql:ConnectionString");
SlowQueryInterceptor interceptor = null;
if (SystemEnvironment.SlowDbMs.HasValue)
{
interceptor = new SlowQueryInterceptor(SystemEnvironment.SlowDbMs.Value);
}
services.AddDbContext<TvContext>(
options =>
{
@ -438,6 +444,11 @@ public class Startup @@ -438,6 +444,11 @@ public class Startup
}
);
}
if (interceptor != null)
{
options.AddInterceptors(interceptor);
}
},
ServiceLifetime.Scoped,
ServiceLifetime.Singleton);
@ -467,6 +478,11 @@ public class Startup @@ -467,6 +478,11 @@ public class Startup
}
);
}
if (interceptor != null)
{
options.AddInterceptors(interceptor);
}
});
if (databaseProvider == Provider.Sqlite.Name)
@ -509,6 +525,8 @@ public class Startup @@ -509,6 +525,8 @@ public class Startup
c.DefaultRequestHeaders.Add("User-Agent", $"ErsatzTV/{etvVersion}");
});
services.AddHttpClient("RefitCustomClient").AddHttpMessageHandler<SlowApiHandler>();
services.Configure<TraktConfiguration>(Configuration.GetSection("Trakt"));
services.AddResponseCompression(options => { options.EnableForHttps = true; });
@ -851,6 +869,8 @@ public class Startup @@ -851,6 +869,8 @@ public class Startup
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));
services.AddTransient<SlowApiHandler>();
// run-once/blocking startup services
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();

Loading…
Cancel
Save