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 1 month 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. 93
      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/). @@ -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

101
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)
@ -203,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -203,6 +212,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.AsSplitQuery()
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
@ -243,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -243,6 +253,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -268,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -268,6 +279,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -291,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -291,6 +303,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
@ -320,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -320,6 +333,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@ -394,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -394,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@ -410,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -410,6 +425,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
@ -465,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -465,6 +481,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
@ -504,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -504,6 +521,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@ -527,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -527,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
@ -588,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -588,6 +607,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 +912,55 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -883,6 +912,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 +1056,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -978,6 +1056,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 +1130,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData> @@ -1033,6 +1130,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");

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

@ -20,7 +20,7 @@ public class ArtworkRepository : IArtworkRepository @@ -20,7 +20,7 @@ public class ArtworkRepository : IArtworkRepository
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId 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)")
.Map(result => result.ToList());
}

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

@ -300,12 +300,18 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -300,12 +300,18 @@ 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
};
}
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();
return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id

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

93
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));
@ -322,7 +324,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -322,7 +324,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName),
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
@ -363,7 +365,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -363,7 +365,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
Artists = [],
AlbumArtists = [],
@ -448,7 +450,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -448,7 +450,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
DurationSeconds = durationSeconds,
Artwork = [],
@ -500,15 +502,13 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -500,15 +502,13 @@ 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);
foreach (List<SongTag> tags in maybeTags.RightToSeq())
{
Option<RemoteStreamMetadata> maybeFallbackMetadata =
_fallbackMetadataProvider.GetFallbackMetadata(remoteStream);
@ -517,35 +517,22 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -517,35 +517,22 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(path),
Artwork = [],
Actors = [],
Genres = [],
Studios = [],
Tags = []
};
Tags = [],
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;
}
}
Title = definition.Title,
Plot = definition.Plot,
ContentRating = definition.ContentRating,
Year = definition.Year
};
foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata)
{
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging
@ -557,9 +544,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -557,9 +544,6 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return result;
}
return Option<RemoteStreamMetadata>.None;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read embedded remote stream metadata from {Path}", path);
@ -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,
@ -1304,7 +1290,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1304,7 +1290,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (ShowNfo nfo in maybeNfo.RightToSeq())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
return new ShowMetadata
{
@ -1357,7 +1343,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1357,7 +1343,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
DateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Name,
Disambiguation = nfo.Disambiguation,
Biography = nfo.Biography,
@ -1394,7 +1380,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1394,7 +1380,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (EpisodeNfo nfo in maybeNfo.RightToSeq().Flatten())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{
@ -1450,7 +1436,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1450,7 +1436,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (MovieNfo nfo in maybeNfo.RightToSeq())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var year = 0;
if (nfo.Year > 1000)
@ -1536,7 +1522,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1536,7 +1522,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
DateTime dateUpdated = _fileSystem.File.GetLastWriteTimeUtc(nfoFileName);
var year = 0;
if (nfo.Year > 1000)
@ -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());
}
}

58
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,22 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder @@ -377,4 +401,22 @@ 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)
{
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