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/). @@ -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`)
- Draw order (`z_index`)
- 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
- Fix startup on systems unsupported by NvEncSharp

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

@ -73,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -73,9 +73,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{
return;
}
@ -105,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -105,6 +107,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
@ -193,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -193,6 +198,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.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(vm => vm.Artwork)
.Include(p => p.Items)
@ -243,6 +252,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -243,6 +252,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -268,6 +278,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -268,6 +278,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -291,6 +302,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -291,6 +302,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -320,6 +332,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -320,6 +332,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@ -394,6 +407,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -394,6 +407,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@ -410,6 +424,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -410,6 +424,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@ -465,6 +480,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -465,6 +480,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
@ -504,6 +520,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -504,6 +520,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@ -527,6 +544,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -527,6 +544,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
@ -588,6 +606,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -588,6 +606,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title,
templateContext,
otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None
};
@ -883,6 +911,55 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -883,6 +911,55 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
@ -978,6 +1055,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -978,6 +1055,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
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)
{
string artworkPath = artwork.Path;
@ -1033,6 +1129,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -1033,6 +1129,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}

2
ErsatzTV.Application/MediaCards/Mapper.cs

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

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

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

17
ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs

@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Streaming; @@ -4,8 +4,13 @@ namespace ErsatzTV.Core.Streaming;
public class YamlRemoteStreamDefinition
{
[YamlMember(Alias = "url", ApplyNamingConventions = false)]
public string Url { get; set; }
[YamlMember(Alias = "script", ApplyNamingConventions = false)]
public string Script { get; set; }
[YamlMember(Alias = "duration", ApplyNamingConventions = false)]
public string Duration { get; set; }
[YamlMember(Alias = "fallback_query", ApplyNamingConventions = false)]
@ -13,4 +18,16 @@ public class YamlRemoteStreamDefinition @@ -13,4 +18,16 @@ public class YamlRemoteStreamDefinition
[YamlMember(Alias = "is_live", ApplyNamingConventions = false)]
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 @@ @@ -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 @@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -2467,6 +2467,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2467,6 +2467,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentRating")
.HasColumnType("longtext");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime(6)");
@ -2479,6 +2482,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2479,6 +2482,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("OriginalTitle")
.HasColumnType("longtext");
b.Property<string>("Plot")
.HasColumnType("longtext");
b.Property<DateTime?>("ReleaseDate")
.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 @@ @@ -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 @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.10");
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -2356,6 +2356,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2356,6 +2356,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@ -2368,6 +2371,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2368,6 +2371,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");

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

@ -300,6 +300,11 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -300,6 +300,11 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
parameters)
.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
};
}
@ -309,7 +314,9 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -309,7 +314,9 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"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();
}

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
@ -14,7 +15,12 @@ public interface ILocalMetadataProvider @@ -14,7 +15,12 @@ public interface ILocalMetadataProvider
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song);
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(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);

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

@ -6,6 +6,7 @@ using ErsatzTV.Core.Extensions; @@ -6,6 +6,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Interfaces.Metadata.Nfo;
@ -16,8 +17,6 @@ namespace ErsatzTV.Scanner.Core.Metadata; @@ -16,8 +17,6 @@ namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly char[] GenreSeparators = { '/', '|', ';', '\\' };
private readonly IArtistNfoReader _artistNfoReader;
private readonly IArtistRepository _artistRepository;
private readonly IClient _client;
@ -25,7 +24,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -25,7 +24,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IFileSystem _fileSystem;
private readonly IImageRepository _imageRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
@ -52,7 +50,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -52,7 +50,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IRemoteStreamRepository remoteStreamRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader,
IEpisodeNfoReader episodeNfoReader,
IArtistNfoReader artistNfoReader,
@ -74,7 +71,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -74,7 +71,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_remoteStreamRepository = remoteStreamRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader;
_episodeNfoReader = episodeNfoReader;
_artistNfoReader = artistNfoReader;
@ -228,13 +224,19 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -228,13 +224,19 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await RefreshFallbackMetadata(image);
}
public async Task<bool> RefreshTagMetadata(RemoteStream remoteStream, CancellationToken cancellationToken) =>
// Option<RemoteStreamMetadata> maybeMetadata = LoadRemoteStreamMetadata(remoteStream);
// foreach (RemoteStreamMetadata metadata in maybeMetadata)
// {
// return await ApplyMetadataUpdate(remoteStream, metadata);
// }
await RefreshFallbackMetadata(remoteStream, cancellationToken);
public async Task<bool> RefreshMetadata(
RemoteStream remoteStream,
YamlRemoteStreamDefinition definition,
CancellationToken cancellationToken)
{
Option<RemoteStreamMetadata> maybeMetadata = LoadRemoteStreamMetadata(remoteStream, definition);
foreach (RemoteStreamMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(remoteStream, metadata, cancellationToken);
}
return await RefreshFallbackMetadata(remoteStream, cancellationToken);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -500,65 +502,47 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -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;
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 =
_fallbackMetadataProvider.GetFallbackMetadata(remoteStream);
var result = new RemoteStreamMetadata
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = [],
Actors = [],
Genres = [],
Studios = [],
Tags = []
};
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = [],
Actors = [],
Genres = [],
Studios = [],
Tags = [],
Title = definition.Title,
Plot = definition.Plot,
ContentRating = definition.ContentRating,
Year = definition.Year
};
foreach (SongTag tag in tags)
{
switch (tag.Tag)
{
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)
{
result.OriginalTitle = fallbackMetadata.OriginalTitle;
foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata)
// preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{
result.Tags.Add(tag);
}
result.Tags.Add(tag);
}
return result;
}
return Option<RemoteStreamMetadata>.None;
return result;
}
catch (Exception ex)
{
@ -1267,6 +1251,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1267,6 +1251,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider
? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle;
existing.Plot = metadata.Plot;
existing.ContentRating = metadata.ContentRating;
bool updated = await UpdateMetadataCollections(
existing,
@ -1754,15 +1740,4 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1754,15 +1740,4 @@ public class LocalMetadataProvider : ILocalMetadataProvider
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 @@ -182,10 +182,10 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
Either<BaseError, MediaItemScanResult<RemoteStream>> maybeVideo = await _remoteStreamRepository
.GetOrAdd(libraryPath, knownFolder, file, cancellationToken)
.BindT(video => ParseRemoteStreamDefinition(video, deserializer, cancellationToken))
.BindT(video => UpdateMetadata(video, cancellationToken))
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateLibraryFolderId(video, knownFolder))
.BindT(video => UpdateMetadata(video, cancellationToken))
//.BindT(video => UpdateThumbnail(video, cancellationToken))
.BindT(video => UpdateThumbnail(video, cancellationToken))
//.BindT(UpdateSubtitles)
.BindT(FlagNormal);
@ -261,7 +261,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -261,7 +261,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
return video;
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> ParseRemoteStreamDefinition(
private async Task<Either<BaseError, RemoteStreamWithDefinition>> ParseRemoteStreamDefinition(
MediaItemScanResult<RemoteStream> result,
IDeserializer deserializer,
CancellationToken cancellationToken)
@ -331,7 +331,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -331,7 +331,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
result.IsUpdated = true;
}
return result;
return new RemoteStreamWithDefinition(result, definition);
}
catch (Exception ex)
{
@ -341,12 +341,12 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -341,12 +341,12 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateMetadata(
MediaItemScanResult<RemoteStream> result,
RemoteStreamWithDefinition result,
CancellationToken cancellationToken)
{
try
{
RemoteStream remoteStream = result.Item;
RemoteStream remoteStream = result.Result.Item;
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
var shouldUpdate = true;
@ -363,12 +363,36 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -363,12 +363,36 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
remoteStream.RemoteStreamMetadata ??= [];
_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;
}
catch (Exception ex)
@ -377,4 +401,21 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -377,4 +401,21 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
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 @@ @@ -108,6 +108,7 @@
<EmbeddedResource Include="Resources\sequential-schedule.schema.json" />
<EmbeddedResource Include="Resources\empty.sqlite3" />
<EmbeddedResource Include="Resources\test.avs" />
<EmbeddedResource Include="Resources\Templates\_remoteStream.sbntxt" />
</ItemGroup>
<ItemGroup>

52
ErsatzTV/Resources/Templates/_remoteStream.sbntxt

@ -0,0 +1,52 @@ @@ -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 @@ -86,6 +86,12 @@ public class ResourceExtractorService : BackgroundService
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractTemplateResource(
assembly,
"_remoteStream.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractScriptResource(
assembly,
"_threePartEpisodes.js",

Loading…
Cancel
Save