Merge p1-w2-tb-web-cli: design majors Web.Services + Web.Client + CLI

This commit is contained in:
Daniel Harvey
2026-05-17 18:19:03 -04:00
15 changed files with 97 additions and 71 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ builder.Services.AddSingleton<FileDatabase>(provider =>
// Add services // Add services
builder.Services.AddScoped<TrackRepository>(); builder.Services.AddScoped<TrackRepository>();
builder.Services.AddScoped<DeepDrftWeb.Services.TrackService>(); builder.Services.AddScoped<DeepDrftWeb.Services.ITrackService, DeepDrftWeb.Services.TrackService>();
builder.Services.AddScoped<AudioProcessor>(); builder.Services.AddScoped<AudioProcessor>();
builder.Services.AddScoped<DeepDrftContent.Services.TrackService>(); builder.Services.AddScoped<DeepDrftContent.Services.TrackService>();
builder.Services.AddScoped<CliService>(); builder.Services.AddScoped<CliService>();
+4 -18
View File
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using DeepDrftWeb.Services.Repositories;
using DeepDrftContent.Services; using DeepDrftContent.Services;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using NetBlocks.Models; using NetBlocks.Models;
using DeepDrftCli.Utils;
namespace DeepDrftCli.Services; namespace DeepDrftCli.Services;
@@ -13,18 +13,15 @@ namespace DeepDrftCli.Services;
public class CliService public class CliService
{ {
private readonly ILogger<CliService> _logger; private readonly ILogger<CliService> _logger;
private readonly TrackRepository _trackRepository; private readonly DeepDrftWeb.Services.ITrackService _webTrackService;
private readonly DeepDrftWeb.Services.TrackService _webTrackService;
private readonly DeepDrftContent.Services.TrackService _contentTrackService; private readonly DeepDrftContent.Services.TrackService _contentTrackService;
public CliService( public CliService(
ILogger<CliService> logger, ILogger<CliService> logger,
TrackRepository trackRepository, DeepDrftWeb.Services.ITrackService webTrackService,
DeepDrftWeb.Services.TrackService webTrackService,
DeepDrftContent.Services.TrackService contentTrackService) DeepDrftContent.Services.TrackService contentTrackService)
{ {
_logger = logger; _logger = logger;
_trackRepository = trackRepository;
_webTrackService = webTrackService; _webTrackService = webTrackService;
_contentTrackService = contentTrackService; _contentTrackService = contentTrackService;
} }
@@ -232,7 +229,7 @@ public class CliService
foreach (var track in tracks) foreach (var track in tracks)
{ {
Console.WriteLine($"{track.Id,-5} {TruncateString(track.TrackName, 25),-25} {TruncateString(track.Artist, 20),-20} {TruncateString(track.Album ?? "", 15),-15} {TruncateString(track.Genre ?? "", 10),-10}"); Console.WriteLine($"{track.Id,-5} {CliUtils.TruncateString(track.TrackName, 25),-25} {CliUtils.TruncateString(track.Artist, 20),-20} {CliUtils.TruncateString(track.Album ?? "", 15),-15} {CliUtils.TruncateString(track.Genre ?? "", 10),-10}");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -285,17 +282,6 @@ public class CliService
Console.WriteLine(" - Use * to indicate required fields in interactive mode"); Console.WriteLine(" - Use * to indicate required fields in interactive mode");
} }
/// <summary>
/// Truncates a string to fit within specified length
/// </summary>
private string TruncateString(string input, int maxLength)
{
if (string.IsNullOrEmpty(input))
return string.Empty;
return input.Length <= maxLength ? input : input.Substring(0, maxLength - 3) + "...";
}
/// <summary> /// <summary>
/// Prompts user for track metadata interactively /// Prompts user for track metadata interactively
/// </summary> /// </summary>
+4 -13
View File
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Terminal.Gui; using Terminal.Gui;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftCli.Utils;
namespace DeepDrftCli.Services; namespace DeepDrftCli.Services;
@@ -10,7 +11,7 @@ namespace DeepDrftCli.Services;
public class GuiService public class GuiService
{ {
private readonly ILogger<GuiService> _logger; private readonly ILogger<GuiService> _logger;
private readonly DeepDrftWeb.Services.TrackService _webTrackService; private readonly DeepDrftWeb.Services.ITrackService _webTrackService;
private readonly DeepDrftContent.Services.TrackService _contentTrackService; private readonly DeepDrftContent.Services.TrackService _contentTrackService;
// GUI Components // GUI Components
@@ -23,7 +24,7 @@ public class GuiService
public GuiService( public GuiService(
ILogger<GuiService> logger, ILogger<GuiService> logger,
DeepDrftWeb.Services.TrackService webTrackService, DeepDrftWeb.Services.ITrackService webTrackService,
DeepDrftContent.Services.TrackService contentTrackService) DeepDrftContent.Services.TrackService contentTrackService)
{ {
_logger = logger; _logger = logger;
@@ -785,7 +786,7 @@ public class GuiService
// Create display items for the list view // Create display items for the list view
var displayItems = _tracks.Select(t => var displayItems = _tracks.Select(t =>
$"{t.Id,4} │ {TruncateString(t.TrackName, 25),25} │ {TruncateString(t.Artist, 20),20} │ {TruncateString(t.Album ?? "", 15),15} │ {TruncateString(t.Genre ?? "", 10),10}" $"{t.Id,4} │ {CliUtils.TruncateString(t.TrackName, 25),25} │ {CliUtils.TruncateString(t.Artist, 20),20} │ {CliUtils.TruncateString(t.Album ?? "", 15),15} │ {CliUtils.TruncateString(t.Genre ?? "", 10),10}"
).ToArray(); ).ToArray();
_trackListView?.SetSource(displayItems); _trackListView?.SetSource(displayItems);
@@ -894,14 +895,4 @@ public class GuiService
UpdateStatus("Ready"); UpdateStatus("Ready");
} }
/// <summary>
/// Truncate string to fit display width
/// </summary>
private string TruncateString(string input, int maxLength)
{
if (string.IsNullOrEmpty(input))
return string.Empty;
return input.Length <= maxLength ? input : input.Substring(0, maxLength - 3) + "...";
}
} }
+16
View File
@@ -0,0 +1,16 @@
namespace DeepDrftCli.Utils;
internal static class CliUtils
{
/// <summary>
/// Truncates a string to fit within the specified column width,
/// appending "..." when the string is longer.
/// </summary>
internal static string TruncateString(string input, int maxLength)
{
if (string.IsNullOrEmpty(input))
return string.Empty;
return input.Length <= maxLength ? input : input.Substring(0, maxLength - 3) + "...";
}
}
+14 -4
View File
@@ -1,5 +1,4 @@
using DeepDrftContent.Services.Audio; using DeepDrftContent.Services.Audio;
using DeepDrftContent.Services.Constants;
using DeepDrftContent.Services.FileDatabase.Models; using DeepDrftContent.Services.FileDatabase.Models;
using DeepDrftContent.Middleware; using DeepDrftContent.Middleware;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -10,15 +9,23 @@ namespace DeepDrftContent.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public class TrackController : ControllerBase public class TrackController : ControllerBase
{ {
private readonly DeepDrftContent.Services.FileDatabase.Services.FileDatabase _fileDatabase; private readonly DeepDrftContent.Services.TrackService _trackService;
private readonly WavOffsetService _wavOffsetService; private readonly WavOffsetService _wavOffsetService;
private readonly ILogger<TrackController> _logger; private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
// AudioBinaryDto over the wire, not a WAV file path. TrackService.AddTrackFromWavAsync is
// file-path-oriented and not applicable here. If a file-upload flow is added in future,
// route it through TrackService instead.
private readonly DeepDrftContent.Services.FileDatabase.Services.FileDatabase _fileDatabase;
public TrackController( public TrackController(
DeepDrftContent.Services.TrackService trackService,
DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase, DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase,
WavOffsetService wavOffsetService, WavOffsetService wavOffsetService,
ILogger<TrackController> logger) ILogger<TrackController> logger)
{ {
_trackService = trackService;
_fileDatabase = fileDatabase; _fileDatabase = fileDatabase;
_wavOffsetService = wavOffsetService; _wavOffsetService = wavOffsetService;
_logger = logger; _logger = logger;
@@ -31,7 +38,7 @@ public class TrackController : ControllerBase
try try
{ {
var file = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId); var file = await _trackService.GetAudioBinaryAsync(trackId);
if (file == null) if (file == null)
{ {
_logger.LogWarning("Track not found: {TrackId}", trackId); _logger.LogWarning("Track not found: {TrackId}", trackId);
@@ -72,7 +79,10 @@ public class TrackController : ControllerBase
{ {
_logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId); _logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId);
var audioBinary = AudioBinary.From(track); var audioBinary = AudioBinary.From(track);
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary); // Direct FileDatabase write: this endpoint receives an already-processed AudioBinaryDto,
// not a WAV file, so TrackService.AddTrackFromWavAsync does not apply. See constructor comment.
var success = await _fileDatabase.RegisterResourceAsync(
DeepDrftContent.Services.Constants.VaultConstants.Tracks, trackId, audioBinary);
return success ? Ok() : BadRequest("Failed to store audio track"); return success ? Ok() : BadRequest("Failed to store audio track");
} }
} }
+4
View File
@@ -1,7 +1,9 @@
using DeepDrftContent.Services;
using DeepDrftContent.Services.Audio; using DeepDrftContent.Services.Audio;
using DeepDrftContent.Services.Constants; using DeepDrftContent.Services.Constants;
using DeepDrftContent.Services.FileDatabase.Models; using DeepDrftContent.Services.FileDatabase.Models;
using DeepDrftContent.Services.FileDatabase.Services; using DeepDrftContent.Services.FileDatabase.Services;
using DeepDrftContent.Services.Processors;
using DeepDrftContent.Models; using DeepDrftContent.Models;
namespace DeepDrftContent namespace DeepDrftContent
@@ -12,6 +14,8 @@ namespace DeepDrftContent
{ {
// Audio services // Audio services
builder.Services.AddSingleton<WavOffsetService>(); builder.Services.AddSingleton<WavOffsetService>();
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackService>();
// File Database // File Database
builder.Configuration.AddJsonFile("environment/filedatabase.json", optional: false, reloadOnChange: true); builder.Configuration.AddJsonFile("environment/filedatabase.json", optional: false, reloadOnChange: true);
@@ -4,8 +4,6 @@ namespace DeepDrftWeb.Client.Common;
public class DarkModeSettings() public class DarkModeSettings()
{ {
// public EventCallback<bool> IsDarkModeChanged { get; set; }
[PersistentState] [PersistentState]
public bool IsDarkMode public bool IsDarkMode
{ {
@@ -14,7 +12,6 @@ public class DarkModeSettings()
{ {
if (value == field) return; if (value == field) return;
field = value; field = value;
// IsDarkModeChanged.InvokeAsync(value);
} }
} = false; } = false;
} }
@@ -225,6 +225,9 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.disposePlayer", playerId); return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.disposePlayer", playerId);
} }
// TODO: The typeof(T) switch below requires updating whenever a new result type is added.
// Consider introducing a shared marker interface (e.g. IAudioResult with a static factory
// method) so InvokeJsAsync can construct the failure result generically without a type switch.
private async Task<T> InvokeJsAsync<T>(string identifier, params object[] args) private async Task<T> InvokeJsAsync<T>(string identifier, params object[] args)
{ {
try try
@@ -2,6 +2,7 @@ using DeepDrftModels.Entities;
using DeepDrftWeb.Client.Clients; using DeepDrftWeb.Client.Clients;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using NetBlocks.Models; using NetBlocks.Models;
using System.Buffers;
namespace DeepDrftWeb.Client.Services; namespace DeepDrftWeb.Client.Services;
@@ -135,29 +136,25 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
private async Task StreamAudio(TrackMediaResponse audio) private async Task StreamAudio(TrackMediaResponse audio)
{ {
const int bufferSize = 32 * 1024;
var rentedBuffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try try
{ {
const int bufferSize = 32 * 1024;
long totalBytesRead = 0; long totalBytesRead = 0;
int currentBytes; int currentBytes;
do do
{ {
var buffer = new byte[bufferSize]; currentBytes = await audio.Stream.ReadAsync(rentedBuffer, 0, bufferSize);
currentBytes = await audio.Stream.ReadAsync(buffer, 0, buffer.Length);
if (currentBytes > 0) if (currentBytes > 0)
{ {
totalBytesRead += currentBytes; totalBytesRead += currentBytes;
if (currentBytes < bufferSize) // Slice to actual bytes read before sending to interop
{ var chunk = rentedBuffer[..currentBytes];
var trimmedBuffer = new byte[currentBytes];
Array.Copy(buffer, trimmedBuffer, currentBytes);
buffer = trimmedBuffer;
}
var appendResult = await _audioInterop.AppendAudioBlockAsync(PlayerId, buffer); var appendResult = await _audioInterop.AppendAudioBlockAsync(PlayerId, chunk);
if (!appendResult.Success) if (!appendResult.Success)
{ {
throw new Exception($"Failed to append audio block: {appendResult.Error}"); throw new Exception($"Failed to append audio block: {appendResult.Error}");
@@ -191,6 +188,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
await NotifyStateChanged(); await NotifyStateChanged();
throw; throw;
} }
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
} }
public async Task TogglePlayPause() public async Task TogglePlayPause()
@@ -53,8 +53,6 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Resume AudioContext immediately on track selection (user interaction) to avoid clicks later // Resume AudioContext immediately on track selection (user interaction) to avoid clicks later
await _audioInterop.EnsureAudioContextReady(PlayerId); await _audioInterop.EnsureAudioContextReady(PlayerId);
// NotifyStateChanged();
await NotifyTrackSelected(); await NotifyTrackSelected();
await LoadTrackStreaming(track); await LoadTrackStreaming(track);
+15
View File
@@ -0,0 +1,15 @@
using DeepDrftModels.Entities;
using DeepDrftModels.Models;
using NetBlocks.Models;
namespace DeepDrftWeb.Services;
public interface ITrackService
{
Task<ResultContainer<TrackEntity?>> GetById(long id);
Task<ResultContainer<List<TrackEntity>>> GetAll();
Task<ResultContainer<PagedResult<TrackEntity>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending);
Task<ResultContainer<TrackEntity>> Create(TrackEntity newTrack);
Task<ResultContainer<TrackEntity>> Update(TrackEntity track);
Task<Result> Delete(long id);
}
@@ -26,11 +26,18 @@ public class TrackRepository
public async Task<PagedResult<TrackEntity>> GetPage(PagingParameters<TrackEntity> pageParameters) public async Task<PagedResult<TrackEntity>> GetPage(PagingParameters<TrackEntity> pageParameters)
{ {
// Two separate queries with no transaction: count and page can be momentarily inconsistent
// under concurrent writes. Acceptable — SQLite concurrency is low and the UI is read-only.
// If filtering is added, the count query must be updated to apply the same filter.
var count = await _db.Tracks.CountAsync(); var count = await _db.Tracks.CountAsync();
var page = await _db.Tracks var orderBy = pageParameters.OrderBy ?? (t => t.Id);
.OrderBy(pageParameters.OrderBy ?? (t => t.Id)) var ordered = pageParameters.IsDescending
.Skip((pageParameters.Page - 1) * pageParameters.PageSize) ? _db.Tracks.OrderByDescending(orderBy)
: _db.Tracks.OrderBy(orderBy);
var page = await ordered
.Skip(pageParameters.Skip)
.Take(pageParameters.PageSize) .Take(pageParameters.PageSize)
.ToListAsync(); .ToListAsync();
+3 -5
View File
@@ -6,10 +6,8 @@ using NetBlocks.Models;
namespace DeepDrftWeb.Services; namespace DeepDrftWeb.Services;
public class TrackService public class TrackService : ITrackService
{ {
private readonly string _sortLastAscending = Enumerable.Repeat(char.MaxValue, 64).Aggregate(string.Empty, (a, b) => a + b);
private readonly string _sortLastDescending = Enumerable.Repeat(char.MinValue.ToString(), 64).Aggregate(string.Empty, (a, b) => a + b);
private readonly TrackRepository _repository; private readonly TrackRepository _repository;
public TrackService(TrackRepository repository) public TrackService(TrackRepository repository)
@@ -65,13 +63,13 @@ public class TrackService
parameters.OrderBy = entity => entity.Artist; parameters.OrderBy = entity => entity.Artist;
break; break;
case "Album": case "Album":
parameters.OrderBy = entity => entity.Album ?? _sortLastAscending; parameters.OrderBy = entity => entity.Album ?? "";
break; break;
case "ReleaseDate": case "ReleaseDate":
parameters.OrderBy = entity => entity.ReleaseDate ?? DateOnly.MaxValue; parameters.OrderBy = entity => entity.ReleaseDate ?? DateOnly.MaxValue;
break; break;
case "Genre": case "Genre":
parameters.OrderBy = entity => entity.Genre ?? _sortLastAscending; parameters.OrderBy = entity => entity.Genre ?? "";
break; break;
} }
+2 -2
View File
@@ -10,9 +10,9 @@ namespace DeepDrftWeb.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public class TrackController : ControllerBase public class TrackController : ControllerBase
{ {
private readonly TrackService _trackService; private readonly ITrackService _trackService;
public TrackController(TrackService trackService) public TrackController(ITrackService trackService)
{ {
_trackService = trackService; _trackService = trackService;
} }
+1 -1
View File
@@ -22,7 +22,7 @@ public static class Startup
// Add Track services // Add Track services
builder.Services builder.Services
.AddScoped<TrackRepository>() .AddScoped<TrackRepository>()
.AddScoped<TrackService>(); .AddScoped<ITrackService, TrackService>();
} }
public static string GetKestrelUrl(this WebApplicationBuilder builder) public static string GetKestrelUrl(this WebApplicationBuilder builder)