Compare commits

...
Author SHA1 Message Date
Jason Dove 73887706ed update changelog for release v0.3.2-alpha [no ci] 2021-12-03 14:57:19 -06:00
Jason DoveandGitHub abc103308b optimize song artwork scanning (#527) 2021-12-03 13:40:55 -06:00
Jason DoveandGitHub 3773bbec19 use blurhash for song backgrounds (#526)
* generate blurhash for all local artwork

* use blurhash song background if available

* only write blur hash to disk once

* use multiple blur hashes

* update changelog

* fix song detail outline

* reset song metadata (artwork)
2021-12-03 12:30:47 -06:00
Jason DoveandGitHub e223d6a43f remove unused cli project (#525) [no ci] 2021-12-02 09:01:47 -06:00
Jason DoveandGitHub 8369111e31 update dependencies (#524) 2021-12-02 08:45:43 -06:00
Jason DoveandGitHub 35ba2bab2c fix unicode song metadata on windows (#523)
* test setting utf8 encoding with ffprobe

* use utf8 encoding for console (logging) output

* use proper sink package

* reset song metadata on windows

* fix nfo processing with missing year

* update changelog
2021-12-01 13:43:09 -06:00
Jason DoveandGitHub 094ed71ad0 fix docker builds with custom (local) nuget package (#520) 2021-11-30 20:49:02 -06:00
Jason DoveandGitHub 89e24b2b78 use custom log database backend that is more portable (#519) 2021-11-30 20:34:14 -06:00
Jason DoveandGitHub 848795af32 fix artwork upload on windows (#518)
* update changelog for release v0.3.1-alpha [no ci]

* fix artwork upload on windows
2021-11-30 12:23:24 -06:00
52 changed files with 19695 additions and 989 deletions
-3
View File
@@ -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
View File
@@ -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;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace ErsatzTV.CommandLine
{
public class Config
{
public string ServerUrl { get; set; }
}
}
-10
View File
@@ -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>
-75
View File
@@ -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();
}
}
+4
View File
@@ -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)
+2 -4
View File
@@ -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);
+7 -6
View File
@@ -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);
}
+40 -23
View File
@@ -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,
+2 -2
View File
@@ -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");
+8 -1
View File
@@ -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" />
+40 -5
View File
@@ -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;
}
}
}
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)
{
}
}
}
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");
}
}
}
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");
}
}
}
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)
{
}
}
}
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");
+3 -3
View File
@@ -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" />
+3
View File
@@ -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>()
+2
View File
@@ -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/
+2
View File
@@ -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/
+2
View File
@@ -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.
+1
View File
@@ -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>