Compare commits

..
Author SHA1 Message Date
Jason Dove 0308106c1b update changelog for release v0.4.5-alpha [no ci] 2022-03-29 20:12:01 -05:00
Jason DoveandGitHub ba93e3eeea always check for plex on the localhost (#716) 2022-03-29 19:39:55 -05:00
Jason DoveandGitHub aa2c914d8a always transcode and normalize, except with HLS Direct (#715)
* remove transcode, normalize video, normalize audio settings

* cleanup

* update changelog
2022-03-28 18:25:33 -05:00
Jason Dove caa9bf82d5 update changelog [no ci] 2022-03-28 13:10:33 -05:00
Jason Dove e397035c5a update changelog [no ci] 2022-03-18 14:07:15 -05:00
Jason DoveandGitHub d32f881c4e use cliwrap for windows wrapper (#710) 2022-03-18 13:36:38 -05:00
Jason DoveandGitHub 9b3c24559d add deinterlace option to ffmpeg profile (#709)
* update dependencies

* add option to deinterlace video
2022-03-18 10:30:56 -05:00
Jason Dove 7c75b169ec try gon again 2022-03-16 11:39:44 -05:00
Jason Dove 4f1952340f fix gon since brew is not working 2022-03-16 11:24:52 -05:00
Jason Dove ac2de24f6e fix build 2022-03-16 10:50:38 -05:00
Jason DoveandGitHub 4b9781dad4 add v2 channels table (#708)
* change mock api port; fix cors

* fix version request

* accept base url env var

* tweak colors

* fix version service

* add basic channels list

* fix auto page title for ts classes

* add GET /api/channels
2022-03-16 10:28:54 -05:00
Jason DoveandGitHub d88c179b63 axios test (#705)
* add version controller

* use axios to get version

* allow typescript

* use version api service
2022-03-15 17:55:05 -05:00
Jason DoveandGitHub 809a623a95 vue formatting tweaks (#704)
* add .prettierrc

* apply formatting
2022-03-14 10:59:04 -05:00
Jason Dove b453dce57e try npm ci with cache 2022-03-13 20:38:04 -05:00
Jason Dove edd31755c0 try npm install again 2022-03-13 20:21:16 -05:00
Jason DoveandGitHub 7de1a87bbf bug fixes (#703)
* catch expected shutdown error in scheduler service

* fix streaming mode inconsistencies
2022-03-13 18:45:21 -05:00
Jason DoveandGitHub 5731edc82e v2 ui tweaks (#702)
* fix m3u and xml urls (kind of)

* use svg logos

* tweak theme colors
2022-03-13 12:44:35 -05:00
Jason DoveandGitHub 2f668e53dd fix npm build (#701) 2022-03-13 11:11:08 -05:00
Jason DoveandGitHub abd223acd2 fix docker builds (#700) 2022-03-13 10:52:03 -05:00
6a9075dc11 add vue ui at /v2 (#698)
* add vue ui

* add channels mock api

* Initial Vue framework with Vuetify UI (#688)

* fix hls direct streaming mode (#682)

* duration analysis on files with missing duration metadata (#683)

* first pass

* analyze zero-duration files

* add readme note for WIP

* add vuetify and basic sidebar layout - responsive

* add vue-router and initial home page

* setup composition-api for vue2

* install pinia ie... vuex4

* mixing for automatic page title

* add logo files

* tweak theme colors

* install store

* use store for menu toggle

* replicate old menu

* implement menu and children menus

* rename state to application state

* update vue files and add version to sidebar

* lock logo and make expandable list remove minified menu

* remove todo, will add to PR

* top bar links and attempt at snackbar with state

* fix snackbar

* add search basic component

* fix search bar placement

* remove un-used footer

* Revert "Merge branch 'jasongdove:main' into intitial-vuetify-ui"

This reverts commit 43016d502b.

Co-authored-by: Jason Dove <jason@jasondove.me>

* Add ESLint and Prettier for VueJS (#691)

* add prettier to config

* run npm run lint over project to clean up files

* replace hr tag with v-dividers

* add vue-lint github action

* fix the dodo in me

* Hu

* Fix path

* convert to multi-run step

* forgot the name

* add vue-lint github action

fix the dodo in me

Hu

Fix path

convert to multi-run step

forgot the name

* Fix new line at end of file

* WIP

* dockerfile consistency

* use npm ci and node v14

* force prettier indenting and end of line (#695)

* disable filename hashing

* don't build tests in docker

* update dependencies

* fix running both uis

Co-authored-by: James Mackay <info@notexpectedyet.com>
2022-03-12 14:15:31 -06:00
Jason DoveandGitHub d5a03963c0 use video and audio format instead of video and audio codec (#696) 2022-03-11 19:58:05 -06:00
103 changed files with 47379 additions and 912 deletions
+23 -3
View File
@@ -51,6 +51,16 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
with:
working-directory: ErsatzTV/client-app
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
@@ -104,9 +114,9 @@ jobs:
- name: Notarize
shell: bash
run: |
brew tap mitchellh/gon
brew install mitchellh/gon/gon
gon -log-level=debug -log-json ./gon.json
curl -o gon.zip -L -s "https://github.com/mitchellh/gon/releases/latest/download/gon_macos.zip"
unzip -o -q gon.zip
./gon -log-level=debug -log-json ./gon.json
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
@@ -166,6 +176,16 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
with:
working-directory: ErsatzTV/client-app
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
+22
View File
@@ -0,0 +1,22 @@
name: Lint VueJS Files on PR Request
on:
pull_request:
jobs:
vue-lint:
runs-on: ubuntu-latest
steps:
# Checkout the current repo
- name: Checkout current repository
uses: actions/checkout@v2
# Setup NodeJS version 14
- name: Setup NodeJS V14.x.x
uses: actions/setup-node@v2
with:
node-version: '14'
# CD into the current client directory and lint and build the client
- name: Lint and Build the client
run: |
cd ./ErsatzTV/client-app/
npm ci --no-optional
npm run lint
npm run build --if-present
+1
View File
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
+21 -1
View File
@@ -5,6 +5,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.4.5-alpha] - 2022-03-29
### Fixed
- Fix streaming mode inconsistencies when `mode` parameter is unspecified
- Fix startup on Windows 7
### Added
- Add option to automatically deinterlace video when transcoding
- Previously, this was always enabled; the purpose of the option is to allow disabling any deinterlace filters
- Note that there is no performance gain to disabling the option with progressive content; filters are only ever applied to interlaced content
### Changed
- Change FFmpeg Profile video codec and audio codec text fields to select fields
- The appropriate video encoder will be determined based on the video format and hardware acceleration selections
- Remove FFmpeg Profile `Transcode`, `Normalize Video` and `Normalize Audio` settings
- All content will be transcoded and have audio and video normalized
- The only exception to this rule is `HLS Direct` streaming mode, which directly copies video and audio streams
- Always try to connect to Plex at `http://localhost:32400` even if that address isn't advertised by the Plex API
- If Plex isn't on the localhost, all other addresses will be checked as with previous releases
## [0.4.4-alpha] - 2022-03-10
### Fixed
- Fix `HLS Direct` streaming mode
@@ -1017,7 +1036,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.5-alpha...HEAD
[0.4.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.4-alpha...v0.4.5-alpha
[0.4.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.3-alpha...v0.4.4-alpha
[0.4.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.2-alpha...v0.4.3-alpha
[0.4.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.1-alpha...v0.4.2-alpha
+1 -1
View File
@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
<PackageReference Include="CliWrap" Version="3.4.2" />
</ItemGroup>
<ItemGroup>
+10 -6
View File
@@ -1,14 +1,13 @@
using ErsatzTV.Core;
using System.Diagnostics;
using Asmichi.ProcessManagement;
using System.Reflection;
using CliWrap;
namespace ErsatzTV_Windows;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly IChildProcess? _childProcess;
private readonly CancellationTokenSource _tokenSource;
public TrayApplicationContext()
{
@@ -19,6 +18,8 @@ public class TrayApplicationContext : ApplicationContext
Visible = true
};
_tokenSource = new CancellationTokenSource();
AddMenuItem("Launch Web UI", LaunchWebUI);
AddMenuItem("Show Logs", ShowLogs);
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
@@ -29,8 +30,11 @@ public class TrayApplicationContext : ApplicationContext
if (File.Exists(exe))
{
var si = new ChildProcessStartInfo(exe);
_childProcess = ChildProcess.Start(si);
Cli.Wrap(exe)
.WithWorkingDirectory(folder)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(_tokenSource.Token);
}
}
@@ -64,7 +68,7 @@ public class TrayApplicationContext : ApplicationContext
protected override void Dispose(bool disposing)
{
_childProcess?.Dispose();
_tokenSource?.Cancel();
base.Dispose(disposing);
}
+20 -4
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -19,11 +20,26 @@ internal static class Mapper
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
channel.Id,
channel.Number,
channel.Name,
channel.FFmpegProfile.Name,
channel.PreferredLanguageCode,
GetStreamingMode(channel));
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
private static string GetWatermark(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark))
.Match(a => a.Path, string.Empty);
private static string GetStreamingMode(Channel channel) =>
channel.StreamingMode switch
{
StreamingMode.TransportStream => "MPEG-TS (Legacy)",
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => throw new ArgumentOutOfRangeException()
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
@@ -0,0 +1,21 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelByNumber(string ChannelNumber) : IRequest<Option<ChannelViewModel>>;
@@ -0,0 +1,14 @@
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
}
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
@@ -7,20 +7,18 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool NormalizeFramerate,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -18,9 +18,9 @@ public class CreateFFmpegProfileHandler :
CreateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
}
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -39,23 +39,21 @@ public class CreateFFmpegProfileHandler :
{
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
VideoFormat = request.VideoFormat,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioCodec = request.AudioCodec,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio,
NormalizeFramerate = request.NormalizeFramerate
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -8,20 +8,18 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool NormalizeFramerate,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -18,9 +18,9 @@ public class
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -30,23 +30,21 @@ public class
{
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.VideoFormat = update.VideoFormat;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
p.NormalizeLoudness = update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
p.NormalizeFramerate = update.Transcode && update.NormalizeFramerate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.DeinterlaceVideo = update.DeinterlaceVideo;
await dbContext.SaveChangesAsync();
return new UpdateFFmpegProfileResult(p.Id);
}
@@ -8,20 +8,18 @@ public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution,
bool NormalizeVideo,
string VideoCodec,
FFmpegProfileVideoFormat VideoFormat,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
bool NormalizeFramerate);
bool NormalizeFramerate,
bool DeinterlaceVideo);
@@ -10,23 +10,21 @@ internal static class Mapper
profile.Id,
profile.Name,
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution),
profile.NormalizeVideo,
profile.VideoCodec,
profile.VideoFormat,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.AudioFormat,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.NormalizeLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio,
profile.NormalizeVideo && profile.NormalizeFramerate);
profile.NormalizeFramerate,
profile.DeinterlaceVideo == true);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -9,10 +9,11 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex;
public class
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
{
private const string LocalhostUri = "http://localhost:32400";
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
@@ -72,6 +73,18 @@ public class
private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
{
if (server.Connections.All(c => c.Uri != LocalhostUri))
{
var localhost = new PlexConnection
{
PlexMediaSource = server,
PlexMediaSourceId = server.Id,
Uri = LocalhostUri
};
server.Connections.Add(localhost);
}
Option<PlexMediaSource> maybeExisting =
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
@@ -98,7 +111,11 @@ public class
private async Task FindConnectionToActivate(PlexMediaSource server)
{
var prioritized = server.Connections.OrderBy(pc => pc.IsActive ? 0 : 1).ToList();
var prioritized = server.Connections
.OrderByDescending(pc => pc.Uri == LocalhostUri)
.ThenByDescending(pc => pc.IsActive)
.ToList();
foreach (PlexConnection connection in server.Connections)
{
connection.IsActive = false;
@@ -25,10 +25,10 @@ public class GetHlsPlaylistByChannelNumberHandler :
GetHlsPlaylistByChannelNumber request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
DateTimeOffset now = DateTimeOffset.Now;
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, now);
return await LanguageExtensions.Apply(validation, parameters => GetPlaylist(dbContext, request, parameters, now));
return await validation.Apply(parameters => GetPlaylist(dbContext, request, parameters, now));
}
private Task<string> GetPlaylist(
@@ -166,85 +166,48 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (BaseError error in maybePlayoutItem.LeftToSeq())
{
var offlineTranscodeMessage =
$"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Where(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.FirstOrDefaultAsync(cancellationToken)
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
.MapT(pi => pi.StartOffset - now);
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
switch (error)
{
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
Process offlineProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(offlineProcess, finish);
case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime,
request.PtsOffset);
Process doesNotExistProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(doesNotExistProcess, finish);
default:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
Process errorProcess = await ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
var message =
$"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}";
return BaseError.New(message);
}
return new PlayoutItemProcessModel(errorProcess, finish);
}
}
@@ -282,17 +245,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
// TODO: shuffle? does it really matter since we loop anyway
MediaItem item = items[new Random().Next(items.Count)];
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Where(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
.MapT(pi => pi.StartOffset - now);
MediaVersion version = item.GetHeadVersion();
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
@@ -9,13 +9,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class FFmpegPlaybackSettingsCalculatorTests
{
private static readonly MediaVersion TestVersion = new() { SampleAspectRatio = "1:1", Width = 1920, Height = 1080 };
[TestFixture]
public class CalculateSettings
{
private readonly FFmpegPlaybackSettingsCalculator _calculator;
public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator();
[Test]
public void Should_Not_GenPts_ForHlsSegmenter()
{
@@ -24,7 +26,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -47,7 +49,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -68,7 +70,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -89,7 +91,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -112,7 +114,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -135,7 +137,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -156,7 +158,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
@@ -179,7 +181,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
now,
@@ -203,7 +205,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
now,
@@ -217,33 +219,11 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
}
[Test]
public void ShouldNot_SetScaledSize_When_NotNormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeVideo = false };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
}
[Test]
public void ShouldNot_SetScaledSize_When_ContentIsCorrectSize_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -271,7 +251,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -299,7 +278,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -328,8 +306,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -358,8 +334,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1280, Height = 720 }
};
@@ -389,8 +363,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -414,45 +386,14 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_NotPadToDesiredResolution_When_NotNormalizingVideo()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
// not anamorphic
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream()
public void Should_SetDesiredVideoFormat_When_ContentIsPadded_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -473,19 +414,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
actual.VideoCodec.Should().Be("testCodec");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
public void
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
Should_SetDesiredVideoFormat_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -507,19 +446,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("testCodec");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForHttpLiveStreaming()
public void Should_SetCopyVideoFormat_When_ContentIsCorrectSize_ForHttpLiveStreamingDirect()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -541,18 +477,16 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.Copy);
}
[Test]
public void Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_ForTransportStream()
public void Should_NotSetCopyVideoFormat_When_ContentIsCorrectSize_And_CorrectFormat_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
VideoFormat = FFmpegProfileVideoFormat.H264
};
// not anamorphic
@@ -563,7 +497,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "libx264" },
new MediaStream { Codec = "h264" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
@@ -574,79 +508,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = false,
NormalizeVideo = true,
NormalizeAudio = true,
NormalizeLoudness = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
actual.NormalizeLoudness.Should().BeFalse();
actual.AudioCodec.Should().Be("copy");
actual.VideoFormat.Should().Be(FFmpegProfileVideoFormat.H264);
}
[Test]
@@ -654,8 +516,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
};
@@ -682,12 +542,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
};
@@ -719,8 +577,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
};
@@ -747,13 +603,10 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
public void Should_SetVideoBufferSize_When_ContentIsCorrectSize_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
};
@@ -781,21 +634,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetDesiredAudioFormat_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now,
@@ -805,24 +654,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("aac");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
}
[Test]
public void Should_SetCopyAudioCodec_When_NotNormalizingAudio_ForTransportStream()
public void Should_SetDesiredAudioFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = false,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -832,53 +678,21 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("copy");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Aac);
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_ForTransportStream()
public void Should_SetCopyAudioFormat_ForHttpLiveStreamingDirect()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
AudioFormat = FFmpegProfileAudioFormat.Aac
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.AudioCodec.Should().Be("aac");
}
[Test]
public void Should_SetCopyAudioCodec_When_NormalizingAudio_ForHttpLiveStreaming()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -888,26 +702,22 @@ public class FFmpegPlaybackSettingsCalculatorTests
false,
None);
actual.AudioCodec.Should().Be("copy");
actual.AudioFormat.Should().Be(FFmpegProfileAudioFormat.Copy);
}
[Test]
public void Should_SetAudioBitrate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioBitrate_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBitrate = 2424,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -921,22 +731,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioBufferSize_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioBufferSize_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBufferSize = 2424,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -950,22 +756,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioChannels_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioChannels_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioFormat = FFmpegProfileAudioFormat.Ac3,
AudioChannels = 6
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -979,22 +781,18 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioSampleRate_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioFormat = FFmpegProfileAudioFormat.Ac3,
AudioSampleRate = 48
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1008,21 +806,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioChannels_When_NormalizingAudio_ForTransportStream()
public void Should_SetAudioChannels_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioChannels = 6
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1036,21 +830,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingAudio_ForTransportStream()
public void Should_SetAudioSampleRate_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1064,17 +854,15 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetAudioDuration_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
public void Should_SetAudioDuration_With_CorrectFormat_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48,
AudioCodec = "ac3"
AudioFormat = FFmpegProfileAudioFormat.Ac3
};
var version = new MediaVersion { Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
var version = new MediaVersion { SampleAspectRatio = "1:1", Width = 1920, Height = 1080, Duration = TimeSpan.FromMinutes(5) }; // not pulled from here
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@@ -1093,21 +881,17 @@ public class FFmpegPlaybackSettingsCalculatorTests
}
[Test]
public void Should_SetNormalizeLoudness_When_NormalizingAudio_ForTransportStream()
public void Should_SetNormalizeLoudness_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
@@ -1119,34 +903,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.NormalizeLoudness.Should().BeTrue();
}
[Test]
public void Should_NotSetNormalizeLoudness_When_NotNormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = false,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.NormalizeLoudness.Should().BeFalse();
}
}
[TestFixture]
@@ -1165,7 +921,7 @@ public class FFmpegPlaybackSettingsCalculatorTests
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
+14 -49
View File
@@ -118,21 +118,15 @@ public class TranscodingTests
new() { Width = 1280, Height = 720 }
};
public static string[] SoftwareCodecs =
{
"libx264",
"libx265"
};
public static HardwareAccelerationKind[] NoAcceleration =
{
HardwareAccelerationKind.None
};
public static string[] NvidiaCodecs =
public static FFmpegProfileVideoFormat[] VideoFormats =
{
"h264_nvenc",
"hevc_nvenc"
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc
};
public static HardwareAccelerationKind[] NvidiaAcceleration =
@@ -140,34 +134,16 @@ public class TranscodingTests
HardwareAccelerationKind.Nvenc
};
public static string[] VaapiCodecs =
{
"h264_vaapi",
"hevc_vaapi"
};
public static HardwareAccelerationKind[] VaapiAcceleration =
{
HardwareAccelerationKind.Vaapi
};
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
public static string[] QsvCodecs =
{
"h264_qsv",
"hevc_qsv"
};
public static HardwareAccelerationKind[] QsvAcceleration =
{
HardwareAccelerationKind.Qsv
@@ -176,27 +152,16 @@ public class TranscodingTests
[Test, Combinatorial]
public async Task Transcode(
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))]
InputFormat inputFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
[ValueSource(typeof(TestData), nameof(TestData.Paddings))]
Padding padding,
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))]
VideoScanKind videoScanKind,
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))]
Watermark watermark,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))]
string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))]
HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))] InputFormat inputFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))] Resolution profileResolution,
[ValueSource(typeof(TestData), nameof(TestData.Paddings))] Padding padding,
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))] VideoScanKind videoScanKind,
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))] Watermark watermark,
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))] FFmpegProfileVideoFormat profileVideoFormat,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.QsvCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{
if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3")
@@ -209,7 +174,7 @@ public class TranscodingTests
}
string name = GetStringSha256Hash(
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileCodec}_{profileAcceleration}");
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
@@ -360,8 +325,8 @@ public class TranscodingTests
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
{
HardwareAcceleration = profileAcceleration,
VideoCodec = profileCodec,
AudioCodec = "aac"
VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac
},
StreamingMode = StreamingMode.TransportStream
},
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Channels;
public record ChannelResponseModel(
int Id,
string Number,
string Name,
string FFmpegProfile,
string Language,
string StreamingMode);
+7 -10
View File
@@ -7,35 +7,32 @@ public record FFmpegProfile
public int Id { get; set; }
public string Name { get; set; }
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public string VideoCodec { get; set; }
public bool NormalizeVideo { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string AudioCodec { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
public bool NormalizeLoudness { get; set; }
public int AudioChannels { get; set; }
public int AudioSampleRate { get; set; }
public bool NormalizeAudio { get; set; }
public bool NormalizeFramerate { get; set; }
public bool? DeinterlaceVideo { get; set; }
public static FFmpegProfile New(string name, Resolution resolution) =>
new()
{
Name = name,
ThreadCount = 0,
Transcode = true,
ResolutionId = resolution.Id,
Resolution = resolution,
VideoCodec = "libx264",
AudioCodec = "ac3",
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Ac3,
VideoBitrate = 2000,
VideoBufferSize = 4000,
AudioBitrate = 192,
@@ -43,8 +40,8 @@ public record FFmpegProfile
NormalizeLoudness = true,
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeVideo = true,
NormalizeAudio = true,
DeinterlaceVideo = true,
NormalizeFramerate = false,
HardwareAcceleration = HardwareAccelerationKind.None
};
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain;
public enum FFmpegProfileAudioFormat
{
None = 0,
Aac = 1,
Ac3 = 2,
Copy = 99
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain;
public enum FFmpegProfileVideoFormat
{
None = 0,
H264 = 1,
Hevc = 2,
Mpeg2Video = 3,
Copy = 99
}
@@ -22,7 +22,7 @@ public class FFmpegComplexFilterBuilder
private Option<int> _watermarkIndex;
private string _pixelFormat;
private string _videoDecoder;
private string _videoEncoder;
private FFmpegProfileVideoFormat _videoFormat;
private Option<string> _subtitle;
private bool _boxBlur;
@@ -131,9 +131,9 @@ public class FFmpegComplexFilterBuilder
return this;
}
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
public FFmpegComplexFilterBuilder WithVideoFormat(FFmpegProfileVideoFormat videoFormat)
{
_videoEncoder = videoEncoder;
_videoFormat = videoFormat;
return this;
}
@@ -379,21 +379,21 @@ public class FFmpegComplexFilterBuilder
string outputPixelFormat = null;
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
{
switch (acceleration, _videoEncoder, _pixelFormat)
switch (acceleration, _videoFormat, _pixelFormat)
{
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv420p10le"):
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le"):
outputPixelFormat = "yuv420p";
break;
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv444p10le"):
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv444p10le"):
outputPixelFormat = "yuv444p";
break;
}
}
string outputFormat = (_videoEncoder, _pixelFormat) switch
string outputFormat = (acceleration, _videoFormat, _pixelFormat) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
_ => null
};
@@ -485,7 +485,7 @@ public class FFmpegComplexFilterBuilder
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
_pixelFormat == "yuv420p10le" && _videoFormat == FFmpegProfileVideoFormat.H264)
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
@@ -85,9 +85,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
FFmpegProfileAudioFormat.Copy => AudioFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}")
};
var audioState = new AudioState(
playbackSettings.AudioCodec,
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
@@ -114,14 +122,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
// TODO: need formats for these codecs
string videoFormat = playbackSettings.VideoCodec switch
string videoFormat = playbackSettings.VideoFormat switch
{
"libx265" or "hevc_nvenc" or "hevc_qsv" or "hevc_vaapi" or "hevc_videotoolbox" => VideoFormat.Hevc,
"libx264" or "h264_nvenc" or "h264_qsv" or "h264_vaapi" or "h264_videotoolbox" => VideoFormat.H264,
"mpeg2video" => VideoFormat.Mpeg2Video,
"copy" => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video codec {playbackSettings.VideoCodec}")
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
};
HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch
@@ -13,7 +13,7 @@ public class FFmpegPlaybackSettings
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
public bool PadToDesiredResolution { get; set; }
public string VideoCodec { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public Option<int> VideoBitrate { get; set; }
public Option<int> VideoBufferSize { get; set; }
public Option<int> AudioBitrate { get; set; }
@@ -21,7 +21,7 @@ public class FFmpegPlaybackSettings
public Option<int> AudioChannels { get; set; }
public Option<int> AudioSampleRate { get; set; }
public Option<TimeSpan> AudioDuration { get; set; }
public string AudioCodec { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { get; set; }
public bool NormalizeLoudness { get; set; }
@@ -82,8 +82,8 @@ public class FFmpegPlaybackSettingsCalculator
switch (streamingMode)
{
case StreamingMode.HttpLiveStreamingDirect:
result.AudioCodec = "copy";
result.VideoCodec = "copy";
result.AudioFormat = FFmpegProfileAudioFormat.Copy;
result.VideoFormat = FFmpegProfileVideoFormat.Copy;
result.Deinterlace = false;
break;
case StreamingMode.TransportStreamHybrid:
@@ -98,91 +98,71 @@ public class FFmpegPlaybackSettingsCalculator
{
int fixedHeight = scaledSize.Height + scaledSize.Height % 2;
int fixedWidth = scaledSize.Width + scaledSize.Width % 2;
result.ScaledSize = Some((IDisplaySize) new DisplaySize(fixedWidth, fixedHeight));
result.ScaledSize = Some((IDisplaySize)new DisplaySize(fixedWidth, fixedHeight));
}
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
if (ffmpegProfile.NormalizeFramerate)
{
if (ffmpegProfile.NormalizeFramerate)
{
result.FrameRate = targetFramerate;
}
result.VideoTrackTimeScale = 90000;
result.FrameRate = targetFramerate;
}
result.VideoTrackTimeScale = 90000;
foreach (MediaStream stream in videoStream.Where(s => s.AttachedPic == false))
{
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, stream))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoFormat = ffmpegProfile.VideoFormat;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder =
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
) =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
else
{
result.VideoCodec = "copy";
}
}
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
{
result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
audioStream.IfSome(
stream =>
result.VideoDecoder =
(result.HardwareAcceleration, stream.Codec, stream.PixelFormat) switch
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le"
) =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
}
else
{
result.AudioCodec = "copy";
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
if (videoVersion.VideoScanKind == VideoScanKind.Interlaced)
{
result.Deinterlace = true;
}
result.AudioFormat = ffmpegProfile.AudioFormat;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
audioStream.IfSome(
stream =>
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true &&
videoVersion.VideoScanKind == VideoScanKind.Interlaced;
break;
}
@@ -192,25 +172,17 @@ public class FFmpegPlaybackSettingsCalculator
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile)
{
string softwareCodec = ffmpegProfile.VideoCodec switch
{
{ } c when c.Contains("hevc") || c.Contains("265") => "libx265",
{ } c when c.Contains("264") => "libx264",
{ } c when c.Contains("mpeg2") => "mpeg2video",
_ => "libx264"
};
return new FFmpegPlaybackSettings
{
HardwareAcceleration = HardwareAccelerationKind.None,
ThreadCount = ffmpegProfile.ThreadCount,
FormatFlags = CommonFormatFlags,
VideoCodec = softwareCodec,
AudioCodec = ffmpegProfile.AudioCodec,
VideoFormat = ffmpegProfile.VideoFormat,
AudioFormat = ffmpegProfile.AudioFormat
};
}
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
@@ -227,9 +199,6 @@ public class FFmpegPlaybackSettingsCalculator
private static bool IsOddSize(MediaVersion version) =>
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
IDisplaySize sarSize = SARSize(version);
+17 -11
View File
@@ -71,7 +71,10 @@ internal class FFmpegProcessBuilder
return this;
}
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, Option<string> pixelFormat, string encoder)
public FFmpegProcessBuilder WithHardwareAcceleration(
HardwareAccelerationKind hwAccel,
Option<string> pixelFormat,
FFmpegProfileVideoFormat videoFormat)
{
_hwAccel = hwAccel;
@@ -84,14 +87,14 @@ internal class FFmpegProcessBuilder
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break;
case HardwareAccelerationKind.Nvenc:
string outputFormat = (encoder, pixelFormat.IfNone("")) switch
string outputFormat = (videoFormat, pixelFormat.IfNone("")) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
(FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
(FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
// ("hevc_nvenc", "yuv444p10le") => "p016le",
_ => "cuda"
};
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
@@ -462,14 +465,17 @@ internal class FFmpegProcessBuilder
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
public FFmpegProcessBuilder WithPlaybackArgs(
FFmpegPlaybackSettings playbackSettings,
string videoCodec,
string audioCodec)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-c:v", videoCodec,
"-flags", "cgop",
// disable scene change detection except with mpeg2video
"-sc_threshold", playbackSettings.VideoCodec == "mpeg2video" ? "1000000000" : "0"
"-sc_threshold", playbackSettings.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video ? "1000000000" : "0"
};
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
@@ -512,7 +518,7 @@ internal class FFmpegProcessBuilder
arguments.AddRange(
new[]
{
"-c:a", playbackSettings.AudioCodec,
"-c:a", audioCodec,
"-movflags", "+faststart",
"-muxdelay", "0",
"-muxpreload", "0"
@@ -591,9 +597,9 @@ internal class FFmpegProcessBuilder
Option<MediaStream> maybeAudioStream,
string videoPath,
Option<string> audioPath,
string videoCodec)
FFmpegProfileVideoFormat videoFormat)
{
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
_complexFilterBuilder = _complexFilterBuilder.WithVideoFormat(videoFormat);
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
+20 -157
View File
@@ -35,7 +35,7 @@ public class FFmpegProcessService : IFFmpegProcessService
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
public Task<Process> ForPlayoutItem(
string ffmpegPath,
bool saveReports,
Channel channel,
@@ -56,144 +56,7 @@ public class FFmpegProcessService : IFFmpegProcessService
long ptsOffset,
Option<int> targetFramerate)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
videoVersion,
videoStream,
maybeAudioStream,
start,
now,
inPoint,
outPoint,
hlsRealtime,
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
.Flatten()
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
.Map(
wm =>
WatermarkCalculator.CalculateFadePoints(
start,
inPoint,
outPoint,
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
// foreach (List<FadePoint> fadePoints in maybeFadePoints)
// {
// foreach (FadePoint fadePoint in fadePoints)
// {
// _logger.LogDebug("Fade point filter: {FadePointFilter}", fadePoint.ToFilter());
// }
// }
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithVaapiDriver(vaapiDriver, vaapiDevice)
.WithHardwareAcceleration(
playbackSettings.HardwareAcceleration,
videoStream.PixelFormat,
playbackSettings.VideoCodec)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInputCodec(
playbackSettings.StreamSeek,
fillerKind == FillerKind.Fallback,
videoPath,
audioPath,
playbackSettings.VideoDecoder,
videoStream.Codec,
videoStream.PixelFormat,
playbackSettings.Deinterlace)
.WithWatermark(watermarkOptions, maybeFadePoints, channel.FFmpegProfile.Resolution)
.WithFrameRate(playbackSettings.FrameRate)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
playbackSettings.ScaledSize.Match(
scaledSize =>
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithScaling(scaledSize);
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
{
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
}
builder = builder
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
},
() =>
{
if (playbackSettings.PadToDesiredResolution)
{
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
else
{
builder = builder
.WithFilterComplex(
videoStream,
maybeAudioStream,
videoPath,
audioPath,
channel.FFmpegProfile.VideoCodec);
}
});
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithDuration(finish - now);
return channel.StreamingMode switch
{
// HLS needs to segment and generate playlist
StreamingMode.HttpLiveStreamingSegmenter => builder.WithHls(
channel.Number,
videoVersion,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build(),
_ => builder.WithFormat("mpegts").WithInitialDiscontinuity().WithPipe().Build()
};
throw new NotSupportedException();
}
public async Task<Process> ForError(
@@ -225,6 +88,19 @@ public class FFmpegProcessService : IFFmpegProcessService
var videoStream = new MediaStream { Index = 0 };
var audioStream = new MediaStream { Index = 0 };
string videoCodec = playbackSettings.VideoFormat switch
{
FFmpegProfileVideoFormat.Hevc => "libx265",
FFmpegProfileVideoFormat.Mpeg2Video => "mpeg2video",
_ => "libx264"
};
string audioCodec = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => "ac3",
_ => "aac"
};
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
@@ -239,9 +115,9 @@ public class FFmpegProcessService : IFFmpegProcessService
audioStream,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
"fake-audio-path",
playbackSettings.VideoCodec)
playbackSettings.VideoFormat)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithPlaybackArgs(playbackSettings, videoCodec, audioCodec)
.WithMetadata(channel, None);
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
@@ -266,19 +142,7 @@ public class FFmpegProcessService : IFFmpegProcessService
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInfiniteLoop()
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
throw new NotSupportedException();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
@@ -398,7 +262,7 @@ public class FFmpegProcessService : IFFmpegProcessService
None,
videoPath,
None,
playbackSettings.VideoCodec)
playbackSettings.VideoFormat)
.WithOutputFormat("apng", outputFile)
.Build();
@@ -431,8 +295,7 @@ public class FFmpegProcessService : IFFmpegProcessService
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
if (videoVersion is CoverArtMediaVersion)
{
+2 -2
View File
@@ -44,8 +44,8 @@ public class ChannelPlaylist
_ => "ts?mode=ts-legacy"
};
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
string acodec = channel.FFmpegProfile.AudioCodec;
string vcodec = channel.FFmpegProfile.VideoFormat.ToString().ToLowerInvariant();
string acodec = channel.FFmpegProfile.AudioFormat.ToString().ToLowerInvariant();
sb.AppendLine(
$"#EXTINF:0 tvg-id=\"{channel.Number}.etv\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"{channel.Group}\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
+1 -1
View File
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
</ItemGroup>
@@ -12,5 +12,8 @@ public class FFmpegProfileConfiguration : IEntityTypeConfiguration<FFmpegProfile
builder.Property(p => p.NormalizeFramerate)
.HasDefaultValue(false);
builder.Property(p => p.DeinterlaceVideo)
.HasDefaultValue(true);
}
}
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
<PackageReference Include="CliWrap" Version="3.4.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfileFormats : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AudioFormat",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "VideoFormat",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AudioFormat",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "VideoFormat",
table: "FFmpegProfile");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Convert_FFmpegProfileFormats : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// h264
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET VideoFormat = 1 WHERE VideoCodec LIKE '%264%'");
// hevc
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET VideoFormat = 2 WHERE VideoCodec LIKE '%265%'");
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET VideoFormat = 2 WHERE VideoCodec LIKE '%hevc%'");
// mpeg-2
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET VideoFormat = 3 WHERE VideoCodec LIKE '%mpeg2%'");
// anything else => h264
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET VideoFormat = 1 WHERE VideoFormat = 0");
// aac
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET AudioFormat = 1 WHERE AudioCodec LIKE '%AAC%'");
// ac3
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET AudioFormat = 2 WHERE AudioCodec LIKE '%AC3%'");
// anything else => aac
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET AudioFormat = 1 WHERE AudioFormat = 0");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_FFmpegProfileVideoCodecAudioCodec : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AudioCodec",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "VideoCodec",
table: "FFmpegProfile");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AudioCodec",
table: "FFmpegProfile",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VideoCodec",
table: "FFmpegProfile",
type: "TEXT",
nullable: true);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfileDeinterlaceVideo : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DeinterlaceVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeinterlaceVideo",
table: "FFmpegProfile");
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Fix_FFmpegProfileDeinterlaceVideoDefaultValue : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "DeinterlaceVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: true,
oldClrType: typeof(bool),
oldType: "INTEGER");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<bool>(
name: "DeinterlaceVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
oldClrType: typeof(bool),
oldType: "INTEGER",
oldDefaultValue: true);
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_FFmpegProfileTranscodeNormalizeVideoNormalizeAudio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizeAudio",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "NormalizeVideo",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "Transcode",
table: "FFmpegProfile");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NormalizeAudio",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NormalizeVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Transcode",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}
@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
modelBuilder.HasAnnotation("ProductVersion", "6.0.3");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -499,21 +499,23 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioFormat")
.HasColumnType("INTEGER");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<bool>("DeinterlaceVideo")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<int>("HardwareAcceleration")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeFramerate")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -522,18 +524,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<bool>("NormalizeLoudness")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideo")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<string>("VaapiDevice")
.HasColumnType("TEXT");
@@ -546,8 +542,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.Property<int>("VideoFormat")
.HasColumnType("INTEGER");
b.HasKey("Id");
@@ -0,0 +1,20 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController
{
private readonly IMediator _mediator;
public ChannelController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("/api/channels")]
public async Task<List<ChannelResponseModel>> GetAll() => await _mediator.Send(new GetAllChannelsForApi());
}
@@ -0,0 +1,20 @@
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class VersionController
{
private static readonly string Version;
static VersionController()
{
Version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "unknown";
}
[HttpGet("/api/version")]
public string GetVersion() => Version;
}
+41 -4
View File
@@ -18,20 +18,17 @@ namespace ErsatzTV.Controllers;
public class IptvController : ControllerBase
{
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(
IMediator mediator,
ILogger<IptvController> logger,
IFFmpegSegmenterService ffmpegSegmenterService,
IHlsPlaylistFilter hlsPlaylistFilter)
IFFmpegSegmenterService ffmpegSegmenterService)
{
_mediator = mediator;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
_hlsPlaylistFilter = hlsPlaylistFilter;
}
[HttpGet("iptv/channels.m3u")]
@@ -52,6 +49,26 @@ public class IptvController : ControllerBase
[FromQuery]
string mode = null)
{
// if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
foreach (ChannelViewModel channel in maybeChannel)
{
switch (channel.StreamingMode)
{
case StreamingMode.TransportStream:
mode = "ts-legacy";
break;
case StreamingMode.TransportStreamHybrid:
mode = "ts";
break;
default:
return Redirect($"/iptv/channel/{channelNumber}.m3u8");
}
}
}
FFmpegProcessRequest request = mode switch
{
"ts-legacy" => new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber),
@@ -97,6 +114,26 @@ public class IptvController : ControllerBase
[FromQuery]
string mode = "mixed")
{
// if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
foreach (ChannelViewModel channel in maybeChannel)
{
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingDirect:
mode = "hls-direct";
break;
case StreamingMode.HttpLiveStreamingSegmenter:
mode = "segmenter";
break;
default:
return Redirect($"/iptv/channel/{channelNumber}.ts");
}
}
}
switch (mode)
{
case "segmenter":
+53 -3
View File
@@ -2,25 +2,68 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<SpaRoot>client-app\</SpaRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>VSTHRD200</NoWarn>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
</PropertyGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- Build Target: Run webpack dist build -->
<Message Importance="high" Text="Running npm build..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="wwwroot\v2\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build">
<!-- Build Target: Ensure Node.js is installed -->
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
</Target>
<Target Name="DebugEnsureNpm" AfterTargets="DebugEnsureNodeEnv">
<!-- Build Target: Ensure Node.js is installed -->
<Exec Command="npm --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
</Target>
<Target Name="EnsureNodeModulesInstalled" BeforeTargets="Build" Inputs="package.json" Outputs="packages-lock.json">
<!-- Build Target: Restore NPM packages using npm -->
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" />
</Target>
<ItemGroup>
<Folder Include="Resources" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.0.0" />
<PackageReference Include="FluentValidation" Version="10.3.6" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
<PackageReference Include="FluentValidation" Version="10.4.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.4.0" />
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Markdig" Version="0.27.0" />
<PackageReference Include="Markdig" Version="0.28.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -40,6 +83,13 @@
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
<PackageReference Include="System.Runtime.Handles" Version="4.3.0" />
<PackageReference Include="VueCliMiddleware" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<Content Remove="$(SpaRoot)**" />
<None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
<ItemGroup>
+13 -23
View File
@@ -11,45 +11,35 @@
<MudTable Hover="true" Items="_ffmpegProfiles">
<ToolBarContent>
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
<MudSpacer/>
<MudText Color="Color.Tertiary">Colored settings will be normalized</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Transcode</MudTh>
<MudTh>Resolution</MudTh>
<MudTh>Video Codec</MudTh>
<MudTh>Audio Codec</MudTh>
<MudTh>Video Format</MudTh>
<MudTh>Audio Format</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Transcode">
@(context.Transcode ? "Yes" : "No")
</MudTd>
<MudTd DataLabel="Resolution">
<MudText Color="@(context.Transcode && context.NormalizeVideo ? Color.Tertiary : Color.Inherit)">
@context.Resolution.Name
</MudText>
</MudTd>
<MudTd DataLabel="Video Codec">
<MudText Color="@(context.Transcode && context.NormalizeVideo ? Color.Tertiary : Color.Inherit)">
@context.VideoCodec
</MudText>
</MudTd>
<MudTd DataLabel="Audio Codec">
<MudText Color="@(context.Transcode && context.NormalizeAudio ? Color.Tertiary : Color.Inherit)">
@context.AudioCodec
</MudText>
<MudTd DataLabel="Resolution">@context.Resolution.Name</MudTd>
<MudTd DataLabel="Video Format">
@if (context.HardwareAcceleration != HardwareAccelerationKind.None)
{
@($"{context.VideoFormat.ToString().ToLowerInvariant()} / {context.HardwareAcceleration.ToString().ToLowerInvariant()}")
}
else
{
@context.VideoFormat.ToString().ToLowerInvariant()
}
</MudTd>
<MudTd DataLabel="Audio Format">@context.AudioFormat.ToString().ToLowerInvariant()</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Profile">
+20 -19
View File
@@ -31,28 +31,29 @@
<MudTextField Label="Thread Count" @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
<MudSelect Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
@foreach (ResolutionViewModel resolution in _resolutions)
{
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Label="Transcode" @bind-Checked="@_model.Transcode" For="@(() => _model.Transcode)"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Video</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.VideoCodec" For="@(() => _model.VideoCodec)"/>
<MudSelect Label="Format" @bind-Value="_model.VideoFormat" For="@(() => _model.VideoFormat)">
<MudSelectItem Value="@FFmpegProfileVideoFormat.H264">h264</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Hevc">hevc</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Mpeg2Video">mpeg-2</MudSelectItem>
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
<MudTextField Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
<MudTextField Label="Buffer Size" @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Hardware Acceleration" @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
<MudSelect Label="Hardware Acceleration" @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in Enum.GetValues<HardwareAccelerationKind>())
{
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
@@ -76,32 +77,32 @@
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
<MudCheckBox Label="Normalize Frame Rate" @bind-Checked="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Frame Rate" @bind-Checked="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)"/>
<MudCheckBox Label="Auto Deinterlace Video" @bind-Checked="@_model.DeinterlaceVideo" For="@(() => _model.DeinterlaceVideo)"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Audio</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
<MudSelect Label="Format" @bind-Value="_model.AudioFormat" For="@(() => _model.AudioFormat)">
<MudSelectItem Value="@FFmpegProfileAudioFormat.Aac">aac</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.Ac3">ac3</MudSelectItem>
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
<MudTextField Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
<MudTextField Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
<MudTextField Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
<MudTextField Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Loudness" @bind-Checked="@_model.NormalizeLoudness" For="@(() => _model.NormalizeLoudness)"/>
<MudCheckBox Label="Normalize Loudness" @bind-Checked="@_model.NormalizeLoudness" For="@(() => _model.NormalizeLoudness)"/>
</MudElement>
</MudItem>
</MudGrid>
+6 -2
View File
@@ -56,7 +56,7 @@ public class SchedulerService : BackgroundService
{
await Task.Delay(TimeSpan.FromMinutes(toWait), cancellationToken);
}
catch (TaskCanceledException)
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
@@ -89,10 +89,14 @@ public class SchedulerService : BackgroundService
await ScanPlexMediaSources(cancellationToken);
await MatchTraktLists(cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during scheduler run");
try
{
using (IServiceScope scope = _serviceScopeFactory.CreateScope())
+47 -1
View File
@@ -53,6 +53,7 @@ using FluentValidation.AspNetCore;
using Ganss.XSS;
using MediatR;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.SpaServices;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
@@ -61,15 +62,22 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Refit;
using Serilog;
using VueCliMiddleware;
namespace ErsatzTV;
public class Startup
{
public Startup(IConfiguration configuration) => Configuration = configuration;
public Startup(IConfiguration configuration, IWebHostEnvironment env)
{
Configuration = configuration;
CurrentEnvironment = env;
}
public IConfiguration Configuration { get; }
private IWebHostEnvironment CurrentEnvironment { get; }
public void ConfigureServices(IServiceCollection services)
{
BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get<BugsnagConfiguration>();
@@ -129,6 +137,13 @@ public class Startup
options.ImplicitlyValidateChildProperties = true;
});
if (!CurrentEnvironment.IsDevelopment())
{
services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2");
}
services.AddMemoryCache();
services.AddRazorPages();
services.AddServerSideBlazor();
@@ -253,12 +268,43 @@ public class Startup
app.UseRouting();
if (!env.IsDevelopment())
{
app.Map(
"/v2",
app2 =>
{
if (string.IsNullOrWhiteSpace(env.WebRootPath))
{
env.WebRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
}
app2.UseRouting();
app2.UseEndpoints(e => e.MapFallbackToFile("index.html"));
app2.UseFileServer(
new FileServerOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(env.WebRootPath, "v2"))
});
});
}
app.UseEndpoints(
endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
if (env.IsDevelopment())
{
endpoints.MapToVueCliProxy(
"/v2/{*path}",
new SpaOptions { SourcePath = "client-app" },
"serve",
regex: "Compiled successfully",
forceKill: true);
}
});
}
@@ -6,69 +6,75 @@ namespace ErsatzTV.Validators;
public class FFmpegProfileEditViewModelValidator : AbstractValidator<FFmpegProfileEditViewModel>
{
private static readonly List<string> QsvEncoders = new() { "h264_qsv", "hevc_qsv", "mpeg2_qsv" };
private static readonly List<string> NvencEncoders = new() { "h264_nvenc", "hevc_nvenc" };
private static readonly List<string> VaapiEncoders = new() { "h264_vaapi", "hevc_vaapi", "mpeg2_vaapi" };
private static readonly List<string> VideoToolboxEncoders = new() { "h264_videotoolbox", "hevc_videotoolbox" };
private static readonly List<FFmpegProfileVideoFormat> QsvFormats = new()
{
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc,
FFmpegProfileVideoFormat.Mpeg2Video
};
private static readonly List<FFmpegProfileVideoFormat> NvencFormats = new()
{
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc
};
private static readonly List<FFmpegProfileVideoFormat> VaapiFormats = new()
{
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc,
FFmpegProfileVideoFormat.Mpeg2Video
};
private static readonly List<FFmpegProfileVideoFormat> VideoToolboxFormats = new()
{
FFmpegProfileVideoFormat.H264,
FFmpegProfileVideoFormat.Hevc
};
public FFmpegProfileEditViewModelValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.ThreadCount).GreaterThanOrEqualTo(0);
When(
x => x.Transcode,
() =>
{
RuleFor(x => x.VideoCodec).NotEmpty();
RuleFor(x => x.VideoBitrate).GreaterThan(0);
RuleFor(x => x.VideoBufferSize).GreaterThan(0);
RuleFor(x => x.VideoFormat).NotEqual(FFmpegProfileVideoFormat.None);
RuleFor(x => x.VideoBitrate).GreaterThan(0);
RuleFor(x => x.VideoBufferSize).GreaterThan(0);
RuleFor(x => x.AudioCodec).NotEmpty();
RuleFor(x => x.AudioBitrate).GreaterThan(0);
RuleFor(x => x.AudioChannels).GreaterThan(0);
});
RuleFor(x => x.AudioFormat).NotEqual(FFmpegProfileAudioFormat.None);
RuleFor(x => x.AudioBitrate).GreaterThan(0);
RuleFor(x => x.AudioChannels).GreaterThan(0);
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Qsv,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => QsvEncoders.Contains(c))
.WithMessage("QSV codec is required (h264_qsv, hevc_qsv, mpeg2_qsv)");
RuleFor(x => x.VideoFormat).Must(c => QsvFormats.Contains(c))
.WithMessage("QSV supports formats (h264, hevc, mpeg2video)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Nvenc,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => NvencEncoders.Contains(c))
.WithMessage("NVENC codec is required (h264_nvenc, hevc_nvenc)");
RuleFor(x => x.VideoFormat).Must(c => NvencFormats.Contains(c))
.WithMessage("NVENC supports formats (h264, hevc)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Vaapi,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => VaapiEncoders.Contains(c))
.WithMessage("VAAPI codec is required (h264_vaapi, hevc_vaapi, mpeg2_vaapi)");
RuleFor(x => x.VideoFormat).Must(c => VaapiFormats.Contains(c))
.WithMessage("VAAPI supports formats (h264, hevc, mpeg2video)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.VideoToolbox,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => VideoToolboxEncoders.Contains(c))
.WithMessage("VideoToolbox codec is required (h264_videotoolbox, hevc_videotoolbox)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.None,
() =>
{
RuleFor(x => x.VideoCodec).Must(
c => !QsvEncoders.Contains(c) && !NvencEncoders.Contains(c) && !VaapiEncoders.Contains(c) &&
!VideoToolboxEncoders.Contains(c))
.WithMessage("Hardware acceleration is required for this codec");
RuleFor(x => x.VideoFormat).Must(c => VideoToolboxFormats.Contains(c))
.WithMessage("VideoToolbox supports formats (h264, hevc)");
});
}
}
@@ -16,67 +16,61 @@ public class FFmpegProfileEditViewModel
AudioBitrate = viewModel.AudioBitrate;
AudioBufferSize = viewModel.AudioBufferSize;
AudioChannels = viewModel.AudioChannels;
AudioCodec = viewModel.AudioCodec;
AudioFormat = viewModel.AudioFormat;
AudioSampleRate = viewModel.AudioSampleRate;
NormalizeLoudness = viewModel.NormalizeLoudness;
Id = viewModel.Id;
Name = viewModel.Name;
NormalizeAudio = viewModel.NormalizeAudio;
NormalizeVideo = viewModel.NormalizeVideo;
NormalizeFramerate = viewModel.NormalizeFramerate;
DeinterlaceVideo = viewModel.DeinterlaceVideo;
Resolution = viewModel.Resolution;
ThreadCount = viewModel.ThreadCount;
Transcode = viewModel.Transcode;
HardwareAcceleration = viewModel.HardwareAcceleration;
VaapiDriver = viewModel.VaapiDriver;
VaapiDevice = viewModel.VaapiDevice;
VideoBitrate = viewModel.VideoBitrate;
VideoBufferSize = viewModel.VideoBufferSize;
VideoCodec = viewModel.VideoCodec;
VideoFormat = viewModel.VideoFormat;
}
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public int AudioSampleRate { get; set; }
public bool NormalizeLoudness { get; set; }
public int Id { get; set; }
public string Name { get; set; }
public bool NormalizeAudio { get; set; }
public bool NormalizeVideo { get; set; }
public bool NormalizeFramerate { get; set; }
public bool DeinterlaceVideo { get; set; }
public ResolutionViewModel Resolution { get; set; }
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string VideoCodec { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public CreateFFmpegProfile ToCreate() =>
new(
Name,
ThreadCount,
Transcode,
HardwareAcceleration,
VaapiDriver,
VaapiDevice,
Resolution.Id,
NormalizeVideo,
VideoCodec,
VideoFormat,
VideoBitrate,
VideoBufferSize,
AudioCodec,
AudioFormat,
AudioBitrate,
AudioBufferSize,
NormalizeLoudness,
AudioChannels,
AudioSampleRate,
NormalizeAudio,
NormalizeFramerate
NormalizeFramerate,
DeinterlaceVideo
);
public UpdateFFmpegProfile ToUpdate() =>
@@ -84,22 +78,20 @@ public class FFmpegProfileEditViewModel
Id,
Name,
ThreadCount,
Transcode,
HardwareAcceleration,
VaapiDriver,
VaapiDevice,
Resolution.Id,
NormalizeVideo,
VideoCodec,
VideoFormat,
VideoBitrate,
VideoBufferSize,
AudioCodec,
AudioFormat,
AudioBitrate,
AudioBufferSize,
NormalizeLoudness,
AudioChannels,
AudioSampleRate,
NormalizeAudio,
NormalizeFramerate
NormalizeFramerate,
DeinterlaceVideo
);
}
+23
View File
@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "none"
}
+25
View File
@@ -0,0 +1,25 @@
# ErsatzTV Vuetify UI - WIP
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
+21505
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
{
"name": "client-app",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mdi/font": "5.9.55",
"axios": "^0.26.1",
"core-js": "^3.8.3",
"pinia": "^2.0.11",
"roboto-fontface": "*",
"vue": "^2.6.14",
"vue-class-component": "^7.2.6",
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.2.0",
"vuetify": "^2.6.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/composition-api": "^1.4.9",
"@vue/eslint-config-typescript": "^9.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "~1.32.0",
"sass-loader": "^10.0.0",
"typescript": "~4.5.5",
"vue-cli-plugin-vuetify": "~2.4.7",
"vue-template-compiler": "^2.6.14",
"vuetify-loader": "^1.7.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"plugin:prettier/recommended",
"eslint:recommended",
"@vue/typescript"
],
"parserOptions": {
"parser": "@typescript-eslint/parser"
},
"rules": {
"prettier/prettier": [
"error",
{
"endOfLine": "auto",
"tabWidth": 4
}
]
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<template>
<v-app>
<Toolbar />
<!-- Sizes your content based upon application components -->
<v-main>
<!-- Provides the application the proper gutter -->
<v-container fluid>
<!-- If using vue-router -->
<router-view></router-view>
</v-container>
<SnackBar />
</v-main>
</v-app>
</template>
<script>
import Vue from 'vue';
import Toolbar from '@/components/Navigation/ToolBar.vue';
import SnackBar from '@/components/PopUps/SnackBar';
export default Vue.extend({
name: 'App',
components: { Toolbar, SnackBar },
data: () => ({})
});
</script>
<style>
main {
background-attachment: fixed;
background-repeat: no-repeat;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
background-position: center;
}
</style>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="67.791206mm"
height="36.611721mm"
viewBox="0 0 67.791206 36.611721"
version="1.1"
id="svg8"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="ersatztv-logo.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.49497475"
inkscape:cx="199.98633"
inkscape:cy="92.251514"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="3408"
inkscape:window-height="1364"
inkscape:window-x="14"
inkscape:window-y="36"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-66.205082,-134.16488)">
<g
aria-label="Etv"
id="text78"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:0px;word-spacing:0px;fill:#c0c000;fill-opacity:1;stroke:none;stroke-width:0.264583">
<path
d="m 66.205082,170.2805 v -36.11562 h 24.159765 v 6.02754 H 73.671293 v 8.60722 h 14.262695 v 5.8291 H 73.671293 v 9.64903 h 16.767968 v 6.00273 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#c0c000;fill-opacity:1;stroke-width:0.264583"
id="path98" />
<path
d="m 107.75293,170.10687 q -2.00918,0.66973 -4.31601,0.66973 -3.522269,0 -5.605863,-1.83555 -2.083593,-1.83555 -2.083593,-6.10195 v -14.16348 h -3.943946 v -5.23379 h 3.943946 v -6.62285 h 7.168556 v 6.62285 h 4.56406 v 5.23379 h -4.56406 v 13.41934 q 0,1.68671 0.69453,2.23242 0.69453,0.5457 2.05879,0.5457 1.0914,0 2.08359,-0.19844 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#00c0c0;fill-opacity:1;stroke-width:0.264583"
id="path100" />
<path
d="m 133.99629,143.44183 -9.05371,26.83867 h -6.82129 l -9.07852,-26.83867 h 7.51583 l 4.98574,18.03301 4.96093,-18.03301 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#00c000;fill-opacity:1;stroke-width:0.264583"
id="path102" />
</g>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="58.779785"
y="199.18695"
id="text82"><tspan
sodipodi:role="line"
id="tspan80"
x="58.779785"
y="199.18695"
style="stroke-width:0.264583"></tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="191.74141mm"
height="34.2752mm"
viewBox="0 0 191.74141 34.2752"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="ersatztv.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="368.16022"
inkscape:cy="84.671368"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="3436"
inkscape:window-height="1389"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="0"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-8.4242743,-125.76403)">
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:19.8158px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.495396"
x="5.4054561"
y="159.5748"
id="text841"><tspan
sodipodi:role="line"
id="tspan839"
x="5.4054561"
y="159.5748"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:47.558px;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.495396"><tspan
style="fill:#c0c000;fill-opacity:1"
id="tspan843">Ersatz</tspan><tspan
style="fill:#00c0c0;fill-opacity:1"
id="tspan845">T</tspan><tspan
style="fill:#00c000;fill-opacity:1"
id="tspan847">V</tspan></tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,27 @@
<template>
<span>
<v-img
v-if="!isNavigationMini"
src="@/assets/images/ersatztv.svg"
class="ma-3"
></v-img>
<v-img
v-if="isNavigationMini"
src="@/assets/images/ersatztv-logo.svg"
class="ma-1"
></v-img>
<v-divider inset></v-divider>
</span>
</template>
<script>
import { mapState } from 'pinia';
import { applicationState } from '@/stores/applicationState';
export default {
name: 'SideBarLogo',
computed: {
...mapState(applicationState, ['isNavigationMini'])
}
};
</script>
@@ -0,0 +1,43 @@
<template>
<v-list nav dense>
<span v-for="(nav, i) in navigation" :key="i">
<SideBarMenuItem
v-if="!nav.children"
:name="nav.name"
:path="nav.path"
:icon="nav.meta.icon"
:disabled="nav.meta.disabled"
/>
<SideBarMenuItemExpandable
v-else
@click.native="disableMiniNavigation()"
:name="nav.name"
:icon="nav.meta.icon"
:disabled="nav.meta.disabled"
:children="nav.children"
/>
</span>
</v-list>
</template>
<script>
import SideBarMenuItem from './SideBarMenuItem';
import SideBarMenuItemExpandable from './SideBarMenuItemExpandable';
import { mapState } from 'pinia';
import { applicationState } from '@/stores/applicationState';
export default {
name: 'NavSidebar',
components: { SideBarMenuItem, SideBarMenuItemExpandable },
data: () => ({
navigation: null
}),
computed: {
...mapState(applicationState, ['disableMiniNavigation'])
},
beforeMount: function () {
//Pull in navigation from routes and load into DOM
this.navigation = this.$router.options.routes;
}
};
</script>
@@ -0,0 +1,37 @@
<template>
<v-list-item-group color="primary">
<v-list-item :to="path" :disabled="disabled">
<v-list-item-icon>
<v-icon v-text="icon" :disabled="disabled" />
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="name" />
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</template>
<script>
export default {
name: 'SideBarMenuItem',
props: {
name: {
type: String,
required: true
},
path: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
disabled: {
type: Boolean,
required: true
}
}
};
</script>
@@ -0,0 +1,69 @@
<template>
<v-list-group
color="primary"
v-model="menuItemOpened"
:disabled="disabled"
:prepend-icon="icon"
no-action
>
<template v-slot:activator>
<v-list-item-content>
<v-list-item-title v-text="name"></v-list-item-title>
</v-list-item-content>
</template>
<v-list-item
v-for="child in children"
:key="child.name"
:to="child.path"
:disabled="child.meta.disabled"
>
<v-list-item-icon>
<v-icon
v-text="child.meta.icon"
:disabled="child.meta.disabled"
/>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="child.name"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-group>
</template>
<script>
export default {
name: 'SideBarMenuItemExpandable',
props: {
name: {
type: String,
required: true
},
icon: {
type: String,
required: true
},
disabled: {
type: Boolean,
required: true
},
children: {
type: Array,
required: true
}
},
data: () => ({
opened: false
}),
computed: {
menuItemOpened: {
get: function () {
return this.opened;
},
set: function () {
return !this.opened;
}
}
}
};
</script>
@@ -0,0 +1,28 @@
<template>
<span v-if="!isNavigationMini" class="text-center">
<v-divider inset></v-divider>
<h4 class="pt-2">ErsatzTV Version</h4>
<p>{{ currentServerVersion }}</p>
</span>
</template>
<script>
import { mapState } from 'pinia';
import { applicationState } from '@/stores/applicationState';
export default {
name: 'SideBarVersion',
setup() {
applicationState().getVersion();
},
computed: {
...mapState(applicationState, [
'currentServerVersion',
'isNavigationMini'
])
}
};
</script>
<style scoped></style>
@@ -0,0 +1,49 @@
<template>
<nav>
<v-app-bar flat app dense absolute>
<v-app-bar-nav-icon @click.stop="toggleMiniNavigation()" />
<ToolBarSearch />
<v-spacer />
<ToolBarLinks />
</v-app-bar>
<v-navigation-drawer app :mini-variant="isNavigationMini" permanent>
<template v-slot:prepend>
<SideBarLogo />
</template>
<SideBarMenu />
<template v-slot:append>
<SideBarVersion />
</template>
</v-navigation-drawer>
</nav>
</template>
<script>
import SideBarLogo from './SideBarLogo.vue';
import SideBarMenu from './SideBarMenu.vue';
import SideBarVersion from './SideBarVersion';
import ToolBarLinks from './ToolBarLinks';
import ToolBarSearch from './ToolBarSearch';
import { mapState } from 'pinia';
import { applicationState } from '@/stores/applicationState';
export default {
name: 'NavToolbar',
components: {
SideBarMenu,
SideBarLogo,
SideBarVersion,
ToolBarLinks,
ToolBarSearch
},
computed: {
...mapState(applicationState, [
'isNavigationMini',
'toggleMiniNavigation'
])
}
};
</script>
@@ -0,0 +1,97 @@
<template>
<span>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
class="ma-2"
outlined
color="primary"
@click="copyTextToClipboard(navBarURLs.m3uURL)"
v-bind="attrs"
v-on="on"
small
>
<v-icon>mdi-playlist-play</v-icon> M3U
</v-btn>
</template>
<span>{{ clickToCopyText }}</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
class="ma-2"
outlined
color="primary"
@click="copyTextToClipboard(navBarURLs.xmlURL)"
v-bind="attrs"
v-on="on"
small
>
<v-icon>mdi-xml</v-icon> XML
</v-btn>
</template>
<span>{{ clickToCopyText }}</span>
</v-tooltip>
<v-btn
icon
color="secondary"
:href="navBarURLs.documentationURL"
target="_blank"
>
<v-icon>mdi-file-document</v-icon>
</v-btn>
<v-btn
icon
color="secondary"
:href="navBarURLs.discordURL"
target="_blank"
>
<v-icon>mdi-discord</v-icon>
</v-btn>
<v-btn
icon
color="secondary"
:href="navBarURLs.githubURL"
target="_blank"
>
<v-icon>mdi-github</v-icon>
</v-btn>
</span>
</template>
<script>
import { mapState } from 'pinia';
import { applicationState } from '@/stores/applicationState';
import { snackbarState } from '@/stores/snackbarState';
export default {
name: 'ToolBarLinks.vue',
data: () => ({
toast: false,
clickToCopyText: 'Click to copy to clipboard!',
successCopyText: 'Successfully copied text to clipboard!',
failedCopyText: 'Failed to copy text to clipboard! Error: '
}),
computed: {
...mapState(applicationState, ['navBarURLs']),
...mapState(snackbarState, ['showSnackbar'])
},
methods: {
copyTextToClipboard(text) {
try {
navigator.clipboard.writeText(text);
this.showSnackbar(this.successCopyText);
} catch (e) {
console.error(e);
this.showSnackbar(`${this.failedCopyText}${e}`);
}
}
}
};
</script>
<style scoped></style>
@@ -0,0 +1,18 @@
<template>
<v-text-field
outlined
class="ml-5"
label="Search..."
prepend-inner-icon="mdi-magnify"
hide-details
dense
></v-text-field>
</template>
<script>
export default {
name: 'ToolBarSearch'
};
</script>
<style scoped></style>
@@ -0,0 +1,35 @@
<template>
<v-snackbar v-model="snackbar" :timeout="timeout">
{{ currentMessage }}
<v-btn text color="primary" @click.native="closeSnackbar()"
>Close</v-btn
>
</v-snackbar>
</template>
<script>
import { mapState } from 'pinia';
import { snackbarState } from '@/stores/snackbarState';
export default {
data: () => ({
timeout: 4000
}),
computed: {
...mapState(snackbarState, [
'currentMessage',
'isVisible',
'closeSnackbar',
'openSnackbar'
]),
snackbar: {
get() {
return this.isVisible;
},
set() {
this.closeSnackbar();
}
}
}
};
</script>
+23
View File
@@ -0,0 +1,23 @@
import Vue from 'vue';
import App from './App.vue';
import vuetify from './plugins/vuetify';
import router from './router';
import { createPinia, PiniaVuePlugin } from 'pinia';
import autoPageTitleMixin from './mixins/autoPageTitle';
import 'roboto-fontface/css/roboto/roboto-fontface.css';
import '@mdi/font/css/materialdesignicons.css';
Vue.config.productionTip = false;
Vue.use(PiniaVuePlugin);
const pinia = createPinia();
// Mixin to automate the page title when navigating... Will default to "ErsatzTV if no title value exported from page.
Vue.mixin(autoPageTitleMixin);
new Vue({
vuetify,
router,
pinia,
render: (h) => h(App)
}).$mount('#app');
@@ -0,0 +1,18 @@
function getTitle(vm) {
const { title } = vm.$options;
if (title) {
return typeof title === 'function' ? title.call(vm) : title;
}
if (vm._data.title) {
return vm._data.title;
}
}
export default {
created() {
const title = getTitle(this);
if (title) {
document.title = `ErsatzTV | ${title}`;
}
}
};
@@ -0,0 +1,6 @@
export interface Channel {
id: number;
number: string;
name: string;
streamingMode: string;
}
@@ -0,0 +1,29 @@
import Vue from 'vue';
import Vuetify from 'vuetify';
import colors from 'vuetify/lib/util/colors';
Vue.use(Vuetify);
export default new Vuetify({
icons: {
iconfont: 'mdi' // default - only for display purposes
},
theme: {
themes: {
dark: {
primary: '#c0c000',
secondary: '#00c0c0',
accent: colors.yellow.accent2,
error: colors.red,
warning: colors.orange,
info: colors.lightBlue,
success: colors.green,
background: '#121212'
}
},
options: {
customProperties: true
},
dark: true
}
});
+225
View File
@@ -0,0 +1,225 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import HomePage from '../views/HomePage.vue';
import ChannelsPage from '../views/ChannelsPage.vue';
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'Home',
component: HomePage,
meta: {
icon: 'mdi-home',
disabled: false
}
},
{
path: '/channels',
name: 'Channels',
component: ChannelsPage,
meta: {
icon: 'mdi-broadcast',
disabled: false
}
},
{
path: '/ffmpeg-profiles',
name: 'FFmpeg Profiles',
meta: {
icon: 'mdi-video-input-component',
disabled: true
}
},
{
path: '/watermarks',
name: 'Watermarks',
meta: {
icon: 'mdi-watermark',
disabled: true
}
},
{
path: '/sources',
name: 'Media Sources',
meta: {
icon: 'mdi-server-network',
disabled: false
},
children: [
{
path: '/sources/local',
name: 'Local',
meta: {
icon: 'mdi-folder',
disabled: true
}
},
{
path: '/sources/emby',
name: 'Emby',
meta: {
icon: 'mdi-emby',
disabled: true
}
},
{
path: '/sources/jellyfin',
name: 'Jellyfin',
meta: {
icon: 'mdi-jellyfish',
disabled: true
}
},
{
path: '/sources/plex',
name: 'Plex',
meta: {
icon: 'mdi-plex',
disabled: true
}
}
]
},
{
path: '/media',
name: 'Media',
meta: {
icon: 'mdi-cog',
disabled: false
},
children: [
{
path: '/media/libraries',
name: 'Libraries',
meta: {
icon: 'mdi-library',
disabled: true
}
},
{
path: '/media/trash',
name: 'Trash',
meta: {
icon: 'mdi-trash-can',
disabled: true
}
},
{
path: '/media/tv-shows',
name: 'TV Shows',
meta: {
icon: 'mdi-television-classic',
disabled: true
}
},
{
path: '/media/movies',
name: 'Movies',
meta: {
icon: 'mdi-movie',
disabled: true
}
},
{
path: '/media/music-videos',
name: 'Music Videos',
meta: {
icon: 'mdi-music-circle',
disabled: true
}
},
{
path: '/media/other-videos',
name: 'Other Videos',
meta: {
icon: 'mdi-video',
disabled: true
}
},
{
path: '/media/songs',
name: 'Songs',
meta: {
icon: 'mdi-album',
disabled: true
}
}
]
},
{
path: '/lists',
name: 'Lists',
meta: {
icon: 'mdi-format-list-bulleted',
disabled: false
},
children: [
{
path: '/lists/collections',
name: 'Collections',
meta: {
icon: 'mdi-collage',
disabled: true
}
},
{
path: '/lists/trakt-lists',
name: 'Trakt Lists',
meta: {
icon: 'mdi-hammer',
disabled: true
}
},
{
path: '/lists/filler-presets',
name: 'Filler Presets',
meta: {
icon: 'mdi-tune-vertical',
disabled: true
}
}
]
},
{
path: '/schedules',
name: 'Schedules',
meta: {
icon: 'mdi-calendar',
disabled: true
}
},
{
path: '/playouts',
name: 'Playouts',
meta: {
icon: 'mdi-clipboard-play-multiple',
disabled: true
}
},
{
path: '/settings',
name: 'Settings',
meta: {
icon: 'mdi-cog',
disabled: true
}
},
{
path: '/Logs',
name: 'Logs',
meta: {
icon: 'mdi-card-text',
disabled: true
}
}
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
});
export default router;
@@ -0,0 +1,72 @@
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import axios from 'axios';
export function isAxiosError(value: any): value is AxiosError {
return typeof value?.response === 'object';
}
export abstract class AbstractApiService {
protected readonly http: AxiosInstance;
protected constructor(
protected readonly path?: string,
protected readonly baseURL: string = process.env.VUE_APP_ETV_BASE_URL ??
'/'
) {
if (path) {
this.baseURL += path;
}
this.http = axios.create({
baseURL
// ... further stuff, e.g. `withCredentials: true`
});
this.http.defaults.headers.common['Accept'] =
'application/json;charset=UTF-8';
this.http.defaults.headers.common['Content-Type'] =
'application/json;charset=UTF-8';
}
protected createParams(record: Record<string, any>): URLSearchParams {
const params: URLSearchParams = new URLSearchParams();
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
const value: any = record[key];
if (value !== null && value !== undefined) {
params.append(key, value);
} else {
console.debug(
`Param key '${key}' was null or undefined and will be ignored`
);
}
}
}
return params;
}
protected handleResponse<T>(response: AxiosResponse<T>): T {
return response.data;
}
protected handleError(error: unknown): never {
if (error instanceof Error) {
if (isAxiosError(error)) {
if (error.response) {
console.log(error.response.data);
console.log(error.response.status);
console.log(error.response.headers);
throw error;
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser
console.log(error.request);
throw new Error(error as any);
}
} else {
// Something happened in setting up the request that triggered an Error
console.log('Error', error.message);
throw new Error(error.message);
}
}
throw new Error(error as any);
}
}
@@ -0,0 +1,17 @@
import { AbstractApiService } from './AbstractApiService';
import { Channel } from '@/models/Channel';
class ChannelApiService extends AbstractApiService {
public constructor() {
super();
}
public getAll(): Promise<Channel[]> {
return this.http
.get('/api/channels')
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
}
export const channelApiService: ChannelApiService = new ChannelApiService();
@@ -0,0 +1,16 @@
import { AbstractApiService } from './AbstractApiService';
class VersionApiService extends AbstractApiService {
public constructor() {
super();
}
public version(): Promise<string> {
return this.http
.get('/api/version')
.then(this.handleResponse.bind(this))
.catch(this.handleError.bind(this));
}
}
export const versionApiService: VersionApiService = new VersionApiService();
+11
View File
@@ -0,0 +1,11 @@
import Vue, { VNode } from 'vue';
declare global {
namespace JSX {
interface Element extends VNode {}
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia';
import { versionApiService } from '@/services/VersionService';
const originURL = `${window.location.origin}`;
export const applicationState = defineStore('appState', {
state: () => {
return {
miniMenu: false,
currentVersion: 'unknown',
m3uURL: originURL + '/iptv/channels.m3u', // this will need to be fixed for reverse proxies
xmlURL: originURL + '/iptv/xmltv.xml', // this will need to be fixed for reverse proxies
documentationURL: 'https://ersatztv.org/',
githubURL: 'https://github.com/jasongdove/ErsatzTV',
discordURL: 'https://discord.gg/hHaJm3yGy6'
};
},
getters: {
isNavigationMini(state) {
return state.miniMenu;
},
currentServerVersion(state) {
return state.currentVersion;
},
navBarURLs(state) {
return {
m3uURL: state.m3uURL,
xmlURL: state.xmlURL,
documentationURL: state.documentationURL,
githubURL: state.githubURL,
discordURL: state.discordURL
};
}
},
actions: {
toggleMiniNavigation() {
this.miniMenu = !this.miniMenu;
},
disableMiniNavigation() {
this.miniMenu = false;
},
async getVersion() {
this.currentVersion = await versionApiService.version();
}
}
});
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia';
export const snackbarState = defineStore('snackbarState', {
state: () => {
return {
visible: false,
message: ''
};
},
getters: {
isVisible(state) {
return state.visible;
},
currentMessage(state) {
return state.message;
}
},
actions: {
showSnackbar(message) {
this.message = message;
this.visible = true;
},
closeSnackbar() {
this.visible = false;
}
}
});
@@ -0,0 +1,36 @@
<template>
<div>
<v-data-table
:headers="headers"
:items="channels"
:sort-by="['number']"
class="elevation-1"
></v-data-table>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import { Channel } from '@/models/Channel';
import { channelApiService } from '@/services/ChannelService';
@Component
export default class Channels extends Vue {
private channels: Channel[] = [];
private headers = [
{ text: 'Number', value: 'number' },
{ text: 'Logo', value: 'logo' },
{ text: 'Name', value: 'name' },
{ text: 'Language', value: 'language' },
{ text: 'Mode', value: 'streamingMode' },
{ text: 'FFmpeg Profile', value: 'ffmpegProfile' }
];
title: string = 'Channels';
async mounted(): Promise<void> {
this.channels = await channelApiService.getAll();
}
}
</script>
@@ -0,0 +1,13 @@
<template>
<div>
<h1>{{ this.$route.name }}</h1>
</div>
</template>
<script>
export default {
title() {
return `Home`;
}
};
</script>
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"allowJs": true,
"experimentalDecorators": true,
"jsx": "preserve",
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env",
"vuetify"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
+13
View File
@@ -0,0 +1,13 @@
const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: ['vuetify'],
runtimeCompiler: true,
pwa: {
name: 'ErsatzTV'
},
publicPath: '/v2/',
outputDir: '../wwwroot/v2',
filenameHashing: false
});
+121
View File
@@ -0,0 +1,121 @@
{
"uuid": "49d843b2-cad8-4e26-a7f7-e2081683ee0e",
"lastMigration": 19,
"name": "ErsatzTV (copy)",
"endpointPrefix": "",
"latency": 0,
"port": 8409,
"hostname": "0.0.0.0",
"routes": [
{
"uuid": "56cb284d-6911-4695-a8ca-eecffbfcbd57",
"documentation": "get all channels",
"method": "get",
"endpoint": "api/channels",
"responses": [
{
"uuid": "4fc6ab6f-6a1b-4084-8bb2-94f00d62840c",
"body": "[\n {{#repeat (faker 'datatype.number' min=1 max=5)}}\n { \n \"id\": {{@index}},\n \"number\": {{faker 'datatype.number' min=1 max=25}},\n \"name\": \"{{faker 'hacker.adjective'}} {{faker 'hacker.noun'}}\",\n \"group\": \"\",\n \"categories\": [],\n \"ffmpegProfile\": \"1080 h264\",\n \"logo\": \"\",\n \"language\": \"{{oneOf (array '' 'eng')}}\",\n \"streamingMode\": \"{{oneOf (array 'HLS Segmenter' 'MPEG-TS')}}\"\n }\n {{/repeat}}\n]",
"latency": 0,
"statusCode": 200,
"label": "",
"headers": [],
"filePath": "",
"sendFileAsBody": false,
"rules": [],
"rulesOperator": "OR",
"disableTemplating": false,
"fallbackTo404": false
}
],
"enabled": true,
"randomResponse": false,
"sequentialResponse": false
},
{
"uuid": "a87f888a-2038-495e-9f89-2ae32b854004",
"documentation": "get all ffmpeg profiles",
"method": "get",
"endpoint": "api/ffmpeg/profiles",
"responses": [
{
"uuid": "2f42cd38-2591-475f-a4bf-e5fb3455a8b3",
"body": "[\n {{#repeat (faker 'datatype.number' min=2 max=3)}}\n { \n \"id\": {{@index}},\n \"name\": \"{{faker 'hacker.adjective'}} {{faker 'hacker.noun'}}\",\n \"transcode\": {{faker 'datatype.boolean'}},\n \"resolution\": \"{{oneOf (array '1920x1080' '1280x720' '720x480')}}\",\n \"videoCodec\": \"{{oneOf (array 'hevc_nvenc' 'h264_nvenc')}}\",\n \"audioCodec\": \"{{oneOf (array 'aac' 'ac3')}}\"\n }\n {{/repeat}}\n]",
"latency": 0,
"statusCode": 200,
"label": "",
"headers": [],
"filePath": "",
"sendFileAsBody": false,
"rules": [],
"rulesOperator": "OR",
"disableTemplating": false,
"fallbackTo404": false
}
],
"enabled": true,
"randomResponse": false,
"sequentialResponse": false
},
{
"uuid": "83dcafd5-74f6-4e5e-b9ae-3435a1541e8b",
"documentation": "",
"method": "get",
"endpoint": "api/version",
"responses": [
{
"uuid": "73ae76ef-09db-472a-8db8-10a90d4c3664",
"body": "develop",
"latency": 0,
"statusCode": 200,
"label": "",
"headers": [],
"filePath": "",
"sendFileAsBody": false,
"rules": [],
"rulesOperator": "OR",
"disableTemplating": false,
"fallbackTo404": false
}
],
"enabled": true,
"randomResponse": false,
"sequentialResponse": false
}
],
"proxyMode": false,
"proxyHost": "",
"proxyRemovePrefix": false,
"tlsOptions": {
"enabled": false,
"type": "CERT",
"pfxPath": "",
"certPath": "",
"keyPath": "",
"caPath": "",
"passphrase": ""
},
"cors": true,
"headers": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Access-Control-Allow-Origin",
"value": "*"
}
],
"proxyReqHeaders": [
{
"key": "",
"value": ""
}
],
"proxyResHeaders": [
{
"key": "",
"value": ""
}
]
}
+77
View File
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="191.74141mm"
height="34.2752mm"
viewBox="0 0 191.74141 34.2752"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="ersatztv.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="368.16022"
inkscape:cy="84.671368"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="3436"
inkscape:window-height="1389"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="0"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-8.4242743,-125.76403)">
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:19.8158px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.495396"
x="5.4054561"
y="159.5748"
id="text841"><tspan
sodipodi:role="line"
id="tspan839"
x="5.4054561"
y="159.5748"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:47.558px;font-family:Roboto;-inkscape-font-specification:'Roboto, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.495396"><tspan
style="fill:#c0c000;fill-opacity:1"
id="tspan843">Ersatz</tspan><tspan
style="fill:#00c0c0;fill-opacity:1"
id="tspan845">T</tspan><tspan
style="fill:#00c000;fill-opacity:1"
id="tspan847">V</tspan></tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Some files were not shown because too many files have changed in this diff Show More