Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73887706ed | ||
|
|
abc103308b | ||
|
|
3773bbec19 | ||
|
|
e223d6a43f | ||
|
|
8369111e31 | ||
|
|
35ba2bab2c | ||
|
|
094ed71ad0 | ||
|
|
89e24b2b78 | ||
|
|
848795af32 |
@@ -52,9 +52,6 @@ jobs:
|
||||
# Pack files
|
||||
if [ "${{ matrix.target }}" == "win-x64" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
|
||||
cp lib/linux-arm/* "$release_name/"
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
|
||||
+18
-1
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.2-alpha] - 2021-12-03
|
||||
### Fixed
|
||||
- Fix artwork upload on Windows
|
||||
- Fix unicode song metadata on Windows
|
||||
- Fix unicode console output on Windows
|
||||
- Fix TV Show NFO metadata processing when `year` is missing
|
||||
- Fix song detail outline to help legibility on white backgrounds
|
||||
- Optimize song artwork scanning to prevent re-processing album artwork for each song
|
||||
|
||||
### Changed
|
||||
- Use custom log database backend which should be more portable (i.e. work in osx-arm64)
|
||||
- Use cover art blurhashes for song backgrounds instead of solid colors or box blur
|
||||
|
||||
## [0.3.1-alpha] - 2021-11-30
|
||||
### Fixed
|
||||
- Fix song page links in UI
|
||||
- Show song artist in playout detail
|
||||
@@ -835,7 +850,9 @@ 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.3.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...HEAD
|
||||
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
|
||||
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
|
||||
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
|
||||
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
|
||||
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands
|
||||
{
|
||||
[Command("channel", Description = "Create or rename a channel")]
|
||||
public class ChannelCommand : ICommand
|
||||
{
|
||||
private readonly ChannelsApi _channelsApi;
|
||||
private readonly FFmpegProfileApi _ffmpegProfileApi;
|
||||
private readonly ILogger<ChannelCommand> _logger;
|
||||
|
||||
public ChannelCommand(IConfiguration configuration, ILogger<ChannelCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_channelsApi = new ChannelsApi(configuration["ServerUrl"]);
|
||||
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
|
||||
public int Number { get; set; }
|
||||
|
||||
[CommandParameter(1, Name = "channel-name", Description = "The channel name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")]
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
|
||||
[CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")]
|
||||
public string FFmpegProfileName { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
Option<ChannelViewModel> maybeChannel = await _channelsApi.ApiChannelsGetAsync()
|
||||
.Map(list => Optional(list.SingleOrDefault(c => c.Number == Number)));
|
||||
|
||||
FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
|
||||
.Map(
|
||||
list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName))
|
||||
.IfNone(new FFmpegProfileViewModel { Id = 1 }));
|
||||
|
||||
await maybeChannel.Match(
|
||||
channel => RenameChannel(channel, ffmpegProfile),
|
||||
() => AddChannel(ffmpegProfile));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to synchronize channel: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile)
|
||||
{
|
||||
int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName)
|
||||
? existing.FfmpegProfileId
|
||||
: ffmpegProfile.Id;
|
||||
|
||||
if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId ||
|
||||
existing.StreamingMode != StreamingMode)
|
||||
{
|
||||
var updateChannel = new UpdateChannel(
|
||||
existing.Id,
|
||||
Name,
|
||||
existing.Number,
|
||||
newFFmpegProfileId,
|
||||
existing.Logo,
|
||||
StreamingMode);
|
||||
|
||||
await _channelsApi.ApiChannelsPatchAsync(updateChannel);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully synchronized channel {ChannelNumber} - {ChannelName}",
|
||||
Number,
|
||||
Name);
|
||||
}
|
||||
|
||||
private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile)
|
||||
{
|
||||
var createChannel = new CreateChannel(
|
||||
Name,
|
||||
Number,
|
||||
ffmpegProfile.Id,
|
||||
null,
|
||||
StreamingMode);
|
||||
|
||||
await _channelsApi.ApiChannelsPostAsync(createChannel);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully created channel {ChannelNumber} - {ChannelName}",
|
||||
Number,
|
||||
Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands
|
||||
{
|
||||
[Command("config", Description = "Configure ErsatzTV server url")]
|
||||
public class ConfigCommand : ICommand
|
||||
{
|
||||
[CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")]
|
||||
public string ServerUrl { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
// TODO: validate URL
|
||||
|
||||
string configFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"ersatztv");
|
||||
|
||||
string configFile = Path.Combine(configFolder, "cli.json");
|
||||
|
||||
var config = new Config { ServerUrl = ServerUrl };
|
||||
string contents = JsonSerializer.Serialize(config);
|
||||
await File.WriteAllTextAsync(configFile, contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands
|
||||
{
|
||||
[Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")]
|
||||
public class FFmpegProfileCommand : ICommand
|
||||
{
|
||||
private readonly FFmpegProfileApi _ffmpegProfileApi;
|
||||
private readonly ILogger<FFmpegProfileCommand> _logger;
|
||||
|
||||
public FFmpegProfileCommand(IConfiguration configuration, ILogger<FFmpegProfileCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[CommandOption("thread-count", Description = "The number of threads")]
|
||||
public int ThreadCount { get; set; } = 0;
|
||||
|
||||
[CommandOption("transcode", Description = "Whether to transcode all media")]
|
||||
public bool Transcode { get; set; } = true;
|
||||
|
||||
// public int ResolutionId { get; set; } = resolution.Id;
|
||||
// Resolution { get; set; } = resolution;
|
||||
[CommandOption("resolution", Description = "The resolution")]
|
||||
public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080;
|
||||
|
||||
[CommandOption("video-codec", Description = "The video codec")]
|
||||
public string VideoCodec { get; set; } = "libx264";
|
||||
|
||||
[CommandOption("audio-codec", Description = "The audio codec")]
|
||||
public string AudioCodec { get; set; } = "ac3";
|
||||
|
||||
[CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")]
|
||||
public int VideoBitrate { get; set; } = 2000;
|
||||
|
||||
[CommandOption("video-buffer-size", Description = "The video buffer size in kBit")]
|
||||
public int VideoBufferSize { get; set; } = 2000;
|
||||
|
||||
[CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")]
|
||||
public int AudioBitrate { get; set; } = 192;
|
||||
|
||||
[CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")]
|
||||
public int AudioBufferSize { get; set; } = 50;
|
||||
|
||||
[CommandOption("audio-volume", Description = "The audio volume as a whole number percent")]
|
||||
public int AudioVolume { get; set; } = 100;
|
||||
|
||||
[CommandOption("audio-channels", Description = "The number of audio channels")]
|
||||
public int AudioChannels { get; set; } = 2;
|
||||
|
||||
[CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")]
|
||||
public int AudioSampleRate { get; set; } = 48;
|
||||
|
||||
[CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")]
|
||||
public bool NormalizeResolution { get; set; } = true;
|
||||
|
||||
[CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")]
|
||||
public bool NormalizeVideoCodec { get; set; } = true;
|
||||
|
||||
[CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")]
|
||||
public bool NormalizeAudioCodec { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"normalize-audio",
|
||||
Description = "Whether to normalize audio channels and sample rate of all media")]
|
||||
public bool NormalizeAudio { get; set; } = true;
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
|
||||
.Map(list => Optional(list.SingleOrDefault(p => p.Name == Name)));
|
||||
|
||||
await maybeFFmpegProfile.Match(UpdateProfile, AddProfile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdateProfile(FFmpegProfileViewModel existing)
|
||||
{
|
||||
var updateFFmpegProfile = new UpdateFFmpegProfile(
|
||||
existing.Id,
|
||||
Name,
|
||||
ThreadCount,
|
||||
Transcode,
|
||||
(int) Resolution,
|
||||
NormalizeResolution,
|
||||
VideoCodec,
|
||||
NormalizeVideoCodec,
|
||||
VideoBitrate,
|
||||
VideoBufferSize,
|
||||
AudioCodec,
|
||||
NormalizeAudioCodec,
|
||||
AudioBitrate,
|
||||
AudioBufferSize,
|
||||
AudioVolume,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio);
|
||||
|
||||
await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile);
|
||||
|
||||
_logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name);
|
||||
}
|
||||
|
||||
private async ValueTask AddProfile()
|
||||
{
|
||||
var createFFmpegProfile = new CreateFFmpegProfile(
|
||||
Name,
|
||||
ThreadCount,
|
||||
Transcode,
|
||||
(int) Resolution,
|
||||
NormalizeResolution,
|
||||
VideoCodec,
|
||||
NormalizeVideoCodec,
|
||||
VideoBitrate,
|
||||
VideoBufferSize,
|
||||
AudioCodec,
|
||||
NormalizeAudioCodec,
|
||||
AudioBitrate,
|
||||
AudioBufferSize,
|
||||
AudioVolume,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio);
|
||||
|
||||
|
||||
await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile);
|
||||
|
||||
_logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using LanguageExt.Common;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands.MediaCollections
|
||||
{
|
||||
[Command("collection clear", Description = "Removes all items from a media collection")]
|
||||
public class MediaCollectionClearCommand : ICommand
|
||||
{
|
||||
private readonly ILogger<MediaCollectionClearCommand> _logger;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
public MediaCollectionClearCommand(IConfiguration configuration, ILogger<MediaCollectionClearCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverUrl = configuration["ServerUrl"];
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
|
||||
public string Name { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = console.GetCancellationToken();
|
||||
|
||||
Either<Error, Unit> result = await ClearMediaCollection(cancellationToken);
|
||||
|
||||
result.Match(
|
||||
_ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name),
|
||||
error => _logger.LogError(
|
||||
"Unable to clear media collection: {Error}",
|
||||
error.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to clear media collection: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<Error, Unit>> ClearMediaCollection(CancellationToken cancellationToken) =>
|
||||
await EnsureMediaCollectionExists(cancellationToken)
|
||||
.BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken));
|
||||
|
||||
private async Task<Either<Error, int>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
|
||||
{
|
||||
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
|
||||
Option<MediaCollectionViewModel> maybeExisting =
|
||||
(await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken))
|
||||
.SingleOrDefault(mc => mc.Name == Name);
|
||||
return await maybeExisting.MatchAsync(
|
||||
existing => existing.Id,
|
||||
async () =>
|
||||
{
|
||||
var data = new CreateSimpleMediaCollection(Name);
|
||||
MediaCollectionViewModel result =
|
||||
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
|
||||
return result.Id;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<Error, Unit>> ClearMediaCollectionImpl(
|
||||
int mediaCollectionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
|
||||
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
|
||||
mediaCollectionId,
|
||||
new List<int>(),
|
||||
cancellationToken);
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using LanguageExt.Common;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands.MediaCollections
|
||||
{
|
||||
[Command("collection create", Description = "Creates a new media collection")]
|
||||
public class MediaCollectionCreateCommand : ICommand
|
||||
{
|
||||
private readonly ILogger<MediaCollectionCreateCommand> _logger;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
public MediaCollectionCreateCommand(IConfiguration configuration, ILogger<MediaCollectionCreateCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverUrl = configuration["ServerUrl"];
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
|
||||
public string Name { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = console.GetCancellationToken();
|
||||
|
||||
Either<Error, Unit> result = await CreateMediaCollection(cancellationToken);
|
||||
result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to create media collection: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<Error, Unit>> CreateMediaCollection(CancellationToken cancellationToken) =>
|
||||
await EnsureMediaCollectionExists(cancellationToken);
|
||||
|
||||
private async Task<Either<Error, Unit>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
|
||||
{
|
||||
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
|
||||
|
||||
bool needToAdd = await mediaCollectionsApi
|
||||
.ApiMediaCollectionsGetAsync(cancellationToken)
|
||||
.Map(list => list.All(mc => mc.Name != Name));
|
||||
|
||||
if (needToAdd)
|
||||
{
|
||||
var data = new CreateSimpleMediaCollection(Name);
|
||||
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
|
||||
_logger.LogInformation("Successfully created media collection {MediaCollection}", Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Media collection {MediaCollection} is already present", Name);
|
||||
}
|
||||
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands
|
||||
{
|
||||
[Command("playout build", Description = "Builds a playout with the requested channel and schedule")]
|
||||
public class PlayoutCommand : ICommand
|
||||
{
|
||||
private readonly ILogger<PlayoutCommand> _logger;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
public PlayoutCommand(IConfiguration configuration, ILogger<PlayoutCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverUrl = configuration["ServerUrl"];
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
|
||||
public int ChannelNumber { get; set; }
|
||||
|
||||
[CommandParameter(1, Name = "schedule-name", Description = "The schedule name")]
|
||||
public string ScheduleName { get; set; }
|
||||
|
||||
// [Option("--type <type>")]
|
||||
// [Required]
|
||||
// public ProgramSchedulePlayoutType PlayoutType { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = console.GetCancellationToken();
|
||||
|
||||
var channelsApi = new ChannelsApi(_serverUrl);
|
||||
Option<ChannelViewModel> maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber));
|
||||
|
||||
await maybeChannel.Match(
|
||||
channel => BuildPlayout(cancellationToken, channel),
|
||||
() =>
|
||||
{
|
||||
_logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to build playout: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel)
|
||||
{
|
||||
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
|
||||
Option<ProgramScheduleViewModel> maybeSchedule = await programScheduleApi
|
||||
.ApiSchedulesGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(s => s.Name == ScheduleName));
|
||||
|
||||
await maybeSchedule.Match(
|
||||
schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken),
|
||||
() =>
|
||||
{
|
||||
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask SynchronizePlayoutAsync(
|
||||
int channelId,
|
||||
int scheduleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var playoutApi = new PlayoutApi(_serverUrl);
|
||||
Option<PlayoutViewModel> maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId));
|
||||
await maybeExisting.Match(
|
||||
existing =>
|
||||
{
|
||||
var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
|
||||
if (existing.Channel.Id != data.ChannelId ||
|
||||
existing.ProgramSchedule.Id != data.ProgramScheduleId ||
|
||||
existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType)
|
||||
{
|
||||
return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
|
||||
return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken);
|
||||
});
|
||||
|
||||
_logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands.Schedules
|
||||
{
|
||||
[Command("schedule add-item", Description = "Adds an item to the end of a schedule")]
|
||||
public class ScheduleAddItemCommand : ICommand
|
||||
{
|
||||
private readonly ILogger<ScheduleAddItemCommand> _logger;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
public ScheduleAddItemCommand(IConfiguration configuration, ILogger<ScheduleAddItemCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverUrl = configuration["ServerUrl"];
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
|
||||
public string ScheduleName { get; set; }
|
||||
|
||||
[CommandParameter(1, Name = "collection-name", Description = "The media collection name")]
|
||||
public string CollectionName { get; set; }
|
||||
|
||||
// [CommandParameter(2, Description = "The collection playback order")]
|
||||
// public PlaybackOrder Order { get; set; }
|
||||
|
||||
[CommandOption("start-type", 's', Description = "The playout start type")]
|
||||
public StartType StartType { get; set; } = StartType.Dynamic;
|
||||
|
||||
[CommandOption("start-time", 't', Description = "The playout start time (of day)")]
|
||||
public string StartTime { get; set; } = null;
|
||||
|
||||
[CommandOption("playout-mode", 'm', Description = "The playout mode")]
|
||||
public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood;
|
||||
|
||||
[CommandOption(
|
||||
"multiple-count",
|
||||
'c',
|
||||
Description = "How many items to play from the collection (for Multiple playout mode)")]
|
||||
public int? MultipleCount { get; set; } = null;
|
||||
|
||||
[CommandOption(
|
||||
"playout-duration",
|
||||
'd',
|
||||
Description = "How long to play items from the collection (for Duration playout mode)")]
|
||||
public string PlayoutDuration { get; set; } = null;
|
||||
|
||||
[CommandOption(
|
||||
"offline-tail",
|
||||
'o',
|
||||
Description =
|
||||
"Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")]
|
||||
public bool? OfflineTail { get; set; } = null;
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = console.GetCancellationToken();
|
||||
|
||||
Option<ProgramScheduleViewModel> maybeSchedule = await GetSchedule(cancellationToken);
|
||||
await maybeSchedule.Match(
|
||||
programSchedule => AddItemToSchedule(cancellationToken, programSchedule),
|
||||
() =>
|
||||
{
|
||||
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to add item to schedule: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask AddItemToSchedule(
|
||||
CancellationToken cancellationToken,
|
||||
ProgramScheduleViewModel programSchedule)
|
||||
{
|
||||
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
|
||||
Option<MediaCollectionViewModel> maybeMediaCollection = await mediaCollectionsApi
|
||||
.ApiMediaCollectionsGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName));
|
||||
|
||||
await maybeMediaCollection.Match(
|
||||
collection =>
|
||||
AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken),
|
||||
() =>
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to locate collection {Collection}",
|
||||
CollectionName);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Option<ProgramScheduleViewModel>> GetSchedule(CancellationToken cancellationToken)
|
||||
{
|
||||
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
|
||||
return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName));
|
||||
}
|
||||
|
||||
private async Task AddScheduleItem(
|
||||
int programScheduleId,
|
||||
int mediaCollectionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
|
||||
|
||||
var request = new AddProgramScheduleItem
|
||||
{
|
||||
ProgramScheduleId = programScheduleId,
|
||||
StartType = StartType,
|
||||
StartTime = StartTime,
|
||||
PlayoutMode = PlayoutMode,
|
||||
MediaCollectionId = mediaCollectionId,
|
||||
PlayoutDuration = PlayoutDuration,
|
||||
MultipleCount = MultipleCount,
|
||||
OfflineTail = OfflineTail
|
||||
};
|
||||
|
||||
await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Collection {Collection} has been added to schedule {Schedule}",
|
||||
CollectionName,
|
||||
ScheduleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using ErsatzTV.Api.Sdk.Api;
|
||||
using ErsatzTV.Api.Sdk.Model;
|
||||
using LanguageExt;
|
||||
using LanguageExt.Common;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.CommandLine.Commands.Schedules
|
||||
{
|
||||
[Command("schedule create", Description = "Creates a new schedule")]
|
||||
public class ScheduleCreateCommand : ICommand
|
||||
{
|
||||
private readonly ILogger<ScheduleCreateCommand> _logger;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
public ScheduleCreateCommand(IConfiguration configuration, ILogger<ScheduleCreateCommand> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_serverUrl = configuration["ServerUrl"];
|
||||
}
|
||||
|
||||
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[CommandParameter(1, Name = "playback-order", Description = "The collection playback order")]
|
||||
public PlaybackOrder Order { get; set; }
|
||||
|
||||
[CommandOption("reset", Description = "Resets the schedule to contain no items")]
|
||||
public bool Reset { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
try
|
||||
{
|
||||
CancellationToken cancellationToken = console.GetCancellationToken();
|
||||
|
||||
Either<Error, Unit> result = await EnsureScheduleExistsAsync(cancellationToken);
|
||||
result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Unable to create schedule: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<Error, Unit>> EnsureScheduleExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
|
||||
|
||||
Option<ProgramScheduleViewModel> maybeExisting = await programScheduleApi
|
||||
.ApiSchedulesGetAsync(cancellationToken)
|
||||
.Map(list => list.SingleOrDefault(schedule => schedule.Name == Name));
|
||||
|
||||
await maybeExisting.Match(
|
||||
existing =>
|
||||
{
|
||||
// TODO: update playback order if changed?
|
||||
_logger.LogInformation("Schedule {Schedule} is already present", Name);
|
||||
|
||||
if (Reset)
|
||||
{
|
||||
return programScheduleApi
|
||||
.ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken)
|
||||
.Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var data = new CreateProgramSchedule(Name, Order);
|
||||
return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken)
|
||||
.Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name));
|
||||
});
|
||||
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace ErsatzTV.CommandLine
|
||||
{
|
||||
public class Config
|
||||
{
|
||||
public string ServerUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ErsatzTV.CommandLine
|
||||
{
|
||||
public enum DesiredResolution
|
||||
{
|
||||
W720H480 = 1,
|
||||
W1280H720 = 2,
|
||||
W1920H1080 = 3,
|
||||
W3840H2160 = 4
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<AssemblyName>ersatztv-cli</AssemblyName>
|
||||
<LangVersion>9</LangVersion>
|
||||
<PackageVersion>0.0.1</PackageVersion>
|
||||
<AssemblyVersion>0.0.1</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="1.6.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
|
||||
<PackageReference Include="RestSharp" Version="106.11.7" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.Extensions.Hosting.Abstractions, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
|
||||
<HintPath>..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.CommandLine
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
IHost host = CreateHostBuilder(args).Build();
|
||||
try
|
||||
{
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.UseTypeActivator(host.Services.GetService)
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Host terminated unexpectedly");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
public static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureServices(
|
||||
(_, services) =>
|
||||
{
|
||||
services.AddSingleton<IConsole, SystemConsole>();
|
||||
IEnumerable<Type> typesThatImplementICommand = typeof(Program).Assembly.GetTypes()
|
||||
.Where(x => typeof(ICommand).IsAssignableFrom(x))
|
||||
.Where(x => !x.IsAbstract);
|
||||
foreach (Type t in typesThatImplementICommand)
|
||||
{
|
||||
services.AddTransient(t);
|
||||
}
|
||||
})
|
||||
.ConfigureAppConfiguration(
|
||||
(_, configuration) =>
|
||||
{
|
||||
configuration.Sources.Clear();
|
||||
|
||||
string configFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"ersatztv");
|
||||
|
||||
configuration.SetBasePath(configFolder);
|
||||
configuration.AddJsonFile("cli.json", true, true);
|
||||
})
|
||||
.UseSerilog()
|
||||
.UseConsoleLifetime();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string SourcePath { get; set; }
|
||||
public string BlurHash43 { get; set; }
|
||||
public string BlurHash54 { get; set; }
|
||||
public string BlurHash64 { get; set; }
|
||||
public ArtworkKind ArtworkKind { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public DateTime DateUpdated { get; set; }
|
||||
|
||||
@@ -27,7 +27,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private string _videoEncoder;
|
||||
private Option<string> _subtitle;
|
||||
private bool _boxBlur;
|
||||
private Option<int> _randomColor;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -102,12 +101,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithRandomColor(Option<int> randomColor)
|
||||
{
|
||||
_randomColor = randomColor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithSubtitleFile(Option<string> subtitleFile)
|
||||
{
|
||||
foreach (string file in subtitleFile)
|
||||
@@ -250,7 +243,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
if (_randomColor.IsNone && !string.IsNullOrWhiteSpace(filter))
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
@@ -276,7 +269,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoFilterQueue.Add(format);
|
||||
}
|
||||
|
||||
if (scaleOrPad && _boxBlur == false && _randomColor.IsNone)
|
||||
if (scaleOrPad && _boxBlur == false)
|
||||
{
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
@@ -286,10 +279,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoFilterQueue.Add("boxblur=40");
|
||||
}
|
||||
|
||||
foreach (int color in _randomColor)
|
||||
if (videoOnly)
|
||||
{
|
||||
videoFilterQueue.Add(
|
||||
$"palettegen=max_colors=8,crop=1:1:{color}:0,scale={_resolution.Width}:{_resolution.Height},setsar=1");
|
||||
videoFilterQueue.Add("deband");
|
||||
}
|
||||
|
||||
foreach (ChannelWatermark watermark in _watermark)
|
||||
|
||||
@@ -285,8 +285,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string videoPath,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat,
|
||||
bool boxBlur,
|
||||
Option<int> randomColor)
|
||||
bool boxBlur)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
@@ -294,8 +293,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat)
|
||||
.WithBoxBlur(boxBlur)
|
||||
.WithRandomColor(randomColor);
|
||||
.WithBoxBlur(boxBlur);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
@@ -68,7 +68,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
outPoint);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None);
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
@@ -276,7 +276,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
MediaVersion videoVersion,
|
||||
string videoPath,
|
||||
bool boxBlur,
|
||||
Option<int> randomColor,
|
||||
Option<string> watermarkPath,
|
||||
ChannelWatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
@@ -303,7 +303,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
: None;
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride);
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride, watermarkPath);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
@@ -323,7 +323,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur, randomColor)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithSubtitleFile(subtitleFile);
|
||||
|
||||
@@ -370,7 +370,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
Channel channel,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
MediaVersion videoVersion,
|
||||
Option<ChannelWatermark> watermarkOverride)
|
||||
Option<ChannelWatermark> watermarkOverride,
|
||||
Option<string> watermarkPath)
|
||||
{
|
||||
if (videoVersion is BackgroundImageMediaVersion)
|
||||
{
|
||||
@@ -384,7 +385,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
return new WatermarkOptions(
|
||||
watermarkOverride,
|
||||
videoVersion.MediaFiles.Head().Path,
|
||||
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
|
||||
0,
|
||||
false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -50,7 +51,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
string[] backgrounds =
|
||||
{
|
||||
"background_blank.png",
|
||||
@@ -60,12 +61,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
};
|
||||
|
||||
// use random ETV color by default
|
||||
string artworkPath = Path.Combine(
|
||||
string backgroundPath = Path.Combine(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
backgrounds[NextRandom(backgrounds.Length)]);
|
||||
|
||||
Option<string> watermarkPath = None;
|
||||
|
||||
var boxBlur = false;
|
||||
Option<int> randomColor = None;
|
||||
|
||||
const int HORIZONTAL_MARGIN_PERCENT = 3;
|
||||
const int VERTICAL_MARGIN_PERCENT = 5;
|
||||
@@ -135,7 +137,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithFontName("OPTIKabel-Heavy")
|
||||
.WithFontSize(fontSize)
|
||||
.WithPrimaryColor("&HFFFFFF")
|
||||
.WithOutlineColor("&H555555")
|
||||
.WithOutlineColor("&H444444")
|
||||
.WithAlignment(0)
|
||||
.WithMarginRight(rightMargin)
|
||||
.WithMarginLeft(leftMargin)
|
||||
@@ -149,23 +151,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
foreach (Artwork artwork in Optional(
|
||||
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
|
||||
{
|
||||
int backgroundRoll = NextRandom(16);
|
||||
if (backgroundRoll < 8)
|
||||
{
|
||||
randomColor = backgroundRoll;
|
||||
}
|
||||
else
|
||||
{
|
||||
boxBlur = true;
|
||||
}
|
||||
|
||||
string customPath = _imageCache.GetPathForImage(
|
||||
artwork.Path,
|
||||
ArtworkKind.Thumbnail,
|
||||
Option<int>.None);
|
||||
|
||||
artworkPath = customPath;
|
||||
|
||||
// signal that we want to use cover art as watermark
|
||||
videoVersion = new CoverArtMediaVersion
|
||||
{
|
||||
@@ -179,10 +164,42 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
|
||||
}
|
||||
};
|
||||
|
||||
string customPath = _imageCache.GetPathForImage(
|
||||
artwork.Path,
|
||||
ArtworkKind.Thumbnail,
|
||||
Option<int>.None);
|
||||
|
||||
watermarkPath = customPath;
|
||||
|
||||
// randomize selected blur hash
|
||||
var hashes = new List<string>
|
||||
{
|
||||
artwork.BlurHash43,
|
||||
artwork.BlurHash54,
|
||||
artwork.BlurHash64
|
||||
}.Filter(s => !string.IsNullOrWhiteSpace(s)).ToList();
|
||||
|
||||
if (hashes.Any())
|
||||
{
|
||||
string hash = hashes[NextRandom(hashes.Count)];
|
||||
|
||||
backgroundPath = await _imageCache.WriteBlurHash(
|
||||
hash,
|
||||
channel.FFmpegProfile.Resolution);
|
||||
|
||||
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
|
||||
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
|
||||
}
|
||||
else
|
||||
{
|
||||
backgroundPath = customPath;
|
||||
boxBlur = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string videoPath = artworkPath;
|
||||
string videoPath = backgroundPath;
|
||||
|
||||
videoVersion.MediaFiles = new List<MediaFile>
|
||||
{
|
||||
@@ -197,7 +214,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoVersion,
|
||||
videoPath,
|
||||
boxBlur,
|
||||
randomColor,
|
||||
watermarkPath,
|
||||
watermarkLocation,
|
||||
HORIZONTAL_MARGIN_PERCENT,
|
||||
VERTICAL_MARGIN_PERCENT,
|
||||
|
||||
@@ -118,8 +118,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
sb.AppendLine("[V4+ Styles]");
|
||||
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Shadow, Alignment, Encoding");
|
||||
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},{await _shadow.IfNoneAsync(0)}, {await _alignment.IfNoneAsync(0)},1");
|
||||
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Outline, Shadow, Alignment, Encoding");
|
||||
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
|
||||
|
||||
sb.AppendLine("[Events]");
|
||||
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
MediaVersion videoVersion,
|
||||
string videoPath,
|
||||
bool boxBlur,
|
||||
Option<int> randomColor,
|
||||
Option<string> watermarkPath,
|
||||
ChannelWatermarkLocation watermarkLocation,
|
||||
int horizontalMarginPercent,
|
||||
int verticalMarginPercent,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Images
|
||||
@@ -12,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Images
|
||||
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
||||
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
||||
Task<bool> IsAnimated(string fileName);
|
||||
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
|
||||
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
Task<bool> CloneArtwork(
|
||||
Domain.Metadata metadata,
|
||||
Option<Artwork> maybeArtwork,
|
||||
ArtworkKind artworkKind,
|
||||
string sourcePath,
|
||||
DateTime lastWriteTime);
|
||||
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
|
||||
|
||||
@@ -139,6 +139,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
|
||||
string sourcePath = artworkFile;
|
||||
if (await _metadataRepository.CloneArtwork(
|
||||
metadata,
|
||||
maybeArtwork,
|
||||
artworkKind,
|
||||
sourcePath,
|
||||
lastWriteTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// if ffmpeg path is passed, we need pre-processing
|
||||
foreach (string path in ffmpegPath)
|
||||
{
|
||||
@@ -179,7 +190,28 @@ namespace ErsatzTV.Core.Metadata
|
||||
async artwork =>
|
||||
{
|
||||
artwork.Path = cacheName;
|
||||
artwork.SourcePath = sourcePath;
|
||||
artwork.DateUpdated = lastWriteTime;
|
||||
|
||||
if (metadata is SongMetadata)
|
||||
{
|
||||
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
4,
|
||||
3);
|
||||
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
5,
|
||||
4);
|
||||
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
6,
|
||||
4);
|
||||
}
|
||||
|
||||
await _metadataRepository.UpdateArtworkPath(artwork);
|
||||
},
|
||||
async () =>
|
||||
@@ -187,10 +219,31 @@ namespace ErsatzTV.Core.Metadata
|
||||
var artwork = new Artwork
|
||||
{
|
||||
Path = cacheName,
|
||||
SourcePath = sourcePath,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = lastWriteTime,
|
||||
ArtworkKind = artworkKind
|
||||
};
|
||||
|
||||
if (metadata is SongMetadata)
|
||||
{
|
||||
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
4,
|
||||
3);
|
||||
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
5,
|
||||
4);
|
||||
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
|
||||
cacheName,
|
||||
artworkKind,
|
||||
6,
|
||||
4);
|
||||
}
|
||||
|
||||
metadata.Artwork.Add(artwork);
|
||||
await _metadataRepository.AddArtwork(metadata, artwork);
|
||||
});
|
||||
|
||||
@@ -969,9 +969,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private static int? GetYear(int year, string premiered)
|
||||
private static int? GetYear(int? year, string premiered)
|
||||
{
|
||||
if (year > 1000)
|
||||
if (year is > 1000)
|
||||
{
|
||||
return year;
|
||||
}
|
||||
@@ -989,9 +989,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(int year, string aired)
|
||||
private static DateTime? GetAired(int? year, string aired)
|
||||
{
|
||||
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
|
||||
DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -141,7 +142,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
FileName = ffprobePath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
|
||||
@@ -9,8 +9,15 @@ namespace ErsatzTV.Core.Metadata.Nfo
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlIgnore]
|
||||
public int? Year { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
public string YearAsText
|
||||
{
|
||||
get => Year.HasValue ? Year.ToString() : null;
|
||||
set => Year = !string.IsNullOrWhiteSpace(value) ? int.Parse(value) : default(int?);
|
||||
}
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
@@ -209,51 +209,52 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { artwork.Path, artwork.DateUpdated, artwork.Id }).ToUnit();
|
||||
"UPDATE Artwork SET Path = @Path, SourcePath = @SourcePath, DateUpdated = @DateUpdated, BlurHash43 = @BlurHash43, BlurHash43 = @BlurHash54, BlurHash43 = @BlurHash64 WHERE Id = @Id",
|
||||
new { artwork.Path, artwork.SourcePath, artwork.DateUpdated, artwork.BlurHash43, artwork.BlurHash54, artwork.BlurHash64, artwork.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> AddArtwork(Metadata metadata, Artwork artwork)
|
||||
{
|
||||
var parameters = new
|
||||
{
|
||||
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path
|
||||
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path,
|
||||
artwork.SourcePath, artwork.BlurHash43, artwork.BlurHash54, artwork.BlurHash64
|
||||
};
|
||||
|
||||
return metadata switch
|
||||
{
|
||||
MovieMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
ShowMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
SeasonMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
EpisodeMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
ArtistMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path)
|
||||
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
MusicVideoMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
SongMetadata => _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
|
||||
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
|
||||
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
|
||||
parameters)
|
||||
.ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
@@ -266,6 +267,52 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
|
||||
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
|
||||
|
||||
public async Task<bool> CloneArtwork(
|
||||
Metadata metadata,
|
||||
Option<Artwork> maybeArtwork,
|
||||
ArtworkKind artworkKind,
|
||||
string sourcePath,
|
||||
DateTime lastWriteTime)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<Artwork> maybeExisting = await dbContext.Artwork
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
a => a.SourcePath == sourcePath && a.ArtworkKind == artworkKind && a.DateUpdated == lastWriteTime)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
foreach (Artwork existing in maybeExisting)
|
||||
{
|
||||
Artwork artwork = await maybeArtwork.IfNoneAsync(new Artwork());
|
||||
if (maybeArtwork.IsNone)
|
||||
{
|
||||
metadata.Artwork.Add(artwork);
|
||||
}
|
||||
|
||||
artwork.Path = existing.Path;
|
||||
artwork.SourcePath = existing.SourcePath;
|
||||
artwork.ArtworkKind = artworkKind;
|
||||
artwork.BlurHash43 = existing.BlurHash43;
|
||||
artwork.BlurHash54 = existing.BlurHash54;
|
||||
artwork.BlurHash64 = existing.BlurHash64;
|
||||
artwork.DateAdded = existing.DateAdded;
|
||||
artwork.DateUpdated = existing.DateUpdated;
|
||||
|
||||
if (maybeArtwork.IsNone)
|
||||
{
|
||||
await AddArtwork(metadata, artwork);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UpdateArtworkPath(artwork);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<MediaStream> MediaStreams { get; set; }
|
||||
public DbSet<Movie> Movies { get; set; }
|
||||
public DbSet<MovieMetadata> MovieMetadata { get; set; }
|
||||
public DbSet<Artwork> Artwork { get; set; }
|
||||
public DbSet<Artist> Artists { get; set; }
|
||||
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
|
||||
public DbSet<MusicVideo> MusicVideos { get; set; }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00015" />
|
||||
|
||||
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Images
|
||||
@@ -66,8 +67,11 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
try
|
||||
{
|
||||
string tempFileName = _tempFilePool.GetNextTempFile(TempFileCategory.CachedArtwork);
|
||||
await using var fs = new FileStream(tempFileName, FileMode.OpenOrCreate);
|
||||
await stream.CopyToAsync(fs);
|
||||
// ReSharper disable once UseAwaitUsing
|
||||
using (var fs = new FileStream(tempFileName, FileMode.OpenOrCreate, FileAccess.Write))
|
||||
{
|
||||
await stream.CopyToAsync(fs);
|
||||
}
|
||||
byte[] hash = await ComputeFileHash(tempFileName);
|
||||
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
string subfolder = hex[..2];
|
||||
@@ -100,9 +104,13 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
private static async Task<byte[]> ComputeFileHash(string fileName)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
await using var fs = new FileStream(fileName, FileMode.Open);
|
||||
fs.Position = 0;
|
||||
return await md5.ComputeHashAsync(fs);
|
||||
// ReSharper disable once UseAwaitUsing
|
||||
// ReSharper disable once ConvertToUsingDeclaration
|
||||
using (var fs = new FileStream(fileName, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
fs.Position = 0;
|
||||
return await md5.ComputeHashAsync(fs);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind)
|
||||
@@ -174,5 +182,32 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y)
|
||||
{
|
||||
var encoder = new Blurhash.ImageSharp.Encoder();
|
||||
string targetFile = GetPathForImage(fileName, artworkKind, Option<int>.None);
|
||||
await using var fs = new FileStream(targetFile, FileMode.Open, FileAccess.Read);
|
||||
using var image = await Image.LoadAsync<Rgb24>(fs);
|
||||
return encoder.Encode(image, x, y);
|
||||
}
|
||||
|
||||
public async Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
|
||||
string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
|
||||
string targetFile = GetPathForImage(base64, ArtworkKind.Poster, targetSize.Height);
|
||||
if (!_localFileSystem.FileExists(targetFile))
|
||||
{
|
||||
string folder = Path.GetDirectoryName(targetFile);
|
||||
_localFileSystem.EnsureFolderExists(folder);
|
||||
|
||||
var decoder = new Blurhash.ImageSharp.Decoder();
|
||||
using Image<Rgb24> image = decoder.Decode(blurHash, targetSize.Width, targetSize.Height);
|
||||
await image.SaveAsPngAsync(targetFile);
|
||||
}
|
||||
|
||||
return targetFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3843
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_SongMetadataOnWindows : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE SongMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+3846
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_ArtworkBlurHash : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BlurHash",
|
||||
table: "Artwork",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlurHash",
|
||||
table: "Artwork");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3852
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MoreArtworkBlurHashes : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "BlurHash",
|
||||
table: "Artwork",
|
||||
newName: "BlurHash64");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BlurHash43",
|
||||
table: "Artwork",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BlurHash54",
|
||||
table: "Artwork",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlurHash43",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BlurHash54",
|
||||
table: "Artwork");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "BlurHash64",
|
||||
table: "Artwork",
|
||||
newName: "BlurHash");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3852
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_SongMetadataBlurHash : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 5)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 5");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Artwork SET DateUpdated = '0001-01-01 00:00:00' WHERE SongMetadataId IS NOT NULL");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN
|
||||
(SELECT LF.Id FROM LibraryFolder LF INNER JOIN LibraryPath LP on LF.LibraryPathId = LP.Id INNER JOIN Library L on LP.LibraryId = L.Id WHERE MediaKind = 5)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+3855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_ArtworkSourcePath : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourcePath",
|
||||
table: "Artwork",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourcePath",
|
||||
table: "Artwork");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("ArtworkKind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlurHash43")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BlurHash54")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BlurHash64")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -177,6 +186,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int?>("SongMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SourcePath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArtistMetadataId");
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.4" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.4" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.5" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.5" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="6.0.453" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
|
||||
<PackageReference Include="Markdig" Version="0.26.0" />
|
||||
@@ -37,7 +37,7 @@
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="6.0.0" />
|
||||
<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" />
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using Blazored.LocalStorage;
|
||||
using Dapper;
|
||||
@@ -120,6 +121,8 @@ namespace ErsatzTV
|
||||
services.AddCourier(Assembly.GetAssembly(typeof(LibraryScanProgress)));
|
||||
services.AddBlazoredLocalStorage();
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
Log.Logger.Information(
|
||||
"ErsatzTV version {Version}",
|
||||
Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
|
||||
@@ -11,6 +11,8 @@ WORKDIR /source
|
||||
|
||||
# copy csproj and restore as distinct layers
|
||||
COPY *.sln .
|
||||
COPY nuget.config .
|
||||
COPY lib/nuget/* ./lib/nuget/
|
||||
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
||||
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
||||
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
|
||||
|
||||
@@ -5,6 +5,8 @@ WORKDIR /source
|
||||
|
||||
# copy csproj and restore as distinct layers
|
||||
COPY *.sln .
|
||||
COPY nuget.config .
|
||||
COPY lib/nuget/* ./lib/nuget/
|
||||
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
||||
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
||||
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
|
||||
|
||||
@@ -5,6 +5,8 @@ WORKDIR /source
|
||||
|
||||
# copy csproj and restore as distinct layers
|
||||
COPY *.sln .
|
||||
COPY nuget.config .
|
||||
COPY lib/nuget/* ./lib/nuget/
|
||||
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
||||
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
||||
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,5 +4,6 @@
|
||||
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
|
||||
<clear />
|
||||
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="local-packages" value="lib/nuget" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user