Browse Source

add remote stream metadata

pull/2690/head
Jason Dove 8 months ago
parent
commit
face568465
No known key found for this signature in database
  1. 4
      CHANGELOG.md
  2. 100
      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. 9
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  13. 8
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  14. 113
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  15. 57
      ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs
  16. 1
      ErsatzTV/ErsatzTV.csproj
  17. 52
      ErsatzTV/Resources/Templates/_remoteStream.sbntxt
  18. 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

100
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)
@ -243,6 +252,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -268,6 +278,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -291,6 +302,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml, xml,
cancellationToken); cancellationToken);
@ -320,6 +332,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 +407,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -410,6 +424,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 +480,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
} }
@ -504,6 +520,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate, musicVideoTemplate,
songTemplate, songTemplate,
otherVideoTemplate, otherVideoTemplate,
remoteStreamTemplate,
minifier, minifier,
xml); xml);
@ -527,6 +544,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 +606,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 +911,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 +1055,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 +1129,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");

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

@ -300,6 +300,11 @@ 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
}; };
} }
@ -309,7 +314,9 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
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
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id OR OtherVideoMetadataId = @Id)", OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id
OR ArtistMetadataId = @Id OR MusicVideoMetadataId = @Id OR SongMetadataId = @Id
OR OtherVideoMetadataId = @Id OR RemoteStreamMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit(); new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
} }

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);

113
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));
@ -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 = 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,
@ -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());
}
} }

57
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,21 @@ 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)
: MediaItem
{
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