Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions listenarr.application/Audiobooks/AuthorMonitoringService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using Listenarr.Application.Interfaces;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Metadata;
using Listenarr.Domain.Common;
using Listenarr.Domain.Models;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -234,7 +235,7 @@ private async Task<MonitorAuthorSyncResult> SyncAuthorInternalAsync(
{
result.Succeeded = false;
result.ErrorMessage = "Author catalog could not be loaded.";
monitoredAuthor.LastError = TruncateError(result.ErrorMessage);
monitoredAuthor.LastError = StringUtils.Truncate(result.ErrorMessage, 2048);
monitoredAuthor.LastCheckedAt = DateTime.UtcNow;
monitoredAuthor.UpdatedAt = DateTime.UtcNow;
await _authors.UpsertAsync(monitoredAuthor, cancellationToken);
Expand Down Expand Up @@ -318,7 +319,7 @@ private async Task<MonitorAuthorSyncResult> SyncAuthorInternalAsync(
result.ErrorMessage = ex.Message;
result.FailedCount++;
monitoredAuthor.LastCheckedAt = DateTime.UtcNow;
monitoredAuthor.LastError = TruncateError(ex.Message);
monitoredAuthor.LastError = StringUtils.Truncate(ex.Message, 2048);
monitoredAuthor.UpdatedAt = DateTime.UtcNow;
await _authors.UpsertAsync(monitoredAuthor, cancellationToken);
return result;
Expand Down Expand Up @@ -514,15 +515,5 @@ private static string BuildTitleAuthorKey(string? title, IEnumerable<string>? au
var match = System.Text.RegularExpressions.Regex.Match(value, "\\d{4}");
return match.Success ? match.Value : null;
}

private static string? TruncateError(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}

return value.Length <= 2048 ? value : value[..2048];
}
}
}
15 changes: 3 additions & 12 deletions listenarr.application/Audiobooks/SeriesMonitoringService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using Listenarr.Application.Interfaces;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Metadata;
using Listenarr.Domain.Common;
using Listenarr.Domain.Models;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -233,7 +234,7 @@ private async Task<MonitorSeriesSyncResult> SyncSeriesInternalAsync(
{
result.Succeeded = false;
result.ErrorMessage = "Series catalog could not be loaded.";
monitoredSeries.LastError = TruncateError(result.ErrorMessage);
monitoredSeries.LastError = StringUtils.Truncate(result.ErrorMessage, 2048);
monitoredSeries.LastCheckedAt = DateTime.UtcNow;
monitoredSeries.UpdatedAt = DateTime.UtcNow;
await _series.UpsertAsync(monitoredSeries, cancellationToken);
Expand Down Expand Up @@ -317,7 +318,7 @@ private async Task<MonitorSeriesSyncResult> SyncSeriesInternalAsync(
result.ErrorMessage = ex.Message;
result.FailedCount++;
monitoredSeries.LastCheckedAt = DateTime.UtcNow;
monitoredSeries.LastError = TruncateError(ex.Message);
monitoredSeries.LastError = StringUtils.Truncate(ex.Message, 2048);
monitoredSeries.UpdatedAt = DateTime.UtcNow;
await _series.UpsertAsync(monitoredSeries, cancellationToken);
return result;
Expand Down Expand Up @@ -513,15 +514,5 @@ private static string BuildTitleAuthorKey(string? title, IEnumerable<string>? au
var match = System.Text.RegularExpressions.Regex.Match(value, "\\d{4}");
return match.Success ? match.Value : null;
}

private static string? TruncateError(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}

return value.Length <= 2048 ? value : value[..2048];
}
}
}
4 changes: 2 additions & 2 deletions listenarr.application/Common/FileNamingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ private static HashSet<char> BuildPortableInvalidFileNameChars()
/// <summary>
/// Ensure the generated path does not exceed platform limits.
/// On Windows: total path ≤ 259 chars, each component ≤ 255 chars.
/// Truncates the longest non-root components first while preserving the file extension.
/// StringUtils.Truncates the longest non-root components first while preserving the file extension.
/// </summary>
public string EnsurePathWithinLimits(string fullPath)
{
Expand Down Expand Up @@ -553,7 +553,7 @@ public string EnsurePathWithinLimits(string fullPath)

if (result != originalPath)
{
_logger.LogWarning("Path truncated to fit Windows MAX_PATH limit ({Limit} chars). Original length: {OriginalLength}, New length: {NewLength}. Truncated path: {Path}",
_logger.LogWarning("Path truncated to fit Windows MAX_PATH limit ({Limit} chars). Original length: {OriginalLength}, New length: {NewLength}. StringUtils.Truncated path: {Path}",
WindowsMaxPath, originalPath.Length, result.Length, result);
}

Expand Down
2 changes: 0 additions & 2 deletions listenarr.application/Interfaces/IFileMover.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ public interface IFileMover
{
Task<bool> MoveDirectoryAsync(string source, string destination);

Task<bool> CopyDirectoryAsync(string source, string destination);

/// <summary>
/// Perform the given action on the given file
/// </summary>
Expand Down
67 changes: 27 additions & 40 deletions listenarr.application/Notification/NotificationPayloadBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Listenarr.Application.Common;
using Listenarr.Domain.Common;
using Microsoft.AspNetCore.Http;

namespace Listenarr.Application.Notification
Expand Down Expand Up @@ -95,16 +96,8 @@ public static JsonNode CreateDiscordPayload(string trigger, object data, string?
narrators = DecodeHtml(narrators);
description = DecodeHtml(description);

// Use centralized constants declared at class scope

static string Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value.Length <= max ? value : value.Substring(0, max);
}

var embed = new JsonObject();
if (!string.IsNullOrWhiteSpace(title)) embed["title"] = Truncate(title, MAX_TITLE);
if (!string.IsNullOrWhiteSpace(title)) embed["title"] = StringUtils.Truncate(title, MAX_TITLE);

string? absoluteImageUrl = null;
string? thumbnailUrl = null;
Expand All @@ -131,7 +124,7 @@ static string Truncate(string? value, int max)
}
else if (!string.IsNullOrWhiteSpace(absoluteImageUrl))
{
embed["thumbnail"] = new JsonObject { ["url"] = Truncate(absoluteImageUrl, 2000) };
embed["thumbnail"] = new JsonObject { ["url"] = StringUtils.Truncate(absoluteImageUrl, 2000) };
}

var embeds = new JsonArray();
Expand All @@ -140,42 +133,42 @@ static string Truncate(string? value, int max)
if (!string.IsNullOrWhiteSpace(author))
{
var fa = new JsonObject();
fa["name"] = Truncate("Author", MAX_FIELD_NAME);
fa["value"] = Truncate(author, MAX_FIELD_VALUE);
fa["name"] = StringUtils.Truncate("Author", MAX_FIELD_NAME);
fa["value"] = StringUtils.Truncate(author, MAX_FIELD_VALUE);
fa["inline"] = false;
fields.Add(fa);
}

if (!string.IsNullOrWhiteSpace(publisher))
{
var f = new JsonObject();
f["name"] = Truncate("Publisher", MAX_FIELD_NAME);
f["value"] = Truncate(publisher, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Publisher", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(publisher, MAX_FIELD_VALUE);
f["inline"] = true;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(year))
{
var f = new JsonObject();
f["name"] = Truncate("Year", MAX_FIELD_NAME);
f["value"] = Truncate(year, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Year", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(year, MAX_FIELD_VALUE);
f["inline"] = true;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(narrators))
{
var f = new JsonObject();
f["name"] = Truncate("Narrated by", MAX_FIELD_NAME);
f["value"] = Truncate(narrators, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Narrated by", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(narrators, MAX_FIELD_VALUE);
f["inline"] = false;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(description))
{
var cleanedDescription = CleanHtml(description);
var truncatedDesc = Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500));
var truncatedDesc = StringUtils.Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500));
var f = new JsonObject();
f["name"] = Truncate("Description", MAX_FIELD_NAME);
f["name"] = StringUtils.Truncate("Description", MAX_FIELD_NAME);
f["value"] = truncatedDesc;
f["inline"] = false;
fields.Add(f);
Expand Down Expand Up @@ -226,7 +219,7 @@ static string Truncate(string? value, int max)
{
int reduce = Math.Min(excess, descriptionText.Length);
descriptionText = descriptionText.Substring(0, Math.Max(0, descriptionText.Length - reduce));
e["description"] = Truncate(descriptionText, MAX_DESCRIPTION);
e["description"] = StringUtils.Truncate(descriptionText, MAX_DESCRIPTION);
excess = excess - reduce;
}

Expand All @@ -241,7 +234,7 @@ static string Truncate(string? value, int max)
{
int reduce = Math.Min(excess, v.Length);
var newVal = v.Substring(0, Math.Max(0, v.Length - reduce));
fo["value"] = Truncate(newVal, MAX_FIELD_VALUE);
fo["value"] = StringUtils.Truncate(newVal, MAX_FIELD_VALUE);
excess -= reduce;
}
}
Expand Down Expand Up @@ -312,14 +305,8 @@ static string Truncate(string? value, int max)

// Use centralized constants declared at class scope

static string Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value.Length <= max ? value : value.Substring(0, max);
}

var embed = new JsonObject();
if (!string.IsNullOrWhiteSpace(title)) embed["title"] = Truncate(title, MAX_TITLE);
if (!string.IsNullOrWhiteSpace(title)) embed["title"] = StringUtils.Truncate(title, MAX_TITLE);

string? absoluteImageUrl = null;
string? thumbnailUrl = null;
Expand Down Expand Up @@ -404,7 +391,7 @@ static string Truncate(string? value, int max)
}
else if (!string.IsNullOrWhiteSpace(absoluteImageUrl))
{
embed["thumbnail"] = new JsonObject { ["url"] = Truncate(absoluteImageUrl, 2000) };
embed["thumbnail"] = new JsonObject { ["url"] = StringUtils.Truncate(absoluteImageUrl, 2000) };
thumbnailSet = true;
}
else if (!string.IsNullOrWhiteSpace(thumbnailUrl))
Expand All @@ -424,42 +411,42 @@ static string Truncate(string? value, int max)
if (!string.IsNullOrWhiteSpace(author))
{
var fa = new JsonObject();
fa["name"] = Truncate("Author", MAX_FIELD_NAME);
fa["value"] = Truncate(author, MAX_FIELD_VALUE);
fa["name"] = StringUtils.Truncate("Author", MAX_FIELD_NAME);
fa["value"] = StringUtils.Truncate(author, MAX_FIELD_VALUE);
fa["inline"] = false;
fields.Add(fa);
}

if (!string.IsNullOrWhiteSpace(publisher))
{
var f = new JsonObject();
f["name"] = Truncate("Publisher", MAX_FIELD_NAME);
f["value"] = Truncate(publisher, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Publisher", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(publisher, MAX_FIELD_VALUE);
f["inline"] = true;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(year))
{
var f = new JsonObject();
f["name"] = Truncate("Year", MAX_FIELD_NAME);
f["value"] = Truncate(year, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Year", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(year, MAX_FIELD_VALUE);
f["inline"] = true;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(narrators))
{
var f = new JsonObject();
f["name"] = Truncate("Narrated by", MAX_FIELD_NAME);
f["value"] = Truncate(narrators, MAX_FIELD_VALUE);
f["name"] = StringUtils.Truncate("Narrated by", MAX_FIELD_NAME);
f["value"] = StringUtils.Truncate(narrators, MAX_FIELD_VALUE);
f["inline"] = false;
fields.Add(f);
}
if (!string.IsNullOrWhiteSpace(description))
{
var cleanedDescription = CleanHtml(description);
var truncatedDesc = Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500));
var truncatedDesc = StringUtils.Truncate(cleanedDescription, Math.Min(MAX_FIELD_VALUE, 500));
var f = new JsonObject();
f["name"] = Truncate("Description", MAX_FIELD_NAME);
f["name"] = StringUtils.Truncate("Description", MAX_FIELD_NAME);
f["value"] = truncatedDesc;
f["inline"] = false;
fields.Add(f);
Expand Down
45 changes: 27 additions & 18 deletions listenarr.domain/Common/FileUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public sealed record AudioMatchProfile(
".wv", ".wma", ".ape", ".alac", ".aif", ".aiff"
};

private static StringComparison GetStringComparison()
{
return OperatingSystem.IsLinux()
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
}

/// <summary>
/// Returns true when the file path has a recognized audio extension.
/// </summary>
Expand Down Expand Up @@ -375,27 +382,29 @@ public static string AppendSequenceSuffix(string desiredPath, int sequenceNumber
/// <summary>
/// Returns true if the given childPath (either file or directory) is inside of the parentPath
/// </summary>
/// <param name="childPath">Path to test</param>
/// <param name="parentPath">Supposed parent path</param>
/// <returns>True when childPath is inside parentPath</returns>
public static bool IsPathInsideOf(string childPath, string parentPath)
/// <param name="a">Path to test</param>
/// <param name="b">Supposed parent path</param>
/// <returns>True when childPath is inside parentPath or equal to it</returns>
public static bool IsPathInsideOf(string a, string b)
{
try
{
if (string.IsNullOrWhiteSpace(childPath) || string.IsNullOrWhiteSpace(parentPath))
return false;
a = EnsureTrailingSeparator(NormalizeStoredPath(a));
b = EnsureTrailingSeparator(NormalizeStoredPath(b));

var normalizedChild = NormalizeStoredPath(childPath);
var normalizedRoot = NormalizeStoredPath(parentPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
return !IsSameDirectory(a, b) && a.StartsWith(b, GetStringComparison());
}

return normalizedChild.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
}
catch (Exception caughtEx_3) when (caughtEx_3 is not OperationCanceledException && caughtEx_3 is not OutOfMemoryException && caughtEx_3 is not StackOverflowException)
{
return false;
}
/// <summary>
/// Checks given directories are the same or not
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns>true when they are the same directory</returns>
public static bool IsSameDirectory(string a, string b)
{
a = EnsureTrailingSeparator(NormalizeStoredPath(a));
b = EnsureTrailingSeparator(NormalizeStoredPath(b));

return string.Equals(a, b, GetStringComparison());
}

public static string? GetCommonDirectory(IEnumerable<string> paths)
Expand Down
7 changes: 7 additions & 0 deletions listenarr.domain/Common/StringUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,12 @@ public static int LevenshteinDistance(string s, string t)
}
return d[n, m];
}

public static string Truncate(string? s, int max)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
if (s.Length <= max) return s;
return s.Substring(0, max - 3) + "...";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
using Listenarr.Infrastructure.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Listenarr.Infrastructure.Extensions
{
Expand Down Expand Up @@ -102,6 +103,8 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection
services.AddScoped<IArchiveExtractor, ArchiveExtractor>();
// Bind FileMover options from configuration (optional)
services.Configure<FileMoverOptions>(config.GetSection("FileMover"));
services.AddSingleton(resolver =>
resolver.GetRequiredService<IOptions<FileMoverOptions>>().Value);
// Gateway that wraps adapters for higher-level orchestration
services.AddScoped<IDownloadClientGateway, DownloadClientGateway>();
// Process runner for external process execution (robocopy, ffprobe, playwright installer)
Expand Down
Loading
Loading