Browse Source

add channel guide templates (#1596)

* generate channels xmltv fragment from template

* generate movie xmltv fragment from template

* generate episode xmltv fragment from template

* add channel guide template changelog
pull/1597/head
Jason Dove 1 year ago committed by GitHub
parent
commit
69f9b6f137
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 173
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  3. 77
      ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs
  4. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj
  5. 2
      ErsatzTV.Core/FileSystemLayout.cs
  6. 3
      ErsatzTV/ErsatzTV.csproj
  7. 30
      ErsatzTV/Resources/Templates/_channel.sbntxt
  8. 63
      ErsatzTV/Resources/Templates/_episode.sbntxt
  9. 56
      ErsatzTV/Resources/Templates/_movie.sbntxt
  10. 18
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  11. 61
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -15,6 +15,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- When enabled, embedded text subtitles will be periodically extracted, and considered for playback - When enabled, embedded text subtitles will be periodically extracted, and considered for playback
- Add `sub_language` and `sub_language_tag` fields to search index - Add `sub_language` and `sub_language_tag` fields to search index
- Add `/iptv` request logging to streaming log category at debug level - Add `/iptv` request logging to streaming log category at debug level
- Add channel guide (XMLTV) template system
- Templates should be copied from `_channel.sbntxt`, `_movie.sbntxt`, or `_episode.sbntxt` which are located in the config subfolder `templates/channel-guide`
- Copy the file, remove the leading underscore from the name, and only make edits to the copied file
- The default templates will be extracted and overwritten every time ErsatzTV is started
- The templates use [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
- The templates contain comments describing which fields are available for use in the templates
### Fixed ### Fixed
- Fix antiforgery error caused by reusing existing browser tabs across docker container restarts - Fix antiforgery error caused by reusing existing browser tabs across docker container restarts

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

@ -12,6 +12,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
using Newtonsoft.Json; using Newtonsoft.Json;
using Scriban;
using WebMarkupMin.Core;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -38,6 +40,27 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{ {
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
@ -46,6 +69,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata) .ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season) .ThenInclude(i => (i as Episode).Season)
@ -57,7 +81,13 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(i => (i as Episode).Season) .ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(em => em.Genres) .ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(i => (i as Movie).MovieMetadata)
@ -68,6 +98,10 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(p => p.Items) .Include(p => p.Items)
.ThenInclude(i => i.MediaItem) .ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(p => p.Items) .Include(p => p.Items)
@ -167,6 +201,97 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string description = GetDescription(displayItem); string description = GetDescription(displayItem);
Option<ContentRating> contentRating = GetContentRating(displayItem); Option<ContentRating> contentRating = GetContentRating(displayItem);
if (displayItem.MediaItem is Movie templateMovie)
{
foreach (MovieMetadata metadata in templateMovie.MovieMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
string result = await movieTemplate.RenderAsync(
new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle,
MovieTitle = title,
MovieHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
MoviePlot = metadata.Plot,
MovieHasYear = metadata.Year.HasValue,
MovieYear = metadata.Year,
MovieGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
MovieHasArtwork = !string.IsNullOrWhiteSpace(poster),
MovieArtworkUrl = poster,
MovieHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
MovieContentRating = metadata.ContentRating,
MovieGuids = metadata.Guids.Map(g => g.Guid)
});
MarkupMinificationResult minified = minifier.Minify(result);
await xml.WriteRawAsync(minified.MinifiedContent);
}
i++;
continue;
}
if (displayItem.MediaItem is Episode templateEpisode)
{
foreach (EpisodeMetadata metadata in templateEpisode.EpisodeMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
foreach (ShowMetadata showMetadata in Optional(
templateEpisode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten())
{
showMetadata.Genres ??= [];
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
string result = await episodeTemplate.RenderAsync(
new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelNumber = request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
CustomTitle = displayItem.CustomTitle,
ShowTitle = title,
EpisodeHasTitle = !string.IsNullOrWhiteSpace(subtitle),
EpisodeTitle = subtitle,
EpisodeHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
EpisodePlot = metadata.Plot,
ShowHasYear = showMetadata.Year.HasValue,
ShowYear = showMetadata.Year,
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
EpisodeNumber = metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
ShowContentRating = showMetadata.ContentRating,
ShowGuids = showMetadata.Guids.Map(g => g.Guid),
EpisodeGuids = metadata.Guids.Map(g => g.Guid)
});
MarkupMinificationResult minified = minifier.Minify(result);
await xml.WriteRawAsync(minified.MinifiedContent);
}
}
i++;
continue;
}
await xml.WriteStartElementAsync(null, "programme", null); await xml.WriteStartElementAsync(null, "programme", null);
await xml.WriteAttributeStringAsync(null, "start", null, start); await xml.WriteAttributeStringAsync(null, "start", null, start);
await xml.WriteAttributeStringAsync(null, "stop", null, stop); await xml.WriteAttributeStringAsync(null, "stop", null, stop);
@ -381,6 +506,52 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
File.Move(tempFile, targetFile, true); File.Move(tempFile, targetFile, true);
} }
private string GetMovieTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private string GetEpisodeTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate episode 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;

77
ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs

@ -5,7 +5,10 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
using Scriban;
using WebMarkupMin.Core;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -13,16 +16,19 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public RefreshChannelListHandler( public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem) ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger)
{ {
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger;
} }
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken) public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
@ -31,6 +37,35 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
templateFileName);
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
string text = await File.ReadAllTextAsync(templateFileName, cancellationToken);
var template = Template.Parse(text, templateFileName);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream(); await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create( await using var xml = XmlWriter.Create(
ms, ms,
@ -38,34 +73,18 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken)) await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
{ {
await xml.WriteStartElementAsync(null, "channel", null); string result = await template.RenderAsync(
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv"); new
{
await xml.WriteStartElementAsync(null, "display-name", null); ChannelNumber = channel.Number,
await xml.WriteStringAsync($"{channel.Number} {channel.Name}"); ChannelName = channel.Name,
await xml.WriteEndElementAsync(); // display-name (number and name) ChannelCategories = GetCategories(channel.Categories),
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
await xml.WriteStartElementAsync(null, "display-name", null); ChannelArtworkPath = channel.ArtworkPath
await xml.WriteStringAsync(channel.Number); });
await xml.WriteEndElementAsync(); // display-name (number)
MarkupMinificationResult minified = minifier.Minify(result);
await xml.WriteStartElementAsync(null, "display-name", null); await xml.WriteRawAsync(minified.MinifiedContent);
await xml.WriteStringAsync(channel.Name);
await xml.WriteEndElementAsync(); // display-name (name)
foreach (string category in GetCategories(channel.Categories))
{
await xml.WriteStartElementAsync(null, "category", null);
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
await xml.WriteStringAsync(category);
await xml.WriteEndElementAsync(); // category
}
await xml.WriteStartElementAsync(null, "icon", null);
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
await xml.WriteEndElementAsync(); // icon
await xml.WriteEndElementAsync(); // channel
} }
await xml.FlushAsync(); await xml.FlushAsync();

1
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -21,6 +21,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" /> <PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" /> <PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
</ItemGroup> </ItemGroup>

2
ErsatzTV.Core/FileSystemLayout.cs

@ -53,6 +53,8 @@ public static class FileSystemLayout
public static readonly string MusicVideoCreditsTemplatesFolder = public static readonly string MusicVideoCreditsTemplatesFolder =
Path.Combine(TemplatesFolder, "music-video-credits"); Path.Combine(TemplatesFolder, "music-video-credits");
public static readonly string ChannelGuideTemplatesFolder = Path.Combine(TemplatesFolder, "channel-guide");
public static readonly string ScriptsFolder = Path.Combine(AppDataFolder, "scripts"); public static readonly string ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
public static readonly string MultiEpisodeShuffleTemplatesFolder = public static readonly string MultiEpisodeShuffleTemplatesFolder =

3
ErsatzTV/ErsatzTV.csproj

@ -73,6 +73,9 @@
<EmbeddedResource Include="Resources\Templates\_default.ass.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_default.ass.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_ArtistTitle_LeftMiddle.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_ArtistTitle_LeftMiddle.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_ArtistTitleAlbum_CenterTop.sbntxt" /> <EmbeddedResource Include="Resources\Templates\_ArtistTitleAlbum_CenterTop.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_channel.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_episode.sbntxt" />
<EmbeddedResource Include="Resources\Templates\_movie.sbntxt" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

30
ErsatzTV/Resources/Templates/_channel.sbntxt

@ -0,0 +1,30 @@
{{ ##
Available values:
- channel_number
- channel_name
- channel_categories
- channel_has_artwork
- channel_artwork_path
{RequestBase} and {AccessTokenUri} are replaced dynamically when XMLTV is requested,
and must remain as-is in this template to work properly with ETV URLs.
External URLs do not require these placeholders.
The resulting XML will be minified by ErsatzTV - so feel free to keep things nicely formatted here.
## }}
<channel id="{{ channel_number }}.etv">
<display-name>{{ channel_number }} {{ channel_name }}</display-name>
<display-name>{{ channel_number }}</display-name>
<display-name>{{ channel_name }}</display-name>
{{ for category in channel_categories }}
<category lang="en">{{ category }}</category>
{{ end }}
{{ if channel_has_artwork }}
<icon src="{RequestBase}/iptv/logos/{{ channel_artwork_path }}.jpg{AccessTokenUri}" />
{{ else }}
<icon src="{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}" />
{{ end }}
</channel>

63
ErsatzTV/Resources/Templates/_episode.sbntxt

@ -0,0 +1,63 @@
{{ ##
Available values:
- programme_start
- programme_stop
- channel_number
- has_custom_title
- custom_title
- show_title
- episode_has_title
- episode_title
- episode_has_plot
- episode_plot
- show_has_year
- show_year
- show_genres
- episode_has_artwork
- episode_artwork_url
- season_number
- episode_number
- show_has_content_rating
- show_content_rating
- show_guids
- episode_guids
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_number }}.etv">
{{ if has_custom_title }}
<title lang="en">{{ custom_title }}</title>
{{ else }}
<title lang="en">{{ show_title }}</title>
{{ if episode_has_title }}
<sub-title lang="en">{{ episode_title }}</sub-title>
{{ end }}
{{ if episode_has_plot }}
<desc lang="en">{{ episode_plot }}</desc>
{{ end }}
<category lang="en">Series</category>
{{ for genre in show_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ if episode_has_artwork }}
<icon src="{{ episode_artwork_url }}" />
{{ end }}
{{ end }}
<episode-num system="onscreen">S{{ season_number | math.format '00' }}E{{ episode_number | math.format '00' }}</episode-num>
<episode-num system="xmltv_ns">{{ season_number - 1 }}.{{ episode_number - 1 }}.0/1</episode-num>
{{ if show_has_content_rating }}
{{ for rating in show_content_rating | string.split '/' }}
{{ if rating | string.downcase | string.starts_with 'us:' }}
<rating system="VCHIP">
{{ else }}
<rating>
{{ end }}
<value>{{ rating | string.replace 'us:' '' | string.replace 'US:' '' }}</value>
</rating>
{{ end }}
{{ end }}
<previously-shown />
</programme>

56
ErsatzTV/Resources/Templates/_movie.sbntxt

@ -0,0 +1,56 @@
{{ ##
Available values:
- programme_start
- programme_stop
- channel_number
- has_custom_title
- custom_title
- movie_title
- movie_has_plot
- movie_plot
- movie_has_year
- movie_year
- movie_genres
- movie_has_artwork
- movie_artwork_url
- movie_has_content_rating
- movie_content_rating
- movie_guids
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_number }}.etv">
{{ if has_custom_title }}
<title lang="en">{{ custom_title }}</title>
{{ else }}
<title lang="en">{{ movie_title }}</title>
{{ if movie_has_plot }}
<desc lang="en">{{ movie_plot }}</desc>
{{ end }}
{{ if movie_has_year }}
<date>{{ movie_year }}</date>
{{ end }}
<category lang="en">Movie</category>
{{ for genre in movie_genres }}
<category lang="en">{{ genre }}</category>
{{ end }}
{{ if movie_has_artwork }}
<icon src="{{ movie_artwork_url }}" />
{{ end }}
{{ end }}
{{ if movie_has_content_rating }}
{{ for rating in movie_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>

18
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -42,6 +42,24 @@ public class ResourceExtractorService : BackgroundService
"_ArtistTitleAlbum_CenterTop.sbntxt", "_ArtistTitleAlbum_CenterTop.sbntxt",
FileSystemLayout.MusicVideoCreditsTemplatesFolder, FileSystemLayout.MusicVideoCreditsTemplatesFolder,
stoppingToken); stoppingToken);
await ExtractTemplateResource(
assembly,
"_channel.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractTemplateResource(
assembly,
"_movie.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractTemplateResource(
assembly,
"_episode.sbntxt",
FileSystemLayout.ChannelGuideTemplatesFolder,
stoppingToken);
await ExtractScriptResource( await ExtractScriptResource(
assembly, assembly,

61
ErsatzTV/Startup.cs

@ -327,49 +327,26 @@ public class Startup
"https://github.com/ErsatzTV/ErsatzTV", "https://github.com/ErsatzTV/ErsatzTV",
"https://discord.gg/hHaJm3yGy6"); "https://discord.gg/hHaJm3yGy6");
if (!Directory.Exists(FileSystemLayout.AppDataFolder)) List<string> directoriesToCreate =
[
FileSystemLayout.AppDataFolder,
FileSystemLayout.TranscodeFolder,
FileSystemLayout.TempFilePoolFolder,
FileSystemLayout.FontsCacheFolder,
FileSystemLayout.TemplatesFolder,
FileSystemLayout.MusicVideoCreditsTemplatesFolder,
FileSystemLayout.ChannelGuideTemplatesFolder,
FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder
];
foreach (string directory in directoriesToCreate)
{ {
Directory.CreateDirectory(FileSystemLayout.AppDataFolder); if (directory is not null && !Directory.Exists(directory))
} {
Directory.CreateDirectory(directory);
if (!Directory.Exists(FileSystemLayout.TranscodeFolder)) }
{
Directory.CreateDirectory(FileSystemLayout.TranscodeFolder);
}
if (!Directory.Exists(FileSystemLayout.TempFilePoolFolder))
{
Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder);
}
if (!Directory.Exists(FileSystemLayout.FontsCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.FontsCacheFolder);
}
if (!Directory.Exists(FileSystemLayout.TemplatesFolder))
{
Directory.CreateDirectory(FileSystemLayout.TemplatesFolder);
}
if (!Directory.Exists(FileSystemLayout.MusicVideoCreditsTemplatesFolder))
{
Directory.CreateDirectory(FileSystemLayout.MusicVideoCreditsTemplatesFolder);
}
if (!Directory.Exists(FileSystemLayout.ScriptsFolder))
{
Directory.CreateDirectory(FileSystemLayout.ScriptsFolder);
}
if (!Directory.Exists(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder))
{
Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder);
}
if (!Directory.Exists(FileSystemLayout.AudioStreamSelectorScriptsFolder))
{
Directory.CreateDirectory(FileSystemLayout.AudioStreamSelectorScriptsFolder);
} }
// until we add a setting for a file-specific scheme://host:port to access // until we add a setting for a file-specific scheme://host:port to access

Loading…
Cancel
Save