Skip to content

Commit

Permalink
Fix scheduling, add a way to manually refresh a torrent, rework Compo…
Browse files Browse the repository at this point in the history
…siteService, improve endpoint responses further
  • Loading branch information
aannenko committed Aug 9, 2024
1 parent 078ce0f commit 2ff5f44
Show file tree
Hide file tree
Showing 15 changed files with 197 additions and 77 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Do more with Transmission!<br>

### Given
Raspberry Pi with LibreELEC 12 and Docker add-on<br>
Central European Time [time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (change it to your time zone)
Central European Time [time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (use your time zone instead)

### Steps
SSH to your LibreELEC and execute the following commands:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using static TransmissionManager.Api.Composite.Dto.AddOrUpdateTorrentResult;

namespace TransmissionManager.Api.Composite.Dto;

public readonly record struct AddOrUpdateTorrentResult(ResultType Type, long Id, string? ErrorMessage)
{
public enum ResultType
{
Add,
Update,
Error
}
}
146 changes: 105 additions & 41 deletions src/TransmissionManager.Api/Composite/Services/CompositeService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using TransmissionManager.Api.Composite.Dto;
using TransmissionManager.Api.Composite.Extensions;
using TransmissionManager.Api.Database.Abstractions;
using TransmissionManager.Api.Database.Dto;
Expand All @@ -7,6 +8,7 @@
using TransmissionManager.Api.Trackers.Services;
using TransmissionManager.Api.Transmission.Models;
using TransmissionManager.Api.Transmission.Services;
using static TransmissionManager.Api.Composite.Dto.AddOrUpdateTorrentResult;

namespace TransmissionManager.Api.Composite.Services;

Expand All @@ -21,20 +23,24 @@ public sealed class CompositeService<TTorrentService>(

private static readonly ConcurrentDictionary<long, bool> _runningNameUpdates = [];

public async Task<bool> TryAddOrUpdateTorrentAsync(
public async Task<AddOrUpdateTorrentResult> AddOrUpdateTorrentAsync(
TorrentPostRequest dto,
CancellationToken cancellationToken = default)
{
var transmissionTorrent = await GetTransmissionTorrentAsync(
dto.WebPageUri,
dto.MagnetRegexPattern,
dto.DownloadDir,
cancellationToken);
var (magnetUri, trackerError) =
await GetMagnetUriAsync(dto.WebPageUri, dto.MagnetRegexPattern, cancellationToken);

if (string.IsNullOrEmpty(magnetUri))
return new(ResultType.Error, -1, trackerError);

var (transmissionTorrent, transmissionError) =
await SendMagnetToTransmissionAsync(magnetUri, dto.DownloadDir, cancellationToken);

if (transmissionTorrent is null)
return false;
return new(ResultType.Error, -1, transmissionError);

var torrentId = torrentService.FindPage(new(1, 0, WebPageUri: dto.WebPageUri)).SingleOrDefault()?.Id;
var resultType = torrentId is null ? ResultType.Add : ResultType.Update;
TorrentUpdateDto? updateDto = null;
if (torrentId is null)
torrentId = torrentService.AddOne(dto.ToTorrentAddDto(transmissionTorrent));
Expand All @@ -46,77 +52,135 @@ public async Task<bool> TryAddOrUpdateTorrentAsync(
torrentId.Value,
updateDto ?? dto.ToTorrentUpdateDto(transmissionTorrent));

return true;
return new(resultType, torrentId.Value, null);
}

public async Task<bool> TryRefreshTorrentAsync(
public async Task<string?> RefreshTorrentAsync(
long torrentId,
CancellationToken cancellationToken = default)
{
const string error = "Refresh of the torrent with id '{0}' has failed: '{1}'.";
var torrent = torrentService.FindOneById(torrentId);

if (torrent is null)
return false;
return string.Format(error, torrentId, "No such torrent.");

var transmissionGetResponse = await transmissionClient
.GetTorrentsAsync([torrent.TransmissionId], _getNameOnlyFieldsArray, cancellationToken)
.ConfigureAwait(false);
var (transmissionGetTorrent, transmissionGetError) =
await GetTorrentFromTransmissionAsync(torrent.TransmissionId, cancellationToken);

if (transmissionGetResponse.Arguments?.Torrents?.Length is null or 0)
return false;
if (transmissionGetTorrent is null)
return string.Format(error, torrentId, transmissionGetError);

var transmissionTorrent = await GetTransmissionTorrentAsync(
torrent.WebPageUri,
torrent.MagnetRegexPattern,
torrent.DownloadDir,
cancellationToken);
var (magnetUri, trackerError) =
await GetMagnetUriAsync(torrent.WebPageUri, torrent.MagnetRegexPattern, cancellationToken);

if (transmissionTorrent is null)
return false;
if (string.IsNullOrEmpty(magnetUri))
return string.Format(error, torrentId, trackerError);

var updateDto = torrent.ToTorrentUpdateDto(transmissionTorrent);
var (transmissionAddTorrent, transmissionAddError) =
await SendMagnetToTransmissionAsync(magnetUri, torrent.DownloadDir, cancellationToken);

if (transmissionAddTorrent is null)
return string.Format(error, torrentId, transmissionAddError);

var updateDto = torrent.ToTorrentUpdateDto(transmissionAddTorrent);
if (!torrentService.TryUpdateOneById(torrent.Id, updateDto))
return false;
return string.Format(error, torrentId, "No such torrent.");

if (transmissionTorrent.HashString == transmissionTorrent.Name)
if (transmissionAddTorrent.HashString == transmissionAddTorrent.Name)
_ = UpdateTorrentNameWithRetriesAsync(torrentId, updateDto);

return true;
return null;
}

private async ValueTask<TransmissionTorrentAddResponseItem?> GetTransmissionTorrentAsync(
private async Task<(string? Magnet, string? Error)> GetMagnetUriAsync(
string webPageUri,
string? magnetRegexPattern,
CancellationToken cancellationToken)
{
string? magnetUri = null;
string error = string.Empty;
try
{
magnetUri = await magnetRetriever
.FindMagnetUriAsync(webPageUri, magnetRegexPattern, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e) when (e is HttpRequestException or ArgumentException or InvalidOperationException)
{
error = $": '{e.Message}'";
}

return magnetUri is null
? (null, $"Could not retrieve a magnet link from '{webPageUri}'{error}.")
: (magnetUri, null);
}

private async Task<(TransmissionTorrentAddResponseItem? Torrent, string? Error)> SendMagnetToTransmissionAsync(
string magnetUri,
string downloadDir,
CancellationToken cancellationToken)
{
var magnetUri = await magnetRetriever
.FindMagnetUri(webPageUri, magnetRegexPattern, cancellationToken)
.ConfigureAwait(false);
TransmissionTorrentAddResponse? transmissionResponse = null;
string error = string.Empty;
try
{
transmissionResponse = await transmissionClient
.AddTorrentUsingMagnetUriAsync(magnetUri, downloadDir, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException e)
{
error = $": '{e.Message}'";
}

var transmissionTorrent =
transmissionResponse?.Arguments?.TorrentAdded ?? transmissionResponse?.Arguments?.TorrentDuplicate;

if (transmissionTorrent is null)
error = $"Could not add a torrent to Transmission{error}.";

return (transmissionTorrent, error);
}

if (magnetUri is null)
return null;
private async Task<(TransmissionTorrentGetResponseItem? Torrent, string? Error)> GetTorrentFromTransmissionAsync(
long transmissionId,
CancellationToken cancellationToken)
{
TransmissionTorrentGetResponse? transmissionResponse = null;
string error = string.Empty;
try
{
transmissionResponse = await transmissionClient
.GetTorrentsAsync([transmissionId], cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException e)
{
error = $": '{e.Message}'";
}

var transmissionResponse = await transmissionClient
.AddTorrentMagnetAsync(magnetUri, downloadDir, cancellationToken)
.ConfigureAwait(false);
TransmissionTorrentGetResponseItem? transmissionTorrent = null;
if (transmissionResponse?.Arguments?.Torrents?.Length is 1)
transmissionTorrent = transmissionResponse.Arguments.Torrents[0];
else
error = $"Could not get a torrent with a Transmission id '{transmissionId}' from Transmission{error}.";

return transmissionResponse.Arguments?.TorrentAdded ?? transmissionResponse.Arguments?.TorrentDuplicate;
return new(transmissionTorrent, error);
}

private async Task UpdateTorrentNameWithRetriesAsync(long id, TorrentUpdateDto dto)
{
ArgumentNullException.ThrowIfNull(dto?.TransmissionId);

if (!_runningNameUpdates.TryAdd(dto.TransmissionId.Value, true))
if (!_runningNameUpdates.TryAdd(id, true))
return;

long[] singleTransmissionIdArray = [dto.TransmissionId.Value];

const int numberOfRetries = 40; // make attempts to get the name for 6 hours
for (int i = 1, delaySeconds = i * i; i <= numberOfRetries; i++)
for (int i = 1, millisecondsDelay = i * i * 1000; i <= numberOfRetries; i++)
{
await Task.Delay(TimeSpan.FromSeconds(delaySeconds)).ConfigureAwait(false);
await Task.Delay(millisecondsDelay).ConfigureAwait(false);

TransmissionTorrentGetResponse? transmissionResponse = null;
try
Expand Down Expand Up @@ -144,6 +208,6 @@ private async Task UpdateTorrentNameWithRetriesAsync(long id, TorrentUpdateDto d
}
}

_runningNameUpdates.TryRemove(dto.TransmissionId.Value, out _);
_runningNameUpdates.TryRemove(id, out _);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace TransmissionManager.Api.Endpoints.Dto;

public sealed class TorrentPutRequest
public sealed class TorrentPatchRequest
{
public long? TransmissionId { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace TransmissionManager.Api.Endpoints.Extensions;

public static class TorrentPutRequestExtensions
{
public static TorrentUpdateDto ToTorrentUpdateDto(this TorrentPutRequest dto)
public static TorrentUpdateDto ToTorrentUpdateDto(this TorrentPatchRequest dto)
{
return new()
{
Expand Down
45 changes: 35 additions & 10 deletions src/TransmissionManager.Api/Endpoints/TorrentEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using MiniValidation;
using TransmissionManager.Api.Composite.Dto;
using TransmissionManager.Api.Composite.Services;
using TransmissionManager.Api.Database.Models;
using TransmissionManager.Api.Database.Services;
Expand All @@ -11,13 +12,16 @@ namespace TransmissionManager.Api.Endpoints;

public static class TorrentEndpoints
{
private const string _torrentsApiAddress = "/api/v1/torrents";

public static IEndpointRouteBuilder MapTorrentEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/torrents");
var group = builder.MapGroup(_torrentsApiAddress);

group.MapGet("/", FindPage);
group.MapGet("/{id}", FindOneById);
group.MapPost("/", TryAddOrUpdateOneAsync);
group.MapPost("/", AddOrUpdateOneAsync);
group.MapPost("/{id}", RefreshOneByIdAsync);
group.MapPatch("/{id}", UpdateOneById);
group.MapDelete("/{id}", RemoveOneById);

Expand Down Expand Up @@ -50,33 +54,54 @@ private static Results<Ok<Torrent>, NotFound> FindOneById(
return result is not null ? TypedResults.Ok(result) : TypedResults.NotFound();
}

private static async Task<Results<Ok<bool>, ValidationProblem>> TryAddOrUpdateOneAsync(
private static async Task<Results<Created, NoContent, BadRequest<string>, ValidationProblem>> AddOrUpdateOneAsync(
[FromServices] CompositeService<SchedulableTorrentService> service,
TorrentPostRequest dto,
CancellationToken cancellationToken = default)
{
if (!MiniValidator.TryValidate(dto, out var errors))
return TypedResults.ValidationProblem(errors);

var isSuccess = await service.TryAddOrUpdateTorrentAsync(dto, cancellationToken);
return TypedResults.Ok(isSuccess);
var (resultType, id, errorMessage) = await service.AddOrUpdateTorrentAsync(dto, cancellationToken);

return resultType switch
{
AddOrUpdateTorrentResult.ResultType.Add => TypedResults.Created($"{_torrentsApiAddress}/{id}"),
AddOrUpdateTorrentResult.ResultType.Update => TypedResults.NoContent(),
_ => TypedResults.BadRequest(errorMessage)
};
}

private static async Task<Results<NoContent, BadRequest<string>>> RefreshOneByIdAsync(
[FromServices] CompositeService<TorrentService> service,
long id,
CancellationToken cancellationToken)
{
var error = await service.RefreshTorrentAsync(id, cancellationToken);
return string.IsNullOrEmpty(error)
? TypedResults.NoContent()
: TypedResults.BadRequest(error);
}

private static Results<Ok, NotFound, ValidationProblem> UpdateOneById(
private static Results<NoContent, NotFound, ValidationProblem> UpdateOneById(
[FromServices] SchedulableTorrentService service,
long id,
TorrentPutRequest dto)
TorrentPatchRequest dto)
{
if (!MiniValidator.TryValidate(dto, out var errors))
return TypedResults.ValidationProblem(errors);

return service.TryUpdateOneById(id, dto.ToTorrentUpdateDto()) ? TypedResults.Ok() : TypedResults.NotFound();
return service.TryUpdateOneById(id, dto.ToTorrentUpdateDto())
? TypedResults.NoContent()
: TypedResults.NotFound();
}

private static Results<Ok, NotFound> RemoveOneById(
private static Results<NoContent, NotFound> RemoveOneById(
[FromServices] SchedulableTorrentService service,
long id)
{
return service.TryDeleteOneById(id) ? TypedResults.Ok() : TypedResults.NotFound();
return service.TryDeleteOneById(id)
? TypedResults.NoContent()
: TypedResults.NotFound();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Coravel.Invocable;
using TransmissionManager.Api.Composite.Services;
using TransmissionManager.Api.Database.Services;

namespace TransmissionManager.Api.Scheduling.Services;

public sealed class TorrentRefreshTask(
ILogger<TorrentRefreshTask> logger,
CompositeService<TorrentService> compositeService,
long torrentId)
: IInvocable, ICancellableInvocable
{
public CancellationToken CancellationToken { get; set; }

public async Task Invoke()
{
var error = await compositeService.RefreshTorrentAsync(torrentId, CancellationToken);
if (!string.IsNullOrEmpty(error))
{
logger.LogError("Could not refresh the torrent with id '{id}' on schedule: '{error}'.", torrentId, error);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ public TorrentSchedulerService(ILogger<IScheduler> logger, IScheduler scheduler)
_scheduler.LogScheduledTaskProgress(logger);
}

[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TorrentUpdateTask))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TorrentRefreshTask))]
public void ScheduleTorrentUpdates(long torrentId, string cron)
{
_scheduler.ScheduleWithParams<TorrentUpdateTask>(torrentId)
_scheduler.ScheduleWithParams<TorrentRefreshTask>(torrentId)
.Cron(cron)
.Zoned(TimeZoneInfo.Local)
.PreventOverlapping(torrentId.ToString());
Expand Down
Loading

0 comments on commit 2ff5f44

Please sign in to comment.