diff --git a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudApiClient.cs b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudApiClient.cs
index dfcdcfc7f..0a257e30e 100644
--- a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudApiClient.cs
+++ b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudApiClient.cs
@@ -20,6 +20,7 @@
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.Utility;
+using Duplicati.Library.Utility.Options;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
@@ -137,6 +138,11 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
private string? _validationKey;
+ ///
+ /// The timeout configuration
+ ///
+ private readonly TimeoutOptionsHelper.Timeouts _timeouts;
+
///
/// Whether a relogin has been attempted
///
@@ -160,11 +166,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// The email address for authentication
/// The password for authentication
/// The device ID for authentication
- private MovistarCloudApiClient(string email, string password, string device_id)
+ /// The timeout configuration
+ private MovistarCloudApiClient(string email, string password, string device_id, TimeoutOptionsHelper.Timeouts timeouts)
{
_email = email ?? throw new ArgumentNullException(nameof(email));
_password = password ?? throw new ArgumentNullException(nameof(password));
_device_id = device_id;
+ _timeouts = timeouts;
}
///
@@ -173,11 +181,12 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// The email address for authentication
/// The password for authentication
/// The device ID for authentication
+ /// The timeout configuration
/// The cancellation token
/// The initialized API client
- public static async Task CreateAsync(string email, string password, string device_id, CancellationToken cancellationToken = default)
+ public static async Task CreateAsync(string email, string password, string device_id, TimeoutOptionsHelper.Timeouts timeouts, CancellationToken cancellationToken = default)
{
- var client = new MovistarCloudApiClient(email, password, device_id);
+ var client = new MovistarCloudApiClient(email, password, device_id, timeouts);
await client.EnsureLoggedInAsync(cancellationToken).ConfigureAwait(false);
return client;
}
@@ -250,6 +259,7 @@ internal sealed class MovistarCloudApiClient : IDisposable
};
_http = HttpClientHelper.CreateClient(handler);
+ _http.Timeout = Timeout.InfiniteTimeSpan;
_http.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
}
@@ -273,15 +283,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// Reads the response body as JSON or throws if it looks like HTML.
///
/// The HTTP response
- /// The read timeout
/// The cancellation token
/// The response body as a string
/// Thrown when the session appears expired
- private async Task ReadJsonOrThrowAsync(HttpResponseMessage resp, TimeSpan timeout, CancellationToken ct)
+ private async Task ReadJsonOrThrowAsync(HttpResponseMessage resp, CancellationToken ct)
{
await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
- using var timeoutStream = stream.ObserveReadTimeout(timeout, false);
- using var reader = new StreamReader(timeoutStream, Encoding.UTF8);
+ using var reader = new StreamReader(stream, Encoding.UTF8);
var body = await reader.ReadToEndAsync(ct).ConfigureAwait(false);
if (LooksLikeHtml(resp, body))
@@ -315,10 +323,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
};
using var http = HttpClientHelper.CreateClient(handler);
+ http.Timeout = Timeout.InfiniteTimeSpan;
http.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
// Any public/light endpoint works; we use the home page
- using var respInitial = await http.GetAsync($"{BaseUrl}/", ct).ConfigureAwait(false);
+ using var respInitial = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, cts =>
+ http.GetAsync($"{BaseUrl}/", cts)
+ ).ConfigureAwait(false);
respInitial.EnsureSuccessStatusCode();
var url = $"{BaseUrl}{LoginPath}";
@@ -332,9 +343,12 @@ internal sealed class MovistarCloudApiClient : IDisposable
req.Content = content;
req.Headers.Add("X-Deviceid", _device_id);
- using var resp = await http.SendAsync(req, ct);
- var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
- resp.EnsureSuccessStatusCode();
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await http.SendAsync(req, cts).ConfigureAwait(false);
+ resp.EnsureSuccessStatusCode();
+ return await resp.Content.ReadAsStringAsync(cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var data = doc.RootElement.GetProperty("data");
@@ -353,14 +367,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
/// The folder ID (0 for root)
/// The maximum number of items to return
- /// The operation timeout
/// The cancellation token
/// The list of remote files
- public async Task> ListFilesAsync(long folderId, int limit, TimeSpan timeout, CancellationToken ct)
+ public async Task> ListFilesAsync(long folderId, int limit, CancellationToken ct)
{
if (folderId == 0)
{
- folderId = await GetRootFolderIdAsync(timeout, ct).ConfigureAwait(false);
+ folderId = await GetRootFolderIdAsync(ct).ConfigureAwait(false);
}
var result = new List();
@@ -368,10 +381,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
// List Files
var url = AddValidationKey($"{BaseUrl}{MediaListPath}&folderid={folderId}&limit={limit}");
var payload = new { data = new { fields = new[] { "name", "modificationdate", "size", "etag" } } };
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var media = doc.RootElement.GetProperty("data").GetProperty("media");
@@ -391,10 +407,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
// List Directories
url = AddValidationKey($"{BaseUrl}{FolderListPath}&parentid={folderId}&limit={limit}");
payload = new { data = new { fields = new[] { "name", "date" } } };
-
using var contentDir = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var respDir = await Http.PostAsync(url, contentDir, ct).ConfigureAwait(false);
- body = await ReadJsonOrThrowAsync(respDir, timeout, ct).ConfigureAwait(false);
+
+ body = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, ct, async cts =>
+ {
+ using var respDir = await Http.PostAsync(url, contentDir, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(respDir, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var docDir = JsonDocument.Parse(body);
media = docDir.RootElement.GetProperty("data").GetProperty("folders");
@@ -420,10 +439,9 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// The destination folder ID
/// The remote file name
/// The local file path
- /// The operation timeout
/// The cancellation token
/// The upload result
- public async Task UploadFileAsync(long folderId, string remoteName, string localFilePath, TimeSpan timeout, CancellationToken ct)
+ public async Task UploadFileAsync(long folderId, string remoteName, string localFilePath, CancellationToken ct)
{
var fi = new FileInfo(localFilePath);
if (!fi.Exists) throw new FileNotFoundException("Local file not found", localFilePath);
@@ -446,13 +464,15 @@ internal sealed class MovistarCloudApiClient : IDisposable
form.Add(new StringContent(JsonSerializer.Serialize(meta), Encoding.UTF8, "application/json"), "data");
await using var fs = File.OpenRead(localFilePath);
- await using var timeoutStream = fs.ObserveReadTimeout(timeout, false);
+ await using var timeoutStream = fs.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
var filePart = new StreamContent(timeoutStream);
filePart.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(filePart, "file", remoteName);
using var resp = await Http.PostAsync(url, form, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, cts =>
+ ReadJsonOrThrowAsync(resp, cts)
+ ).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var idElem = doc.RootElement.GetProperty("id");
@@ -467,17 +487,19 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// Gets the validation status of an uploaded file.
///
/// The file ID
- /// The operation timeout
/// The cancellation token
/// The validation status string
- public async Task GetValidationStatusAsync(long id, TimeSpan timeout, CancellationToken ct)
+ public async Task GetValidationStatusAsync(long id, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{ValidationStatusPath}");
var payload = new { data = new { ids = new[] { new { id } } } };
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
return doc.RootElement.GetProperty("data").GetProperty("ids")[0].GetProperty("status").GetString() ?? "";
@@ -487,17 +509,19 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// Gets a signed download URL for a file.
///
/// The file ID
- /// The operation timeout
/// The cancellation token
/// The signed download URL
- public async Task GetDownloadUrlAsync(long fileId, TimeSpan timeout, CancellationToken ct)
+ public async Task GetDownloadUrlAsync(long fileId, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{DownloadUrlPath}");
var payload = new { data = new { ids = new[] { fileId }, fields = new[] { "url", "name", "size", "etag" } } };
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
return doc.RootElement.GetProperty("data").GetProperty("media")[0].GetProperty("url").GetString()!;
@@ -508,48 +532,56 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
/// The signed URL to download from
/// The local file path to save to
- /// The operation timeout
/// The cancellation token
- public async Task DownloadToFileAsync(string signedUrl, string localFilePath, TimeSpan timeout, CancellationToken ct)
+ public async Task DownloadToFileAsync(string signedUrl, string localFilePath, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(localFilePath) ?? ".");
- using var resp = await Http.GetAsync(signedUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ using var resp = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, cts =>
+ Http.GetAsync(signedUrl, HttpCompletionOption.ResponseHeadersRead, cts)
+ ).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
- await using var rs = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
- await using var timeoutReadStream = rs.ObserveReadTimeout(timeout, false);
+ await using var rs = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, cts =>
+ resp.Content.ReadAsStreamAsync(cts)
+ ).ConfigureAwait(false);
+
await using var fs = File.Create(localFilePath);
- await using var timeoutWriteStream = fs.ObserveWriteTimeout(timeout, false);
- await timeoutReadStream.CopyToAsync(timeoutWriteStream, ct).ConfigureAwait(false);
+ await using var timeoutWriteStream = fs.ObserveWriteTimeout(_timeouts.ReadWriteTimeout, false);
+ await rs.CopyToAsync(timeoutWriteStream, ct).ConfigureAwait(false);
}
///
/// Soft deletes a file (moves to trash).
///
/// The file ID to delete
- /// The operation timeout
/// The cancellation token
- public async Task SoftDeleteFileAsync(long fileId, TimeSpan timeout, CancellationToken ct)
+ public async Task SoftDeleteFileAsync(long fileId, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{DeletePath}");
var payload = new { data = new { files = new[] { fileId } } };
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- _ = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ _ = await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
}
///
/// Gets the storage space information.
///
- /// The operation timeout
/// The cancellation token
/// The storage space information
- public async Task GetStorageSpaceAsync(TimeSpan timeout, CancellationToken ct)
+ public async Task GetStorageSpaceAsync(CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{StorageSpacePath}");
- using var resp = await Http.PostAsync(url, new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"), ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"), cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var d = doc.RootElement.GetProperty("data");
@@ -565,10 +597,9 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// Lists items in the trash.
///
/// The number of items to return
- /// The operation timeout
/// The cancellation token
/// The list of trash entries
- public async Task> ListTrashAsync(int pageSize, TimeSpan timeout, CancellationToken ct)
+ public async Task> ListTrashAsync(int pageSize, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{TrashPath}");
@@ -580,10 +611,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
["origin"] = new[] { "omh", "dropbox" }
}
};
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var entries = doc.RootElement.GetProperty("data").GetProperty("entries");
@@ -606,15 +640,17 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
/// The parent folder ID
/// The folder name to search for
- /// The operation timeout
/// The cancellation token
/// The folder ID if found, null otherwise
- public async Task TryGetFolderIdByNameAsync(long parentId, string name, TimeSpan timeout, CancellationToken ct)
+ public async Task TryGetFolderIdByNameAsync(long parentId, string name, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{FolderListPath}&parentid={parentId}&limit=2000");
- using var resp = await Http.GetAsync(url, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.GetAsync(url, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var folders = doc.RootElement.GetProperty("data").GetProperty("folders");
@@ -635,24 +671,22 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
/// The parent folder ID
/// The folder name to create
- /// The operation timeout
/// The cancellation token
/// The created or existing folder ID
- public async Task CreateFolderAsync(long parentId, string name, TimeSpan timeout, CancellationToken ct)
+ public async Task CreateFolderAsync(long parentId, string name, CancellationToken ct)
{
if (parentId == 0)
{
- parentId = await GetRootFolderIdAsync(timeout, ct).ConfigureAwait(false);
+ parentId = await GetRootFolderIdAsync(ct).ConfigureAwait(false);
}
// Idempotency: if it already exists, return its id
- var existing = await TryGetFolderIdByNameAsync(parentId, name, timeout, ct).ConfigureAwait(false);
+ var existing = await TryGetFolderIdByNameAsync(parentId, name, ct).ConfigureAwait(false);
if (existing.HasValue)
return existing.Value;
// Create folder
var url = AddValidationKey($"{BaseUrl}{FolderCreatePath}");
-
var payload = new
{
data = new
@@ -663,10 +697,13 @@ internal sealed class MovistarCloudApiClient : IDisposable
parentid = parentId
}
};
-
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
- using var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.PostAsync(url, content, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var createdFolder = doc.RootElement.GetProperty("data").GetProperty("folder");
@@ -678,14 +715,17 @@ internal sealed class MovistarCloudApiClient : IDisposable
///
/// Gets the root folder ID for the current user.
///
- /// The operation timeout
/// The cancellation token
/// The root folder ID
- public async Task GetRootFolderIdAsync(TimeSpan timeout, CancellationToken ct)
+ public async Task GetRootFolderIdAsync(CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{RootFolderPath}");
- using var resp = await Http.GetAsync(url, ct).ConfigureAwait(false);
- var body = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ var body = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.GetAsync(url, cts).ConfigureAwait(false);
+ return await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
var folders = doc.RootElement.GetProperty("data").GetProperty("folders");
@@ -696,41 +736,43 @@ internal sealed class MovistarCloudApiClient : IDisposable
/// Asserts that a folder exists by checking if it can be listed.
///
/// The folder ID to check
- /// The operation timeout
/// The cancellation token
- public async Task AssertFolderExistsByIdAsync(long folderId, TimeSpan timeout, CancellationToken ct)
+ public async Task AssertFolderExistsByIdAsync(long folderId, CancellationToken ct)
{
var url = AddValidationKey($"{BaseUrl}{MediaListPath}&folderid={folderId}&limit=1");
- using var resp = await Http.GetAsync(url, ct).ConfigureAwait(false);
- _ = await ReadJsonOrThrowAsync(resp, timeout, ct).ConfigureAwait(false);
+
+ await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, ct, async cts =>
+ {
+ using var resp = await Http.GetAsync(url, cts).ConfigureAwait(false);
+ _ = await ReadJsonOrThrowAsync(resp, cts).ConfigureAwait(false);
+ }).ConfigureAwait(false);
}
///
/// Ensures a folder path exists, creating parent folders as needed.
///
/// The folder path to ensure
- /// The operation timeout
/// The cancellation token
/// The folder ID of the final path segment
- public async Task EnsureFolderPathAsync(string path, TimeSpan timeout, CancellationToken ct)
+ public async Task EnsureFolderPathAsync(string path, CancellationToken ct)
{
var p = path.Trim();
if (string.IsNullOrWhiteSpace(p))
throw new ArgumentException("root-folder-path empty");
var segments = p.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- var currentId = await GetRootFolderIdAsync(timeout, ct).ConfigureAwait(false);
+ var currentId = await GetRootFolderIdAsync(ct).ConfigureAwait(false);
foreach (var segment in segments)
{
- var existing = await TryGetFolderIdByNameAsync(currentId, segment, timeout, ct).ConfigureAwait(false);
+ var existing = await TryGetFolderIdByNameAsync(currentId, segment, ct).ConfigureAwait(false);
if (existing.HasValue)
{
currentId = existing.Value;
continue;
}
- currentId = await CreateFolderAsync(currentId, segment, timeout, ct).ConfigureAwait(false);
+ currentId = await CreateFolderAsync(currentId, segment, ct).ConfigureAwait(false);
}
return currentId;
diff --git a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs
index 3f38aff5d..c776ed6cf 100644
--- a/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs
+++ b/Duplicati/Library/Backend/MovistarCloud/MovistarCloudBackend.cs
@@ -47,15 +47,15 @@ public sealed class MovistarCloudBackend : IBackend
///
/// The device ID option name coming from the X-Deviceid header
///
- private const string DeviceIdOption = "deviceID";
+ private const string DeviceIdOption = "deviceID";
///
/// The list limit option name
///
private const string ListLimitOption = "list-limit";
///
- /// The wait for validation option name
+ /// The ignore validation result option name
///
- private const string WaitForValidationOption = "wait-for-validation";
+ private const string IgnoreValidationResultOption = "ignore-validation-result";
///
/// The validation timeout option name
///
@@ -82,10 +82,6 @@ public sealed class MovistarCloudBackend : IBackend
///
private const int DefaultListLimit = 2000;
///
- /// The default wait for validation value
- ///
- private const bool DefaultWaitForValidation = true;
- ///
/// The default validation timeout value
///
private const string DefaultValidationTimeout = "10m";
@@ -125,47 +121,47 @@ public sealed class MovistarCloudBackend : IBackend
/// The root folder ID
///
private long _rootFolderId;
-
+
///
/// The maximum number of items to return per listing call
///
private readonly int _listLimit;
-
+
///
/// Whether to wait until uploaded file becomes usable (status=U)
///
private readonly bool _waitForValidation;
-
+
///
/// The maximum time to wait for server-side upload validation
///
private readonly TimeSpan _validationTimeout;
-
+
///
/// The polling interval for validation status
///
private readonly TimeSpan _validationPollInterval;
-
+
///
/// Whether diagnostics logging is enabled
///
private readonly bool _diagnostics;
-
+
///
/// The diagnostics level (basic or trash)
///
private readonly DiagnosticsLevel _diagnosticsLevel;
-
+
///
/// The number of items to list from trash when diagnostics-level=trash
///
private readonly int _trashPageSize;
-
+
///
/// The optional root folder path
///
private readonly string? _rootFolderPathOpt;
-
+
///
/// Whether the destination has been resolved
///
@@ -174,32 +170,56 @@ public sealed class MovistarCloudBackend : IBackend
///
/// The API client instance
///
- private readonly MovistarCloudApiClient? _client;
-
+ private MovistarCloudApiClient? _client;
+
+ ///
+ /// The email address for authentication
+ ///
+ private readonly string _email;
+
+ ///
+ /// The password for authentication
+ ///
+ private readonly string _password;
+
+ ///
+ /// The device ID for authentication
+ ///
+ private readonly string _deviceId;
+
///
/// The mapping of file names to IDs
///
private readonly Dictionary _nameToId = new(StringComparer.Ordinal);
-
+
///
/// The timeout configuration
///
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
///
- /// Gets the API client, throwing if not initialized
+ /// Gets or creates the API client, initializing it on first use.
///
- private MovistarCloudApiClient Client
- => _client ?? throw new InvalidOperationException(
- "Backend not initialized. This instance was created using the default constructor for metadata only."
- );
+ /// The cancellation token
+ /// The API client
+ private async Task GetClientAsync(CancellationToken ct)
+ {
+ if (_client != null)
+ return _client;
+
+ if (string.IsNullOrWhiteSpace(_email) || string.IsNullOrWhiteSpace(_password) || string.IsNullOrWhiteSpace(_deviceId))
+ throw new InvalidOperationException("Backend not initialized with credentials.");
+
+ _client = await MovistarCloudApiClient.CreateAsync(_email, _password, _deviceId, _timeouts, ct).ConfigureAwait(false);
+ return _client;
+ }
///
public string DisplayName => Strings.MovistarCloudBackend.DisplayName;
-
+
///
public string ProtocolKey => "movistarcloud";
-
+
///
public string Description => Strings.MovistarCloudBackend.Description;
@@ -208,9 +228,9 @@ public sealed class MovistarCloudBackend : IBackend
[
new CommandLineArgument(EmailOption, CommandLineArgument.ArgumentType.String, Strings.MovistarCloudBackend.EmailShort, Strings.MovistarCloudBackend.EmailLong),
new CommandLineArgument(PasswordOption, CommandLineArgument.ArgumentType.Password, Strings.MovistarCloudBackend.PasswordShort, Strings.MovistarCloudBackend.PasswordLong),
- new CommandLineArgument(DeviceIdOption, CommandLineArgument.ArgumentType.String, Strings.MovistarCloudBackend.DeviceIdShort, Strings.MovistarCloudBackend.DeviceIdLong),
+ new CommandLineArgument(DeviceIdOption, CommandLineArgument.ArgumentType.String, Strings.MovistarCloudBackend.DeviceIdShort, Strings.MovistarCloudBackend.DeviceIdLong),
new CommandLineArgument(ListLimitOption, CommandLineArgument.ArgumentType.Integer, Strings.MovistarCloudBackend.ListLimitShort, Strings.MovistarCloudBackend.ListLimitLong, DefaultListLimit.ToString()),
- new CommandLineArgument(WaitForValidationOption, CommandLineArgument.ArgumentType.Boolean, Strings.MovistarCloudBackend.WaitForValidationShort, Strings.MovistarCloudBackend.WaitForValidationLong, DefaultWaitForValidation.ToString()),
+ new CommandLineArgument(IgnoreValidationResultOption, CommandLineArgument.ArgumentType.Boolean, Strings.MovistarCloudBackend.IgnoreValidationResultShort, Strings.MovistarCloudBackend.IgnoreValidationResultLong, false.ToString()),
new CommandLineArgument(ValidationTimeoutOption, CommandLineArgument.ArgumentType.Timespan, Strings.MovistarCloudBackend.ValidationTimeoutShort, Strings.MovistarCloudBackend.ValidationTimeoutLong, DefaultValidationTimeout),
new CommandLineArgument(ValidationPollIntervalOption, CommandLineArgument.ArgumentType.Timespan, Strings.MovistarCloudBackend.ValidationPollIntervalShort, Strings.MovistarCloudBackend.ValidationPollIntervalLong, DefaultValidationPollInterval),
new CommandLineArgument(DiagnosticsOption, CommandLineArgument.ArgumentType.Boolean, Strings.MovistarCloudBackend.DiagnosticsShort, Strings.MovistarCloudBackend.DiagnosticsLong, DefaultDiagnostics.ToString()),
@@ -225,7 +245,7 @@ public sealed class MovistarCloudBackend : IBackend
public MovistarCloudBackend()
{
_listLimit = DefaultListLimit;
- _waitForValidation = DefaultWaitForValidation;
+ _waitForValidation = true;
_validationTimeout = Timeparser.ParseTimeSpan(DefaultValidationTimeout);
_validationPollInterval = Timeparser.ParseTimeSpan(DefaultValidationPollInterval);
_diagnostics = DefaultDiagnostics;
@@ -233,6 +253,9 @@ public sealed class MovistarCloudBackend : IBackend
_trashPageSize = DefaultTrashPageSize;
_timeouts = null!;
_rootFolderPathOpt = null;
+ _email = null!;
+ _password = null!;
+ _deviceId = null!;
}
///
@@ -242,20 +265,15 @@ public sealed class MovistarCloudBackend : IBackend
/// The options dictionary
public MovistarCloudBackend(string url, Dictionary options)
{
- var email = RequireOption(options, EmailOption);
- var password = RequireOption(options, PasswordOption);
- var device_id = RequireOption(options, DeviceIdOption);
-
+ _email = RequireOption(options, EmailOption);
+ _password = RequireOption(options, PasswordOption);
+ _deviceId = RequireOption(options, DeviceIdOption);
var uri = new Utility.Uri(url);
_rootFolderPathOpt = uri.HostAndPath;
-
- _listLimit = Library.Utility.Utility.ParseIntOption(options, ListLimitOption, DefaultListLimit);
- //TODO: Possible replacement of ParseBoolOption to accept default value.
- if (options.ContainsKey(WaitForValidationOption))
- _waitForValidation = Library.Utility.Utility.ParseBoolOption(options, WaitForValidationOption);
- else _waitForValidation = DefaultWaitForValidation;
+ _listLimit = Library.Utility.Utility.ParseIntOption(options, ListLimitOption, DefaultListLimit);
+ _waitForValidation = !Library.Utility.Utility.ParseBoolOption(options, IgnoreValidationResultOption);
_validationTimeout = Library.Utility.Utility.ParseTimespanOption(options, ValidationTimeoutOption, DefaultValidationTimeout);
_validationPollInterval = Library.Utility.Utility.ParseTimespanOption(options, ValidationPollIntervalOption, DefaultValidationPollInterval);
@@ -264,11 +282,8 @@ public sealed class MovistarCloudBackend : IBackend
_trashPageSize = Math.Clamp(Library.Utility.Utility.ParseIntOption(options, TrashPageSizeOption, DefaultTrashPageSize), 1, 200);
_timeouts = TimeoutOptionsHelper.Parse(options);
- _client = MovistarCloudApiClient.CreateAsync(email, password, device_id, CancellationToken.None).GetAwaiter().GetResult();
}
- ///
-
///
/// Ensures the destination folder is resolved, creating it if necessary.
///
@@ -278,21 +293,23 @@ public sealed class MovistarCloudBackend : IBackend
if (_destinationResolved)
return;
+ var client = await GetClientAsync(ct).ConfigureAwait(false);
+
if (_rootFolderId > 0)
{
- await Client.WithAutoRelogin(x => Client.AssertFolderExistsByIdAsync(_rootFolderId, _timeouts.ShortTimeout, x), ct);
+ await client.WithAutoRelogin(x => client.AssertFolderExistsByIdAsync(_rootFolderId, x), ct);
_destinationResolved = true;
return;
}
if (!string.IsNullOrWhiteSpace(_rootFolderPathOpt))
{
- var id = await Client.WithAutoRelogin(x => Client.EnsureFolderPathAsync(_rootFolderPathOpt!, _timeouts.ShortTimeout, x), ct);
+ var id = await client.WithAutoRelogin(x => client.EnsureFolderPathAsync(_rootFolderPathOpt!, x), ct);
_rootFolderId = id;
_destinationResolved = true;
return;
}
-
+
throw new FolderMissingException("Missing destination folder: specify path on configuration.");
}
@@ -302,8 +319,9 @@ public sealed class MovistarCloudBackend : IBackend
await EnsureDestinationResolvedAsync(cancellationToken).ConfigureAwait(false);
ValidateRemoteName(remotename);
- var upload = await Client.WithAutoRelogin(
- ct => Client.UploadFileAsync(_rootFolderId, remotename, filename, _timeouts.ReadWriteTimeout, ct),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ var upload = await client.WithAutoRelogin(
+ ct => client.UploadFileAsync(_rootFolderId, remotename, filename, ct),
cancellationToken).ConfigureAwait(false);
if (_waitForValidation)
@@ -312,8 +330,8 @@ public sealed class MovistarCloudBackend : IBackend
var usableStatus = false;
while (DateTime.UtcNow < deadline)
{
- var st = await Client.WithAutoRelogin(
- ct => Client.GetValidationStatusAsync(upload.Id, _timeouts.ShortTimeout, ct),
+ var st = await client.WithAutoRelogin(
+ ct => client.GetValidationStatusAsync(upload.Id, ct),
cancellationToken).ConfigureAwait(false);
if (string.Equals(st, "U", StringComparison.OrdinalIgnoreCase))
@@ -328,7 +346,7 @@ public sealed class MovistarCloudBackend : IBackend
{
//Right now we dont see other cases appart of status "V" and Status "U"
//TODO: validate new status in the previous block if they appear
- Logging.Log.WriteWarningMessage(LOGTAG, "ValidationTimeout",null, "Validation did not reach status=U within {0} for {1} (uploadId={2}). Continuing as best-effort.",_validationTimeout, remotename, upload.Id);
+ Logging.Log.WriteWarningMessage(LOGTAG, "ValidationTimeout", null, "Validation did not reach status=U within {0} for {1} (uploadId={2}). Continuing as best-effort.", _validationTimeout, remotename, upload.Id);
}
}
@@ -340,12 +358,15 @@ public sealed class MovistarCloudBackend : IBackend
{
await EnsureDestinationResolvedAsync(cancellationToken).ConfigureAwait(false);
var id = await ResolveIdByNameAsync(remotename, cancellationToken).ConfigureAwait(false);
+ if (id == null)
+ throw new FileMissingException($"File {remotename} not found in Movistar Cloud.");
- var signedUrl = await Client.WithAutoRelogin(
- ct => Client.GetDownloadUrlAsync((long)id, _timeouts.ShortTimeout, ct),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ var signedUrl = await client.WithAutoRelogin(
+ ct => client.GetDownloadUrlAsync((long)id, ct),
cancellationToken).ConfigureAwait(false);
- await Client.DownloadToFileAsync(signedUrl, filename, _timeouts.ReadWriteTimeout, cancellationToken).ConfigureAwait(false);
+ await client.DownloadToFileAsync(signedUrl, filename, cancellationToken).ConfigureAwait(false);
}
///
@@ -355,8 +376,9 @@ public sealed class MovistarCloudBackend : IBackend
var id = await ResolveIdByNameAsync(remotename, cancellationToken, allowMissing: true).ConfigureAwait(false);
if (id == null) return;
- await Client.WithAutoRelogin(
- ct => Client.SoftDeleteFileAsync(id.Value, _timeouts.ShortTimeout, ct),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ await client.WithAutoRelogin(
+ ct => client.SoftDeleteFileAsync(id.Value, ct),
cancellationToken).ConfigureAwait(false);
_nameToId.Remove(remotename);
@@ -365,19 +387,21 @@ public sealed class MovistarCloudBackend : IBackend
///
public async Task TestAsync(CancellationToken cancellationToken)
{
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+
// Case A: we already have an id -> check that it exists
if (_rootFolderId > 0)
{
- await Client.WithAutoRelogin(
- ct => Client.AssertFolderExistsByIdAsync(_rootFolderId, _timeouts.ShortTimeout, ct),
+ await client.WithAutoRelogin(
+ ct => client.AssertFolderExistsByIdAsync(_rootFolderId, ct),
cancellationToken
).ConfigureAwait(false);
}
// Case B: no id but there is a path -> resolve/create and fill _rootFolderId
else if (!string.IsNullOrWhiteSpace(_rootFolderPathOpt))
{
- var resolvedId = await Client.WithAutoRelogin(
- ct => Client.EnsureFolderPathAsync(_rootFolderPathOpt!, _timeouts.ShortTimeout, ct),
+ var resolvedId = await client.WithAutoRelogin(
+ ct => client.EnsureFolderPathAsync(_rootFolderPathOpt!, ct),
cancellationToken
).ConfigureAwait(false);
@@ -388,7 +412,7 @@ public sealed class MovistarCloudBackend : IBackend
}
// Case C: nothing -> indicate missing folder
else
- {
+ {
throw new FolderMissingException("Missing destination folder during Test: specify path on configuration.");
}
@@ -407,8 +431,9 @@ public sealed class MovistarCloudBackend : IBackend
{
try
{
- var space = await Client.WithAutoRelogin(
- ct => Client.GetStorageSpaceAsync(_timeouts.ListTimeout, ct),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ var space = await client.WithAutoRelogin(
+ ct => client.GetStorageSpaceAsync(ct),
cancellationToken
).ConfigureAwait(false);
@@ -418,8 +443,8 @@ public sealed class MovistarCloudBackend : IBackend
if (_diagnosticsLevel == DiagnosticsLevel.Trash)
{
- var trash = await Client.WithAutoRelogin(
- ct => Client.ListTrashAsync(_trashPageSize, _timeouts.ListTimeout, ct),
+ var trash = await client.WithAutoRelogin(
+ ct => client.ListTrashAsync(_trashPageSize, ct),
cancellationToken
).ConfigureAwait(false);
@@ -443,8 +468,9 @@ public sealed class MovistarCloudBackend : IBackend
{
await EnsureDestinationResolvedAsync(cancellationToken).ConfigureAwait(false);
- var files = await Client.WithAutoRelogin(
- ct => Client.ListFilesAsync(_rootFolderId, _listLimit, _timeouts.ListTimeout, ct),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ var files = await client.WithAutoRelogin(
+ ct => client.ListFilesAsync(_rootFolderId, _listLimit, ct),
cancellationToken
).ConfigureAwait(false);
@@ -465,14 +491,15 @@ public sealed class MovistarCloudBackend : IBackend
{
if (!string.IsNullOrWhiteSpace(_rootFolderPathOpt))
{
- var id = await Client.WithAutoRelogin(
- x => Client.EnsureFolderPathAsync(_rootFolderPathOpt!, _timeouts.ShortTimeout, x),
+ var client = await GetClientAsync(cancellationToken).ConfigureAwait(false);
+ var id = await client.WithAutoRelogin(
+ x => client.EnsureFolderPathAsync(_rootFolderPathOpt!, x),
cancellationToken);
_rootFolderId = id;
_destinationResolved = true;
return;
}
-
+
throw new UserInformationException(
"Unable to create the destination folder because no destination path was provided. Specify Path so the Movistar Cloud backend knows which remote folder to create or use.",
"MovistarCloudMissingRootFolderPath");
@@ -495,8 +522,9 @@ public sealed class MovistarCloudBackend : IBackend
if (_nameToId.TryGetValue(remotename, out var id))
return id;
- var files = await Client.WithAutoRelogin(
- x => Client.ListFilesAsync(_rootFolderId, _listLimit, _timeouts.ListTimeout, x),
+ var client = await GetClientAsync(ct).ConfigureAwait(false);
+ var files = await client.WithAutoRelogin(
+ x => client.ListFilesAsync(_rootFolderId, _listLimit, x),
ct).ConfigureAwait(false);
_nameToId.Clear();
@@ -561,22 +589,22 @@ public sealed class MovistarCloudBackend : IBackend
///
public string Name { get; }
-
+
///
public long Size { get; }
-
+
///
public bool IsFolder { get; }
-
+
///
public DateTime LastAccess { get; }
-
+
///
public DateTime LastModification { get; }
-
+
///
public DateTime Created { get; }
-
+
///
public bool IsArchived { get; }
}
diff --git a/Duplicati/Library/Backend/MovistarCloud/Strings.cs b/Duplicati/Library/Backend/MovistarCloud/Strings.cs
index 316e7d463..576588962 100644
--- a/Duplicati/Library/Backend/MovistarCloud/Strings.cs
+++ b/Duplicati/Library/Backend/MovistarCloud/Strings.cs
@@ -35,8 +35,8 @@ namespace Duplicati.Library.Backend.MovistarCloud.Strings
public static string RootFolderPathLong => LC.L(@"The path where to store the backup (e.g., /Duplicati/Backups/Computer).");
public static string ListLimitShort => LC.L(@"List limit");
public static string ListLimitLong => LC.L(@"The maximum number of items returned per listing call.");
- public static string WaitForValidationShort => LC.L(@"Wait for validation");
- public static string WaitForValidationLong => LC.L(@"Wait until the uploaded file becomes usable (status=U).");
+ public static string IgnoreValidationResultShort => LC.L(@"Ignore validation result");
+ public static string IgnoreValidationResultLong => LC.L(@"If set, the backend will not wait for server-side upload validation and will continue immediately after the upload.");
public static string ValidationTimeoutShort => LC.L(@"Validation timeout");
public static string ValidationTimeoutLong => LC.L(@"The maximum time to wait for server-side upload validation.");
public static string ValidationPollIntervalShort => LC.L(@"Validation poll interval");