Skip to content

Commit 10c73eb

Browse files
committed
chore(release): bump version to 2.1.4 and update changelog with Library Inbound processing enhancements
- Added unit tests for manual and scheduled Library Inbound processing scenarios. - Improved handling of media folders without `melodee.json` and refined timestamp-based scan selection. - Updated `VersionPrefix` to 2.1.4 across all projects.
1 parent 931853c commit 10c73eb

10 files changed

Lines changed: 201 additions & 16 deletions

File tree

docs/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.1.3
1+
2.1.4

docs/pages/changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
## [Unreleased]
2525

26+
## [2.1.4] - 2026-06-16
27+
2628
### Added
2729

2830
- Added public DecentDB usage and migration documentation covering Melodee's
@@ -34,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3436
- Admin dashboard and login health warnings now open-check MusicBrainz and
3537
ArtistSearch DecentDB files and link to the migration guide when unsupported
3638
DecentDB file-format versions are detected.
39+
- Manual Library Inbound processing now bypasses the inbound root timestamp
40+
shortcut so admin-triggered and force-mode scans inspect waiting releases.
41+
- Library Inbound scan selection now treats media folders without
42+
`melodee.json` as unprocessed even when preserved file timestamps predate
43+
the previous scan, while still avoiding churn for old folders that already
44+
have Melodee metadata.
3745

3846
## [2.1.3] - 2026-06-15
3947

src/Melodee.Blazor/Melodee.Blazor.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
<!-- Auto-incrementing version -->
1818

19-
<VersionPrefix>2.1.3</VersionPrefix>
19+
<VersionPrefix>2.1.4</VersionPrefix>
2020

2121
<VersionSuffix>build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</VersionSuffix>
2222
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>

src/Melodee.Cli/Melodee.Cli.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<Deterministic>false</Deterministic>
1010
<NoWarn>$(NoWarn);NU1507</NoWarn>
1111

12-
<VersionPrefix>2.1.3</VersionPrefix>
12+
<VersionPrefix>2.1.4</VersionPrefix>
1313

1414
<VersionSuffix>build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</VersionSuffix>
1515
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>

src/Melodee.Common/Jobs/LibraryInboundProcessJob.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ namespace Melodee.Common.Jobs;
4949
/// <list type="bullet">
5050
/// <item>Inbound library path not configured</item>
5151
/// <item>Library is locked (IsLocked=true)</item>
52-
/// <item>No changes detected since last scan</item>
52+
/// <item>No changes detected since last scan for scheduled runs</item>
5353
/// </list>
5454
/// </para>
5555
/// <para>
@@ -82,7 +82,7 @@ public override async Task Execute(IJobExecutionContext context)
8282
return;
8383
}
8484

85-
if (!inboundLibrary.NeedsScanning())
85+
if (!ShouldBypassScanTimestamp(context) && !inboundLibrary.NeedsScanning())
8686
{
8787
Logger.Debug(
8888
"[{JobName}] Inbound library does not need scanning. Directory last scanned [{LastScanAt}], Directory last write [{LastWriteTime}]",
@@ -153,6 +153,17 @@ private static bool IsManualTrigger(IJobExecutionContext context)
153153
return context.Trigger is not ICronTrigger;
154154
}
155155

156+
private static bool IsForceMode(IJobExecutionContext context)
157+
{
158+
return context.MergedJobDataMap.ContainsKey(MelodeeJobExecutionContext.ForceMode) &&
159+
context.MergedJobDataMap.GetBoolean(MelodeeJobExecutionContext.ForceMode);
160+
}
161+
162+
private static bool ShouldBypassScanTimestamp(IJobExecutionContext context)
163+
{
164+
return IsManualTrigger(context) || IsForceMode(context);
165+
}
166+
156167
private static bool ShouldChainToNextJob(IJobExecutionContext context)
157168
{
158169
if (!IsManualTrigger(context))

src/Melodee.Common/Melodee.Common.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<NoWarn>$(NoWarn);NU1507</NoWarn>
1010

1111
<!-- Auto-incrementing version -->
12-
<VersionPrefix>2.1.3</VersionPrefix>
12+
<VersionPrefix>2.1.4</VersionPrefix>
1313
<VersionSuffix>build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</VersionSuffix>
1414
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
1515
<FileVersion>$(VersionPrefix).0</FileVersion>

src/Melodee.Common/Models/Extensions/FileSystemDirectoryInfoExtensions.cs

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,9 @@ public static IEnumerable<FileSystemDirectoryInfo> GetFileSystemDirectoryInfosTo
323323
{
324324
// Include directories with any files (not just media files) so that
325325
// script-based deletion can evaluate and clean up non-media directories
326-
var hasFiles = dir.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly).Any();
327-
var hasMediaFiles = hasFiles && dir.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly)
328-
.Any(x => FileHelper.IsFileMediaType(x.Extension));
326+
var fileState = GetTopLevelFileProcessState(dir, modifiedSinceValue);
329327

330-
if (dir.LastWriteTimeUtc >= modifiedSinceValue && (hasMediaFiles || hasFiles))
328+
if (ShouldProcessDirectory(dir, fileState, modifiedSinceValue))
331329
{
332330
var fsDir = dir.ToFileSystemDirectoryInfo();
333331
if (fsDir != null)
@@ -338,16 +336,61 @@ public static IEnumerable<FileSystemDirectoryInfo> GetFileSystemDirectoryInfosTo
338336
}
339337

340338
// Check if the root directory itself has files (media or non-media)
341-
var rootHasFiles = dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly).Any();
342-
var rootHasMediaFiles = rootHasFiles && dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly)
343-
.Any(x => x.LastWriteTimeUtc >= modifiedSinceValue && FileHelper.IsFileMediaType(x.Extension));
344-
345-
if (rootHasMediaFiles || (rootHasFiles && dirInfo.LastWriteTimeUtc >= modifiedSinceValue))
339+
var rootFileState = GetTopLevelFileProcessState(dirInfo, modifiedSinceValue);
340+
if (ShouldProcessDirectory(dirInfo, rootFileState, modifiedSinceValue))
346341
{
347342
yield return fileSystemDirectoryInfo;
348343
}
349344
}
350345

346+
private static DirectoryProcessFileState GetTopLevelFileProcessState(DirectoryInfo directoryInfo, DateTime modifiedSince)
347+
{
348+
var hasFiles = false;
349+
var hasMediaFiles = false;
350+
var hasModifiedMediaFiles = false;
351+
var hasMelodeeJsonFiles = false;
352+
353+
foreach (var fileInfo in directoryInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly))
354+
{
355+
hasFiles = true;
356+
var isMediaFile = FileHelper.IsFileMediaType(fileInfo.Extension);
357+
hasMediaFiles |= isMediaFile;
358+
hasModifiedMediaFiles |= isMediaFile && fileInfo.LastWriteTimeUtc >= modifiedSince;
359+
hasMelodeeJsonFiles |= string.Equals(fileInfo.Name, Album.JsonFileName, StringComparison.OrdinalIgnoreCase);
360+
361+
if (hasMediaFiles && hasModifiedMediaFiles && hasMelodeeJsonFiles)
362+
{
363+
break;
364+
}
365+
}
366+
367+
return new DirectoryProcessFileState(hasFiles, hasMediaFiles, hasModifiedMediaFiles, hasMelodeeJsonFiles);
368+
}
369+
370+
private static bool ShouldProcessDirectory(
371+
DirectoryInfo directoryInfo,
372+
DirectoryProcessFileState fileState,
373+
DateTime modifiedSince)
374+
{
375+
if (!fileState.HasFiles)
376+
{
377+
return false;
378+
}
379+
380+
if (directoryInfo.LastWriteTimeUtc >= modifiedSince || fileState.HasModifiedMediaFiles)
381+
{
382+
return true;
383+
}
384+
385+
return fileState is { HasMediaFiles: true, HasMelodeeJsonFiles: false };
386+
}
387+
388+
private readonly record struct DirectoryProcessFileState(
389+
bool HasFiles,
390+
bool HasMediaFiles,
391+
bool HasModifiedMediaFiles,
392+
bool HasMelodeeJsonFiles);
393+
351394
public static (string, int) GetNextFileNameForType(this FileSystemDirectoryInfo fileSystemDirectoryInfo, string imageType)
352395
{
353396
var highestNumberFound = 0;

src/Melodee.Mql/Melodee.Mql.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
88
<NoWarn>$(NoWarn);NU1507</NoWarn>
99

10-
<VersionPrefix>2.1.3</VersionPrefix>
10+
<VersionPrefix>2.1.4</VersionPrefix>
1111
<VersionSuffix>build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</VersionSuffix>
1212
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
1313
<FileVersion>$(VersionPrefix).0</FileVersion>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Melodee.Common.Jobs;
2+
using Moq;
3+
using Quartz;
4+
5+
namespace Melodee.Tests.Common.Jobs;
6+
7+
public class LibraryInboundProcessJobTests
8+
{
9+
[Fact]
10+
public void ShouldBypassScanTimestamp_WithManualContext_ReturnsTrue()
11+
{
12+
var context = new MelodeeJobExecutionContext(CancellationToken.None);
13+
14+
var result = InvokeShouldBypassScanTimestamp(context);
15+
16+
Assert.True(result);
17+
}
18+
19+
[Fact]
20+
public void ShouldBypassScanTimestamp_WithScheduledContext_ReturnsFalse()
21+
{
22+
var context = CreateScheduledContext(new JobDataMap());
23+
24+
var result = InvokeShouldBypassScanTimestamp(context);
25+
26+
Assert.False(result);
27+
}
28+
29+
[Fact]
30+
public void ShouldBypassScanTimestamp_WithScheduledForceModeContext_ReturnsTrue()
31+
{
32+
var jobDataMap = new JobDataMap
33+
{
34+
[MelodeeJobExecutionContext.ForceMode] = true
35+
};
36+
var context = CreateScheduledContext(jobDataMap);
37+
38+
var result = InvokeShouldBypassScanTimestamp(context);
39+
40+
Assert.True(result);
41+
}
42+
43+
private static IJobExecutionContext CreateScheduledContext(JobDataMap jobDataMap)
44+
{
45+
var context = new Mock<IJobExecutionContext>();
46+
context.SetupGet(x => x.Trigger).Returns(Mock.Of<ICronTrigger>());
47+
context.SetupGet(x => x.MergedJobDataMap).Returns(jobDataMap);
48+
49+
return context.Object;
50+
}
51+
52+
private static bool InvokeShouldBypassScanTimestamp(IJobExecutionContext context)
53+
{
54+
var method = typeof(LibraryInboundProcessJob).GetMethod(
55+
"ShouldBypassScanTimestamp",
56+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
57+
58+
return (bool)method!.Invoke(null, [context])!;
59+
}
60+
}

tests/Melodee.Tests.Common/Models/Extensions/FileSystemDirectoryInfoExtensionsTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using FluentAssertions;
22
using Melodee.Common.Models;
33
using Melodee.Common.Models.Extensions;
4+
using NodaTime;
45

56
namespace Melodee.Tests.Common.Models.Extensions;
67

@@ -311,6 +312,68 @@ public void AllAlbumMediaDirectories_WithMediaDirectories_ReturnsMediaDirs()
311312
mediaDirs.Should().Contain(d => d.Name == "CD2");
312313
}
313314

315+
[Fact]
316+
public void GetFileSystemDirectoryInfosToProcess_WithOldUnprocessedMediaDirectory_ReturnsDirectory()
317+
{
318+
var root = CreateTestDirectory();
319+
var albumPath = Path.Combine(root.FullName(), "Album");
320+
Directory.CreateDirectory(albumPath);
321+
var mediaPath = Path.Combine(albumPath, "song.mp3");
322+
File.WriteAllText(mediaPath, "fake media");
323+
var oldWriteTime = DateTime.UtcNow.AddDays(-10);
324+
File.SetLastWriteTimeUtc(mediaPath, oldWriteTime);
325+
Directory.SetLastWriteTimeUtc(albumPath, oldWriteTime);
326+
327+
var modifiedSince = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(-1));
328+
var directories = root.GetFileSystemDirectoryInfosToProcess(modifiedSince, SearchOption.AllDirectories).ToList();
329+
330+
directories.Should().ContainSingle(d => d.Name == "Album");
331+
}
332+
333+
[Fact]
334+
public void GetFileSystemDirectoryInfosToProcess_WithOldProcessedMediaDirectory_SkipsDirectory()
335+
{
336+
var root = CreateTestDirectory();
337+
var albumPath = Path.Combine(root.FullName(), "Album");
338+
Directory.CreateDirectory(albumPath);
339+
var mediaPath = Path.Combine(albumPath, "song.mp3");
340+
var metadataPath = Path.Combine(albumPath, Album.JsonFileName);
341+
File.WriteAllText(mediaPath, "fake media");
342+
File.WriteAllText(metadataPath, "{}");
343+
var oldWriteTime = DateTime.UtcNow.AddDays(-10);
344+
File.SetLastWriteTimeUtc(mediaPath, oldWriteTime);
345+
File.SetLastWriteTimeUtc(metadataPath, oldWriteTime);
346+
Directory.SetLastWriteTimeUtc(albumPath, oldWriteTime);
347+
348+
var modifiedSince = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(-1));
349+
var directories = root.GetFileSystemDirectoryInfosToProcess(modifiedSince, SearchOption.AllDirectories).ToList();
350+
351+
directories.Should().NotContain(d => d.Name == "Album");
352+
}
353+
354+
[Fact]
355+
public void GetFileSystemDirectoryInfosToProcess_WithModifiedMediaFileInOldProcessedDirectory_ReturnsDirectory()
356+
{
357+
var root = CreateTestDirectory();
358+
var albumPath = Path.Combine(root.FullName(), "Album");
359+
Directory.CreateDirectory(albumPath);
360+
var mediaPath = Path.Combine(albumPath, "song.mp3");
361+
var metadataPath = Path.Combine(albumPath, Album.JsonFileName);
362+
File.WriteAllText(mediaPath, "fake media");
363+
File.WriteAllText(metadataPath, "{}");
364+
var oldWriteTime = DateTime.UtcNow.AddDays(-10);
365+
var modifiedSince = DateTime.UtcNow.AddDays(-1);
366+
File.SetLastWriteTimeUtc(metadataPath, oldWriteTime);
367+
Directory.SetLastWriteTimeUtc(albumPath, oldWriteTime);
368+
File.SetLastWriteTimeUtc(mediaPath, DateTime.UtcNow);
369+
370+
var directories = root
371+
.GetFileSystemDirectoryInfosToProcess(Instant.FromDateTimeUtc(modifiedSince), SearchOption.AllDirectories)
372+
.ToList();
373+
374+
directories.Should().ContainSingle(d => d.Name == "Album");
375+
}
376+
314377
[Fact]
315378
public void GetParent_DirectoryWithParent_ReturnsParent()
316379
{

0 commit comments

Comments
 (0)