Browse Source

channel logo and watermark fixes (#2100)

* channel logo and watermark fixes

* update changelog
pull/2101/head
Jason Dove 1 year ago committed by GitHub
parent
commit
f6249d9fa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 17
      ErsatzTV.Application/Artworks/ArtworkContentTypeModel.cs
  3. 3
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  4. 5
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  5. 5
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  6. 5
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  7. 13
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  8. 11
      ErsatzTV.Application/Channels/Mapper.cs
  9. 2
      ErsatzTV.Application/Images/Commands/SaveArtworkToDisk.cs
  10. 2
      ErsatzTV.Application/Images/Queries/GetCachedImagePath.cs
  11. 10
      ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs
  12. 5
      ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs
  13. 40
      ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs
  14. 5
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs
  15. 10
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  16. 5
      ErsatzTV.Application/Watermarks/Mapper.cs
  17. 2
      ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdHandler.cs
  18. 5
      ErsatzTV.Application/Watermarks/WatermarkViewModel.cs
  19. 1
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  20. 1
      ErsatzTV.Core/Domain/Metadata/Artwork.cs
  21. 6
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  22. 5908
      ErsatzTV.Infrastructure.MySql/Migrations/20250701110522_Add_ArtworkOriginalContentType.Designer.cs
  23. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250701110522_Add_ArtworkOriginalContentType.cs
  24. 5911
      ErsatzTV.Infrastructure.MySql/Migrations/20250701115339_Add_ChannelWatermarkOriginalContentType.Designer.cs
  25. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250701115339_Add_ChannelWatermarkOriginalContentType.cs
  26. 6
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  27. 5747
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250701110542_Add_ArtworkOriginalContentType.Designer.cs
  28. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250701110542_Add_ArtworkOriginalContentType.cs
  29. 5750
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250701115311_Add_ChannelWatermarkOriginalContentType.Designer.cs
  30. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250701115311_Add_ChannelWatermarkOriginalContentType.cs
  31. 6
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  32. 10
      ErsatzTV/Controllers/ArtworkController.cs
  33. 4
      ErsatzTV/Controllers/IptvController.cs
  34. 14
      ErsatzTV/Pages/ChannelEditor.razor
  35. 4
      ErsatzTV/Pages/Channels.razor
  36. 11
      ErsatzTV/Pages/WatermarkEditor.razor
  37. 4
      ErsatzTV/Pages/Watermarks.razor
  38. 9
      ErsatzTV/ViewModels/ChannelEditViewModel.cs
  39. 5
      ErsatzTV/ViewModels/WatermarkEditViewModel.cs

4
CHANGELOG.md

@ -69,6 +69,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix audio sync issue with QSV acceleration - Fix audio sync issue with QSV acceleration
- YAML playout: fix history for marathon and playlist content - YAML playout: fix history for marathon and playlist content
- This allows playouts to be extended correctly, instead of always resetting to the earliest item in each group - This allows playouts to be extended correctly, instead of always resetting to the earliest item in each group
- Fix using channel External Logo URL as watermark
- Fix display of SVG channel logo and watermark in admin UI
- Existing SVG logos and watermarks will have to be re-uploaded to display properly in the admin UI
- This does not affect streaming at all; existing artwork still works fine for streaming
## [25.2.0] - 2025-06-24 ## [25.2.0] - 2025-06-24
### Added ### Added

17
ErsatzTV.Application/Artworks/ArtworkContentTypeModel.cs

@ -0,0 +1,17 @@
using System.Net;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record ArtworkContentTypeModel(string Path, string ContentType)
{
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
}

3
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using System.Net; using System.Net;
using ErsatzTV.Application.Artworks;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -10,7 +11,7 @@ public record ChannelViewModel(
string Group, string Group,
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode, ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,

5
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -9,7 +10,7 @@ public record CreateChannel(
string Group, string Group,
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode, ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,

5
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -49,9 +49,9 @@ public class CreateChannelHandler(
fillerPresetId) => fillerPresetId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{ {
string logo = request.Logo; string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{ {
logo = logo.Replace("iptv/logos/", string.Empty); logo = logo.Replace("iptv/logos/", string.Empty);
@ -62,6 +62,7 @@ public class CreateChannelHandler(
{ {
Path = logo, Path = logo,
ArtworkKind = ArtworkKind.Logo, ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow DateUpdated = DateTime.UtcNow
}); });

5
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -10,7 +11,7 @@ public record UpdateChannel(
string Group, string Group,
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode, ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector, string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,

13
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -1,5 +1,4 @@
using System.Globalization; using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Subtitles; using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -47,9 +46,9 @@ public class UpdateChannelHandler(
c.ActiveMode = update.ActiveMode; c.ActiveMode = update.ActiveMode;
c.Artwork ??= []; c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo)) if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
{ {
string logo = update.Logo; string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal)) if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{ {
logo = logo.Replace("iptv/logos/", string.Empty); logo = logo.Replace("iptv/logos/", string.Empty);
@ -59,6 +58,9 @@ public class UpdateChannelHandler(
foreach (Artwork artwork in maybeLogo) foreach (Artwork artwork in maybeLogo)
{ {
artwork.Path = logo; artwork.Path = logo;
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null;
artwork.DateUpdated = DateTime.UtcNow; artwork.DateUpdated = DateTime.UtcNow;
} }
@ -67,6 +69,9 @@ public class UpdateChannelHandler(
var artwork = new Artwork var artwork = new Artwork
{ {
Path = logo, Path = logo,
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow, DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo ArtworkKind = ArtworkKind.Logo

11
ErsatzTV.Application/Channels/Mapper.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Api.Channels; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@ -45,7 +46,7 @@ internal static class Mapper
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) => internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate); new(resolution.Height, resolution.Width, bitrate);
private static string GetLogo(Channel channel) private static ArtworkContentTypeModel GetLogo(Channel channel)
{ {
Option<Artwork> maybeArtwork = channel.Artwork Option<Artwork> maybeArtwork = channel.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo) .Where(a => a.ArtworkKind == ArtworkKind.Logo)
@ -53,10 +54,12 @@ internal static class Mapper
foreach (Artwork artwork in maybeArtwork) foreach (Artwork artwork in maybeArtwork)
{ {
return artwork.IsExternalUrl() ? artwork.Path : $"iptv/logos/{artwork.Path}"; return artwork.IsExternalUrl()
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
} }
return string.Empty; return ArtworkContentTypeModel.None;
} }
private static string GetStreamingMode(Channel channel) => private static string GetStreamingMode(Channel channel) =>

2
ErsatzTV.Application/Images/Commands/SaveArtworkToDisk.cs

@ -4,4 +4,4 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images; namespace ErsatzTV.Application.Images;
// ReSharper disable once SuggestBaseTypeForParameter // ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>; public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind, string ContentType) : IRequest<Either<BaseError, string>>;

2
ErsatzTV.Application/Images/Queries/GetCachedImagePath.cs

@ -3,5 +3,5 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images; namespace ErsatzTV.Application.Images;
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest< public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null) : IRequest<
Either<BaseError, CachedImagePathViewModel>>; Either<BaseError, CachedImagePathViewModel>>;

10
ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs

@ -42,7 +42,7 @@ public class
{ {
try try
{ {
MimeType mimeType; string mimeType;
string cachePath = _imageCache.GetPathForImage( string cachePath = _imageCache.GetPathForImage(
request.FileName, request.FileName,
@ -84,7 +84,7 @@ public class
File.Move(withExtension, cachePath); File.Move(withExtension, cachePath);
mimeType = new MimeType("image/jpeg"); mimeType = "image/jpeg";
} }
else else
{ {
@ -93,10 +93,12 @@ public class
} }
else else
{ {
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath); mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
? request.ContentType
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
} }
return new CachedImagePathViewModel(cachePath, mimeType.Name); return new CachedImagePathViewModel(cachePath, mimeType);
} }
catch (Exception ex) catch (Exception ex)
{ {

5
ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
@ -6,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
public record CreateWatermark( public record CreateWatermark(
string Name, string Name,
string Image, ArtworkContentTypeModel Image,
ChannelWatermarkMode Mode, ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource, ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location, WatermarkLocation Location,

40
ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs

@ -39,21 +39,33 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
private static Validation<BaseError, ChannelWatermark> Validate(CreateWatermark request) => private static Validation<BaseError, ChannelWatermark> Validate(CreateWatermark request) =>
ValidateName(request) ValidateName(request)
.Map( .Map(
_ => new ChannelWatermark _ =>
{ {
Name = request.Name, var watermark = new ChannelWatermark
Image = request.ImageSource == ChannelWatermarkImageSource.Custom ? request.Image : null, {
Mode = request.Mode, Name = request.Name,
ImageSource = request.ImageSource, Image = null,
Location = request.Location, OriginalContentType = null,
Size = request.Size, Mode = request.Mode,
WidthPercent = request.Width, ImageSource = request.ImageSource,
HorizontalMarginPercent = request.HorizontalMargin, Location = request.Location,
VerticalMarginPercent = request.VerticalMargin, Size = request.Size,
FrequencyMinutes = request.FrequencyMinutes, WidthPercent = request.Width,
DurationSeconds = request.DurationSeconds, HorizontalMarginPercent = request.HorizontalMargin,
Opacity = request.Opacity, VerticalMarginPercent = request.VerticalMargin,
PlaceWithinSourceContent = request.PlaceWithinSourceContent FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent
};
if (request.ImageSource == ChannelWatermarkImageSource.Custom)
{
watermark.Image = request.Image?.Path;
watermark.OriginalContentType = request.Image?.ContentType;
}
return watermark;
}); });
private static Validation<BaseError, string> ValidateName(CreateWatermark request) => private static Validation<BaseError, string> ValidateName(CreateWatermark request) =>

5
ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
@ -7,7 +8,7 @@ namespace ErsatzTV.Application.Watermarks;
public record UpdateWatermark( public record UpdateWatermark(
int Id, int Id,
string Name, string Name,
string Image, ArtworkContentTypeModel Image,
ChannelWatermarkMode Mode, ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource, ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location, WatermarkLocation Location,

10
ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs

@ -33,7 +33,15 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
UpdateWatermark update) UpdateWatermark update)
{ {
p.Name = update.Name; p.Name = update.Name;
p.Image = update.ImageSource == ChannelWatermarkImageSource.Custom ? update.Image : null;
p.Image = null;
p.OriginalContentType = null;
if (update.ImageSource == ChannelWatermarkImageSource.Custom)
{
p.Image = update.Image?.Path;
p.OriginalContentType = update.Image?.ContentType;
}
p.Mode = update.Mode; p.Mode = update.Mode;
p.ImageSource = update.ImageSource; p.ImageSource = update.ImageSource;
p.Location = update.Location; p.Location = update.Location;

5
ErsatzTV.Application/Watermarks/Mapper.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks; namespace ErsatzTV.Application.Watermarks;
@ -7,7 +8,7 @@ internal static class Mapper
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) => public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new( new(
watermark.Id, watermark.Id,
watermark.Image, new ArtworkContentTypeModel(watermark.Image, watermark.OriginalContentType),
watermark.Name, watermark.Name,
watermark.Mode, watermark.Mode,
watermark.ImageSource, watermark.ImageSource,

2
ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdHandler.cs

@ -16,7 +16,7 @@ public class GetWatermarkByIdHandler : IRequestHandler<GetWatermarkById, Option<
GetWatermarkById request, GetWatermarkById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks return await dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == request.Id) .SelectOneAsync(w => w.Id, w => w.Id == request.Id)
.MapT(ProjectToViewModel); .MapT(ProjectToViewModel);

5
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

@ -1,11 +1,12 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Application.Watermarks; namespace ErsatzTV.Application.Watermarks;
public record WatermarkViewModel( public record WatermarkViewModel(
int Id, int Id,
string Image, ArtworkContentTypeModel Image,
string Name, string Name,
ChannelWatermarkMode Mode, ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource, ChannelWatermarkImageSource ImageSource,

1
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -9,6 +9,7 @@ public class ChannelWatermark
public ChannelWatermarkMode Mode { get; set; } public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; } public ChannelWatermarkImageSource ImageSource { get; set; }
public string Image { get; set; } public string Image { get; set; }
public string OriginalContentType { get; set; }
public WatermarkLocation Location { get; set; } public WatermarkLocation Location { get; set; }
public WatermarkSize Size { get; set; } public WatermarkSize Size { get; set; }
public int WidthPercent { get; set; } public int WidthPercent { get; set; }

1
ErsatzTV.Core/Domain/Metadata/Artwork.cs

@ -8,6 +8,7 @@ public class Artwork
public string BlurHash43 { get; set; } public string BlurHash43 { get; set; }
public string BlurHash54 { get; set; } public string BlurHash54 { get; set; }
public string BlurHash64 { get; set; } public string BlurHash64 { get; set; }
public string OriginalContentType { get; set; }
public ArtworkKind ArtworkKind { get; set; } public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }

6
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -205,7 +205,7 @@ public class FFmpegProcessService
channel.Artwork channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo) .Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions( return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark), await watermarkOverride.IfNoneAsync(watermark),
@ -242,7 +242,7 @@ public class FFmpegProcessService
channel.Artwork channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo) .Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions( return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark), await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath, maybeChannelPath,
@ -278,7 +278,7 @@ public class FFmpegProcessService
channel.Artwork channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo) .Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone() .HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None)); .Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions( return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark), await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath, maybeChannelPath,

5908
ErsatzTV.Infrastructure.MySql/Migrations/20250701110522_Add_ArtworkOriginalContentType.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250701110522_Add_ArtworkOriginalContentType.cs

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

5911
ErsatzTV.Infrastructure.MySql/Migrations/20250701115339_Add_ChannelWatermarkOriginalContentType.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250701115339_Add_ChannelWatermarkOriginalContentType.cs

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

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

@ -190,6 +190,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("MusicVideoMetadataId") b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("OriginalContentType")
.HasColumnType("longtext");
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -353,6 +356,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("OriginalContentType")
.HasColumnType("longtext");
b.Property<bool>("PlaceWithinSourceContent") b.Property<bool>("PlaceWithinSourceContent")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");

5747
ErsatzTV.Infrastructure.Sqlite/Migrations/20250701110542_Add_ArtworkOriginalContentType.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20250701110542_Add_ArtworkOriginalContentType.cs

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

5750
ErsatzTV.Infrastructure.Sqlite/Migrations/20250701115311_Add_ChannelWatermarkOriginalContentType.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20250701115311_Add_ChannelWatermarkOriginalContentType.cs

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

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

@ -179,6 +179,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("MusicVideoMetadataId") b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("OriginalContentType")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -338,6 +341,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("OriginalContentType")
.HasColumnType("TEXT");
b.Property<bool>("PlaceWithinSourceContent") b.Property<bool>("PlaceWithinSourceContent")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

10
ErsatzTV/Controllers/ArtworkController.cs

@ -63,17 +63,17 @@ public class ArtworkController : ControllerBase
public async Task<IActionResult> GetPoster(string fileName, CancellationToken cancellationToken) public async Task<IActionResult> GetPoster(string fileName, CancellationToken cancellationToken)
{ {
Either<BaseError, CachedImagePathViewModel> cachedImagePath = Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Poster, 440), cancellationToken); await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Poster, string.Empty, 440), cancellationToken);
return cachedImagePath.Match<IActionResult>( return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
} }
[HttpGet("/artwork/watermarks/{fileName}")] [HttpGet("/artwork/watermarks/{fileName}")]
public async Task<IActionResult> GetWatermark(string fileName, CancellationToken cancellationToken) public async Task<IActionResult> GetWatermark(string fileName, [FromQuery] string contentType, CancellationToken cancellationToken)
{ {
Either<BaseError, CachedImagePathViewModel> cachedImagePath = Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Watermark), cancellationToken); await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Watermark, contentType), cancellationToken);
return cachedImagePath.Match<IActionResult>( return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
@ -83,7 +83,7 @@ public class ArtworkController : ControllerBase
public async Task<IActionResult> GetFanArt(string fileName, CancellationToken cancellationToken) public async Task<IActionResult> GetFanArt(string fileName, CancellationToken cancellationToken)
{ {
Either<BaseError, CachedImagePathViewModel> cachedImagePath = Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt), cancellationToken); await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt, string.Empty), cancellationToken);
return cachedImagePath.Match<IActionResult>( return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
@ -155,7 +155,7 @@ public class ArtworkController : ControllerBase
public async Task<IActionResult> GetThumbnail(string fileName, CancellationToken cancellationToken) public async Task<IActionResult> GetThumbnail(string fileName, CancellationToken cancellationToken)
{ {
Either<BaseError, CachedImagePathViewModel> cachedImagePath = Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, 220), cancellationToken); await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, string.Empty, 220), cancellationToken);
return cachedImagePath.Match<IActionResult>( return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));

4
ErsatzTV/Controllers/IptvController.cs

@ -271,10 +271,10 @@ public class IptvController : ControllerBase
[HttpGet("iptv/logos/{fileName}")] [HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")] [HttpHead("iptv/logos/{fileName}.jpg")]
[HttpGet("iptv/logos/{fileName}.jpg")] [HttpGet("iptv/logos/{fileName}.jpg")]
public async Task<IActionResult> GetImage(string fileName) public async Task<IActionResult> GetImage(string fileName, [FromQuery] string contentType)
{ {
Either<BaseError, CachedImagePathViewModel> cachedImagePath = Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo)); await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo, contentType));
return cachedImagePath.Match<IActionResult>( return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));

14
ErsatzTV/Pages/ChannelEditor.razor

@ -1,5 +1,7 @@
@page "/channels/{Id:int?}" @page "/channels/{Id:int?}"
@page "/channels/add" @page "/channels/add"
@using System.Net
@using ErsatzTV.Application.Artworks
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Filler @using ErsatzTV.Application.Filler
@ -124,9 +126,9 @@
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center"> <MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6"> <MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/> <InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@if (!string.IsNullOrWhiteSpace(_model.Logo) || !string.IsNullOrWhiteSpace(_model.ExternalLogoUrl)) @if (!string.IsNullOrWhiteSpace(_model.Logo?.Path) || !string.IsNullOrWhiteSpace(_model.ExternalLogoUrl))
{ {
<MudElement HtmlTag="img" src="@(string.IsNullOrWhiteSpace(_model.ExternalLogoUrl) ? _model.Logo : _model.ExternalLogoUrl)" Style="max-height: 50px"/> <MudElement HtmlTag="img" src="@(string.IsNullOrWhiteSpace(_model.ExternalLogoUrl) ? _model.Logo.UrlWithContentType : _model.ExternalLogoUrl)" Style="max-height: 50px"/>
} }
</MudItem> </MudItem>
<MudItem xs="6"> <MudItem xs="6">
@ -216,9 +218,9 @@
_model.Number = channelViewModel.Number; _model.Number = channelViewModel.Number;
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId; _model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
if (Artwork.IsExternalUrl(channelViewModel.Logo)) if (channelViewModel.Logo.IsExternalUrl)
{ {
_model.ExternalLogoUrl = channelViewModel.Logo; _model.ExternalLogoUrl = channelViewModel.Logo.Path;
} }
else else
{ {
@ -304,11 +306,11 @@
try try
{ {
Either<BaseError, string> maybeCacheFileName = Either<BaseError, string> maybeCacheFileName =
await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo), _cts.Token); await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo, e.File.ContentType), _cts.Token);
maybeCacheFileName.Match( maybeCacheFileName.Match(
relativeFileName => relativeFileName =>
{ {
_model.Logo = $"iptv/logos/{relativeFileName}"; _model.Logo = new ArtworkContentTypeModel($"iptv/logos/{relativeFileName}", e.File.ContentType);
_model.ExternalLogoUrl = null; _model.ExternalLogoUrl = null;
StateHasChanged(); StateHasChanged();
}, },

4
ErsatzTV/Pages/Channels.razor

@ -43,9 +43,9 @@
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Number">@context.Number</MudTd> <MudTd DataLabel="Number">@context.Number</MudTd>
<MudTd DataLabel="Logo"> <MudTd DataLabel="Logo">
@if (!string.IsNullOrWhiteSpace(context.Logo)) @if (!string.IsNullOrWhiteSpace(context.Logo?.Path))
{ {
<MudElement HtmlTag="img" src="@context.Logo" Style="max-height: 50px"/> <MudElement HtmlTag="img" src="@context.Logo.UrlWithContentType" Style="max-height: 50px"/>
} }
else else
{ {

11
ErsatzTV/Pages/WatermarkEditor.razor

@ -1,5 +1,6 @@
@page "/watermarks/{Id:int}" @page "/watermarks/{Id:int}"
@page "/watermarks/add" @page "/watermarks/add"
@using ErsatzTV.Application.Artworks
@using ErsatzTV.Application.Images @using ErsatzTV.Application.Images
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.FFmpeg.State @using ErsatzTV.FFmpeg.State
@ -38,9 +39,9 @@
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center"> <MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6"> <MudItem xs="6">
<InputFile id="watermarkFileInput" OnChange="UploadWatermark" style="display: none;"/> <InputFile id="watermarkFileInput" OnChange="UploadWatermark" style="display: none;"/>
@if (!string.IsNullOrWhiteSpace(_model.Image) && _model.ImageSource == ChannelWatermarkImageSource.Custom) @if (!string.IsNullOrWhiteSpace(_model.Image?.Path) && _model.ImageSource == ChannelWatermarkImageSource.Custom)
{ {
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{_model.Image}")" Style="max-height: 50px"/> <MudElement HtmlTag="img" src="@($"artwork/watermarks/{_model.Image.UrlWithContentType}")" Style="max-height: 50px"/>
} }
<ValidationMessage For="@(() => _model.Image)" style="color: #f44336 !important;"/> <ValidationMessage For="@(() => _model.Image)" style="color: #f44336 !important;"/>
</MudItem> </MudItem>
@ -183,7 +184,7 @@
{ {
Name = string.Empty, Name = string.Empty,
Mode = ChannelWatermarkMode.Permanent, Mode = ChannelWatermarkMode.Permanent,
Image = string.Empty, Image = ArtworkContentTypeModel.None,
Location = WatermarkLocation.BottomRight, Location = WatermarkLocation.BottomRight,
Size = WatermarkSize.Scaled, Size = WatermarkSize.Scaled,
Width = 15, Width = 15,
@ -224,12 +225,12 @@
try try
{ {
Either<BaseError, string> maybeCacheFileName = await Mediator.Send( Either<BaseError, string> maybeCacheFileName = await Mediator.Send(
new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Watermark), new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Watermark, e.File.ContentType),
_cts.Token); _cts.Token);
maybeCacheFileName.Match( maybeCacheFileName.Match(
relativeFileName => relativeFileName =>
{ {
_model.Image = relativeFileName; _model.Image = new ArtworkContentTypeModel(relativeFileName, e.File.ContentType);
_messageStore.Clear(); _messageStore.Clear();
_editContext.Validate(); _editContext.Validate();
StateHasChanged(); StateHasChanged();

4
ErsatzTV/Pages/Watermarks.razor

@ -27,9 +27,9 @@
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Image"> <MudTd DataLabel="Image">
@if (!string.IsNullOrWhiteSpace(context.Image)) @if (!string.IsNullOrWhiteSpace(context.Image?.Path))
{ {
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{context.Image}")" Style="max-height: 50px"/> <MudElement HtmlTag="img" src="@($"artwork/watermarks/{context.Image.UrlWithContentType}")" Style="max-height: 50px"/>
} }
else if (context.ImageSource == ChannelWatermarkImageSource.ChannelLogo) else if (context.ImageSource == ChannelWatermarkImageSource.ChannelLogo)
{ {

9
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -1,4 +1,5 @@
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels; namespace ErsatzTV.ViewModels;
@ -16,7 +17,7 @@ public class ChannelEditViewModel
public string StreamSelector { get; set; } public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; } public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; } public string PreferredAudioTitle { get; set; }
public string Logo { get; set; } public ArtworkContentTypeModel Logo { get; set; }
public string ExternalLogoUrl { get; set; } public string ExternalLogoUrl { get; set; }
public ChannelProgressMode ProgressMode { get; set; } public ChannelProgressMode ProgressMode { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
@ -43,7 +44,7 @@ public class ChannelEditViewModel
Group, Group,
Categories, Categories,
FFmpegProfileId, FFmpegProfileId,
string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),
StreamSelectorMode, StreamSelectorMode,
StreamSelector, StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,
@ -66,7 +67,7 @@ public class ChannelEditViewModel
Group, Group,
Categories, Categories,
FFmpegProfileId, FFmpegProfileId,
string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : new ArtworkContentTypeModel(ExternalLogoUrl, string.Empty),
StreamSelectorMode, StreamSelectorMode,
StreamSelector, StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,

5
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -1,4 +1,5 @@
using ErsatzTV.Application.Watermarks; using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
@ -30,7 +31,7 @@ public class WatermarkEditViewModel
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Image { get; set; } public ArtworkContentTypeModel Image { get; set; }
public ChannelWatermarkMode Mode { get; set; } public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; } public ChannelWatermarkImageSource ImageSource { get; set; }
public WatermarkLocation Location { get; set; } public WatermarkLocation Location { get; set; }

Loading…
Cancel
Save