Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0308106c1b | ||
|
|
ba93e3eeea | ||
|
|
aa2c914d8a | ||
|
|
caa9bf82d5 | ||
|
|
e397035c5a | ||
|
|
d32f881c4e | ||
|
|
9b3c24559d | ||
|
|
7c75b169ec | ||
|
|
4f1952340f | ||
|
|
ac2de24f6e | ||
|
|
4b9781dad4 | ||
|
|
d88c179b63 | ||
|
|
809a623a95 | ||
|
|
b453dce57e | ||
|
|
edd31755c0 | ||
|
|
7de1a87bbf | ||
|
|
5731edc82e | ||
|
|
2f668e53dd | ||
|
|
abd223acd2 | ||
|
|
6a9075dc11 | ||
|
|
d5a03963c0 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -43,3 +43,4 @@ scripts/generate-api-sdk/swagger.json
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+29
-70
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+3892
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3892
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+3886
File diff suppressed because it is too large
Load Diff
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3889
File diff suppressed because it is too large
Load Diff
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3891
File diff suppressed because it is too large
Load Diff
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3882
File diff suppressed because it is too large
Load Diff
+48
@@ -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;
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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?
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -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/).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset']
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+21505
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user