Skip to content

Commit 8062d88

Browse files
committed
Merge branch 'persistence' into v4
2 parents 7bcc190 + 049b723 commit 8062d88

161 files changed

Lines changed: 18902 additions & 1588 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,5 @@ FodyWeavers.xsd
366366
.aider*
367367
install.bat
368368
.codex
369-
**/launchSettings.json
369+
.tools/
370+
**/launchSettings.json

README.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,8 @@ songs shared by only one user will be ignored.
301301

302302
<!-- sockseek-help:start(daemon) -->
303303
## Daemon / remote mode
304-
Daemon mode is the first step toward running Sockseek as a persistent Soulseek client rather than a one-shot downloader.
305-
Right now it exposes the download engine for remote CLI use; future releases may expand it with long-running client features such as sharing.
304+
Daemon mode runs Sockseek as a long-lived service with an HTTP/SignalR API,
305+
remote CLI access, and durable job, search, and transfer history.
306306

307307
Run `sockseek daemon` to start the HTTP/SignalR daemon. It uses the same config/profile system as the
308308
CLI and listens on `127.0.0.1:5030` by default.
@@ -315,6 +315,8 @@ sockseek "Artist - Title" --remote http://127.0.0.1:5030
315315
```
316316

317317
For HTTP API, SignalR, and client integration notes, see [docs/api.md](docs/api.md).
318+
Daemon setup, history, database, backup, and security are documented in
319+
[docs/daemon.md](docs/daemon.md).
318320
<!-- sockseek-help:end -->
319321

320322
<!-- sockseek-help:start(config) -->
@@ -815,13 +817,6 @@ Most used flags at a glance:
815817
--on-complete <command> Run a command when a download completes. See `--help
816818
on-complete`
817819
```
818-
#### Daemon / Remote Options
819-
```
820-
sockseek daemon Start the HTTP/SignalR daemon instead of running a download
821-
--server-ip <ip> IP/interface for the daemon HTTP API (default: 127.0.0.1)
822-
--server-port <port> Port for the daemon HTTP API (default: 5030)
823-
--remote <url> Use an existing daemon instead of running locally
824-
```
825820
#### Search Options
826821
```
827822
--fast-search Begin downloading as soon as a file satisfying the preferred
@@ -984,6 +979,34 @@ sockseek daemon Start the HTTP/SignalR daemon instead of running
984979
--relax-filtering Slightly relax file filtering in aggregate mode to include
985980
more results
986981
```
982+
#### Daemon / Remote Options
983+
```
984+
sockseek daemon Start the HTTP/SignalR daemon instead of running a download
985+
--server-ip <ip> IP/interface for the daemon HTTP API (default: 127.0.0.1)
986+
--server-port <port> Port for the daemon HTTP API (default: 5030)
987+
--remote <url> Use an existing daemon instead of running locally
988+
--data-dir <path> Directory for daemon data, including sockseek.db
989+
--no-retention Disable scheduled history retention
990+
--successful-job-retention-days <days|forever>
991+
Successful job retention (default: 90)
992+
--unsuccessful-job-retention-days <days|forever>
993+
Failed, cancelled, and interrupted job retention (default: 180)
994+
--transfer-retention-days <days|forever>
995+
Transfer history retention (default: 90)
996+
--search-result-retention-days <days|forever>
997+
Raw search-result retention (default: 30)
998+
```
999+
<!-- sockseek-help-topic:start(database) -->
1000+
#### Sockseek Database
1001+
```
1002+
sockseek database migrate Update the offline database to the current schema
1003+
sockseek database integrity Check an offline database for corruption
1004+
sockseek database backup Create and verify an offline backup
1005+
sockseek database restore Verify and restore an offline backup
1006+
--data-dir <path> Override the configured/default data directory
1007+
--backup <path> Backup destination for `backup`, source for `restore`
1008+
```
1009+
<!-- sockseek-help-topic:end -->
9871010
#### Printing & Debug Options
9881011
```
9891012
-v, --verbose Print extra debug info

Sockseek.Api/Client/SockseekApiClient.cs

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
namespace Sockseek.Api;
66

7+
public sealed record CursorPage<T>(IReadOnlyList<T> Items, string? NextCursor);
8+
public sealed record SequencePage<T>(IReadOnlyList<T> Items, long? NextSequence);
9+
public sealed record AttemptPage<T>(IReadOnlyList<T> Items, int? NextAttemptNumber);
10+
11+
public sealed record TransferHistoryFilter(
12+
Guid? JobId = null,
13+
Guid? WorkflowId = null,
14+
string? Direction = null,
15+
string? Source = null,
16+
string? State = null,
17+
string? TerminalOutcome = null,
18+
string? Username = null,
19+
DateTimeOffset? FromUtc = null,
20+
DateTimeOffset? ToUtc = null);
21+
722
/// <summary>Exception raised for daemon HTTP responses that intentionally return an API error body.</summary>
823
public sealed class SockseekApiRequestException : InvalidOperationException
924
{
@@ -85,16 +100,25 @@ public async Task<IReadOnlyList<ProfileSummaryDto>> GetProfilesAsync(Cancellatio
85100
}
86101

87102
public async Task<IReadOnlyList<JobSummaryDto>> GetJobsAsync(JobQuery query, CancellationToken ct = default)
103+
=> (await GetJobsPageAsync(query, cursor: null, limit: 100, ct)).Items;
104+
105+
public async Task<CursorPage<JobSummaryDto>> GetJobsPageAsync(
106+
JobQuery query,
107+
string? cursor = null,
108+
int limit = 100,
109+
CancellationToken ct = default)
88110
{
89111
var url = "api/jobs"
90112
+ $"?includeAll={query.IncludeAll.ToString().ToLowerInvariant()}"
91113
+ QueryPart("lifecycleState", query.LifecycleState?.ToString())
92114
+ QueryPart("terminalOutcome", query.TerminalOutcome?.ToString())
93115
+ QueryPart("skipReason", query.SkipReason?.ToString())
94116
+ QueryPart("kind", query.Kind?.ToWireString())
95-
+ QueryPart("workflowId", query.WorkflowId?.ToString());
117+
+ QueryPart("workflowId", query.WorkflowId?.ToString())
118+
+ QueryPart("cursor", cursor)
119+
+ QueryPart("limit", limit.ToString(System.Globalization.CultureInfo.InvariantCulture));
96120

97-
return await http.GetFromJsonAsync<IReadOnlyList<JobSummaryDto>>(url, jsonOptions, ct) ?? [];
121+
return await GetCursorPageAsync<JobSummaryDto>(url, ct);
98122
}
99123

100124
public async Task<JobDetailDto?> GetJobDetailAsync(Guid jobId, CancellationToken ct = default)
@@ -130,6 +154,104 @@ public async Task<IReadOnlyList<JobSummaryDto>> GetJobsAsync(JobQuery query, Can
130154
return await ReadRequiredAsync<WorkflowDetailDto>(response, ct);
131155
}
132156

157+
public async Task<CursorPage<WorkflowSummaryDto>> GetWorkflowsPageAsync(
158+
string? cursor = null,
159+
int limit = 100,
160+
CancellationToken ct = default)
161+
=> await GetCursorPageAsync<WorkflowSummaryDto>(
162+
"api/workflows?limit=" + limit.ToString(System.Globalization.CultureInfo.InvariantCulture)
163+
+ QueryPart("cursor", cursor),
164+
ct);
165+
166+
public async Task<WorkflowTreeDto?> GetWorkflowTreeAsync(Guid workflowId, CancellationToken ct = default)
167+
{
168+
using var response = await http.GetAsync($"api/workflows/{workflowId}/tree", ct);
169+
if (response.StatusCode == HttpStatusCode.NotFound)
170+
return null;
171+
await EnsureSuccessAsync(response, ct);
172+
return await ReadRequiredAsync<WorkflowTreeDto>(response, ct);
173+
}
174+
175+
public async Task<SequencePage<SearchRawResultDto>?> GetRawSearchResultsPageAsync(
176+
Guid jobId,
177+
long afterSequence = 0,
178+
int limit = 200,
179+
CancellationToken ct = default)
180+
{
181+
using var response = await http.GetAsync(
182+
$"api/jobs/{jobId}/raw?afterSequence={afterSequence}&limit={limit}", ct);
183+
if (response.StatusCode == HttpStatusCode.NotFound)
184+
return null;
185+
await EnsureSuccessAsync(response, ct);
186+
return new SequencePage<SearchRawResultDto>(
187+
await ReadRequiredAsync<IReadOnlyList<SearchRawResultDto>>(response, ct),
188+
HeaderLong(response, "X-Next-Sequence"));
189+
}
190+
191+
public async Task<CursorPage<TransferHistoryDto>> GetTransfersPageAsync(
192+
TransferHistoryFilter? query = null,
193+
string? cursor = null,
194+
int limit = 100,
195+
CancellationToken ct = default)
196+
{
197+
query ??= new TransferHistoryFilter();
198+
string url = "api/transfers?limit=" + limit.ToString(System.Globalization.CultureInfo.InvariantCulture)
199+
+ QueryPart("jobId", query.JobId?.ToString())
200+
+ QueryPart("workflowId", query.WorkflowId?.ToString())
201+
+ QueryPart("direction", query.Direction)
202+
+ QueryPart("source", query.Source)
203+
+ QueryPart("state", query.State)
204+
+ QueryPart("terminalOutcome", query.TerminalOutcome)
205+
+ QueryPart("username", query.Username)
206+
+ QueryPart("fromUtc", query.FromUtc?.ToString("O"))
207+
+ QueryPart("toUtc", query.ToUtc?.ToString("O"))
208+
+ QueryPart("cursor", cursor);
209+
return await GetCursorPageAsync<TransferHistoryDto>(url, ct);
210+
}
211+
212+
public async Task<TransferHistoryDetailDto?> GetTransferAsync(
213+
Guid transferId,
214+
int attemptLimit = 200,
215+
CancellationToken ct = default)
216+
{
217+
using var response = await http.GetAsync($"api/transfers/{transferId}?attemptLimit={attemptLimit}", ct);
218+
if (response.StatusCode == HttpStatusCode.NotFound)
219+
return null;
220+
await EnsureSuccessAsync(response, ct);
221+
return await ReadRequiredAsync<TransferHistoryDetailDto>(response, ct);
222+
}
223+
224+
public async Task<AttemptPage<TransferAttemptHistoryDto>?> GetTransferAttemptsPageAsync(
225+
Guid transferId,
226+
int afterAttemptNumber = 0,
227+
int limit = 100,
228+
CancellationToken ct = default)
229+
{
230+
using var response = await http.GetAsync(
231+
$"api/transfers/{transferId}/attempts?afterAttemptNumber={afterAttemptNumber}&limit={limit}", ct);
232+
if (response.StatusCode == HttpStatusCode.NotFound)
233+
return null;
234+
await EnsureSuccessAsync(response, ct);
235+
return new AttemptPage<TransferAttemptHistoryDto>(
236+
await ReadRequiredAsync<IReadOnlyList<TransferAttemptHistoryDto>>(response, ct),
237+
HeaderInt(response, "X-Next-Attempt-Number"));
238+
}
239+
240+
public async Task<PersistenceIntegrityResultDto> CheckPersistenceIntegrityAsync(CancellationToken ct = default)
241+
=> await PostWithoutBodyAsync<PersistenceIntegrityResultDto>("api/persistence/integrity", ct);
242+
243+
public async Task<PersistenceBackupResultDto> BackupPersistenceAsync(
244+
PersistenceBackupRequestDto request,
245+
CancellationToken ct = default)
246+
=> await PostRequiredAsync<PersistenceBackupResultDto, PersistenceBackupRequestDto>(
247+
"api/persistence/backup", request, ct);
248+
249+
public async Task<PersistenceCheckpointResultDto> CheckpointPersistenceAsync(CancellationToken ct = default)
250+
=> await PostWithoutBodyAsync<PersistenceCheckpointResultDto>("api/persistence/checkpoint", ct);
251+
252+
public async Task<PersistenceRetentionResultDto> RunPersistenceRetentionAsync(CancellationToken ct = default)
253+
=> await PostWithoutBodyAsync<PersistenceRetentionResultDto>("api/persistence/retention", ct);
254+
133255
public async Task<SearchResultSnapshotDto<FileCandidateDto>?> GetFileResultsAsync(Guid jobId, CancellationToken ct = default)
134256
{
135257
using var response = await http.GetAsync($"api/jobs/{jobId}/results/files", ct);
@@ -325,6 +447,29 @@ private async Task<JobSummaryDto> PostJobAsync<TRequest>(string url, TRequest re
325447
return await ReadRequiredAsync<TResponse>(response, ct);
326448
}
327449

450+
private async Task<TResponse> PostRequiredAsync<TResponse, TRequest>(string url, TRequest request, CancellationToken ct)
451+
{
452+
using var response = await http.PostAsJsonAsync(url, request, jsonOptions, ct);
453+
await EnsureSuccessAsync(response, ct);
454+
return await ReadRequiredAsync<TResponse>(response, ct);
455+
}
456+
457+
private async Task<TResponse> PostWithoutBodyAsync<TResponse>(string url, CancellationToken ct)
458+
{
459+
using var response = await http.PostAsync(url, content: null, ct);
460+
await EnsureSuccessAsync(response, ct);
461+
return await ReadRequiredAsync<TResponse>(response, ct);
462+
}
463+
464+
private async Task<CursorPage<T>> GetCursorPageAsync<T>(string url, CancellationToken ct)
465+
{
466+
using var response = await http.GetAsync(url, ct);
467+
await EnsureSuccessAsync(response, ct);
468+
return new CursorPage<T>(
469+
await ReadRequiredAsync<IReadOnlyList<T>>(response, ct),
470+
Header(response, "X-Next-Cursor"));
471+
}
472+
328473
private async Task<T> ReadRequiredAsync<T>(HttpResponseMessage response, CancellationToken ct)
329474
=> await response.Content.ReadFromJsonAsync<T>(jsonOptions, ct)
330475
?? throw new InvalidOperationException($"Server returned an empty {typeof(T).Name} response.");
@@ -354,6 +499,15 @@ private static async Task EnsureSuccessAsync(HttpResponseMessage response, Cance
354499
private static string QueryPart(string name, string? value)
355500
=> string.IsNullOrWhiteSpace(value) ? "" : $"&{Uri.EscapeDataString(name)}={Uri.EscapeDataString(value)}";
356501

502+
private static string? Header(HttpResponseMessage response, string name)
503+
=> response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null;
504+
505+
private static long? HeaderLong(HttpResponseMessage response, string name)
506+
=> long.TryParse(Header(response, name), out long value) ? value : null;
507+
508+
private static int? HeaderInt(HttpResponseMessage response, string name)
509+
=> int.TryParse(Header(response, name), out int value) ? value : null;
510+
357511
private static bool IsActiveLifecycle(ServerJobLifecycleState state)
358512
=> state != ServerJobLifecycleState.Terminal;
359513
}

Sockseek.Api/Client/SockseekApiJsonContext.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ namespace Sockseek.Api;
9090
[JsonSerializable(typeof(AggregateTrackCandidateDto))]
9191
[JsonSerializable(typeof(AggregateAlbumCandidateDto))]
9292
[JsonSerializable(typeof(FileAttributeDto))]
93+
[JsonSerializable(typeof(TransferHistoryDto))]
94+
[JsonSerializable(typeof(TransferAttemptHistoryDto))]
95+
[JsonSerializable(typeof(TransferHistoryDetailDto))]
96+
[JsonSerializable(typeof(IReadOnlyList<TransferHistoryDto>))]
97+
[JsonSerializable(typeof(IReadOnlyList<TransferAttemptHistoryDto>))]
98+
[JsonSerializable(typeof(PersistenceBackupRequestDto))]
99+
[JsonSerializable(typeof(PersistenceBackupResultDto))]
100+
[JsonSerializable(typeof(PersistenceIntegrityResultDto))]
101+
[JsonSerializable(typeof(PersistenceCheckpointResultDto))]
102+
[JsonSerializable(typeof(PersistenceRetentionResultDto))]
93103

94104
[JsonSerializable(typeof(DownloadSettingsPatchDto))]
95105
[JsonSerializable(typeof(OutputSettingsPatchDto))]
@@ -144,6 +154,8 @@ namespace Sockseek.Api;
144154
[JsonSerializable(typeof(IReadOnlyList<ProfileSummaryDto>))]
145155
[JsonSerializable(typeof(IReadOnlyList<ServerEventDescriptorDto>))]
146156
[JsonSerializable(typeof(IReadOnlyList<JobSummaryDto>))]
157+
[JsonSerializable(typeof(IReadOnlyList<WorkflowSummaryDto>))]
158+
[JsonSerializable(typeof(IReadOnlyList<SearchRawResultDto>))]
147159
[JsonSerializable(typeof(IReadOnlyList<ResourceActionDto>))]
148160
[JsonSerializable(typeof(IReadOnlyList<FileCandidateDto>))]
149161
[JsonSerializable(typeof(IReadOnlyList<AlbumFolderDto>))]
@@ -153,5 +165,6 @@ namespace Sockseek.Api;
153165
[JsonSerializable(typeof(IReadOnlyList<JobDraftDto>))]
154166
[JsonSerializable(typeof(IReadOnlyList<FileCandidateRefDto>))]
155167
[JsonSerializable(typeof(IReadOnlyList<string>))]
168+
[JsonSerializable(typeof(IReadOnlyList<long>))]
156169
[JsonSerializable(typeof(IReadOnlyDictionary<string, bool>))]
157170
public partial class SockseekApiJsonContext : JsonSerializerContext;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Sockseek.Api;
2+
3+
public sealed record PersistenceBackupRequestDto(string? BackupPath = null);
4+
public sealed record PersistenceBackupResultDto(
5+
string BackupPath,
6+
long SizeBytes,
7+
bool IntegrityHealthy,
8+
string IntegrityResult);
9+
public sealed record PersistenceIntegrityResultDto(bool IsHealthy, string Result);
10+
public sealed record PersistenceCheckpointResultDto(int Busy, int LogFrames, int CheckpointedFrames);
11+
public sealed record PersistenceRetentionResultDto(
12+
int PrunedJobs,
13+
int PrunedSearchResults,
14+
int SearchesMarkedPruned,
15+
long DurationMilliseconds,
16+
int PrunedTransfers = 0,
17+
int PrunedTransferAttempts = 0);

Sockseek.Api/Contracts/ServerEvents.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ public sealed record DownloadStartedEventDto(
173173
int DisplayId,
174174
Guid WorkflowId,
175175
SongQueryDto Query,
176-
FileCandidateDto Candidate);
176+
FileCandidateDto Candidate,
177+
Guid TransferId = default);
177178

178179
/// <summary>
179180
/// Coalesced progress event for an active file transfer.
@@ -182,15 +183,17 @@ public sealed record DownloadProgressEventDto(
182183
Guid JobId,
183184
Guid WorkflowId,
184185
long BytesTransferred,
185-
long TotalBytes);
186+
long TotalBytes,
187+
Guid TransferId = default);
186188

187189
/// <summary>
188190
/// Activity event carrying the lower-level transfer state.
189191
/// </summary>
190192
public sealed record DownloadStateChangedEventDto(
191193
Guid JobId,
192194
Guid WorkflowId,
193-
string State);
195+
string State,
196+
Guid TransferId = default);
194197

195198
/// <summary>
196199
/// Activity event emitted immediately when a low-level transfer attempt throws.
@@ -206,7 +209,8 @@ public sealed record DownloadAttemptFailedEventDto(
206209
int MaxAttempts,
207210
string ExceptionType,
208211
string ExceptionMessage,
209-
string Exception);
212+
string Exception,
213+
Guid TransferId = default);
210214

211215
/// <summary>
212216
/// Activity event emitted when a song job changes state.

0 commit comments

Comments
 (0)