fix(storage): recover from unwritable download folders

This commit is contained in:
zarzet
2026-07-29 02:03:09 +07:00
parent 662558e3e6
commit 8dcf7fa998
9 changed files with 489 additions and 64 deletions
+20
View File
@@ -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) ||
+40
View File
@@ -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 {
+41
View File
@@ -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)