Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BMM.Core/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ public override void Initialize()
Mvx.IoCProvider.LazyConstructAndRegisterSingleton<ICurrentUserLoader, CurrentUserLoader>();
Mvx.IoCProvider.RegisterType<IStreamToFileSystemWriter, StreamToFileSystemWriter>();
Mvx.IoCProvider.ConstructAndRegisterSingletonIfNotRegistered<IFileDownloader, HttpClientFileDownloader>();
Mvx.IoCProvider.LazyConstructAndRegisterSingleton<IUnavailableTrackRegistry, UnavailableTrackRegistry>();
Mvx.IoCProvider.LazyConstructAndRegisterSingleton<IDownloadQueue, DownloadQueue>();

Mvx.IoCProvider.LazyConstructAndRegisterSingleton<IOfflineTrackCollectionStorage, OfflineTrackCollectionStorage>();
Expand Down
36 changes: 36 additions & 0 deletions BMM.Core/Extensions/TrackExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,40 @@ public static TimeSpan SumTrackDuration(this IEnumerable<Track> tracks)
long totalSeconds = tracks.Sum(t => t.Duration / 1000);
return TimeSpan.FromSeconds(totalSeconds);
}

/// <summary>
/// Total size of the files belonging to these tracks.
/// </summary>
/// <remarks>
/// Both <c>media</c> and <c>files</c> are optional in the API, so a single track without them used to
/// throw a <see cref="NullReferenceException"/> out of the download button's size check, which the user
/// saw as "An unknown error occurred" with nothing downloaded.
/// </remarks>
public static long SumApproximateDownloadSize(this IEnumerable<Track> tracks)
{
if (tracks == null)
return 0;

return tracks
.Where(track => track?.Media != null)
.SelectMany(track => track.Media)
.Where(medium => medium?.Files != null)
.SelectMany(medium => medium.Files)
.Sum(file => file.Size);
}

/// <summary>
/// The tracks of a collection that the downloader will actually try to fetch. Mirrors the filters in
/// the offline track providers, so that "is everything downloaded" cannot wait for a file that is
/// never going to be requested.
/// </summary>
public static IEnumerable<Track> WhereDownloadable(this IEnumerable<Track> tracks)
{
if (tracks == null)
return Enumerable.Empty<Track>();

return tracks.Where(track => track != null
&& track.Subtype != TrackSubType.Video
&& !string.IsNullOrEmpty(track.Url));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using BMM.Api.Framework.Exceptions;
using BMM.Core.Implementations.Downloading.FileDownloader;

namespace BMM.Core.Implementations.Downloading.DownloadQueue
{
/// <summary>
/// Decides whether a failed download attempt was the network's fault.
/// </summary>
/// <remarks>
/// Analytics showed roughly 62.000 "Connection failure" events against about 113 timeouts, the vast
/// majority of them logged while the device reported no active connection at all. Those failures come
/// back within milliseconds, so a queue that treats them as "this file is done" empties itself in a
/// couple of seconds and then looks exactly like a completed download.
/// </remarks>
public static class DownloadFailureClassifier
{
public static DownloadOutcome Classify(Exception exception, bool cancellationWasRequested)
{
return exception switch
{
// A cancellation we asked for is not a failure. Anything else that surfaces as
// cancellation is a timeout, which is a network problem.
OperationCanceledException when cancellationWasRequested => DownloadOutcome.Cancelled,
OperationCanceledException => DownloadOutcome.ConnectionFailure,

InternetProblemsException => DownloadOutcome.ConnectionFailure,
SocketException => DownloadOutcome.ConnectionFailure,
WebException => DownloadOutcome.ConnectionFailure,

// "Connection failure", "Software caused connection abort", "Socket closed" and
// "Unable to resolve host ..." all arrive as HttpRequestException.
HttpRequestException => DownloadOutcome.ConnectionFailure,

// The response started arriving and then the connection died mid-body.
IOException => DownloadOutcome.ConnectionFailure,

DownloadHttpStatusException statusException => ClassifyStatusCode(statusException.StatusCode),

// Platform downloaders do not fail with framework exception types. iOS reports an NSError
// instead, so it has to tell us itself whether the network was at fault.
IConnectionAwareDownloadException platformException => platformException.WasConnectionFailure
? DownloadOutcome.ConnectionFailure
: DownloadOutcome.PermanentFailure,

_ => IsConnectionFailure(exception.InnerException, cancellationWasRequested)
? DownloadOutcome.ConnectionFailure
: DownloadOutcome.PermanentFailure
};
}

private static bool IsConnectionFailure(Exception inner, bool cancellationWasRequested)
{
return inner != null && Classify(inner, cancellationWasRequested) == DownloadOutcome.ConnectionFailure;
}

private static DownloadOutcome ClassifyStatusCode(HttpStatusCode statusCode)
{
// 401 means the access token needs refreshing, which is worth another attempt later.
// 5xx and 429 are the server asking us to come back. Everything else is about this file.
if (statusCode == HttpStatusCode.Unauthorized
|| statusCode == HttpStatusCode.RequestTimeout
|| statusCode == HttpStatusCode.TooManyRequests
|| (int)statusCode >= 500)
{
return DownloadOutcome.ConnectionFailure;
}

return DownloadOutcome.PermanentFailure;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace BMM.Core.Implementations.Downloading.DownloadQueue
{
/// <summary>
/// The result of a single download attempt. The distinction that matters is whether the failure
/// says something about <em>this file</em> or about <em>the network</em>: a file that is gone should be
/// skipped so the queue can continue, while a dead connection means every remaining item would
/// fail just as fast, so the queue has to stop instead of burning through it.
/// </summary>
public enum DownloadOutcome
{
Success,

/// <summary>
/// This particular file could not be downloaded and retrying now would not help.
/// </summary>
PermanentFailure,

/// <summary>
/// The network is unusable. Nothing is wrong with the file itself.
/// </summary>
ConnectionFailure,

/// <summary>
/// The download was cancelled deliberately (user interaction, app shutdown).
/// </summary>
Cancelled,

/// <summary>
/// The device has run out of storage. Like a connection failure the remaining items are kept, but
/// retrying is pointless until the user frees something up, so the queue must not spin on it.
/// </summary>
OutOfSpace
}
}
Loading