Skip to content

Commit 4977287

Browse files
author
Meyn
committed
Implement retry for failed files in Slskd
1 parent 11ee76f commit 4977287

File tree

11 files changed

+177
-54
lines changed

11 files changed

+177
-54
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ Tubifarry supports **Slskd**, the Soulseek client, as both an **indexer** and **
6161

6262
---
6363

64-
### YouTube Downloader Setup 🎥
64+
### YouTube Downloader Setup 🎥
65+
> #### YouTube Warning ⚠️
66+
> Please be aware that YouTube often blocks Tubifarry as a bot. We are currently waiting for external updates. Logging in and the YouTube-only indexer are disabled for now. If login is necessary, please revert to versions earlier than 1.6.0. We appreciate your patience and understanding during this time.
67+
6568
Tubifarry allows you to download music directly from YouTube. Follow the steps below to configure the YouTube downloader.
6669

6770
#### **Configure the Indexer**:

Tubifarry/Core/Model/AlbumData.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ namespace Tubifarry.Core.Model
1010
/// </summary>
1111
public class AlbumData
1212
{
13+
public string? Guid { get; set; }
14+
1315
public string IndexerName { get; }
1416
// Mixed
1517
public string AlbumId { get; set; } = string.Empty;
@@ -34,6 +36,7 @@ public class AlbumData
3436
// Soulseek
3537
public long? Size { get; set; }
3638
public int Priotity { get; set; }
39+
public string? ExtraInfo { get; set; }
3740

3841
// Not used
3942
public AudioFormat Codec { get; set; } = AudioFormat.AAC;
@@ -45,7 +48,7 @@ public class AlbumData
4548
/// </summary>
4649
public ReleaseInfo ToReleaseInfo() => new()
4750
{
48-
Guid = $"{IndexerName}-{AlbumId}-{Bitrate}",
51+
Guid = Guid ?? $"{IndexerName}-{AlbumId}-{Bitrate}",
4952
Artist = ArtistName,
5053
Album = AlbumName,
5154
DownloadUrl = AlbumId,
@@ -92,12 +95,15 @@ private string ConstructTitle()
9295
calculatedBitrate = (int)(Size.Value * 8 / (Duration * 1000));
9396

9497
if (AudioFormatHelper.IsLossyFormat(Codec) && calculatedBitrate != 0)
95-
title += $" [{Codec} {calculatedBitrate}kbps] [WEB]";
96-
if (!AudioFormatHelper.IsLossyFormat(Codec) && BitDepth != 0)
97-
title += $" [{Codec} {BitDepth}bit] [WEB]";
98+
title += $" [{Codec} {calculatedBitrate}kbps]";
99+
else if (!AudioFormatHelper.IsLossyFormat(Codec) && BitDepth != 0)
100+
title += $" [{Codec} {BitDepth}bit]";
98101
else
99-
title += $" [{Codec}] [WEB]";
102+
title += $" [{Codec}]";
103+
if (ExtraInfo != null)
104+
title += $" [{ExtraInfo}]";
100105

106+
title += " [WEB]";
101107
return title;
102108
}
103109

Tubifarry/Download/Clients/Soulseek/SlskdClient.cs

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,71 @@ public SlskdClient(IHttpClient httpClient, IConfigService configService, IDiskPr
3232
public override async Task<string> Download(RemoteAlbum remoteAlbum, IIndexer indexer)
3333
{
3434
SlskdDownloadItem item = new(remoteAlbum);
35-
HttpRequest request = BuildHttpRequest(remoteAlbum.Release.DownloadUrl, HttpMethod.Post, remoteAlbum.Release.Source);
36-
HttpResponse response = await _httpClient.ExecuteAsync(request);
35+
try
36+
{
37+
HttpRequest request = BuildHttpRequest(remoteAlbum.Release.DownloadUrl, HttpMethod.Post, remoteAlbum.Release.Source);
38+
HttpResponse response = await _httpClient.ExecuteAsync(request);
3739

38-
if (response.StatusCode != HttpStatusCode.Created)
39-
throw new DownloadClientException("Failed to create download.");
40-
if (Settings.UseLRCLIB)
40+
if (response.StatusCode != HttpStatusCode.Created)
41+
throw new DownloadClientException("Failed to create download.");
4142
item.FileStateChanged += FileStateChanged;
42-
AddDownloadItem(item);
43+
AddDownloadItem(item);
44+
}
45+
catch (Exception)
46+
{
47+
try
48+
{
49+
RemoveItemAsync(item).Wait();
50+
}
51+
catch (Exception) { }
52+
return null!;
53+
}
4354
return item.ID.ToString();
4455
}
4556

46-
private void FileStateChanged(object? sender, SlskdDownloadFile file)
57+
private void FileStateChanged(object? sender, SlskdFileState fileState)
4758
{
48-
string filename = file.Filename;
59+
fileState.UpdateMaxRetryCount(Settings.RetryAttempts);
60+
string filename = fileState.File.Filename;
4961
string extension = Path.GetExtension(filename);
5062
AudioFormat format = AudioFormatHelper.GetAudioCodecFromExtension(extension.TrimStart('.'));
63+
if (fileState.GetStatus() == DownloadItemStatus.Warning)
64+
{
65+
_logger.Trace($"Retrying download for file: {filename}. Attempt {fileState.RetryCount} of {fileState.MaxRetryCount}");
66+
_ = RetryDownloadAsync(fileState, (SlskdDownloadItem)sender!);
67+
return;
68+
}
5169

52-
if (file.GetStatus() != DownloadItemStatus.Completed || format == AudioFormat.Unknown)
70+
if (fileState.GetStatus() != DownloadItemStatus.Completed || format == AudioFormat.Unknown)
5371
return;
54-
PostProcess((SlskdDownloadItem)sender!, file);
72+
if (Settings.UseLRCLIB)
73+
PostProcess((SlskdDownloadItem)sender!, fileState.File);
74+
}
75+
76+
private async Task RetryDownloadAsync(SlskdFileState fileState, SlskdDownloadItem item)
77+
{
78+
try
79+
{
80+
using JsonDocument doc = JsonDocument.Parse(item.RemoteAlbum.Release.Source);
81+
JsonElement root = doc.RootElement;
82+
JsonElement matchingItem = root.EnumerateArray()
83+
.FirstOrDefault(x => x.GetProperty("Filename").GetString() == fileState.File.Filename);
84+
85+
if (matchingItem.ValueKind == JsonValueKind.Undefined)
86+
return;
87+
string payload = JsonSerializer.Serialize(new[] { matchingItem });
88+
89+
HttpRequest request = BuildHttpRequest(item.RemoteAlbum.Release.DownloadUrl, HttpMethod.Post, payload);
90+
HttpResponse response = await _httpClient.ExecuteAsync(request);
91+
92+
if (response.StatusCode == HttpStatusCode.Created)
93+
_logger.Trace($"Successfully retried download for file: {fileState.File.Filename}");
94+
}
95+
catch (Exception ex)
96+
{
97+
_logger.Error(ex, $"Failed to retry download for file: {fileState.File.Filename}");
98+
}
99+
fileState.IncrementAttempt();
55100
}
56101

57102
private void PostProcess(SlskdDownloadItem item, SlskdDownloadFile file) => item.PostProcessTasks.Add(Task.Run(async () =>
@@ -111,8 +156,13 @@ private async Task UpdateDownloadItemsAsync()
111156
foreach (SlskdDownloadDirectory dir in data)
112157
{
113158
HashCode hash = new();
114-
foreach (SlskdDownloadFile file in dir.Files ?? new List<SlskdDownloadFile>())
115-
hash.Add(file.Filename);
159+
List<string> sortedFilenames = dir.Files?
160+
.Select(file => file.Filename)
161+
.OrderBy(filename => filename)
162+
.ToList() ?? new List<string>();
163+
164+
foreach (string? filename in sortedFilenames)
165+
hash.Add(filename);
116166
SlskdDownloadItem? item = GetDownloadItem(hash.ToHashCode());
117167
if (item == null)
118168
continue;

Tubifarry/Download/Clients/Soulseek/SlskdModels.cs

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ public class SlskdDownloadItem
1919
public string? Username { get; set; }
2020
public RemoteAlbum RemoteAlbum { get; set; }
2121

22-
public event EventHandler<SlskdDownloadFile>? FileStateChanged;
22+
public event EventHandler<SlskdFileState>? FileStateChanged;
2323

2424
private Logger _logger;
2525

2626
private SlskdDownloadDirectory? _slskdDownloadDirectory;
27-
private Dictionary<string, string> _previousFileStates = new();
27+
private Dictionary<string, SlskdFileState> _previousFileStates = new();
2828

2929
public List<Task> PostProcessTasks { get; } = new();
3030

@@ -35,7 +35,7 @@ public SlskdDownloadDirectory? SlskdDownloadDirectory
3535
{
3636
if (_slskdDownloadDirectory == value)
3737
return;
38-
CompareFileStates(_slskdDownloadDirectory, value);
38+
CompareFileStates(value);
3939
_slskdDownloadDirectory = value;
4040
}
4141
}
@@ -48,21 +48,34 @@ public SlskdDownloadItem(RemoteAlbum remoteAlbum)
4848
_lastUpdateTime = DateTime.UtcNow;
4949
_lastDownloadedSize = 0;
5050
HashCode hash = new();
51-
foreach (SlskdFileData file in FileData)
52-
hash.Add(file.Filename);
51+
List<string?> sortedFilenames = FileData
52+
.Select(file => file.Filename)
53+
.OrderBy(filename => filename)
54+
.ToList();
55+
foreach (string? filename in sortedFilenames)
56+
hash.Add(filename);
5357
ID = hash.ToHashCode();
5458
_downloadClientItem = new() { DownloadId = ID.ToString(), CanBeRemoved = true, CanMoveFiles = true };
5559
}
5660

57-
private void CompareFileStates(SlskdDownloadDirectory? previousDirectory, SlskdDownloadDirectory? newDirectory)
61+
private void CompareFileStates(SlskdDownloadDirectory? newDirectory)
5862
{
5963
if (newDirectory?.Files == null)
6064
return;
6165

6266
foreach (SlskdDownloadFile file in newDirectory.Files)
63-
if (_previousFileStates.TryGetValue(file.Id, out string? previousState) && previousState != file.State)
64-
FileStateChanged?.Invoke(this, file);
65-
_previousFileStates = newDirectory.Files.ToDictionary(file => file.Id, file => file.State);
67+
{
68+
if (_previousFileStates.TryGetValue(file.Filename, out SlskdFileState? fileState) && fileState != null)
69+
{
70+
fileState.UpdateFile(file);
71+
if (fileState.State != fileState.PreviousState)
72+
FileStateChanged?.Invoke(this, fileState);
73+
}
74+
else
75+
_previousFileStates.Add(file.Filename, new(file));
76+
77+
78+
}
6679
}
6780

6881
public OsPath GetFullFolderPath(string downloadPath) => new(Path.Combine(downloadPath, SlskdDownloadDirectory?.Directory
@@ -92,17 +105,17 @@ public DownloadClientItem GetDownloadClientItem(string downloadPath, TimeSpan? t
92105
_lastUpdateTime = now;
93106
_lastDownloadedSize = downloadedSize;
94107

95-
List<DownloadItemStatus> fileStatuses = SlskdDownloadDirectory.Files.Select(file => file.GetStatus()).ToList();
96-
List<string> failedFiles = SlskdDownloadDirectory.Files
108+
List<DownloadItemStatus> fileStatuses = _previousFileStates.Values.Select(file => file.GetStatus()).ToList();
109+
List<string> failedFiles = _previousFileStates.Values
97110
.Where(file => file.GetStatus() == DownloadItemStatus.Failed)
98-
.Select(file => Path.GetFileName(file.Filename)).ToList();
111+
.Select(file => Path.GetFileName(file.File.Filename)).ToList();
99112

100113
DownloadItemStatus status = DownloadItemStatus.Queued;
101114
DateTime lastTime = SlskdDownloadDirectory.Files.Max(x => x.EnqueuedAt > x.StartedAt ? x.EnqueuedAt : x.StartedAt + x.ElapsedTime);
102115

103116
if (now - lastTime > timeout)
104117
status = DownloadItemStatus.Failed;
105-
else if ((double)failedFiles.Count / fileStatuses.Count * 100 > 10)
118+
else if ((double)failedFiles.Count / fileStatuses.Count * 100 > 20)
106119
{
107120
status = DownloadItemStatus.Failed;
108121
_downloadClientItem.Message = $"Downloading {failedFiles.Count} files failed: {string.Join(", ", failedFiles)}";
@@ -121,10 +134,13 @@ public DownloadClientItem GetDownloadClientItem(string downloadPath, TimeSpan? t
121134
}
122135
else if (fileStatuses.Any(status => status == DownloadItemStatus.Paused))
123136
status = DownloadItemStatus.Paused;
124-
else if (fileStatuses.Any(status => status == DownloadItemStatus.Downloading))
125-
status = DownloadItemStatus.Downloading;
126137
else if (fileStatuses.Any(status => status == DownloadItemStatus.Warning))
138+
{
139+
_downloadClientItem.Message = "Some files failed. Retrying download...";
127140
status = DownloadItemStatus.Warning;
141+
}
142+
else if (fileStatuses.Any(status => status == DownloadItemStatus.Downloading))
143+
status = DownloadItemStatus.Downloading;
128144

129145
// Update DownloadClientItem
130146
_downloadClientItem.TotalSize = totalSize;
@@ -136,6 +152,58 @@ public DownloadClientItem GetDownloadClientItem(string downloadPath, TimeSpan? t
136152
}
137153
}
138154

155+
public class SlskdFileState
156+
{
157+
public SlskdDownloadFile File { get; private set; } = null!;
158+
public int RetryCount { get; private set; }
159+
private bool _retried = true;
160+
public int MaxRetryCount { get; private set; } = 1;
161+
public string State => File.State;
162+
public string PreviousState { get; private set; } = "Requested";
163+
164+
public DownloadItemStatus GetStatus()
165+
{
166+
DownloadItemStatus status = GetStatus(State);
167+
if ((status == DownloadItemStatus.Failed && RetryCount < MaxRetryCount) || _retried)
168+
return DownloadItemStatus.Warning;
169+
return status;
170+
}
171+
172+
private static DownloadItemStatus GetStatus(string state) => state switch
173+
{
174+
"Requested" => DownloadItemStatus.Queued, // "Requested" is treated as "Queued"
175+
"Queued, Remotely" or "Queued, Locally" => DownloadItemStatus.Queued, // Both are queued states
176+
"Initializing" => DownloadItemStatus.Queued, // "Initializing" is treated as "Queued"
177+
"InProgress" => DownloadItemStatus.Downloading, // "InProgress" maps to "Downloading"
178+
"Completed, Succeeded" => DownloadItemStatus.Completed, // Successful completion
179+
"Completed, Cancelled" => DownloadItemStatus.Failed, // Cancelled is treated as "Failed"
180+
"Completed, TimedOut" => DownloadItemStatus.Failed, // Timed out is treated as "Failed"
181+
"Completed, Errored" => DownloadItemStatus.Failed, // Errored is treated as "Failed"
182+
"Completed, Rejected" => DownloadItemStatus.Failed, // Rejected is treated as "Failed"
183+
_ => DownloadItemStatus.Queued // Default to "Queued" for unknown states
184+
};
185+
186+
public SlskdFileState(SlskdDownloadFile file) => UpdateFile(file);
187+
188+
public void UpdateFile(SlskdDownloadFile file)
189+
{
190+
if (!_retried)
191+
PreviousState = State;
192+
else if (File != null && GetStatus(file.State) == DownloadItemStatus.Failed)
193+
PreviousState = "Requested";
194+
File = file;
195+
_retried = false;
196+
}
197+
198+
public void UpdateMaxRetryCount(int maxRetryCount) => MaxRetryCount = maxRetryCount;
199+
200+
public void IncrementAttempt()
201+
{
202+
_retried = true;
203+
RetryCount++;
204+
}
205+
}
206+
139207
public record SlskdDownloadDirectory(string Directory, int FileCount, List<SlskdDownloadFile>? Files)
140208
{
141209
public static IEnumerable<SlskdDownloadDirectory> GetDirectories(JsonElement directoriesElement)
@@ -172,22 +240,8 @@ public record SlskdDownloadFile(
172240
double PercentComplete,
173241
TimeSpan RemainingTime,
174242
TimeSpan? EndedAt
175-
)
243+
)
176244
{
177-
public DownloadItemStatus GetStatus() => State switch
178-
{
179-
"Requested" => DownloadItemStatus.Queued, // "Requested" is treated as "Queued"
180-
"Queued, Remotely" or "Queued, Locally" => DownloadItemStatus.Queued, // Both are queued states
181-
"Initializing" => DownloadItemStatus.Queued, // "Initializing" is treated as "Queued"
182-
"InProgress" => DownloadItemStatus.Downloading, // "InProgress" maps to "Downloading"
183-
"Completed, Succeeded" => DownloadItemStatus.Completed, // Successful completion
184-
"Completed, Cancelled" => DownloadItemStatus.Failed, // Cancelled is treated as "Failed"
185-
"Completed, TimedOut" => DownloadItemStatus.Failed, // Timed out is treated as "Failed"
186-
"Completed, Errored" => DownloadItemStatus.Failed, // Errored is treated as "Failed"
187-
"Completed, Rejected" => DownloadItemStatus.Failed, // Rejected is treated as "Failed"
188-
_ => DownloadItemStatus.Queued // Default to "Queued" for unknown states
189-
};
190-
191245
public static IEnumerable<SlskdDownloadFile> GetFiles(JsonElement filesElement)
192246
{
193247
if (filesElement.ValueKind != JsonValueKind.Array)

Tubifarry/Download/Clients/Soulseek/SlskdProviderSettings.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public SlskdProviderSettingsValidator()
3636
.GreaterThanOrEqualTo(0.1)
3737
.WithMessage("Timeout must be at least 0.1 hours.")
3838
.When(c => c.Timeout.HasValue);
39+
40+
// RetryAttempts validation
41+
RuleFor(c => c.RetryAttempts)
42+
.InclusiveBetween(0, 10)
43+
.WithMessage("Retry attempts must be between 0 and 10.");
3944
}
4045
}
4146

@@ -61,13 +66,15 @@ public class SlskdProviderSettings : IProviderConfig
6166
[FieldDefinition(7, Label = "Timeout", Type = FieldType.Textbox, HelpText = "Specify the maximum time to wait for a response from the Slskd instance before timing out. Fractional values are allowed (e.g., 1.5 for 1 hour and 30 minutes). Set leave blank for no timeout.", Unit = "hours", Advanced = true, Placeholder = "Enter timeout in hours")]
6267
public double? Timeout { get; set; }
6368

69+
[FieldDefinition(8, Label = "Retry Attempts", Type = FieldType.Number, HelpText = "The number of times to retry downloading a file if it fails.", Advanced = true, Placeholder = "Enter retry attempts")]
70+
public int RetryAttempts { get; set; } = 1;
71+
6472
[FieldDefinition(98, Label = "Is Fetched remote", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
6573
public bool IsRemotePath { get; set; }
6674

6775
[FieldDefinition(99, Label = "Is Localhost", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)]
6876
public bool IsLocalhost { get; set; }
6977

70-
7178
public TimeSpan? GetTimeout() => Timeout == null ? null : TimeSpan.FromHours(Timeout.Value);
7279

7380
public NzbDroneValidationResult Validate() => new(Validator.Validate(this));

Tubifarry/Download/Clients/YouTube/YoutubeClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using NzbDrone.Core.Organizer;
99
using NzbDrone.Core.Parser.Model;
1010
using NzbDrone.Core.RemotePathMappings;
11+
using Requests;
1112
using Tubifarry.Core.Model;
1213
using Tubifarry.Core.Utilities;
1314
using Xabe.FFmpeg;
@@ -23,6 +24,7 @@ public YoutubeClient(IYoutubeDownloadManager dlManager, IConfigService configSer
2324
{
2425
_dlManager = dlManager;
2526
_naminService = namingConfigService;
27+
RequestHandler.MainRequestHandlers[1].MaxParallelism = 1;
2628
}
2729

2830
public override string Name => "Youtube";

Tubifarry/Download/Clients/YouTube/YoutubeProviderSettings.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public class YoutubeProviderSettings : IProviderConfig
5858
[FieldDefinition(0, Label = "Download Path", Type = FieldType.Path, HelpText = "Specify the directory where downloaded files will be saved.")]
5959
public string DownloadPath { get; set; } = "";
6060

61-
[FieldDefinition(1, Label = "Cookie Path", Type = FieldType.FilePath, Placeholder = "/downloads/Cookies/cookies.txt", HelpText = "Specify the path to the YouTube cookies file. This is optional but required for accessing restricted content.", Advanced = true)]
61+
[FieldDefinition(1, Label = "Cookie Path", Type = FieldType.FilePath, Hidden = HiddenType.HiddenIfNotSet, Placeholder = "/downloads/Cookies/cookies.txt", HelpText = "Specify the path to the YouTube cookies file. This is optional but required for accessing restricted content.", Advanced = true)]
6262
public string CookiePath { get; set; } = string.Empty;
6363

6464
[FieldDefinition(2, Label = "Use ID3v2.3 Tags", HelpText = "Enable this option to use ID3v2.3 tags for better compatibility with older media players like Windows Media Player.", Type = FieldType.Checkbox, Advanced = true)]

0 commit comments

Comments
 (0)