add channel active mode (#2083)

This commit is contained in:
Jason Dove
2025-06-27 21:19:26 +00:00
committed by GitHub
parent 27c701b936
commit 583cbf7b14
24 changed files with 11774 additions and 17 deletions
+7 -1
View File
@@ -29,10 +29,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `channels` (audio only) - `channels` (audio only)
- An example subtitle condition: `lang like 'en%' and external` - An example subtitle condition: `lang like 'en%' and external`
- An example audio condition: `title like '%movie%' and channels > 2` - An example audio condition: `title like '%movie%' and channels > 2`
- Add new channel setting `Active Mode`
- `Active` - default value, channel streams as normal and has normal visibility
- `Hidden` - channel streams as normal and is hidden from M3U/XMLTV/HDHR
- `Inactive` - channel cannot stream (will 404) and is hidden from M3U/XMLTV/HDHR
### Fixed ### Fixed
- Fix QSV acceleration in docker with older Intel devices - Fix QSV acceleration in docker with older Intel devices
- Fix software tonemap when used with NVIDIA accel (`ETV_DISABLE_VULKAN` env var) - Fix HDR transcoding with NVIDIA accel for:
- All NVIDIA docker users
- Windows NVIDIA users who have set the `ETV_DISABLE_VULKAN` env var
## [25.2.0] - 2025-06-24 ## [25.2.0] - 2025-06-24
### Added ### Added
@@ -24,7 +24,8 @@ public record ChannelViewModel(
ChannelSubtitleMode SubtitleMode, ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode, ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate, string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode)
{ {
public string WebEncodedName => WebUtility.UrlEncode(Name); public string WebEncodedName => WebUtility.UrlEncode(Name);
} }
@@ -22,4 +22,5 @@ public record CreateChannel(
ChannelSubtitleMode SubtitleMode, ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode, ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate, string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>; ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -85,7 +85,8 @@ public class CreateChannelHandler(
SubtitleMode = request.SubtitleMode, SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode, MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate, MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode SongVideoMode = request.SongVideoMode,
ActiveMode = request.ActiveMode
}; };
foreach (int id in watermarkId) foreach (int id in watermarkId)
@@ -49,6 +49,18 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder); _localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int inactiveCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ActiveMode != ChannelActiveMode.Active)
.CountAsync(cancellationToken);
if (inactiveCount > 0)
{
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName(); string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName(); string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName(); string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
@@ -85,8 +97,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken); string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName); var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts List<Playout> playouts = await dbContext.Playouts
.AsNoTracking() .AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber) .Filter(pi => pi.Channel.Number == request.ChannelNumber)
@@ -244,7 +254,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string tempFile = Path.GetTempFileName(); string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken); await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true); File.Move(tempFile, targetFile, true);
} }
@@ -118,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2 left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout) where C.Id in (select ChannelId from Playout) and C.ActiveMode = 0
order by CAST(C.Number as double)"; order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb // TODO: this needs to be fixed for sqlite/mariadb
@@ -23,4 +23,5 @@ public record UpdateChannel(
ChannelSubtitleMode SubtitleMode, ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode, ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate, string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>; ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -44,6 +44,7 @@ public class UpdateChannelHandler(
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode; c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate; c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode; c.SongVideoMode = update.SongVideoMode;
c.ActiveMode = update.ActiveMode;
c.Artwork ??= []; c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo)) if (!string.IsNullOrWhiteSpace(update.Logo))
+2 -1
View File
@@ -27,7 +27,8 @@ internal static class Mapper
channel.SubtitleMode, channel.SubtitleMode,
channel.MusicVideoCreditsMode, channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate, channel.MusicVideoCreditsTemplate,
channel.SongVideoMode); channel.SongVideoMode,
channel.ActiveMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) => internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new( new(
@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.Text; using System.Text;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -29,6 +31,12 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var inactiveChannelNumbers = dbContext.Channels
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile)) if (!_localFileSystem.FileExists(channelsFile))
@@ -60,6 +68,11 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
continue; continue;
} }
if (inactiveChannelNumbers.Contains(Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken); string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
channelDataFragment = channelDataFragment channelDataFragment = channelDataFragment
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Hdhr; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
@@ -11,5 +12,5 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) => public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll() _channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList()); .Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active).Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
} }
@@ -28,6 +28,11 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
var result = new List<Channel>(); var result = new List<Channel>();
foreach (Channel channel in channels) foreach (Channel channel in channels)
{ {
if (channel.ActiveMode is not ChannelActiveMode.Active)
{
continue;
}
switch (mode.ToLowerInvariant()) switch (mode.ToLowerInvariant())
{ {
case "segmenter": case "segmenter":
+1
View File
@@ -33,5 +33,6 @@ public class Channel
public string MusicVideoCreditsTemplate { get; set; } public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; } public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelProgressMode ProgressMode { get; set; } public ChannelProgressMode ProgressMode { get; set; }
public ChannelActiveMode ActiveMode { get; set; }
public string WebEncodedName => WebUtility.UrlEncode(Name); public string WebEncodedName => WebUtility.UrlEncode(Name);
} }
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelActiveMode
{
Active = 0,
Hidden = 1,
Inactive = 2
}
@@ -18,7 +18,7 @@ public static class AvailablePixelFormats
private static Option<IPixelFormat> LogUnknownPixelFormat(string pixelFormat, ILogger? logger) private static Option<IPixelFormat> LogUnknownPixelFormat(string pixelFormat, ILogger? logger)
{ {
logger?.LogWarning("Unexpected pixel format {PixelFormat} may have playback issues", pixelFormat); logger?.LogDebug("Unexpected pixel format {PixelFormat} may have playback issues", pixelFormat);
return Option<IPixelFormat>.None; return Option<IPixelFormat>.None;
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelActiveMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ActiveMode",
table: "Channel",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ActiveMode",
table: "Channel");
}
}
}
@@ -241,6 +241,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("ActiveMode")
.HasColumnType("int");
b.Property<string>("Categories") b.Property<string>("Categories")
.HasColumnType("longtext"); .HasColumnType("longtext");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelActiveMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ActiveMode",
table: "Channel",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ActiveMode",
table: "Channel");
}
}
}
@@ -228,6 +228,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("ActiveMode")
.HasColumnType("INTEGER");
b.Property<string>("Categories") b.Property<string>("Categories")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+12 -2
View File
@@ -83,10 +83,15 @@ public class IptvController : ControllerBase
[FromQuery] [FromQuery]
string mode = null) string mode = null)
{ {
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
if (maybeChannel.IsNone || await maybeChannel.Map(c => c.ActiveMode).IfNoneAsync(ChannelActiveMode.Inactive) is ChannelActiveMode.Inactive)
{
return NotFound();
}
// if mode is "unspecified" - find the configured mode and set it or redirect // if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed") if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{ {
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
foreach (ChannelViewModel channel in maybeChannel) foreach (ChannelViewModel channel in maybeChannel)
{ {
switch (channel.StreamingMode) switch (channel.StreamingMode)
@@ -180,10 +185,15 @@ public class IptvController : ControllerBase
[FromQuery] [FromQuery]
string mode = "mixed") string mode = "mixed")
{ {
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
if (maybeChannel.IsNone || await maybeChannel.Map(c => c.ActiveMode).IfNoneAsync(ChannelActiveMode.Inactive) is ChannelActiveMode.Inactive)
{
return NotFound();
}
// if mode is "unspecified" - find the configured mode and set it or redirect // if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed") if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{ {
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
foreach (ChannelViewModel channel in maybeChannel) foreach (ChannelViewModel channel in maybeChannel)
{ {
switch (channel.StreamingMode) switch (channel.StreamingMode)
+6
View File
@@ -30,6 +30,11 @@
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/> <MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudTextField Class="mt-3" Label="Group" @bind-Value="_model.Group" For="@(() => _model.Group)"/> <MudTextField Class="mt-3" Label="Group" @bind-Value="_model.Group" For="@(() => _model.Group)"/>
<MudTextField Class="mt-3" Label="Categories" @bind-Value="_model.Categories" For="@(() => _model.Categories)" Placeholder="Comma-separated list of categories"/> <MudTextField Class="mt-3" Label="Categories" @bind-Value="_model.Categories" For="@(() => _model.Categories)" Placeholder="Comma-separated list of categories"/>
<MudSelect Class="mt-3" Label="Active Mode" @bind-Value="_model.ActiveMode" For="@(() => _model.ActiveMode)">
<MudSelectItem Value="@(ChannelActiveMode.Active)">Active</MudSelectItem>
<MudSelectItem Value="@(ChannelActiveMode.Hidden)">Hidden</MudSelectItem>
<MudSelectItem Value="@(ChannelActiveMode.Inactive)">Inactive</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="Progress Mode" @bind-Value="_model.ProgressMode" For="@(() => _model.ProgressMode)"> <MudSelect Class="mt-3" Label="Progress Mode" @bind-Value="_model.ProgressMode" For="@(() => _model.ProgressMode)">
<MudSelectItem Value="@(ChannelProgressMode.Always)">Always</MudSelectItem> <MudSelectItem Value="@(ChannelProgressMode.Always)">Always</MudSelectItem>
<MudSelectItem Value="@(ChannelProgressMode.OnDemand)">On Demand</MudSelectItem> <MudSelectItem Value="@(ChannelProgressMode.OnDemand)">On Demand</MudSelectItem>
@@ -233,6 +238,7 @@
_model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode; _model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode;
_model.MusicVideoCreditsTemplate = channelViewModel.MusicVideoCreditsTemplate; _model.MusicVideoCreditsTemplate = channelViewModel.MusicVideoCreditsTemplate;
_model.SongVideoMode = channelViewModel.SongVideoMode; _model.SongVideoMode = channelViewModel.SongVideoMode;
_model.ActiveMode = channelViewModel.ActiveMode;
}, },
() => NavigationManager.NavigateTo("404")); () => NavigationManager.NavigateTo("404"));
} }
+5 -2
View File
@@ -33,6 +33,7 @@ public class ChannelEditViewModel
set => _musicVideoCreditsTemplate = value; set => _musicVideoCreditsTemplate = value;
} }
public ChannelSongVideoMode SongVideoMode { get; set; } public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelActiveMode ActiveMode { get; set; }
public UpdateChannel ToUpdate() => public UpdateChannel ToUpdate() =>
new( new(
@@ -55,7 +56,8 @@ public class ChannelEditViewModel
SubtitleMode, SubtitleMode,
MusicVideoCreditsMode, MusicVideoCreditsMode,
MusicVideoCreditsTemplate, MusicVideoCreditsTemplate,
SongVideoMode); SongVideoMode,
ActiveMode);
public CreateChannel ToCreate() => public CreateChannel ToCreate() =>
new( new(
@@ -77,5 +79,6 @@ public class ChannelEditViewModel
SubtitleMode, SubtitleMode,
MusicVideoCreditsMode, MusicVideoCreditsMode,
MusicVideoCreditsTemplate, MusicVideoCreditsTemplate,
SongVideoMode); SongVideoMode,
ActiveMode);
} }