Browse Source

add remote stream metadata (#2690)

* add remote stream metadata

* use ifilesystem for last write time

* clean up unused inheritance

* optimize channel data query

* revert removeartwork change

* properly handle remote stream artwork in orphaned artwork check
pull/2685/head
Jason Dove 8 months ago committed by GitHub
parent
commit
6bd49ffcec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 101
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  4. 2
      ErsatzTV.Core/Domain/Metadata/RemoteStreamMetadata.cs
  5. 17
      ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs
  6. 6882
      ErsatzTV.Infrastructure.MySql/Migrations/20251201160022_Add_RemoteStreamMetadata_ContentRatingPlot.Designer.cs
  7. 40
      ErsatzTV.Infrastructure.MySql/Migrations/20251201160022_Add_RemoteStreamMetadata_ContentRatingPlot.cs
  8. 8
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  9. 6709
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251201155729_Add_RemoteStreamMetadata_ContentRatingPlot.Designer.cs
  10. 38
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251201155729_Add_RemoteStreamMetadata_ContentRatingPlot.cs
  11. 8
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  12. 2
      ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs
  13. 6
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  14. 8
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  15. 129
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  16. 58
      ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs
  17. 1
      ErsatzTV/ErsatzTV.csproj
  18. 52
      ErsatzTV/Resources/Templates/_remoteStream.sbntxt
  19. 6
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

4
CHANGELOG.md

@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Script and arguments (`command` and `args`) - Script and arguments (`command` and `args`)
- Draw order (`z_index`) - Draw order (`z_index`)
- Timing (`start_seconds` and `duration_seconds`) - Timing (`start_seconds` and `duration_seconds`)
- Add remote stream metadata
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
### Fixed ### Fixed
- Fix startup on systems unsupported by NvEncSharp - Fix startup on systems unsupported by NvEncSharp

101
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -73,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName(); string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName(); string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null) songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{ {
return; return;
} }
@ -105,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero; TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null; string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels Option<Channel> maybeChannel = await dbContext.Channels
@ -193,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(vm => vm.Artwork) .ThenInclude(vm => vm.Artwork)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata) .ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork) .ThenInclude(vm => vm.Artwork)
.Include(p => p.Items) .Include(p => p.Items)
@ -203,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata) .ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios) .ThenInclude(sm => sm.Studios)
.AsSplitQuery()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream(); await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
@ -243,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -268,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -291,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -320,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml, XmlWriter xml,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -394,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -410,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml, XmlWriter xml,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -465,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
} }
@ -504,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -527,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate, Template musicVideoTemplate,
Template songTemplate, Template songTemplate,
Template otherVideoTemplate, Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier, XmlMinifier minifier,
XmlWriter xml) XmlWriter xml)
{ {
@ -588,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title, title,
templateContext, templateContext,
otherVideoTemplate), otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None _ => Option<string>.None
}; };
@ -883,6 +912,55 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return Option<string>.None; return Option<string>.None;
} }
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName() private string GetMovieTemplateFileName()
{ {
string templateFileName = _localFileSystem.GetCustomOrDefaultFile( string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
@ -978,6 +1056,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName; return templateFileName;
} }
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind) private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{ {
string artworkPath = artwork.Path; string artworkPath = artwork.Path;
@ -1033,6 +1130,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"), .IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty) OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"), .IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]" _ => "[unknown]"
}; };
} }

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -161,7 +161,7 @@ internal static class Mapper
remoteStreamMetadata.Title, remoteStreamMetadata.Title,
remoteStreamMetadata.OriginalTitle, remoteStreamMetadata.OriginalTitle,
remoteStreamMetadata.SortTitle, remoteStreamMetadata.SortTitle,
string.Empty, // TODO: thumbnail? GetThumbnail(remoteStreamMetadata, None, None),
remoteStreamMetadata.RemoteStream.State); remoteStreamMetadata.RemoteStream.State);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>

2
ErsatzTV.Core/Domain/Metadata/RemoteStreamMetadata.cs

@ -2,6 +2,8 @@
public class RemoteStreamMetadata : Metadata public class RemoteStreamMetadata : Metadata
{ {
public string ContentRating { get; set; }
public string Plot { get; set; }
public int RemoteStreamId { get; set; } public int RemoteStreamId { get; set; }
public RemoteStream RemoteStream { get; set; } public RemoteStream RemoteStream { get; set; }
} }

17
ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs

@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Streaming;
public class YamlRemoteStreamDefinition public class YamlRemoteStreamDefinition
{ {
[YamlMember(Alias = "url", ApplyNamingConventions = false)]
public string Url { get; set; } public string Url { get; set; }
[YamlMember(Alias = "script", ApplyNamingConventions = false)]
public string Script { get; set; } public string Script { get; set; }
[YamlMember(Alias = "duration", ApplyNamingConventions = false)]
public string Duration { get; set; } public string Duration { get; set; }
[YamlMember(Alias = "fallback_query", ApplyNamingConventions = false)] [YamlMember(Alias = "fallback_query", ApplyNamingConventions = false)]
@ -13,4 +18,16 @@ public class YamlRemoteStreamDefinition
[YamlMember(Alias = "is_live", ApplyNamingConventions = false)] [YamlMember(Alias = "is_live", ApplyNamingConventions = false)]
public bool? IsLive { get; set; } public bool? IsLive { get; set; }
[YamlMember(Alias = "title", ApplyNamingConventions = false)]
public string Title { get; set; }
[YamlMember(Alias = "plot", ApplyNamingConventions = false)]
public string Plot { get; set; }
[YamlMember(Alias = "year", ApplyNamingConventions = false)]
public int? Year { get; set; }
[YamlMember(Alias = "content_rating", ApplyNamingConventions = false)]
public string ContentRating { get; set; }
} }

6882
ErsatzTV.Infrastructure.MySql/Migrations/20251201160022_Add_RemoteStreamMetadata_ContentRatingPlot.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.MySql/Migrations/20251201160022_Add_RemoteStreamMetadata_ContentRatingPlot.cs

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStreamMetadata_ContentRatingPlot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContentRating",
table: "RemoteStreamMetadata",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Plot",
table: "RemoteStreamMetadata",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContentRating",
table: "RemoteStreamMetadata");
migrationBuilder.DropColumn(
name: "Plot",
table: "RemoteStreamMetadata");
}
}
}

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

@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -2467,6 +2467,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentRating")
.HasColumnType("longtext");
b.Property<DateTime>("DateAdded") b.Property<DateTime>("DateAdded")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@ -2479,6 +2482,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("OriginalTitle") b.Property<string>("OriginalTitle")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("Plot")
.HasColumnType("longtext");
b.Property<DateTime?>("ReleaseDate") b.Property<DateTime?>("ReleaseDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");

6709
ErsatzTV.Infrastructure.Sqlite/Migrations/20251201155729_Add_RemoteStreamMetadata_ContentRatingPlot.Designer.cs generated

File diff suppressed because it is too large Load Diff

38
ErsatzTV.Infrastructure.Sqlite/Migrations/20251201155729_Add_RemoteStreamMetadata_ContentRatingPlot.cs

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStreamMetadata_ContentRatingPlot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContentRating",
table: "RemoteStreamMetadata",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Plot",
table: "RemoteStreamMetadata",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContentRating",
table: "RemoteStreamMetadata");
migrationBuilder.DropColumn(
name: "Plot",
table: "RemoteStreamMetadata");
}
}
}

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

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.10"); modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{ {
@ -2356,6 +2356,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded") b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -2368,6 +2371,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("OriginalTitle") b.Property<string>("OriginalTitle")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate") b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

2
ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs

@ -20,7 +20,7 @@ public class ArtworkRepository : IArtworkRepository
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL AND A.SongMetadataId IS NULL AND A.ChannelId IS NULL
AND A.OtherVideoMetadataId IS NULL AND A.OtherVideoMetadataId IS NULL AND A.RemoteStreamMetadataId IS NULL
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)") AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList()); .Map(result => result.ToList());
} }

6
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -300,12 +300,18 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)", VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
parameters) parameters)
.ToUnit(), .ToUnit(),
RemoteStreamMetadata => await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, RemoteStreamMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
parameters)
.ToUnit(),
_ => Unit.Default _ => Unit.Default
}; };
} }
public async Task<Unit> RemoveArtwork(Core.Domain.Metadata metadata, ArtworkKind artworkKind) public async Task<Unit> RemoveArtwork(Core.Domain.Metadata metadata, ArtworkKind artworkKind)
{ {
// this is only used by plex, so only needs to support plex media kinds (movie, show, season, episode, other video)
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync( return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id @"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id

8
ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata; namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
@ -14,7 +15,12 @@ public interface ILocalMetadataProvider
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName); Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song); Task<bool> RefreshTagMetadata(Song song);
Task<bool> RefreshTagMetadata(Image image, double? durationSeconds); Task<bool> RefreshTagMetadata(Image image, double? durationSeconds);
Task<bool> RefreshTagMetadata(RemoteStream remoteStream, CancellationToken cancellationToken);
Task<bool> RefreshMetadata(
RemoteStream remoteStream,
YamlRemoteStreamDefinition definition,
CancellationToken cancellationToken);
Task<bool> RefreshFallbackMetadata(Movie movie); Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode); Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder); Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);

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

@ -6,6 +6,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Metadata; using ErsatzTV.Infrastructure.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
@ -16,8 +17,6 @@ namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalMetadataProvider : ILocalMetadataProvider public class LocalMetadataProvider : ILocalMetadataProvider
{ {
private static readonly char[] GenreSeparators = { '/', '|', ';', '\\' };
private readonly IArtistNfoReader _artistNfoReader; private readonly IArtistNfoReader _artistNfoReader;
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly IClient _client; private readonly IClient _client;
@ -25,7 +24,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IFileSystem _fileSystem; private readonly IFileSystem _fileSystem;
private readonly IImageRepository _imageRepository; private readonly IImageRepository _imageRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
@ -52,7 +50,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IRemoteStreamRepository remoteStreamRepository, IRemoteStreamRepository remoteStreamRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
IFileSystem fileSystem, IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader, IMovieNfoReader movieNfoReader,
IEpisodeNfoReader episodeNfoReader, IEpisodeNfoReader episodeNfoReader,
IArtistNfoReader artistNfoReader, IArtistNfoReader artistNfoReader,
@ -74,7 +71,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_remoteStreamRepository = remoteStreamRepository; _remoteStreamRepository = remoteStreamRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_fileSystem = fileSystem; _fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader; _movieNfoReader = movieNfoReader;
_episodeNfoReader = episodeNfoReader; _episodeNfoReader = episodeNfoReader;
_artistNfoReader = artistNfoReader; _artistNfoReader = artistNfoReader;
@ -228,13 +224,19 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await RefreshFallbackMetadata(image); return await RefreshFallbackMetadata(image);
} }
public async Task<bool> RefreshTagMetadata(RemoteStream remoteStream, CancellationToken cancellationToken) => public async Task<bool> RefreshMetadata(
// Option<RemoteStreamMetadata> maybeMetadata = LoadRemoteStreamMetadata(remoteStream); RemoteStream remoteStream,
// foreach (RemoteStreamMetadata metadata in maybeMetadata) YamlRemoteStreamDefinition definition,
// { CancellationToken cancellationToken)
// return await ApplyMetadataUpdate(remoteStream, metadata); {
// } Option<RemoteStreamMetadata> maybeMetadata = LoadRemoteStreamMetadata(remoteStream, definition);
await RefreshFallbackMetadata(remoteStream, cancellationToken); foreach (RemoteStreamMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(remoteStream, metadata, cancellationToken);
}
return await RefreshFallbackMetadata(remoteStream, cancellationToken);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) => public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie)); ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -322,7 +324,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
MetadataKind = MetadataKind.Sidecar, MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName),
Album = nfo.Album, Album = nfo.Album,
Title = nfo.Title, Title = nfo.Title,
Plot = nfo.Plot, Plot = nfo.Plot,
@ -363,7 +365,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
MetadataKind = MetadataKind.Embedded, MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path), DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
Artists = [], Artists = [],
AlbumArtists = [], AlbumArtists = [],
@ -448,7 +450,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
MetadataKind = MetadataKind.Embedded, MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path), DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
Artwork = [], Artwork = [],
@ -500,65 +502,47 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
} }
private Option<RemoteStreamMetadata> LoadRemoteStreamMetadata(RemoteStream remoteStream) private Option<RemoteStreamMetadata> LoadRemoteStreamMetadata(
RemoteStream remoteStream,
YamlRemoteStreamDefinition definition)
{ {
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path; string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
try try
{ {
Either<BaseError, List<SongTag>> maybeTags = _localStatisticsProvider.GetSongTags(remoteStream); Option<RemoteStreamMetadata> maybeFallbackMetadata =
_fallbackMetadataProvider.GetFallbackMetadata(remoteStream);
foreach (List<SongTag> tags in maybeTags.RightToSeq()) var result = new RemoteStreamMetadata
{ {
Option<RemoteStreamMetadata> maybeFallbackMetadata = MetadataKind = MetadataKind.Embedded,
_fallbackMetadataProvider.GetFallbackMetadata(remoteStream); DateAdded = DateTime.UtcNow,
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
var result = new RemoteStreamMetadata
{ Artwork = [],
MetadataKind = MetadataKind.Embedded, Actors = [],
DateAdded = DateTime.UtcNow, Genres = [],
DateUpdated = File.GetLastWriteTimeUtc(path), Studios = [],
Tags = [],
Artwork = [],
Actors = [], Title = definition.Title,
Genres = [], Plot = definition.Plot,
Studios = [], ContentRating = definition.ContentRating,
Tags = [] Year = definition.Year
}; };
foreach (SongTag tag in tags) foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata)
{ {
switch (tag.Tag) result.OriginalTitle = fallbackMetadata.OriginalTitle;
{
case MetadataSongTag.Genre:
result.Genres.Add(new Genre { Name = tag.Value });
break;
case MetadataSongTag.Title:
result.Title = tag.Value;
break;
}
}
foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata) // preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{ {
if (string.IsNullOrWhiteSpace(result.Title)) result.Tags.Add(tag);
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{
result.Tags.Add(tag);
}
} }
return result;
} }
return Option<RemoteStreamMetadata>.None; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -1267,6 +1251,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider
? SortTitle.GetSortTitle(metadata.Title) ? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle; : metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle; existing.OriginalTitle = metadata.OriginalTitle;
existing.Plot = metadata.Plot;
existing.ContentRating = metadata.ContentRating;
bool updated = await UpdateMetadataCollections( bool updated = await UpdateMetadataCollections(
existing, existing,
@ -1304,7 +1290,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (ShowNfo nfo in maybeNfo.RightToSeq()) foreach (ShowNfo nfo in maybeNfo.RightToSeq())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
return new ShowMetadata return new ShowMetadata
{ {
@ -1357,7 +1343,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
MetadataKind = MetadataKind.Sidecar, MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Name, Title = nfo.Name,
Disambiguation = nfo.Disambiguation, Disambiguation = nfo.Disambiguation,
Biography = nfo.Biography, Biography = nfo.Biography,
@ -1394,7 +1380,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (EpisodeNfo nfo in maybeNfo.RightToSeq().Flatten()) foreach (EpisodeNfo nfo in maybeNfo.RightToSeq().Flatten())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata var metadata = new EpisodeMetadata
{ {
@ -1450,7 +1436,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (MovieNfo nfo in maybeNfo.RightToSeq()) foreach (MovieNfo nfo in maybeNfo.RightToSeq())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var year = 0; var year = 0;
if (nfo.Year > 1000) if (nfo.Year > 1000)
@ -1536,7 +1522,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq()) foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq())
{ {
DateTime dateAdded = DateTime.UtcNow; DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var year = 0; var year = 0;
if (nfo.Year > 1000) if (nfo.Year > 1000)
@ -1754,15 +1740,4 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return result; return result;
} }
private static IEnumerable<string> SplitGenres(string genre)
{
char[] delimiters = GenreSeparators.Filter(s => genre.Contains(s, StringComparison.OrdinalIgnoreCase))
.DefaultIfEmpty(',')
.ToArray();
return genre.Split(delimiters, StringSplitOptions.RemoveEmptyEntries)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Select(i => i.Trim());
}
} }

58
ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs

@ -182,10 +182,10 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
Either<BaseError, MediaItemScanResult<RemoteStream>> maybeVideo = await _remoteStreamRepository Either<BaseError, MediaItemScanResult<RemoteStream>> maybeVideo = await _remoteStreamRepository
.GetOrAdd(libraryPath, knownFolder, file, cancellationToken) .GetOrAdd(libraryPath, knownFolder, file, cancellationToken)
.BindT(video => ParseRemoteStreamDefinition(video, deserializer, cancellationToken)) .BindT(video => ParseRemoteStreamDefinition(video, deserializer, cancellationToken))
.BindT(video => UpdateMetadata(video, cancellationToken))
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateLibraryFolderId(video, knownFolder)) .BindT(video => UpdateLibraryFolderId(video, knownFolder))
.BindT(video => UpdateMetadata(video, cancellationToken)) .BindT(video => UpdateThumbnail(video, cancellationToken))
//.BindT(video => UpdateThumbnail(video, cancellationToken))
//.BindT(UpdateSubtitles) //.BindT(UpdateSubtitles)
.BindT(FlagNormal); .BindT(FlagNormal);
@ -261,7 +261,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
return video; return video;
} }
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> ParseRemoteStreamDefinition( private async Task<Either<BaseError, RemoteStreamWithDefinition>> ParseRemoteStreamDefinition(
MediaItemScanResult<RemoteStream> result, MediaItemScanResult<RemoteStream> result,
IDeserializer deserializer, IDeserializer deserializer,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -331,7 +331,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
result.IsUpdated = true; result.IsUpdated = true;
} }
return result; return new RemoteStreamWithDefinition(result, definition);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -341,12 +341,12 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
} }
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateMetadata( private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateMetadata(
MediaItemScanResult<RemoteStream> result, RemoteStreamWithDefinition result,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try try
{ {
RemoteStream remoteStream = result.Item; RemoteStream remoteStream = result.Result.Item;
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path; string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
var shouldUpdate = true; var shouldUpdate = true;
@ -363,12 +363,36 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
remoteStream.RemoteStreamMetadata ??= []; remoteStream.RemoteStreamMetadata ??= [];
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
if (await _localMetadataProvider.RefreshTagMetadata(remoteStream, cancellationToken)) if (await _localMetadataProvider.RefreshMetadata(remoteStream, result.Definition, cancellationToken))
{ {
result.IsUpdated = true; result.Result.IsUpdated = true;
} }
} }
return result.Result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateThumbnail(
MediaItemScanResult<RemoteStream> result,
CancellationToken cancellationToken)
{
try
{
RemoteStream remoteStream = result.Item;
Option<string> maybeThumbnail = LocateThumbnail(remoteStream);
foreach (string thumbnailFile in maybeThumbnail)
{
RemoteStreamMetadata metadata = remoteStream.RemoteStreamMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
}
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@ -377,4 +401,22 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
return BaseError.New(ex.ToString()); return BaseError.New(ex.ToString());
} }
} }
private Option<string> LocateThumbnail(RemoteStream remoteStream)
{
string path = remoteStream.MediaVersions.Head().MediaFiles.Head().Path;
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(path, ext))
.Filter(f => _fileSystem.File.Exists(f))
.HeadOrNone();
}
private class RemoteStreamWithDefinition(
MediaItemScanResult<RemoteStream> result,
YamlRemoteStreamDefinition definition)
{
public MediaItemScanResult<RemoteStream> Result { get; } = result;
public YamlRemoteStreamDefinition Definition { get; } = definition;
}
} }

1
ErsatzTV/ErsatzTV.csproj

@ -108,6 +108,7 @@
<EmbeddedResource Include="Resources\sequential-schedule.schema.json" /> <EmbeddedResource Include="Resources\sequential-schedule.schema.json" />
<EmbeddedResource Include="Resources\empty.sqlite3" /> <EmbeddedResource Include="Resources\empty.sqlite3" />
<EmbeddedResource Include="Resources\test.avs" /> <EmbeddedResource Include="Resources\test.avs" />
<EmbeddedResource Include="Resources\Templates\_remoteStream.sbntxt" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

52
ErsatzTV/Resources/Templates/_remoteStream.sbntxt

@ -0,0 +1,52 @@
{{ ##
Available values:
- programme_start
- programme_stop
- channel_id
- channel_id_legacy
- channel_number
- has_custom_title
- custom_title
- remote_stream_title
- remote_stream_has_plot
- remote_stream_plot
- remote_stream_has_year
- remote_stream_year
- remote_stream_has_artwork
- remote_stream_artwork_url
- remote_stream_has_content_rating
- remote_stream_content_rating
The resulting XML will be minified by ErsatzTV - so feel free to keep things nicely formatted here.
## }}
<programme start="{{ programme_start }}" stop="{{ programme_stop }}" channel="{{ channel_id }}">
{{ if has_custom_title }}
<title lang="en">{{ custom_title }}</title>
{{ else }}
<title lang="en">{{ remote_stream_title }}</title>
{{ if remote_stream_has_plot }}
<desc lang="en">{{ remote_stream_plot }}</desc>
{{ end }}
{{ if remote_stream_has_year }}
<date>{{ remote_stream_year }}</date>
{{ end }}
{{ if remote_stream_has_artwork }}
<icon src="{{ remote_stream_artwork_url }}" />
{{ end }}
{{ end }}
{{ if remote_stream_has_content_rating }}
{{ for rating in remote_stream_content_rating | string.split '/' }}
{{ if rating | string.starts_with 'us:' }}
<rating system="MPAA">
{{ else }}
<rating>
{{ end }}
<value>{{ rating | string.replace 'us:' '' }}</value>
</rating>
{{ end }}
{{ end }}
<previously-shown />
</programme>

6
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -86,6 +86,12 @@ public class ResourceExtractorService : BackgroundService
FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken); stoppingToken);
await ExtractTemplateResource(
assembly,
"_remoteStream.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractScriptResource( await ExtractScriptResource(
assembly, assembly,
"_threePartEpisodes.js", "_threePartEpisodes.js",

Loading…
Cancel
Save