(SettingRegistry.DefaultsPageSize) ?? 50;
diff --git a/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor b/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
index 368bb2e88..7f6a19a5d 100644
--- a/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
+++ b/src/Melodee.Blazor/Components/Pages/Data/AlbumDetail.razor
@@ -294,7 +294,7 @@
| @($"{song.SongNumber.ToStringPadLeft(3)}") |
@song.Title |
@CurrentUser!.FormatDuration(song.Duration.ToDuration()) |
-
+ |
(await imageValidator.ValidateImage(newFileInfo, PictureIdentifier.Front)).Data.IsValid).ConfigureAwait(false))
{
var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
- _album.Images = new List
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
{
- new()
+ _album.Images = new List
{
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- };
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ };
+ }
}
}
}
@@ -810,37 +814,16 @@
if (selectedImageSearchResult != null)
{
var httpClient = HttpClientFactory.CreateClient();
- var imageValidator = new ImageValidator(await ConfigurationFactory.GetConfigurationAsync());
+ var imageValidator = new ImageValidator(ImageProcessor, await ConfigurationFactory.GetConfigurationAsync());
var albumImageFromSearchFileName = Path.Combine(_album.Directory.FullName(), _album.Directory.GetNextFileNameForType(Common.Data.Models.Album.FrontImageType).Item1);
if (selectedImageSearchResult.ImageBytes != null)
{
await File.WriteAllBytesAsync(albumImageFromSearchFileName, selectedImageSearchResult.ImageBytes);
var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
- _album.Images = new List
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
{
- new()
- {
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- };
- }
- else
- {
- if (await httpClient.DownloadFileAsync(
- selectedImageSearchResult.Url,
- albumImageFromSearchFileName,
- async (_, newFileInfo, _) => (await imageValidator.ValidateImage(newFileInfo, PictureIdentifier.Front)).Data.IsValid).ConfigureAwait(false))
- {
- var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
_album.Images = new List
{
new()
@@ -856,6 +839,33 @@
};
}
}
+ else
+ {
+ if (await httpClient.DownloadFileAsync(
+ selectedImageSearchResult.Url,
+ albumImageFromSearchFileName,
+ async (_, newFileInfo, _) => (await imageValidator.ValidateImage(newFileInfo, PictureIdentifier.Front)).Data.IsValid).ConfigureAwait(false))
+ {
+ var newImageInfo = new FileInfo(albumImageFromSearchFileName);
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
+ {
+ _album.Images = new List
+ {
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ };
+ }
+ }
+ }
if ((_album.Images?.Any() ?? false) && selectedImageSearchResult.DoDeleteAllOtherImages)
{
@@ -878,20 +888,23 @@
var newAlbumImageFromSearchFileName = Path.Combine(_album.Directory.FullName(), $"{ImageInfo.ImageFilePrefix}{1.ToStringPadLeft(MelodeeConfiguration.ImageNameNumberPadding)}-{Common.Data.Models.Album.FrontImageType}.jpg");
File.Move(albumImageFromSearchFileName, newAlbumImageFromSearchFileName);
var newImageInfo = new FileInfo(newAlbumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(newAlbumImageFromSearchFileName).ConfigureAwait(false);
- _album.Images = new List
+ var imageInfo = await ImageProcessor.IdentifyAsync(newAlbumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
{
- new()
- {
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- };
+ _album.Images = new List
+ {
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ };
+ }
}
var result = await MediaEditService.SaveMelodeeAlbum(_album);
diff --git a/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor b/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
index 9465a2df3..f12a4625a 100644
--- a/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
+++ b/src/Melodee.Blazor/Components/Pages/Media/AlbumEdit.razor
@@ -9,12 +9,12 @@
@using Melodee.Common.Plugins.Validation
@using Melodee.Common.Services.Scanning
@using Melodee.Common.Services.SearchEngines
-@using SixLabors.ImageSharp
@using Album = Melodee.Common.Data.Models.Album
@using FileInfo = System.IO.FileInfo
@using ImageInfo = Melodee.Common.Models.ImageInfo
@using ImageSearchResult = Melodee.Blazor.Components.Components.ImageSearchResult
+@using Melodee.Common.Imaging
@attribute [Authorize(Roles = "Administrator,Editor")]
@@ -31,6 +31,7 @@
@inject DefaultImages DefaultImages
@inject IMelodeeConfigurationFactory ConfigurationFactory
@inject IHttpClientFactory HttpClientFactory
+@inject IImageProcessor ImageProcessor
Media Album Add/Edit
@@ -393,37 +394,16 @@
{
var httpClient = HttpClientFactory.CreateClient();
var album = await AlbumDiscoveryService.AlbumByUniqueIdAsync(_library.ToFileSystemDirectoryInfo(), ApiKey);
- var imageValidator = new ImageValidator(await ConfigurationFactory.GetConfigurationAsync());
+ var imageValidator = new ImageValidator(ImageProcessor, await ConfigurationFactory.GetConfigurationAsync());
var albumImageFromSearchFileName = Path.Combine(album.Directory.FullName(), album.Directory.GetNextFileNameForType(Album.FrontImageType).Item1);
if (selectedImageSearchResult.ImageBytes != null)
{
await File.WriteAllBytesAsync(albumImageFromSearchFileName, selectedImageSearchResult.ImageBytes);
var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
- _images =
- [
- new()
- {
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- ];
- }
- else
- {
- if (await httpClient.DownloadFileAsync(
- selectedImageSearchResult.Url,
- albumImageFromSearchFileName,
- async (_, newFileInfo, _) => (await imageValidator.ValidateImage(newFileInfo, PictureIdentifier.Front)).Data.IsValid).ConfigureAwait(false))
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
{
- var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
_images =
[
new()
@@ -439,6 +419,33 @@
];
}
}
+ else
+ {
+ if (await httpClient.DownloadFileAsync(
+ selectedImageSearchResult.Url,
+ albumImageFromSearchFileName,
+ async (_, newFileInfo, _) => (await imageValidator.ValidateImage(newFileInfo, PictureIdentifier.Front)).Data.IsValid).ConfigureAwait(false))
+ {
+ var newImageInfo = new FileInfo(albumImageFromSearchFileName);
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
+ {
+ _images =
+ [
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ ];
+ }
+ }
+ }
if (_images.FirstOrDefault() != null)
{
diff --git a/src/Melodee.Blazor/Components/Pages/Media/Library.razor b/src/Melodee.Blazor/Components/Pages/Media/Library.razor
index 48921809e..f44339c72 100644
--- a/src/Melodee.Blazor/Components/Pages/Media/Library.razor
+++ b/src/Melodee.Blazor/Components/Pages/Media/Library.razor
@@ -12,10 +12,10 @@
@using Melodee.Common.Plugins.Validation
@using Melodee.Common.Constants
@using Melodee.Common.Utility
-@using SixLabors.ImageSharp
@using FileInfo = System.IO.FileInfo
@using ImageInfo = Melodee.Common.Models.ImageInfo
@using FilterOperator = Melodee.Common.Filtering.FilterOperator
+@using Melodee.Common.Imaging
@inject MainLayoutProxyService MainLayoutProxyService
@inject ILogger Logger
@@ -30,6 +30,7 @@
@inject NavigationManager NavigationManager
@inject DefaultImages DefaultImages
@inject DirectoryProcessorToStagingService DirectoryProcessorToStagingService
+@inject IImageProcessor ImageProcessor
@attribute [Authorize(Roles = "Administrator,Editor")]
@@ -420,7 +421,7 @@
var httpClient = HttpClientFactory.CreateClient();
var configuration = await ConfigurationFactory.GetConfigurationAsync();
- var imageValidator = new ImageValidator(configuration);
+ var imageValidator = new ImageValidator(ImageProcessor, configuration);
var maxResults = configuration.GetValue(SettingRegistry.SearchEngineDefaultPageSize) ?? configuration.GetValue(SettingRegistry.DefaultsPageSize);
var albumsProcessed = 0;
@@ -459,20 +460,23 @@
album.AlbumTitle(), imageSearchResult.MediaUrl);
var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
- album.Images = new List
+ var imageInfo = await ImageProcessor.IdentifyAsync(albumImageFromSearchFileName).ConfigureAwait(false);
+ if (imageInfo != null)
{
- new()
+ album.Images = new List
{
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- };
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ };
+ }
await MediaEditService.SaveMelodeeAlbum(album);
count++;
diff --git a/src/Melodee.Blazor/Controllers/Melodee/AuthController.cs b/src/Melodee.Blazor/Controllers/Melodee/AuthController.cs
index c1bcc3612..bcde28af3 100644
--- a/src/Melodee.Blazor/Controllers/Melodee/AuthController.cs
+++ b/src/Melodee.Blazor/Controllers/Melodee/AuthController.cs
@@ -47,7 +47,8 @@ public class AuthController(
IOptions tokenOptions,
IOptions googleAuthOptions,
ILogger logger,
- UserPasswordResetService userPasswordResetService) : ControllerBase(
+ UserPasswordResetService userPasswordResetService,
+ IWebHostEnvironment webHostEnvironment) : ControllerBase(
etagRepository,
serializer,
configuration,
@@ -391,12 +392,17 @@ public async Task RequestPasswordResetAsync([FromBody] PasswordRe
var baseUrl = GetBaseUrl(melodeeConfig);
var resetUrl = $"{baseUrl}/reset-password?token={result.Data}";
- return Ok(new
+ if (webHostEnvironment.IsDevelopment())
{
- message = "If an account with that email exists, a password reset link has been sent.",
- resetToken = result.Data,
- resetUrl
- });
+ return Ok(new
+ {
+ message = "Password reset link generated (development mode).",
+ resetToken = result.Data,
+ resetUrl
+ });
+ }
+
+ return Ok(new { message = "If an account with that email exists, a password reset link has been sent." });
}
///
diff --git a/src/Melodee.Blazor/Extensions/QuartzSchedulerExtensions.cs b/src/Melodee.Blazor/Extensions/QuartzSchedulerExtensions.cs
new file mode 100644
index 000000000..5a49ed1e4
--- /dev/null
+++ b/src/Melodee.Blazor/Extensions/QuartzSchedulerExtensions.cs
@@ -0,0 +1,44 @@
+using Melodee.Common.Configuration;
+using Melodee.Common.Constants;
+using Melodee.Common.Enums;
+using Melodee.Common.Extensions;
+using Quartz;
+
+namespace Melodee.Blazor.Extensions;
+
+public static class QuartzSchedulerExtensions
+{
+ private const ScanStatus DefaultScanStatus = ScanStatus.Idle;
+
+ public static async Task ScheduleJobIfConfigured(
+ this IScheduler scheduler,
+ IMelodeeConfiguration configuration,
+ string settingKey,
+ JobKey jobKey,
+ string? triggerName = null,
+ bool includeScanStatusJobData = false) where TJob : IJob
+ {
+ var cronExpression = configuration.GetValue(settingKey);
+ if (cronExpression.Nullify() == null)
+ {
+ return;
+ }
+
+ var jobBuilder = JobBuilder.Create()
+ .WithIdentity(jobKey)
+ .Build();
+
+ var triggerBuilder = TriggerBuilder.Create()
+ .WithIdentity(triggerName ?? $"{jobKey.Name}-trigger")
+ .WithCronSchedule(cronExpression!)
+ .StartNow();
+
+ if (includeScanStatusJobData)
+ {
+ triggerBuilder.UsingJobData(JobMapNameRegistry.ScanStatus, DefaultScanStatus.ToString())
+ .UsingJobData(JobMapNameRegistry.Count, 0);
+ }
+
+ await scheduler.ScheduleJob(jobBuilder, triggerBuilder.Build());
+ }
+}
diff --git a/src/Melodee.Blazor/Melodee.Blazor.csproj b/src/Melodee.Blazor/Melodee.Blazor.csproj
index 29ff482d0..4b10b406c 100644
--- a/src/Melodee.Blazor/Melodee.Blazor.csproj
+++ b/src/Melodee.Blazor/Melodee.Blazor.csproj
@@ -16,7 +16,7 @@
- 2.0.1
+ 2.1.0
build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))
$(VersionPrefix).0
diff --git a/src/Melodee.Blazor/Program.cs b/src/Melodee.Blazor/Program.cs
index fc614afe6..5e063cbba 100644
--- a/src/Melodee.Blazor/Program.cs
+++ b/src/Melodee.Blazor/Program.cs
@@ -8,6 +8,7 @@
using DecentDB.EntityFrameworkCore;
using Melodee.Blazor.Components;
using Melodee.Blazor.Constants;
+using Melodee.Blazor.Extensions;
using Melodee.Blazor.Filters;
using Melodee.Blazor.Hubs;
using Melodee.Blazor.Middleware;
@@ -15,8 +16,7 @@
using Melodee.Common.Configuration;
using Melodee.Common.Constants;
using Melodee.Common.Data;
-using Melodee.Common.Enums;
-using Melodee.Common.Extensions;
+using Melodee.Common.Imaging;
using Melodee.Common.Jobs;
using Melodee.Common.MessageBus.EventHandlers;
using Melodee.Common.Metadata;
@@ -310,6 +310,7 @@
};
});
builder.Services.AddCascadingAuthenticationState();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -573,6 +574,7 @@
.AddScoped()
.AddScoped()
.AddScoped()
+ .AddSingleton()
.AddScoped()
.AddScoped()
.AddScoped()
@@ -609,6 +611,7 @@
.AddScoped()
.AddScoped()
.AddScoped()
+ .AddScoped()
.AddScoped()
.AddScoped()
.AddScoped();
@@ -766,201 +769,79 @@
var melodeeConfigurationFactory = app.Services.GetRequiredService();
var melodeeConfiguration = await melodeeConfigurationFactory.GetConfigurationAsync();
- // Register job history listener to track all job executions
var scopeFactory = app.Services.GetRequiredService();
quartzScheduler.ListenerManager.AddJobListener(
new JobHistoryListener(scopeFactory, Log.Logger),
GroupMatcher.AnyGroup());
- var artistHousekeepingCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsArtistHousekeepingCronExpression);
- if (artistHousekeepingCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.ArtistHousekeepingJobJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("ArtistHousekeepingJobJobKey-trigger")
- .WithCronSchedule(artistHousekeepingCronExpression!)
- .StartNow()
- .Build());
- }
-
- var artistSearchEngineHousekeepingCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsArtistSearchEngineHousekeepingCronExpression);
- if (artistSearchEngineHousekeepingCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.ArtistSearchEngineHousekeepingJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("ArtistSearchEngineHousekeepingJob-trigger")
- .WithCronSchedule(artistSearchEngineHousekeepingCronExpression!)
- .StartNow()
- .Build());
- }
-
- var libraryInboundProcessJobKeyCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsLibraryProcessCronExpression);
- if (libraryInboundProcessJobKeyCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.LibraryInboundProcessJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("LibraryInboundProcessJob-trigger")
- .UsingJobData(JobMapNameRegistry.ScanStatus, ScanStatus.Idle.ToString())
- .UsingJobData(JobMapNameRegistry.Count, 0)
- .WithCronSchedule(libraryInboundProcessJobKeyCronExpression!)
- .StartNow()
- .Build());
- }
-
- var libraryInsertCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsLibraryInsertCronExpression);
- if (libraryInsertCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.LibraryProcessJobJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("LibraryProcessJob-trigger")
- .UsingJobData(JobMapNameRegistry.ScanStatus, ScanStatus.Idle.ToString())
- .UsingJobData(JobMapNameRegistry.Count, 0)
- .WithCronSchedule(libraryInsertCronExpression!)
- .StartNow()
- .Build());
- }
-
- var musicBrainzUpdateDatabaseCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsMusicBrainzUpdateDatabaseCronExpression);
- if (musicBrainzUpdateDatabaseCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.MusicBrainzUpdateDatabaseJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("MusicBrainzUpdateDatabaseJob-trigger")
- .WithCronSchedule(musicBrainzUpdateDatabaseCronExpression!)
- .StartNow()
- .Build());
- }
-
- var nowPlayingCleanupCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsNowPlayingCleanupCronExpression);
- if (nowPlayingCleanupCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.NowPlayingCleanupJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("NowPlayingCleanupJob-trigger")
- .WithCronSchedule(nowPlayingCleanupCronExpression!)
- .StartNow()
- .Build());
- }
-
- var chartUpdateCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsChartUpdateCronExpression);
- if (chartUpdateCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.ChartUpdateJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("ChartUpdateJob-trigger")
- .WithCronSchedule(chartUpdateCronExpression!)
- .StartNow()
- .Build());
- }
-
- var stagingAutoMoveCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsStagingAutoMoveCronExpression);
- if (stagingAutoMoveCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.StagingAutoMoveJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("StagingAutoMoveJob-trigger")
- .UsingJobData(JobMapNameRegistry.ScanStatus, ScanStatus.Idle.ToString())
- .UsingJobData(JobMapNameRegistry.Count, 0)
- .WithCronSchedule(stagingAutoMoveCronExpression!)
- .StartNow()
- .Build());
- }
-
- var stagingAlbumRevalidationCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsStagingAlbumRevalidationCronExpression);
- if (stagingAlbumRevalidationCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.StagingAlbumRevalidationJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("StagingAlbumRevalidationJob-trigger")
- .UsingJobData(JobMapNameRegistry.ScanStatus, ScanStatus.Idle.ToString())
- .UsingJobData(JobMapNameRegistry.Count, 0)
- .WithCronSchedule(stagingAlbumRevalidationCronExpression!)
- .StartNow()
- .Build());
- }
-
- var podcastRefreshCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsPodcastRefreshCronExpression);
- if (podcastRefreshCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.PodcastRefreshJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("PodcastRefreshJob-trigger")
- .WithCronSchedule(podcastRefreshCronExpression!)
- .StartNow()
- .Build());
- }
-
- var podcastDownloadCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsPodcastDownloadCronExpression);
- if (podcastDownloadCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.PodcastDownloadJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("PodcastDownloadJob-trigger")
- .WithCronSchedule(podcastDownloadCronExpression!)
- .StartNow()
- .Build());
- }
-
- var podcastCleanupCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsPodcastCleanupCronExpression);
- if (podcastCleanupCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.PodcastCleanupJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("PodcastCleanupJob-trigger")
- .WithCronSchedule(podcastCleanupCronExpression!)
- .StartNow()
- .Build());
- }
-
- var podcastRecoveryCronExpression = melodeeConfiguration.GetValue(SettingRegistry.JobsPodcastRecoveryCronExpression);
- if (podcastRecoveryCronExpression.Nullify() != null)
- {
- await quartzScheduler.ScheduleJob(
- JobBuilder.Create()
- .WithIdentity(JobKeyRegistry.PodcastRecoveryJobKey)
- .Build(),
- TriggerBuilder.Create()
- .WithIdentity("PodcastRecoveryJob-trigger")
- .WithCronSchedule(podcastRecoveryCronExpression!)
- .StartNow()
- .Build());
- }
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsArtistHousekeepingCronExpression,
+ JobKeyRegistry.ArtistHousekeepingJobJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsArtistSearchEngineHousekeepingCronExpression,
+ JobKeyRegistry.ArtistSearchEngineHousekeepingJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsLibraryProcessCronExpression,
+ JobKeyRegistry.LibraryInboundProcessJobKey,
+ includeScanStatusJobData: true);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsLibraryInsertCronExpression,
+ JobKeyRegistry.LibraryProcessJobJobKey,
+ includeScanStatusJobData: true);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsMusicBrainzUpdateDatabaseCronExpression,
+ JobKeyRegistry.MusicBrainzUpdateDatabaseJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsNowPlayingCleanupCronExpression,
+ JobKeyRegistry.NowPlayingCleanupJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsChartUpdateCronExpression,
+ JobKeyRegistry.ChartUpdateJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsStagingAutoMoveCronExpression,
+ JobKeyRegistry.StagingAutoMoveJobKey,
+ includeScanStatusJobData: true);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsStagingAlbumRevalidationCronExpression,
+ JobKeyRegistry.StagingAlbumRevalidationJobKey,
+ includeScanStatusJobData: true);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsPodcastRefreshCronExpression,
+ JobKeyRegistry.PodcastRefreshJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsPodcastDownloadCronExpression,
+ JobKeyRegistry.PodcastDownloadJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsPodcastCleanupCronExpression,
+ JobKeyRegistry.PodcastCleanupJobKey);
+
+ await quartzScheduler.ScheduleJobIfConfigured(
+ melodeeConfiguration,
+ SettingRegistry.JobsPodcastRecoveryCronExpression,
+ JobKeyRegistry.PodcastRecoveryJobKey);
}
#endregion
diff --git a/src/Melodee.Blazor/Services/PartyModeService.cs b/src/Melodee.Blazor/Services/PartyModeService.cs
index cd82635bf..127d109b1 100644
--- a/src/Melodee.Blazor/Services/PartyModeService.cs
+++ b/src/Melodee.Blazor/Services/PartyModeService.cs
@@ -1,497 +1,554 @@
+using Melodee.Blazor.Security.Extensions;
+using Melodee.Common.Enums.PartyMode;
using Melodee.Common.Models;
+using Melodee.Common.Services;
namespace Melodee.Blazor.Services;
-///
-/// Service for interacting with Party Mode API endpoints.
-///
-public class PartyModeService
+public sealed class PartyModeService(
+ IAuthService authService,
+ PartySessionService partySessionService,
+ PartyQueueService partyQueueService,
+ PartyPlaybackService partyPlaybackService,
+ PartySessionEndpointRegistryService endpointRegistryService,
+ ILogger logger)
{
- private readonly HttpClient _httpClient;
- private readonly ILogger _logger;
-
- public PartyModeService(HttpClient httpClient, ILogger logger)
+ public async Task?> CreateSessionAsync(string name, string? joinCode = null, CancellationToken cancellationToken = default)
{
- _httpClient = httpClient;
- _logger = logger;
- }
-
- private const string BasePath = "api/v1/party-sessions";
+ logger.LogDebug("[PartyModeService] CreateSessionAsync: Name={Name}, HasJoinCode={HasJoinCode}", name, joinCode is not null);
- public async Task?> CreateSessionAsync(string name, string? joinCode = null)
- {
- _logger.LogDebug("[PartyModeService] CreateSessionAsync: Name={Name}, HasJoinCode={HasJoinCode}", name, joinCode != null);
- try
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { Name = name, JoinCode = joinCode };
- var response = await _httpClient.PostAsJsonAsync(BasePath, request);
- if (response.IsSuccessStatusCode)
- {
- var result = await response.Content.ReadFromJsonAsync>();
- _logger.LogDebug("[PartyModeService] CreateSessionAsync succeeded: ApiKey={ApiKey}", result?.Data?.ApiKey);
- return result;
- }
- _logger.LogWarning("[PartyModeService] CreateSessionAsync failed: StatusCode={StatusCode}", response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partySessionService.CreateAsync(name, userId, joinCode, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in CreateSessionAsync");
- return null;
+ logger.LogWarning("[PartyModeService] CreateSessionAsync failed: {Messages}", string.Join(", ", result.Messages ?? []));
+ return new OperationResult(result.Errors?.FirstOrDefault()?.Message ?? "Failed to create session")
+ {
+ Type = result.Type,
+ Data = null!
+ };
}
+
+ logger.LogDebug("[PartyModeService] CreateSessionAsync succeeded: ApiKey={ApiKey}", result.Data.ApiKey);
+ return new OperationResult
+ {
+ Data = MapToDto(result.Data)
+ };
}
- public async Task?> GetMySessionsAsync()
+ public async Task?> GetMySessionsAsync(CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetMySessionsAsync starting");
- try
+ logger.LogDebug("[PartyModeService] GetMySessionsAsync starting");
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var response = await _httpClient.GetAsync($"{BasePath}/my");
- if (response.IsSuccessStatusCode)
- {
- var result = await response.Content.ReadFromJsonAsync>();
- _logger.LogDebug("[PartyModeService] GetMySessionsAsync succeeded: Count={Count}", result?.Count() ?? 0);
- return result;
- }
- _logger.LogWarning("[PartyModeService] GetMySessionsAsync failed: StatusCode={StatusCode}", response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partySessionService.GetUserSessionsAsync(userId, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetMySessionsAsync");
+ logger.LogWarning("[PartyModeService] GetMySessionsAsync failed");
return null;
}
+
+ var sessions = result.Data.Select(MapToDto);
+ logger.LogDebug("[PartyModeService] GetMySessionsAsync succeeded: Count={Count}", result.Data.Count());
+ return sessions;
}
- public async Task?> GetActiveSessionsAsync()
+ public async Task?> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetActiveSessionsAsync starting");
- try
- {
- var response = await _httpClient.GetAsync($"{BasePath}/active");
- if (response.IsSuccessStatusCode)
- {
- var result = await response.Content.ReadFromJsonAsync>();
- _logger.LogDebug("[PartyModeService] GetActiveSessionsAsync succeeded: Count={Count}", result?.Count() ?? 0);
- return result;
- }
- _logger.LogWarning("[PartyModeService] GetActiveSessionsAsync failed: StatusCode={StatusCode}", response.StatusCode);
- return null;
- }
- catch (Exception ex)
+ logger.LogDebug("[PartyModeService] GetActiveSessionsAsync starting");
+
+ var result = await partySessionService.GetActiveSessionsAsync(cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetActiveSessionsAsync");
+ logger.LogWarning("[PartyModeService] GetActiveSessionsAsync failed");
return null;
}
+
+ var sessions = result.Data.Select(MapToDto);
+ logger.LogDebug("[PartyModeService] GetActiveSessionsAsync succeeded: Count={Count}", result.Data.Count());
+ return sessions;
}
- public async Task?> GetSessionAsync(Guid sessionApiKey)
+ public async Task?> GetSessionAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- try
+ logger.LogDebug("[PartyModeService] GetSessionAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var result = await partySessionService.GetAsync(sessionApiKey, cancellationToken);
+
+ if (!result.IsSuccess || result.Data is null)
{
- var response = await _httpClient.GetAsync($"{BasePath}/{sessionApiKey}");
- if (response.IsSuccessStatusCode)
+ logger.LogWarning("[PartyModeService] GetSessionAsync failed: ApiKey={ApiKey}", sessionApiKey);
+ return new OperationResult(result.Errors?.FirstOrDefault()?.Message ?? "Session not found")
{
- var result = await response.Content.ReadFromJsonAsync>();
- _logger.LogDebug("[PartyModeService] GetSessionAsync succeeded: Name={Name}", result?.Data?.Name);
- return result;
- }
- _logger.LogWarning("[PartyModeService] GetSessionAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
- return null;
+ Type = OperationResponseType.NotFound,
+ Data = null!
+ };
}
- catch (Exception ex)
+
+ logger.LogDebug("[PartyModeService] GetSessionAsync succeeded: Name={Name}", result.Data.Name);
+ return new OperationResult
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- return null;
- }
+ Data = MapToDto(result.Data)
+ };
}
- public async Task?> JoinSessionAsync(Guid sessionApiKey, string? joinCode = null)
+ public async Task?> JoinSessionAsync(Guid sessionApiKey, string? joinCode = null, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] JoinSessionAsync: ApiKey={ApiKey}, HasJoinCode={HasJoinCode}", sessionApiKey, joinCode != null);
- try
+ logger.LogDebug("[PartyModeService] JoinSessionAsync: ApiKey={ApiKey}, HasJoinCode={HasJoinCode}", sessionApiKey, joinCode is not null);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { JoinCode = joinCode };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/join", request);
- if (response.IsSuccessStatusCode)
- {
- var result = await response.Content.ReadFromJsonAsync>();
- _logger.LogDebug("[PartyModeService] JoinSessionAsync succeeded: ApiKey={ApiKey}", sessionApiKey);
- return result;
- }
- _logger.LogWarning("[PartyModeService] JoinSessionAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partySessionService.JoinAsync(sessionApiKey, userId, joinCode, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in JoinSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- return null;
+ logger.LogWarning("[PartyModeService] JoinSessionAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
+ return new OperationResult(result.Errors?.FirstOrDefault()?.Message ?? "Failed to join session")
+ {
+ Type = result.Type,
+ Data = null!
+ };
}
+
+ logger.LogDebug("[PartyModeService] JoinSessionAsync succeeded: ApiKey={ApiKey}", sessionApiKey);
+ return new OperationResult
+ {
+ Data = new PartySessionParticipantDto(result.Data.UserId, result.Data.Role.ToString(), result.Data.JoinedAt.ToString())
+ };
}
- public async Task LeaveSessionAsync(Guid sessionApiKey)
+ public async Task LeaveSessionAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] LeaveSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- try
- {
- var response = await _httpClient.PostAsync($"{BasePath}/{sessionApiKey}/leave", null);
- var success = response.IsSuccessStatusCode;
- _logger.LogDebug("[PartyModeService] LeaveSessionAsync: ApiKey={ApiKey}, Success={Success}", sessionApiKey, success);
- return success;
- }
- catch (Exception ex)
+ logger.LogDebug("[PartyModeService] LeaveSessionAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- _logger.LogError(ex, "[PartyModeService] Exception in LeaveSessionAsync: ApiKey={ApiKey}", sessionApiKey);
return false;
}
+
+ var result = await partySessionService.LeaveAsync(sessionApiKey, userId, cancellationToken);
+ logger.LogDebug("[PartyModeService] LeaveSessionAsync: ApiKey={ApiKey}, Success={Success}", sessionApiKey, result.IsSuccess);
+ return result.IsSuccess;
}
- public async Task EndSessionAsync(Guid sessionApiKey)
+ public async Task EndSessionAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] EndSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- try
- {
- var response = await _httpClient.PostAsync($"{BasePath}/{sessionApiKey}/end", null);
- var success = response.IsSuccessStatusCode;
- _logger.LogDebug("[PartyModeService] EndSessionAsync: ApiKey={ApiKey}, Success={Success}", sessionApiKey, success);
- return success;
- }
- catch (Exception ex)
+ logger.LogDebug("[PartyModeService] EndSessionAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- _logger.LogError(ex, "[PartyModeService] Exception in EndSessionAsync: ApiKey={ApiKey}", sessionApiKey);
return false;
}
+
+ var result = await partySessionService.EndAsync(sessionApiKey, userId, cancellationToken);
+ logger.LogDebug("[PartyModeService] EndSessionAsync: ApiKey={ApiKey}, Success={Success}", sessionApiKey, result.IsSuccess);
+ return result.IsSuccess;
}
- public async Task>?> GetParticipantsAsync(Guid sessionApiKey)
+ public async Task>?> GetParticipantsAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetParticipantsAsync: ApiKey={ApiKey}", sessionApiKey);
- try
+ logger.LogDebug("[PartyModeService] GetParticipantsAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var result = await partySessionService.GetParticipantsAsync(sessionApiKey, cancellationToken);
+
+ if (!result.IsSuccess)
{
- var response = await _httpClient.GetAsync($"{BasePath}/{sessionApiKey}/participants");
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>>();
- }
- _logger.LogWarning("[PartyModeService] GetParticipantsAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
+ logger.LogWarning("[PartyModeService] GetParticipantsAsync failed: ApiKey={ApiKey}", sessionApiKey);
return null;
}
- catch (Exception ex)
+
+ var participants = result.Data.Select(p => new PartySessionParticipantDto(p.UserId, p.Role.ToString(), p.JoinedAt.ToString()));
+ return new OperationResult>
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetParticipantsAsync: ApiKey={ApiKey}", sessionApiKey);
- return null;
- }
+ Data = participants
+ };
}
- public async Task?> GetQueueAsync(Guid sessionApiKey)
+ public async Task?> GetQueueAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetQueueAsync: ApiKey={ApiKey}", sessionApiKey);
- try
+ logger.LogDebug("[PartyModeService] GetQueueAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var result = await partyQueueService.GetQueueAsync(sessionApiKey, cancellationToken);
+
+ if (!result.IsSuccess)
{
- var response = await _httpClient.GetAsync($"{BasePath}/{sessionApiKey}/queue");
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] GetQueueAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
+ logger.LogWarning("[PartyModeService] GetQueueAsync failed: ApiKey={ApiKey}", sessionApiKey);
return null;
}
- catch (Exception ex)
+
+ var (revision, items) = result.Data;
+ return new OperationResult
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetQueueAsync: ApiKey={ApiKey}", sessionApiKey);
- return null;
- }
+ Data = new QueueResponseDto(
+ revision,
+ items.Select(i => new PartyQueueItemDto(i.ApiKey, i.SongApiKey, i.EnqueuedByUserId, i.EnqueuedAt.ToString(), i.SortOrder, i.Source)))
+ };
}
public async Task?> AddToQueueAsync(
Guid sessionApiKey,
IEnumerable songApiKeys,
string? source = null,
- long expectedRevision = 1)
+ long expectedRevision = 1,
+ CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] AddToQueueAsync: ApiKey={ApiKey}, SongCount={SongCount}, ExpectedRevision={ExpectedRevision}",
+ logger.LogDebug("[PartyModeService] AddToQueueAsync: ApiKey={ApiKey}, SongCount={SongCount}, ExpectedRevision={ExpectedRevision}",
sessionApiKey, songApiKeys.Count(), expectedRevision);
- try
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { SongApiKeys = songApiKeys, Source = source, ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/queue/items", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
+ return null;
+ }
+
+ var result = await partyQueueService.AddItemsAsync(sessionApiKey, songApiKeys, userId, source, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ if (result.Type == OperationResponseType.Conflict)
{
- _logger.LogWarning("[PartyModeService] AddToQueueAsync conflict (revision mismatch): ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] AddToQueueAsync conflict (revision mismatch): ApiKey={ApiKey}", sessionApiKey);
}
else
{
- _logger.LogWarning("[PartyModeService] AddToQueueAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
+ logger.LogWarning("[PartyModeService] AddToQueueAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
}
return null;
}
- catch (Exception ex)
+
+ var (newRevision, addedItems) = result.Data;
+ return new OperationResult
{
- _logger.LogError(ex, "[PartyModeService] Exception in AddToQueueAsync: ApiKey={ApiKey}", sessionApiKey);
- return null;
- }
+ Data = new AddItemsResponseDto(
+ newRevision,
+ addedItems.Select(i => new PartyQueueItemDto(i.ApiKey, i.SongApiKey, i.EnqueuedByUserId, i.EnqueuedAt.ToString(), i.SortOrder, i.Source)))
+ };
}
- public async Task?> RemoveFromQueueAsync(Guid sessionApiKey, Guid itemApiKey, long expectedRevision)
+ public async Task?> RemoveFromQueueAsync(Guid sessionApiKey, Guid itemApiKey, long expectedRevision, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] RemoveFromQueueAsync: SessionApiKey={SessionApiKey}, ItemApiKey={ItemApiKey}", sessionApiKey, itemApiKey);
- try
+ logger.LogDebug("[PartyModeService] RemoveFromQueueAsync: SessionApiKey={SessionApiKey}, ItemApiKey={ItemApiKey}", sessionApiKey, itemApiKey);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var response = await _httpClient.DeleteAsync(
- $"{BasePath}/{sessionApiKey}/queue/items/{itemApiKey}?expectedRevision={expectedRevision}");
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] RemoveFromQueueAsync failed: SessionApiKey={SessionApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyQueueService.RemoveItemAsync(sessionApiKey, itemApiKey, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in RemoveFromQueueAsync: SessionApiKey={SessionApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] RemoveFromQueueAsync failed: SessionApiKey={SessionApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult { Data = result.Data };
}
public async Task?> ReorderQueueItemAsync(
Guid sessionApiKey,
Guid itemApiKey,
int newIndex,
- long expectedRevision)
+ long expectedRevision,
+ CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] ReorderQueueItemAsync: SessionApiKey={SessionApiKey}, ItemApiKey={ItemApiKey}, NewIndex={NewIndex}",
+ logger.LogDebug("[PartyModeService] ReorderQueueItemAsync: SessionApiKey={SessionApiKey}, ItemApiKey={ItemApiKey}, NewIndex={NewIndex}",
sessionApiKey, itemApiKey, newIndex);
- try
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { NewIndex = newIndex, ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync(
- $"{BasePath}/{sessionApiKey}/queue/items/{itemApiKey}/reorder", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] ReorderQueueItemAsync failed: SessionApiKey={SessionApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyQueueService.ReorderItemAsync(sessionApiKey, itemApiKey, newIndex, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in ReorderQueueItemAsync: SessionApiKey={SessionApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] ReorderQueueItemAsync failed: SessionApiKey={SessionApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult { Data = result.Data };
}
- public async Task?> ClearQueueAsync(Guid sessionApiKey, long expectedRevision)
+ public async Task?> ClearQueueAsync(Guid sessionApiKey, long expectedRevision, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] ClearQueueAsync: SessionApiKey={SessionApiKey}, ExpectedRevision={ExpectedRevision}", sessionApiKey, expectedRevision);
- try
+ logger.LogDebug("[PartyModeService] ClearQueueAsync: SessionApiKey={SessionApiKey}, ExpectedRevision={ExpectedRevision}", sessionApiKey, expectedRevision);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var response = await _httpClient.PostAsync(
- $"{BasePath}/{sessionApiKey}/queue/clear?expectedRevision={expectedRevision}", null);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] ClearQueueAsync failed: SessionApiKey={SessionApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyQueueService.ClearAsync(sessionApiKey, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in ClearQueueAsync: SessionApiKey={SessionApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] ClearQueueAsync failed: SessionApiKey={SessionApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult { Data = result.Data };
}
- public async Task?> GetPlaybackStateAsync(Guid sessionApiKey)
+ public async Task?> GetPlaybackStateAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] GetPlaybackStateAsync: ApiKey={ApiKey}", sessionApiKey);
- try
- {
- var response = await _httpClient.GetAsync($"{BasePath}/{sessionApiKey}/playback");
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] GetPlaybackStateAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
- return null;
- }
- catch (Exception ex)
+ logger.LogDebug("[PartyModeService] GetPlaybackStateAsync: ApiKey={ApiKey}", sessionApiKey);
+
+ var result = await partyPlaybackService.GetPlaybackStateAsync(sessionApiKey, cancellationToken);
+
+ if (!result.IsSuccess || result.Data is null)
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetPlaybackStateAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] GetPlaybackStateAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- public async Task?> PlayAsync(Guid sessionApiKey, double? position = null, long expectedRevision = 0)
+ public async Task?> PlayAsync(Guid sessionApiKey, double? position = null, long expectedRevision = 0, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] PlayAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
- try
+ logger.LogDebug("[PartyModeService] PlayAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { PositionSeconds = position, ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/playback/play", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] PlayAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyPlaybackService.UpdateIntentAsync(sessionApiKey, PlaybackIntent.Play, position, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in PlayAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] PlayAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- public async Task?> PauseAsync(Guid sessionApiKey, double? position = null, long expectedRevision = 0)
+ public async Task?> PauseAsync(Guid sessionApiKey, double? position = null, long expectedRevision = 0, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] PauseAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
- try
+ logger.LogDebug("[PartyModeService] PauseAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { PositionSeconds = position, ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/playback/pause", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] PauseAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyPlaybackService.UpdateIntentAsync(sessionApiKey, PlaybackIntent.Pause, position, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in PauseAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] PauseAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- public async Task?> SkipAsync(Guid sessionApiKey, long expectedRevision)
+ public async Task?> SkipAsync(Guid sessionApiKey, long expectedRevision, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] SkipAsync: ApiKey={ApiKey}, ExpectedRevision={ExpectedRevision}", sessionApiKey, expectedRevision);
- try
+ logger.LogDebug("[PartyModeService] SkipAsync: ApiKey={ApiKey}, ExpectedRevision={ExpectedRevision}", sessionApiKey, expectedRevision);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/playback/skip", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] SkipAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyPlaybackService.UpdateIntentAsync(sessionApiKey, PlaybackIntent.Skip, null, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in SkipAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] SkipAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- public async Task?> SeekAsync(Guid sessionApiKey, double position, long expectedRevision)
+ public async Task?> SeekAsync(Guid sessionApiKey, double position, long expectedRevision, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] SeekAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
- try
+ logger.LogDebug("[PartyModeService] SeekAsync: ApiKey={ApiKey}, Position={Position}", sessionApiKey, position);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { PositionSeconds = position, ExpectedRevision = expectedRevision };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/playback/seek", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] SeekAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyPlaybackService.UpdateIntentAsync(sessionApiKey, PlaybackIntent.Seek, position, userId, expectedRevision, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in SeekAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] SeekAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- public async Task?> SetVolumeAsync(Guid sessionApiKey, double volume)
+ public async Task?> SetVolumeAsync(Guid sessionApiKey, double volume, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] SetVolumeAsync: ApiKey={ApiKey}, Volume={Volume}", sessionApiKey, volume);
- try
+ logger.LogDebug("[PartyModeService] SetVolumeAsync: ApiKey={ApiKey}, Volume={Volume}", sessionApiKey, volume);
+
+ var userId = authService.CurrentUser.UserId();
+ if (userId == 0)
{
- var request = new { Volume = volume };
- var response = await _httpClient.PostAsJsonAsync($"{BasePath}/{sessionApiKey}/playback/volume", request);
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>();
- }
- _logger.LogWarning("[PartyModeService] SetVolumeAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
return null;
}
- catch (Exception ex)
+
+ var result = await partyPlaybackService.UpdateFromHeartbeatAsync(sessionApiKey, null, 0, false, volume, userId, cancellationToken);
+
+ if (!result.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in SetVolumeAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] SetVolumeAsync failed: ApiKey={ApiKey}, StatusCode={Type}", sessionApiKey, result.Type);
return null;
}
+
+ return new OperationResult
+ {
+ Data = new PartyPlaybackStateDto(
+ result.Data.CurrentQueueItemApiKey,
+ result.Data.PositionSeconds,
+ result.Data.IsPlaying,
+ result.Data.Volume)
+ };
}
- // Endpoint Registry Methods
+ public async Task>?> GetEndpointsForSessionAsync(Guid sessionApiKey, CancellationToken cancellationToken = default)
+ {
+ logger.LogDebug("[PartyModeService] GetEndpointsForSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- private const string EndpointsBasePath = "api/v1/endpoints";
+ var userId = authService.CurrentUser.UserId();
- public async Task>?> GetEndpointsForSessionAsync(Guid sessionApiKey)
- {
- _logger.LogDebug("[PartyModeService] GetEndpointsForSessionAsync: ApiKey={ApiKey}", sessionApiKey);
- try
+ var sessionResult = await partySessionService.GetAsync(sessionApiKey, cancellationToken);
+ if (!sessionResult.IsSuccess || sessionResult.Data is null)
{
- var response = await _httpClient.GetAsync($"{EndpointsBasePath}/for-session/{sessionApiKey}");
- if (response.IsSuccessStatusCode)
- {
- return await response.Content.ReadFromJsonAsync>>();
- }
- _logger.LogWarning("[PartyModeService] GetEndpointsForSessionAsync failed: ApiKey={ApiKey}, StatusCode={StatusCode}", sessionApiKey, response.StatusCode);
+ logger.LogWarning("[PartyModeService] GetEndpointsForSessionAsync session not found: ApiKey={ApiKey}", sessionApiKey);
return null;
}
- catch (Exception ex)
+
+ var session = sessionResult.Data;
+ var endpointsResult = await endpointRegistryService.GetEndpointsForUserAsync(userId, cancellationToken);
+
+ if (!endpointsResult.IsSuccess)
{
- _logger.LogError(ex, "[PartyModeService] Exception in GetEndpointsForSessionAsync: ApiKey={ApiKey}", sessionApiKey);
+ logger.LogWarning("[PartyModeService] GetEndpointsForSessionAsync failed: ApiKey={ApiKey}", sessionApiKey);
return null;
}
+
+ var now = NodaTime.SystemClock.Instance.GetCurrentInstant();
+ var staleThreshold = NodaTime.Duration.FromTimeSpan(TimeSpan.FromSeconds(30));
+
+ var dtos = endpointsResult.Data.Select(e =>
+ {
+ var isStale = !e.LastSeenAt.HasValue || e.LastSeenAt.Value < (now - staleThreshold);
+ return new SessionEndpointDto(
+ e.ApiKey,
+ e.Name,
+ e.Type.ToString(),
+ e.IsShared,
+ e.Room,
+ e.LastSeenAt?.ToString(),
+ e.CapabilitiesJson,
+ e.OwnerUserId == userId,
+ e.ApiKey == session.ActiveEndpointId,
+ isStale);
+ });
+
+ return new OperationResult> { Data = dtos };
}
- public async Task AttachEndpointAsync(Guid endpointApiKey, Guid sessionApiKey)
+ public async Task AttachEndpointAsync(Guid endpointApiKey, Guid sessionApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] AttachEndpointAsync: EndpointApiKey={EndpointApiKey}, SessionApiKey={SessionApiKey}", endpointApiKey, sessionApiKey);
- try
- {
- var request = new { SessionApiKey = sessionApiKey };
- var response = await _httpClient.PostAsJsonAsync($"{EndpointsBasePath}/{endpointApiKey}/attach", request);
- var success = response.IsSuccessStatusCode;
- _logger.LogDebug("[PartyModeService] AttachEndpointAsync: Success={Success}", success);
- return success;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "[PartyModeService] Exception in AttachEndpointAsync: EndpointApiKey={EndpointApiKey}", endpointApiKey);
- return false;
- }
+ logger.LogDebug("[PartyModeService] AttachEndpointAsync: EndpointApiKey={EndpointApiKey}, SessionApiKey={SessionApiKey}", endpointApiKey, sessionApiKey);
+
+ var result = await endpointRegistryService.AttachToSessionAsync(endpointApiKey, sessionApiKey, cancellationToken);
+ logger.LogDebug("[PartyModeService] AttachEndpointAsync: Success={Success}", result.IsSuccess);
+ return result.IsSuccess;
}
- public async Task DetachEndpointAsync(Guid endpointApiKey)
+ public async Task DetachEndpointAsync(Guid endpointApiKey, CancellationToken cancellationToken = default)
{
- _logger.LogDebug("[PartyModeService] DetachEndpointAsync: EndpointApiKey={EndpointApiKey}", endpointApiKey);
- try
- {
- var response = await _httpClient.PostAsync($"{EndpointsBasePath}/{endpointApiKey}/detach", null);
- var success = response.IsSuccessStatusCode;
- _logger.LogDebug("[PartyModeService] DetachEndpointAsync: Success={Success}", success);
- return success;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "[PartyModeService] Exception in DetachEndpointAsync: EndpointApiKey={EndpointApiKey}", endpointApiKey);
- return false;
- }
+ logger.LogDebug("[PartyModeService] DetachEndpointAsync: EndpointApiKey={EndpointApiKey}", endpointApiKey);
+
+ var result = await endpointRegistryService.DetachAsync(endpointApiKey, cancellationToken);
+ logger.LogDebug("[PartyModeService] DetachEndpointAsync: Success={Success}", result.IsSuccess);
+ return result.IsSuccess;
+ }
+
+ private static PartySessionDto MapToDto(Common.Data.Models.PartySession session)
+ {
+ return new PartySessionDto(
+ session.ApiKey,
+ session.Name,
+ session.OwnerUserId,
+ session.Status.ToString(),
+ session.QueueRevision,
+ session.PlaybackRevision);
}
}
-// DTO classes (simplified versions matching API responses)
public record PartySessionDto(Guid ApiKey, string Name, int OwnerUserId, string Status, long QueueRevision, long PlaybackRevision);
public record PartySessionParticipantDto(int UserId, string Role, string JoinedAt);
public record QueueResponseDto(long Revision, IEnumerable Items);
diff --git a/src/Melodee.Cli/Command/AlbumImageIssuesCommand.cs b/src/Melodee.Cli/Command/AlbumImageIssuesCommand.cs
index 65acaeed0..d10a08456 100644
--- a/src/Melodee.Cli/Command/AlbumImageIssuesCommand.cs
+++ b/src/Melodee.Cli/Command/AlbumImageIssuesCommand.cs
@@ -5,6 +5,7 @@
using Melodee.Common.Data.Models;
using Melodee.Common.Data.Models.Extensions;
using Melodee.Common.Enums;
+using Melodee.Common.Imaging;
using Melodee.Common.Plugins.Validation;
using Melodee.Common.Utility;
using Microsoft.EntityFrameworkCore;
@@ -24,12 +25,13 @@ public class AlbumImageIssuesCommand : CommandBase
protected override async Task ExecuteAsync(CommandContext context, AlbumImageIssuesSettings settings, CancellationToken cancellationToken)
{
using var scope = CreateServiceProvider().CreateScope();
+ var imageProcessor = scope.ServiceProvider.GetRequiredService();
var dbContextFactory = scope.ServiceProvider.GetRequiredService>();
var configFactory = scope.ServiceProvider.GetRequiredService();
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var configuration = await configFactory.GetConfigurationAsync(cancellationToken);
- var imageValidator = new ImageValidator(configuration);
+ var imageValidator = new ImageValidator(imageProcessor, configuration);
var albums = await dbContext.Albums
.Include(a => a.Artist)
diff --git a/src/Melodee.Cli/Command/CommandBase.cs b/src/Melodee.Cli/Command/CommandBase.cs
index 543fbe3a0..8b26e5a99 100644
--- a/src/Melodee.Cli/Command/CommandBase.cs
+++ b/src/Melodee.Cli/Command/CommandBase.cs
@@ -3,6 +3,7 @@
using Melodee.Cli.Configuration;
using Melodee.Common.Configuration;
using Melodee.Common.Data;
+using Melodee.Common.Imaging;
using Melodee.Common.Metadata;
using Melodee.Common.Models.SearchEngines.ArtistSearchEngineServiceData;
using Melodee.Common.Plugins.Scrobbling;
@@ -116,6 +117,7 @@ protected ServiceProvider CreateServiceProvider()
services.AddSingleton(SpotifyClientConfig.CreateDefault());
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Melodee.Cli/Command/JobRunCommand.cs b/src/Melodee.Cli/Command/JobRunCommand.cs
index 9121d0c65..191492cdd 100644
--- a/src/Melodee.Cli/Command/JobRunCommand.cs
+++ b/src/Melodee.Cli/Command/JobRunCommand.cs
@@ -4,6 +4,7 @@
using Melodee.Common.Constants;
using Melodee.Common.Data;
using Melodee.Common.Data.Models;
+using Melodee.Common.Imaging;
using Melodee.Common.Jobs;
using Melodee.Common.Models.SearchEngines.ArtistSearchEngineServiceData;
using Melodee.Common.Plugins.Scrobbling;
@@ -259,7 +260,8 @@ private static JobBase CreateJob(Type jobType, IServiceProvider sp)
sp.GetRequiredService(),
sp.GetRequiredService>(),
sp.GetRequiredService(),
- sp.GetRequiredService());
+ sp.GetRequiredService(),
+ sp.GetRequiredService());
}
if (jobType == typeof(ArtistSearchEngineRepositoryHousekeepingJob))
diff --git a/src/Melodee.Cli/Command/ParseCommand.cs b/src/Melodee.Cli/Command/ParseCommand.cs
index c7cbc3da8..b5d393d1d 100644
--- a/src/Melodee.Cli/Command/ParseCommand.cs
+++ b/src/Melodee.Cli/Command/ParseCommand.cs
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Melodee.Cli.CommandSettings;
using Melodee.Common.Configuration;
+using Melodee.Common.Imaging;
using Melodee.Common.Models.Extensions;
using Melodee.Common.Plugins.Conversion.Image;
using Melodee.Common.Plugins.MetaData.Directory;
@@ -23,12 +24,13 @@ protected override async Task ExecuteAsync(CommandContext context, ParseSet
{
using (var scope = CreateServiceProvider().CreateScope())
{
+ var imageProcessor = scope.ServiceProvider.GetRequiredService();
var configFactory = scope.ServiceProvider.GetRequiredService();
var config = await configFactory.GetConfigurationAsync();
var serializer = scope.ServiceProvider.GetRequiredService();
- var imageValidator = new ImageValidator(config);
- var imageConvertor = new ImageConvertor(config);
+ var imageValidator = new ImageValidator(imageProcessor, config);
+ var imageConvertor = new ImageConvertor(imageProcessor, config);
var albumValidator = new AlbumValidator(config);
var fileInfo = new FileInfo(settings.Filename);
@@ -50,7 +52,7 @@ protected override async Task ExecuteAsync(CommandContext context, ParseSet
var cue = new CueSheet(
serializer,
[
- new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageConvertor, imageValidator, config)
+ new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageProcessor, imageConvertor, imageValidator, config)
], albumValidator, config);
if (cue.DoesHandleFile(fileInfo.Directory.ToDirectorySystemInfo(), fileInfo.ToFileSystemInfo()))
{
@@ -99,7 +101,7 @@ protected override async Task ExecuteAsync(CommandContext context, ParseSet
var sfv = new SimpleFileVerification(serializer,
[
- new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageConvertor, imageValidator, config)
+ new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageProcessor, imageConvertor, imageValidator, config)
], new AlbumValidator(config), config);
if (sfv.DoesHandleFile(fileInfo.Directory.ToDirectorySystemInfo(), fileInfo.ToFileSystemInfo()))
{
@@ -129,7 +131,7 @@ protected override async Task ExecuteAsync(CommandContext context, ParseSet
var m3u = new M3UPlaylist(serializer,
[
- new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageConvertor, imageValidator, config)
+ new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageProcessor, imageConvertor, imageValidator, config)
], albumValidator, config);
if (m3u.DoesHandleFile(fileInfo.Directory.ToDirectorySystemInfo(), fileInfo.ToFileSystemInfo()))
{
diff --git a/src/Melodee.Cli/Command/ShowMpegInfoCommand.cs b/src/Melodee.Cli/Command/ShowMpegInfoCommand.cs
index 08ab587b5..3c630b727 100644
--- a/src/Melodee.Cli/Command/ShowMpegInfoCommand.cs
+++ b/src/Melodee.Cli/Command/ShowMpegInfoCommand.cs
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Melodee.Cli.CommandSettings;
using Melodee.Common.Configuration;
+using Melodee.Common.Imaging;
using Melodee.Common.Metadata.AudioTags;
using Melodee.Common.Plugins.Conversion.Image;
using Melodee.Common.Plugins.Validation;
@@ -18,12 +19,13 @@ protected override async Task ExecuteAsync(CommandContext context, ShowMpeg
{
using (var scope = CreateServiceProvider().CreateScope())
{
+ var imageProcessor = scope.ServiceProvider.GetRequiredService();
var serializer = scope.ServiceProvider.GetRequiredService();
var configFactory = scope.ServiceProvider.GetRequiredService();
var config = await configFactory.GetConfigurationAsync(cancellationToken);
- var imageValidator = new ImageValidator(config);
- var imageConvertor = new ImageConvertor(config);
+ var imageValidator = new ImageValidator(imageProcessor, config);
+ var imageConvertor = new ImageConvertor(imageProcessor, config);
var fileInfo = new FileInfo(settings.Filename);
if (!fileInfo.Exists)
diff --git a/src/Melodee.Cli/Command/ShowTagsCommand.cs b/src/Melodee.Cli/Command/ShowTagsCommand.cs
index d1a0afd48..65ad92e53 100644
--- a/src/Melodee.Cli/Command/ShowTagsCommand.cs
+++ b/src/Melodee.Cli/Command/ShowTagsCommand.cs
@@ -1,6 +1,7 @@
using Melodee.Cli.CommandSettings;
using Melodee.Common.Configuration;
using Melodee.Common.Extensions;
+using Melodee.Common.Imaging;
using Melodee.Common.Models.Extensions;
using Melodee.Common.Plugins.Conversion.Image;
using Melodee.Common.Plugins.MetaData.Song;
@@ -21,12 +22,13 @@ protected override async Task ExecuteAsync(CommandContext context, ShowTags
{
using (var scope = CreateServiceProvider().CreateScope())
{
+ var imageProcessor = scope.ServiceProvider.GetRequiredService();
var serializer = scope.ServiceProvider.GetRequiredService();
var configFactory = scope.ServiceProvider.GetRequiredService();
var config = await configFactory.GetConfigurationAsync();
- var imageValidator = new ImageValidator(config);
- var imageConvertor = new ImageConvertor(config);
+ var imageValidator = new ImageValidator(imageProcessor, config);
+ var imageConvertor = new ImageConvertor(imageProcessor, config);
var fileInfo = new FileInfo(settings.Filename);
if (!fileInfo.Exists)
@@ -43,7 +45,7 @@ protected override async Task ExecuteAsync(CommandContext context, ShowTags
var isValid = false;
- var metaTag = new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageConvertor, imageValidator, config);
+ var metaTag = new AtlMetaTag(new MetaTagsProcessor(config, serializer), imageProcessor, imageConvertor, imageValidator, config);
var tagResult = await metaTag.ProcessFileAsync(fileInfo.Directory!.ToDirectorySystemInfo(), FileSystemInfoExtensions.ToFileSystemInfo(fileInfo));
if (settings.OnlyTags.Nullify() != null)
diff --git a/src/Melodee.Cli/Melodee.Cli.csproj b/src/Melodee.Cli/Melodee.Cli.csproj
index e596be980..8079a7d7f 100644
--- a/src/Melodee.Cli/Melodee.Cli.csproj
+++ b/src/Melodee.Cli/Melodee.Cli.csproj
@@ -9,7 +9,7 @@
false
$(NoWarn);NU1507
- 2.0.1
+ 2.1.0
build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))
$(VersionPrefix).0
diff --git a/src/Melodee.Common/Imaging/IImageProcessor.cs b/src/Melodee.Common/Imaging/IImageProcessor.cs
new file mode 100644
index 000000000..9e1781cbf
--- /dev/null
+++ b/src/Melodee.Common/Imaging/IImageProcessor.cs
@@ -0,0 +1,74 @@
+namespace Melodee.Common.Imaging;
+
+///
+/// Centralized interface for all image processing operations,
+/// abstracting the underlying imaging library.
+///
+public interface IImageProcessor
+{
+ ///
+ /// Identifies image dimensions and format without fully decoding the image.
+ ///
+ Task IdentifyAsync(string filePath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Identifies image dimensions and format from an in-memory byte array.
+ ///
+ Task IdentifyAsync(ReadOnlyMemory imageBytes, CancellationToken cancellationToken = default);
+
+ ///
+ /// Loads image dimensions from embedded picture data (synchronous).
+ ///
+ ImageDimensions? Identify(ReadOnlyMemory imageBytes);
+
+ ///
+ /// Converts any supported image format to JPEG format bytes.
+ ///
+ byte[] ConvertToJpeg(ReadOnlyMemory imageBytes);
+
+ ///
+ /// Converts any supported image format to GIF format bytes.
+ ///
+ Task ConvertToGifAsync(byte[] imageBytes, CancellationToken cancellationToken = default);
+
+ ///
+ /// Resizes and pads an image to a square of the given target size using a transparent background.
+ ///
+ byte[] ResizeAndPadToSquare(ReadOnlyMemory imageBytes, int targetSize);
+
+ ///
+ /// Resizes an image if it exceeds the given max dimensions, optionally saving as GIF.
+ ///
+ byte[] ResizeImageIfNeeded(ReadOnlyMemory imageBytes, int maxWidth, int maxHeight, bool saveAsGif);
+
+ ///
+ /// Computes an average hash (perceptual hash) for the given image bytes.
+ ///
+ ulong ComputeAverageHash(ReadOnlyMemory imageBytes);
+
+ ///
+ /// Computes a percentage-based similarity between two average hashes.
+ /// Returns a value between 0.0 and 100.0.
+ ///
+ double Similarity(ulong hash1, ulong hash2);
+
+ ///
+ /// Computes the similarity between two image files (0.0 to 100.0).
+ ///
+ double Similarity(string path1, string path2);
+
+ ///
+ /// Computes the similarity between two in-memory images (0.0 to 100.0).
+ ///
+ double Similarity(byte[] image1, byte[] image2);
+
+ ///
+ /// Returns true if two images are identical (100% hash match).
+ ///
+ bool ImagesAreSame(string path1, string path2);
+
+ ///
+ /// Returns true if two in-memory images are identical (100% hash match).
+ ///
+ bool ImagesAreSame(byte[] image1, byte[] image2);
+}
diff --git a/src/Melodee.Common/Imaging/ImageDimensions.cs b/src/Melodee.Common/Imaging/ImageDimensions.cs
new file mode 100644
index 000000000..8aaf8e03d
--- /dev/null
+++ b/src/Melodee.Common/Imaging/ImageDimensions.cs
@@ -0,0 +1,6 @@
+namespace Melodee.Common.Imaging;
+
+///
+/// Simple DTO representing the dimensions of an image.
+///
+public sealed record ImageDimensions(int Width, int Height, string? Format = null);
diff --git a/src/Melodee.Common/Imaging/ImageHasher.cs b/src/Melodee.Common/Imaging/ImageHasher.cs
index 3ed51a5c9..29777ca9f 100644
--- a/src/Melodee.Common/Imaging/ImageHasher.cs
+++ b/src/Melodee.Common/Imaging/ImageHasher.cs
@@ -1,167 +1,75 @@
-using SixLabors.ImageSharp.PixelFormats;
-using SixLabors.ImageSharp.Processing;
-using sixLablorsImageSharp = SixLabors.ImageSharp;
-
namespace Melodee.Common.Imaging;
///
/// Contains a variety of methods useful in generating image hashes for image comparison
/// and recognition.
/// Credit for the AverageHash implementation to David Oftedal of the University of Oslo.
+///
+/// NOTE: This class is now a thin wrapper around .
+/// Prefer injecting directly for new code.
///
public static class ImageHasher
{
- #region Private constants and utility methods
-
- ///
- /// Bitcounts array used for BitCount method (used in Similarity comparisons).
- /// Don't try to read this or understand it, I certainly don't. Credit goes to
- /// David Oftedal of the University of Oslo, Norway for this.
- /// http://folk.uio.no/davidjo/computing.php
- ///
- private static readonly byte[] bitCounts =
- [
- 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 1, 2, 2, 3,
- 2, 3, 3, 4,
- 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4,
- 3, 4, 4, 5,
- 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5,
- 4, 5, 5, 6,
- 4, 5, 5, 6, 5, 6, 6, 7, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5,
- 4, 5, 5, 6,
- 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 2, 3, 3, 4,
- 3, 4, 4, 5,
- 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6,
- 5, 6, 6, 7,
- 4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8
- ];
-
- ///
- /// Counts bits (duh). Utility function for similarity.
- /// I wouldn't try to understand this. I just copy-pasta'd it
- /// from Oftedal's implementation. It works.
- ///
- /// The hash we are counting.
- /// The total bit count.
- private static uint BitCount(ulong num)
- {
- uint count = 0;
- for (; num > 0; num >>= 8)
- {
- count += bitCounts[num & 0xff];
- }
-
- return count;
- }
-
- private static ulong _averageHash;
-
- #endregion Private constants and utility methods
-
- #region Public interface methods
-
///
/// Generate a hash for the image to be able to find like/matching images.
///
+ /// The image processor instance.
/// Image bytes
/// Hash of Image
- public static ulong AverageHash(byte[] bytes)
+ public static ulong AverageHash(IImageProcessor imageProcessor, byte[] bytes)
{
- _averageHash = 0;
- using var image = sixLablorsImageSharp.Image.Load(bytes);
- {
- image.Mutate(ctx => ctx.Resize(8, 8).Grayscale());
- image.ProcessPixelRows(static pixelAccessor =>
- {
- var grayscale = new byte[64];
- uint averageValue = 0;
- for (var y = 0; y < 8; y++)
- {
- var pixelRowSpan = pixelAccessor.GetRowSpan(y);
- for (var x = 0; x < 8; x++)
- {
- var pixel = pixelRowSpan[x].PackedValue;
- var gray = (pixel & 0x00ff0000) >> 16;
- gray += (pixel & 0x0000ff00) >> 8;
- gray += pixel & 0x000000ff;
- gray /= 12;
- grayscale[x + y * 8] = (byte)gray;
- averageValue += gray;
- }
- }
-
- averageValue /= 64;
- for (var i = 0; i < 64; i++)
- {
- if (grayscale[i] >= averageValue)
- {
- _averageHash |= 1UL << (63 - i);
- }
- }
- });
- }
- return _averageHash;
+ return imageProcessor.ComputeAverageHash(bytes);
}
///
/// Computes the average hash of the image content in the given file.
///
+ /// The image processor instance.
/// Path to the input file.
/// The hash of the input file's image content.
- public static ulong AverageHash(string path)
+ public static ulong AverageHash(IImageProcessor imageProcessor, string path)
{
- return AverageHash(File.ReadAllBytes(path));
+ return imageProcessor.ComputeAverageHash(File.ReadAllBytes(path));
}
- public static bool ImagesAreSame(string path1, string path2)
+ public static bool ImagesAreSame(IImageProcessor imageProcessor, string path1, string path2)
{
- return Similarity(path1, path2) == 100;
+ return imageProcessor.ImagesAreSame(path1, path2);
}
- public static bool ImagesAreSame(byte[] image1, byte[] image2)
+ public static bool ImagesAreSame(IImageProcessor imageProcessor, byte[] image1, byte[] image2)
{
- return Similarity(image1, image2) == 100;
+ return imageProcessor.ImagesAreSame(image1, image2);
}
///
/// Returns a percentage-based similarity value between the two given hashes. The higher
/// the percentage, the closer the hashes are to being identical.
///
+ /// The image processor instance.
/// The first hash.
/// The second hash.
/// The similarity percentage.
- public static double Similarity(ulong hash1, ulong hash2)
+ public static double Similarity(IImageProcessor imageProcessor, ulong hash1, ulong hash2)
{
- return (64 - BitCount(hash1 ^ hash2)) * 100 / 64.0;
+ return imageProcessor.Similarity(hash1, hash2);
}
///
/// Returns a percentage-based similarity value between the image content of the two given
/// files. The higher the percentage, the closer the image contents are to being identical.
///
- /// The first image file.
- /// The second image file.
- /// The similarity percentage.
- public static double Similarity(string path1, string path2)
+ public static double Similarity(IImageProcessor imageProcessor, string path1, string path2)
{
- var hash1 = AverageHash(path1);
- var hash2 = AverageHash(path2);
- return Similarity(hash1, hash2);
+ return imageProcessor.Similarity(path1, path2);
}
///
/// Returns a percentage-based similarity value between the image content of the two given
/// files. The higher the percentage, the closer the image contents are to being identical.
///
- /// The first image bytes.
- /// The second image bytes.
- /// The similarity percentage.
- public static double Similarity(byte[] image1, byte[] image2)
+ public static double Similarity(IImageProcessor imageProcessor, byte[] image1, byte[] image2)
{
- var hash1 = AverageHash(image1);
- var hash2 = AverageHash(image2);
- return Similarity(hash1, hash2);
+ return imageProcessor.Similarity(image1, image2);
}
-
- #endregion Public interface methods
}
diff --git a/src/Melodee.Common/Imaging/ImageProcessor.cs b/src/Melodee.Common/Imaging/ImageProcessor.cs
new file mode 100644
index 000000000..29bf46380
--- /dev/null
+++ b/src/Melodee.Common/Imaging/ImageProcessor.cs
@@ -0,0 +1,346 @@
+using SkiaSharp;
+
+namespace Melodee.Common.Imaging;
+
+///
+/// Image processing implementation using SkiaSharp.
+/// Provides centralized image identification, conversion, resizing, and hashing.
+///
+public sealed class ImageProcessor : IImageProcessor
+{
+ private static readonly byte[] bitCounts =
+ [
+ 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 1, 2, 2, 3,
+ 2, 3, 3, 4,
+ 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4,
+ 3, 4, 4, 5,
+ 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5,
+ 4, 5, 5, 6,
+ 4, 5, 5, 6, 5, 6, 6, 7, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5,
+ 4, 5, 5, 6,
+ 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 2, 3, 3, 4,
+ 3, 4, 4, 5,
+ 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6,
+ 5, 6, 6, 7,
+ 4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8
+ ];
+
+ public Task IdentifyAsync(string filePath, CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(filePath))
+ {
+ return Task.FromResult(null);
+ }
+
+ try
+ {
+ using var stream = File.OpenRead(filePath);
+ var codec = SKCodec.Create(stream);
+ if (codec == null)
+ {
+ return Task.FromResult(null);
+ }
+
+ var info = codec.Info;
+ return Task.FromResult(new ImageDimensions(info.Width, info.Height, codec.EncodedFormat.ToString()));
+ }
+ catch
+ {
+ return Task.FromResult(null);
+ }
+ }
+
+ public Task IdentifyAsync(ReadOnlyMemory imageBytes, CancellationToken cancellationToken = default)
+ {
+ if (imageBytes.IsEmpty)
+ {
+ return Task.FromResult(null);
+ }
+
+ try
+ {
+ using var stream = new SKMemoryStream(imageBytes.ToArray());
+ using var codec = SKCodec.Create(stream);
+ if (codec == null)
+ {
+ return Task.FromResult(null);
+ }
+
+ var info = codec.Info;
+ return Task.FromResult(new ImageDimensions(info.Width, info.Height, codec.EncodedFormat.ToString()));
+ }
+ catch
+ {
+ return Task.FromResult(null);
+ }
+ }
+
+ public ImageDimensions? Identify(ReadOnlyMemory imageBytes)
+ {
+ if (imageBytes.IsEmpty)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var stream = new SKMemoryStream(imageBytes.ToArray());
+ using var codec = SKCodec.Create(stream);
+ if (codec == null)
+ {
+ return null;
+ }
+
+ var info = codec.Info;
+ return new ImageDimensions(info.Width, info.Height, codec.EncodedFormat.ToString());
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public byte[] ConvertToJpeg(ReadOnlyMemory imageBytes)
+ {
+ if (imageBytes.IsEmpty)
+ {
+ return imageBytes.ToArray();
+ }
+
+ using var bitmap = SKBitmap.Decode(imageBytes.ToArray());
+ if (bitmap == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 85);
+ if (data == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ return data.ToArray();
+ }
+
+ public async Task ConvertToGifAsync(byte[] imageBytes, CancellationToken cancellationToken = default)
+ {
+ if (imageBytes.Length == 0)
+ {
+ return imageBytes;
+ }
+
+ using var bitmap = SKBitmap.Decode(imageBytes);
+ if (bitmap == null)
+ {
+ return imageBytes;
+ }
+
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Gif, 100);
+ if (data == null)
+ {
+ return imageBytes;
+ }
+
+ return await Task.FromResult(data.ToArray());
+ }
+
+ public byte[] ResizeAndPadToSquare(ReadOnlyMemory imageBytes, int targetSize)
+ {
+ if (imageBytes.IsEmpty || targetSize <= 0)
+ {
+ return imageBytes.ToArray();
+ }
+
+ using var sourceBitmap = SKBitmap.Decode(imageBytes.ToArray());
+ if (sourceBitmap == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ var info = new SKImageInfo(targetSize, targetSize, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using var surface = SKSurface.Create(info);
+ if (surface == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ var canvas = surface.Canvas;
+ canvas.Clear(SKColors.Transparent);
+
+ // Calculate scaling to fit within targetSize while maintaining aspect ratio
+ float scale = Math.Min(
+ (float)targetSize / sourceBitmap.Width,
+ (float)targetSize / sourceBitmap.Height);
+
+ int newWidth = (int)(sourceBitmap.Width * scale);
+ int newHeight = (int)(sourceBitmap.Height * scale);
+
+ // Center the scaled image
+ int x = (targetSize - newWidth) / 2;
+ int y = (targetSize - newHeight) / 2;
+
+ var destRect = new SKRectI(x, y, x + newWidth, y + newHeight);
+ var srcRect = new SKRectI(0, 0, sourceBitmap.Width, sourceBitmap.Height);
+
+ canvas.DrawBitmap(sourceBitmap, srcRect, destRect);
+ canvas.Flush();
+
+ using var image = surface.Snapshot();
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 85);
+ if (data == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ return data.ToArray();
+ }
+
+ public byte[] ResizeImageIfNeeded(ReadOnlyMemory imageBytes, int maxWidth, int maxHeight, bool saveAsGif)
+ {
+ if (imageBytes.IsEmpty)
+ {
+ return imageBytes.ToArray();
+ }
+
+ using var sourceBitmap = SKBitmap.Decode(imageBytes.ToArray());
+ if (sourceBitmap == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ if (sourceBitmap.Width <= maxWidth && sourceBitmap.Height <= maxHeight)
+ {
+ // No resize needed, just re-encode if format change requested
+ using var srcImage = SKImage.FromBitmap(sourceBitmap);
+ using var data = srcImage.Encode(saveAsGif ? SKEncodedImageFormat.Gif : SKEncodedImageFormat.Jpeg, 85);
+ if (data == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ return data.ToArray();
+ }
+
+ // Calculate new size maintaining aspect ratio
+ float scale = Math.Min(
+ (float)maxWidth / sourceBitmap.Width,
+ (float)maxHeight / sourceBitmap.Height);
+
+ int newWidth = (int)(sourceBitmap.Width * scale);
+ int newHeight = (int)(sourceBitmap.Height * scale);
+
+ var resized = sourceBitmap.Resize(new SKImageInfo(newWidth, newHeight, sourceBitmap.ColorType, sourceBitmap.AlphaType), new SKSamplingOptions(SKCubicResampler.Mitchell));
+ if (resized == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ using (resized)
+ {
+ using var image = SKImage.FromBitmap(resized);
+ using var data = image.Encode(saveAsGif ? SKEncodedImageFormat.Gif : SKEncodedImageFormat.Jpeg, 85);
+ if (data == null)
+ {
+ return imageBytes.ToArray();
+ }
+
+ return data.ToArray();
+ }
+ }
+
+ public ulong ComputeAverageHash(ReadOnlyMemory imageBytes)
+ {
+ if (imageBytes.IsEmpty)
+ {
+ return 0;
+ }
+
+ using var sourceBitmap = SKBitmap.Decode(imageBytes.ToArray());
+ if (sourceBitmap == null)
+ {
+ return 0;
+ }
+
+ // Resize to 8x8 and convert to grayscale
+ var resized = sourceBitmap.Resize(new SKImageInfo(8, 8, sourceBitmap.ColorType, sourceBitmap.AlphaType), new SKSamplingOptions(SKCubicResampler.Mitchell));
+ if (resized == null)
+ {
+ return 0;
+ }
+
+ using (resized)
+ {
+ var grayBytes = new byte[64];
+ uint averageValue = 0;
+
+ int i = 0;
+ for (int y = 0; y < 8; y++)
+ {
+ for (int x = 0; x < 8; x++)
+ {
+ var color = resized.GetPixel(x, y);
+ // Rec709 luminance formula: 0.2126 R + 0.7152 G + 0.0722 B
+ byte gray = (byte)((color.Red * 54 + color.Green * 183 + color.Blue * 19) >> 8);
+ grayBytes[i] = gray;
+ averageValue += gray;
+ i++;
+ }
+ }
+
+ averageValue /= 64;
+
+ ulong hash = 0;
+ for (i = 0; i < 64; i++)
+ {
+ if (grayBytes[i] >= averageValue)
+ {
+ hash |= 1UL << (63 - i);
+ }
+ }
+
+ return hash;
+ }
+ }
+
+ public double Similarity(ulong hash1, ulong hash2)
+ {
+ return (64 - BitCount(hash1 ^ hash2)) * 100 / 64.0;
+ }
+
+ public double Similarity(string path1, string path2)
+ {
+ var hash1 = ComputeAverageHash(File.ReadAllBytes(path1));
+ var hash2 = ComputeAverageHash(File.ReadAllBytes(path2));
+ return Similarity(hash1, hash2);
+ }
+
+ public double Similarity(byte[] image1, byte[] image2)
+ {
+ var hash1 = ComputeAverageHash(image1);
+ var hash2 = ComputeAverageHash(image2);
+ return Similarity(hash1, hash2);
+ }
+
+ public bool ImagesAreSame(string path1, string path2)
+ {
+ return Similarity(path1, path2) == 100;
+ }
+
+ public bool ImagesAreSame(byte[] image1, byte[] image2)
+ {
+ return Similarity(image1, image2) == 100;
+ }
+
+ private static uint BitCount(ulong num)
+ {
+ uint count = 0;
+ for (; num > 0; num >>= 8)
+ {
+ count += bitCounts[num & 0xff];
+ }
+
+ return count;
+ }
+}
diff --git a/src/Melodee.Common/Jobs/ArtistHousekeepingJob.cs b/src/Melodee.Common/Jobs/ArtistHousekeepingJob.cs
index b1400436e..9b781ffeb 100644
--- a/src/Melodee.Common/Jobs/ArtistHousekeepingJob.cs
+++ b/src/Melodee.Common/Jobs/ArtistHousekeepingJob.cs
@@ -5,6 +5,7 @@
using Melodee.Common.Data.Models.Extensions;
using Melodee.Common.Enums;
using Melodee.Common.Extensions;
+using Melodee.Common.Imaging;
using Melodee.Common.Models.Extensions;
using Melodee.Common.Plugins.Validation;
using Melodee.Common.Services;
@@ -51,7 +52,8 @@ public class ArtistHousekeepingJob(
ArtistService artistService,
IDbContextFactory contextFactory,
ArtistImageSearchEngineService imageSearchEngine,
- IHttpClientFactory httpClientFactory) : JobBase(logger, configurationFactory)
+ IHttpClientFactory httpClientFactory,
+ IImageProcessor imageProcessor) : JobBase(logger, configurationFactory)
{
public override async Task Execute(IJobExecutionContext context)
{
@@ -60,7 +62,7 @@ public override async Task Execute(IJobExecutionContext context)
var batchSize = configuration.GetValue(SettingRegistry.DefaultsBatchSize);
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var httpClient = httpClientFactory.CreateClient();
- var imageValidator = new ImageValidator(configuration);
+ var imageValidator = new ImageValidator(imageProcessor, configuration);
await using (var scopedContext = await contextFactory.CreateDbContextAsync(context.CancellationToken))
{
var readyToProcessStatus = SafeParser.ToNumber(MetaDataModelStatus.ReadyToProcess);
diff --git a/src/Melodee.Common/Melodee.Common.csproj b/src/Melodee.Common/Melodee.Common.csproj
index b070beed3..fd980c509 100644
--- a/src/Melodee.Common/Melodee.Common.csproj
+++ b/src/Melodee.Common/Melodee.Common.csproj
@@ -9,7 +9,7 @@
$(NoWarn);NU1507
- 2.0.1
+ 2.1.0
build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))
$(VersionPrefix).0
$(VersionPrefix).0
@@ -71,7 +71,8 @@
-
+
+
diff --git a/src/Melodee.Common/Metadata/MelodeeMetadataMaker.cs b/src/Melodee.Common/Metadata/MelodeeMetadataMaker.cs
index dbfc68f1a..13fcc73c3 100644
--- a/src/Melodee.Common/Metadata/MelodeeMetadataMaker.cs
+++ b/src/Melodee.Common/Metadata/MelodeeMetadataMaker.cs
@@ -4,6 +4,7 @@
using Melodee.Common.Constants;
using Melodee.Common.Enums;
using Melodee.Common.Extensions;
+using Melodee.Common.Imaging;
using Melodee.Common.Models;
using Melodee.Common.Models.Extensions;
using Melodee.Common.Models.SpecialArtists;
@@ -17,7 +18,6 @@
using Melodee.Common.Services.SearchEngines;
using Melodee.Common.Utility;
using Serilog;
-using SixLabors.ImageSharp;
using ImageInfo = Melodee.Common.Models.ImageInfo;
namespace Melodee.Common.Metadata;
@@ -29,7 +29,8 @@ public class MelodeeMetadataMaker(
ArtistSearchEngineService artistSearchEngineService,
AlbumImageSearchEngineService albumImageSearchEngineService,
IHttpClientFactory httpClientFactory,
- MediaEditService mediaEditService)
+ MediaEditService mediaEditService,
+ IImageProcessor imageProcessor)
{
///
/// For a given directory generate a Melodee Metadata file (melodee.json). Does not modify files in place.
@@ -73,9 +74,9 @@ public class MelodeeMetadataMaker(
var configElapsedMs = Stopwatch.GetElapsedTime(configStartTicks).TotalMilliseconds;
var albumValidator = new AlbumValidator(configuration);
- var imageValidator = new ImageValidator(configuration);
- var imageConvertor = new ImageConvertor(configuration);
- var songPlugin = new AtlMetaTag(new MetaTagsProcessor(configuration, serializer), imageConvertor, imageValidator, configuration);
+ var imageValidator = new ImageValidator(imageProcessor, configuration);
+ var imageConvertor = new ImageConvertor(imageProcessor, configuration);
+ var songPlugin = new AtlMetaTag(new MetaTagsProcessor(configuration, serializer), imageProcessor, imageConvertor, imageValidator, configuration);
var mp3Files = new Mp3Files([songPlugin], albumValidator, serializer, logger, configuration);
var processStartTicks = Stopwatch.GetTimestamp();
@@ -148,10 +149,10 @@ public class MelodeeMetadataMaker(
continue;
}
- var imageInfo = await Image.LoadAsync(fileInfo.FullName, cancellationToken).ConfigureAwait(false);
+ var imageInfo = await imageProcessor.IdentifyAsync(fileInfo.FullName, cancellationToken).ConfigureAwait(false);
var crc32 = Crc32.Calculate(fileInfo);
- if (albumImages.Any(x => x.IsCrcHashMatch(crc32)))
+ if (imageInfo == null || albumImages.Any(x => x.IsCrcHashMatch(crc32)))
{
continue;
}
@@ -182,7 +183,7 @@ public class MelodeeMetadataMaker(
else
{
var foundAlbumImages =
- (await album.FindImages(songPlugin, duplicateThreshold, imageConvertor, imageValidator,
+ (await album.FindImages(imageProcessor, songPlugin, duplicateThreshold, imageConvertor, imageValidator,
configuration.GetValue(SettingRegistry.ProcessingDoDeleteOriginal), cancellationToken)
.ConfigureAwait(false)).ToArray();
if (foundAlbumImages.Length != 0)
@@ -355,23 +356,26 @@ public class MelodeeMetadataMaker(
cancellationToken).ConfigureAwait(false))
{
var newImageInfo = new FileInfo(albumImageFromSearchFileName);
- var imageInfo = await Image.IdentifyAsync(albumImageFromSearchFileName, cancellationToken)
+ var imageInfo = await imageProcessor.IdentifyAsync(albumImageFromSearchFileName, cancellationToken)
.ConfigureAwait(false);
- album.Images = new List
+ if (imageInfo != null)
{
- new()
+ album.Images = new List
{
- FileInfo = newImageInfo.ToFileSystemInfo(),
- PictureIdentifier = PictureIdentifier.Front,
- CrcHash = Crc32.Calculate(newImageInfo),
- Width = imageInfo.Width,
- Height = imageInfo.Height,
- SortOrder = 1,
- WasEmbeddedInSong = false
- }
- };
- Log.Debug("[{Name}] Downloaded album image [{MediaUrl}]", nameof(MelodeeMetadataMaker),
- imageSearchResult.MediaUrl);
+ new()
+ {
+ FileInfo = newImageInfo.ToFileSystemInfo(),
+ PictureIdentifier = PictureIdentifier.Front,
+ CrcHash = Crc32.Calculate(newImageInfo),
+ Width = imageInfo.Width,
+ Height = imageInfo.Height,
+ SortOrder = 1,
+ WasEmbeddedInSong = false
+ }
+ };
+ Log.Debug("[{Name}] Downloaded album image [{MediaUrl}]", nameof(MelodeeMetadataMaker),
+ imageSearchResult.MediaUrl);
+ }
}
}
else
diff --git a/src/Melodee.Common/Migrations/20250207191118_InitialMigration.Designer.cs b/src/Melodee.Common/Migrations/20250207191118_InitialMigration.Designer.cs
deleted file mode 100644
index 8dd9b1b7f..000000000
--- a/src/Melodee.Common/Migrations/20250207191118_InitialMigration.Designer.cs
+++ /dev/null
@@ -1,3091 +0,0 @@
-//
-using System;
-using Melodee.Common.Data;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using NodaTime;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-
-#nullable disable
-
-namespace Melodee.Common.Migrations
-{
- [DbContext(typeof(MelodeeDbContext))]
- [Migration("20250207191118_InitialMigration")]
- partial class InitialMigration
- {
- ///
- protected override void BuildTargetModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "9.0.1")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("Melodee.Common.Data.Models.Album", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("integer");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
-
- b.Property("AlbumStatus")
- .HasColumnType("smallint");
-
- b.Property("AlbumType")
- .HasColumnType("smallint");
-
- b.Property("AlternateNames")
- .HasMaxLength(1000)
- .HasColumnType("character varying(1000)");
-
- b.Property("AmgId")
- .HasColumnType("text");
-
- b.Property("ApiKey")
- .HasColumnType("uuid");
-
- b.Property("ArtistId")
- .HasColumnType("integer");
-
- b.Property("CalculatedRating")
- .HasColumnType("numeric");
-
- b.Property("Comment")
- .HasMaxLength(4000)
- .HasColumnType("character varying(4000)");
-
- b.Property("CreatedAt")
- .HasColumnType("timestamp with time zone");
-
- b.Property("Description")
- .HasMaxLength(4000)
- .HasColumnType("character varying(4000)");
-
- b.Property("Directory")
- .IsRequired()
- .HasMaxLength(2000)
- .HasColumnType("character varying(2000)");
-
- b.Property("DiscogsId")
- .HasColumnType("text");
-
- b.Property |