mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-23 18:11:02 +02:00
fix(storage): recover from unwritable download folders
This commit is contained in:
@@ -473,6 +473,26 @@ func classifyDownloadErrorType(msg string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// isOutputStorageWriteFailure distinguishes an unwritable destination from a
|
||||
// provider-specific failure. Provider fallback cannot repair the former: all
|
||||
// providers receive the same output path, so continuing only delays the
|
||||
// storage fallback and can replace the useful permission error with an
|
||||
// unrelated error from the last provider.
|
||||
func isOutputStorageWriteFailure(errorType, message string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(errorType), "permission") {
|
||||
return true
|
||||
}
|
||||
lowerMsg := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowerMsg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "permission denied") ||
|
||||
strings.Contains(lowerMsg, "read-only file system") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory")
|
||||
}
|
||||
|
||||
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||
return strings.Contains(lowerMsg, "http "+code) ||
|
||||
strings.Contains(lowerMsg, "http status "+code) ||
|
||||
|
||||
@@ -44,6 +44,46 @@ func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputStorageWriteFailureDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
errorType string
|
||||
message string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "typed permission failure",
|
||||
errorType: "permission",
|
||||
message: "backend omitted details",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "android operation not permitted",
|
||||
message: "failed to create file: open /storage/song.partial: operation not permitted",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "read only destination",
|
||||
message: "open /music/song.flac: read-only file system",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "provider API error",
|
||||
errorType: "api_error",
|
||||
message: "HTTP 404 for /download",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOutputStorageWriteFailure(tc.errorType, tc.message); got != tc.want {
|
||||
t.Fatalf("isOutputStorageWriteFailure(%q, %q) = %v, want %v", tc.errorType, tc.message, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
|
||||
@@ -255,12 +255,37 @@ func attemptVerifiedResumeBeforeMetadata(
|
||||
Service: selectedProvider,
|
||||
}, false
|
||||
}
|
||||
if lastErr != nil && isOutputStorageWriteFailure(errorType, lastErr.Error()) {
|
||||
return buildOutputStorageFailureResponse(
|
||||
selectedProvider,
|
||||
lastErr,
|
||||
lastRetryAfterSeconds,
|
||||
), false
|
||||
}
|
||||
// A failed fast attempt may still succeed after source enrichment resolves a
|
||||
// better provider-native ID. The normal path below retains strict/stop-
|
||||
// fallback semantics for that prepared retry.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func buildOutputStorageFailureResponse(
|
||||
providerID string,
|
||||
err error,
|
||||
retryAfterSeconds int,
|
||||
) *DownloadResponse {
|
||||
message := "Output storage is not writable"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + message,
|
||||
ErrorType: "permission",
|
||||
RetryAfterSeconds: retryAfterSeconds,
|
||||
Service: providerID,
|
||||
}
|
||||
}
|
||||
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
pipelineStartedAt := time.Now()
|
||||
defer func() {
|
||||
@@ -511,6 +536,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if sourceErrType == "" && lastErr != nil {
|
||||
sourceErrType = classifyDownloadErrorType(lastErr.Error())
|
||||
}
|
||||
if lastErr != nil && isOutputStorageWriteFailure(sourceErrType, lastErr.Error()) {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s hit an unwritable output path; stopping provider fallback\n", req.Source)
|
||||
return buildOutputStorageFailureResponse(
|
||||
req.Source,
|
||||
lastErr,
|
||||
lastRetryAfterSeconds,
|
||||
), nil
|
||||
}
|
||||
if strings.EqualFold(sourceErrType, "verification_required") {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||
cachePreparedDownloadRequest(preparationKey, req)
|
||||
@@ -649,6 +682,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if effType == "" {
|
||||
effType = classifyDownloadErrorType(lastErr.Error())
|
||||
}
|
||||
if isOutputStorageWriteFailure(effType, lastErr.Error()) {
|
||||
GoLog("[DownloadWithExtensionFallback] %s hit an unwritable output path; stopping provider fallback\n", providerID)
|
||||
return buildOutputStorageFailureResponse(
|
||||
providerID,
|
||||
lastErr,
|
||||
lastRetryAfterSeconds,
|
||||
), nil
|
||||
}
|
||||
if strings.EqualFold(effType, "verification_required") {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
||||
cachePreparedDownloadRequest(preparationKey, req)
|
||||
|
||||
Reference in New Issue
Block a user