diff --git a/octo-fiesta/Middleware/GlobalExceptionHandler.cs b/octo-fiesta/Middleware/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..3b01cce
--- /dev/null
+++ b/octo-fiesta/Middleware/GlobalExceptionHandler.cs
@@ -0,0 +1,88 @@
+using Microsoft.AspNetCore.Diagnostics;
+
+namespace octo_fiesta.Middleware;
+
+///
+/// Global exception handler that catches unhandled exceptions and returns appropriate Subsonic API error responses
+///
+public class GlobalExceptionHandler : IExceptionHandler
+{
+ private readonly ILogger _logger;
+
+ public GlobalExceptionHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogError(exception, "Unhandled exception occurred: {Message}", exception.Message);
+
+ var (statusCode, subsonicErrorCode, errorMessage) = MapExceptionToResponse(exception);
+
+ httpContext.Response.StatusCode = statusCode;
+ httpContext.Response.ContentType = "application/json";
+
+ var response = CreateSubsonicErrorResponse(subsonicErrorCode, errorMessage);
+ await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
+
+ return true;
+ }
+
+ ///
+ /// Maps exception types to HTTP status codes and Subsonic error codes
+ ///
+ private (int statusCode, int subsonicErrorCode, string message) MapExceptionToResponse(Exception exception)
+ {
+ return exception switch
+ {
+ // Not Found errors (404)
+ FileNotFoundException => (404, 70, "Resource not found"),
+ DirectoryNotFoundException => (404, 70, "Directory not found"),
+
+ // Authentication errors (401)
+ UnauthorizedAccessException => (401, 40, "Wrong username or password"),
+
+ // Bad Request errors (400)
+ ArgumentNullException => (400, 10, "Required parameter is missing"),
+ ArgumentException => (400, 10, "Invalid request"),
+ FormatException => (400, 10, "Invalid format"),
+ InvalidOperationException => (400, 10, "Operation not valid"),
+
+ // External service errors (502)
+ HttpRequestException => (502, 0, "External service unavailable"),
+ TimeoutException => (504, 0, "Request timeout"),
+
+ // Generic server error (500)
+ _ => (500, 0, "An internal server error occurred")
+ };
+ }
+
+ ///
+ /// Creates a Subsonic-compatible error response
+ /// Subsonic error codes:
+ /// 0 = Generic error
+ /// 10 = Required parameter missing
+ /// 20 = Incompatible Subsonic REST protocol version
+ /// 30 = Incompatible Subsonic REST protocol version (server)
+ /// 40 = Wrong username or password
+ /// 50 = User not authorized
+ /// 60 = Trial period for the Subsonic server is over
+ /// 70 = Requested data was not found
+ ///
+ private object CreateSubsonicErrorResponse(int code, string message)
+ {
+ return new Dictionary
+ {
+ ["subsonic-response"] = new
+ {
+ status = "failed",
+ version = "1.16.1",
+ error = new { code, message }
+ }
+ };
+ }
+}
diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs
index a6fc2d9..c19611a 100644
--- a/octo-fiesta/Program.cs
+++ b/octo-fiesta/Program.cs
@@ -4,6 +4,7 @@ using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Qobuz;
using octo_fiesta.Services.Local;
using octo_fiesta.Services.Validation;
+using octo_fiesta.Middleware;
var builder = WebApplication.CreateBuilder(args);
@@ -14,6 +15,10 @@ builder.Services.AddHttpClient();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
+// Exception handling
+builder.Services.AddExceptionHandler();
+builder.Services.AddProblemDetails();
+
// Configuration
builder.Services.Configure(
builder.Configuration.GetSection("Subsonic"));
@@ -66,6 +71,8 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Configure the HTTP request pipeline.
+app.UseExceptionHandler(_ => { }); // Global exception handler
+
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
diff --git a/octo-fiesta/Services/Common/Error.cs b/octo-fiesta/Services/Common/Error.cs
new file mode 100644
index 0000000..aa89217
--- /dev/null
+++ b/octo-fiesta/Services/Common/Error.cs
@@ -0,0 +1,140 @@
+namespace octo_fiesta.Services.Common;
+
+///
+/// Represents a typed error with code, message, and metadata
+///
+public class Error
+{
+ ///
+ /// Unique error code identifier
+ ///
+ public string Code { get; }
+
+ ///
+ /// Human-readable error message
+ ///
+ public string Message { get; }
+
+ ///
+ /// Error type/category
+ ///
+ public ErrorType Type { get; }
+
+ ///
+ /// Additional metadata about the error
+ ///
+ public Dictionary? Metadata { get; }
+
+ private Error(string code, string message, ErrorType type, Dictionary? metadata = null)
+ {
+ Code = code;
+ Message = message;
+ Type = type;
+ Metadata = metadata;
+ }
+
+ ///
+ /// Creates a Not Found error (404)
+ ///
+ public static Error NotFound(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "NOT_FOUND", message, ErrorType.NotFound, metadata);
+ }
+
+ ///
+ /// Creates a Validation error (400)
+ ///
+ public static Error Validation(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "VALIDATION_ERROR", message, ErrorType.Validation, metadata);
+ }
+
+ ///
+ /// Creates an Unauthorized error (401)
+ ///
+ public static Error Unauthorized(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "UNAUTHORIZED", message, ErrorType.Unauthorized, metadata);
+ }
+
+ ///
+ /// Creates a Forbidden error (403)
+ ///
+ public static Error Forbidden(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "FORBIDDEN", message, ErrorType.Forbidden, metadata);
+ }
+
+ ///
+ /// Creates a Conflict error (409)
+ ///
+ public static Error Conflict(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "CONFLICT", message, ErrorType.Conflict, metadata);
+ }
+
+ ///
+ /// Creates an Internal Server Error (500)
+ ///
+ public static Error Internal(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "INTERNAL_ERROR", message, ErrorType.Internal, metadata);
+ }
+
+ ///
+ /// Creates an External Service Error (502/503)
+ ///
+ public static Error ExternalService(string message, string? code = null, Dictionary? metadata = null)
+ {
+ return new Error(code ?? "EXTERNAL_SERVICE_ERROR", message, ErrorType.ExternalService, metadata);
+ }
+
+ ///
+ /// Creates a custom error with specified type
+ ///
+ public static Error Custom(string code, string message, ErrorType type, Dictionary? metadata = null)
+ {
+ return new Error(code, message, type, metadata);
+ }
+}
+
+///
+/// Categorizes error types for appropriate HTTP status code mapping
+///
+public enum ErrorType
+{
+ ///
+ /// Validation error (400 Bad Request)
+ ///
+ Validation,
+
+ ///
+ /// Resource not found (404 Not Found)
+ ///
+ NotFound,
+
+ ///
+ /// Authentication required (401 Unauthorized)
+ ///
+ Unauthorized,
+
+ ///
+ /// Insufficient permissions (403 Forbidden)
+ ///
+ Forbidden,
+
+ ///
+ /// Resource conflict (409 Conflict)
+ ///
+ Conflict,
+
+ ///
+ /// Internal server error (500 Internal Server Error)
+ ///
+ Internal,
+
+ ///
+ /// External service error (502 Bad Gateway / 503 Service Unavailable)
+ ///
+ ExternalService
+}
diff --git a/octo-fiesta/Services/Common/Result.cs b/octo-fiesta/Services/Common/Result.cs
new file mode 100644
index 0000000..178f758
--- /dev/null
+++ b/octo-fiesta/Services/Common/Result.cs
@@ -0,0 +1,99 @@
+namespace octo_fiesta.Services.Common;
+
+///
+/// Represents the result of an operation that can either succeed with a value or fail with an error.
+/// This pattern allows explicit error handling without using exceptions for control flow.
+///
+/// The type of the value returned on success
+public class Result
+{
+ ///
+ /// Indicates whether the operation succeeded
+ ///
+ public bool IsSuccess { get; }
+
+ ///
+ /// Indicates whether the operation failed
+ ///
+ public bool IsFailure => !IsSuccess;
+
+ ///
+ /// The value returned on success (null if failed)
+ ///
+ public T? Value { get; }
+
+ ///
+ /// The error that occurred on failure (null if succeeded)
+ ///
+ public Error? Error { get; }
+
+ private Result(bool isSuccess, T? value, Error? error)
+ {
+ IsSuccess = isSuccess;
+ Value = value;
+ Error = error;
+ }
+
+ ///
+ /// Creates a successful result with a value
+ ///
+ public static Result Success(T value)
+ {
+ return new Result(true, value, null);
+ }
+
+ ///
+ /// Creates a failed result with an error
+ ///
+ public static Result Failure(Error error)
+ {
+ return new Result(false, default, error);
+ }
+
+ ///
+ /// Implicit conversion from T to Result<T> for convenience
+ ///
+ public static implicit operator Result(T value)
+ {
+ return Success(value);
+ }
+
+ ///
+ /// Implicit conversion from Error to Result<T> for convenience
+ ///
+ public static implicit operator Result(Error error)
+ {
+ return Failure(error);
+ }
+}
+
+///
+/// Non-generic Result for operations that don't return a value
+///
+public class Result
+{
+ public bool IsSuccess { get; }
+ public bool IsFailure => !IsSuccess;
+ public Error? Error { get; }
+
+ private Result(bool isSuccess, Error? error)
+ {
+ IsSuccess = isSuccess;
+ Error = error;
+ }
+
+ public static Result Success()
+ {
+ return new Result(true, null);
+ }
+
+ public static Result Failure(Error error)
+ {
+ return new Result(false, error);
+ }
+
+ public static implicit operator Result(Error error)
+ {
+ return Failure(error);
+ }
+}