diff --git a/Dockerfile b/Dockerfile index c8823ccc..cbe9830e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,14 +35,5 @@ RUN apt-get update \ && npm --version \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app/publish . -# Install Playwright -RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ - && sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-debian-bullseye-prod bullseye main" > /etc/apt/sources.list.d/microsoft.list' \ - && apt-get update \ - && apt-get install -y --no-install-recommends powershell \ - && pwsh playwright.ps1 install-deps \ - && pwsh playwright.ps1 install \ - && apt-get remove -y powershell \ - && apt-get clean ENTRYPOINT ["dotnet", "Listenarr.Api.dll"] diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index 29e3461c..c4f8ef9a 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -868,14 +868,22 @@ public async Task GetImportItemAsync( return result; } - // Extract the first subdirectory from file path (qBittorrent uses / separator even on Windows) + // For multi-file torrents, files are inside a subfolder (e.g. "FolderName/file.mkv"). + // For single-file torrents, the file has no directory component (e.g. "file.m4b"). + // In both cases, construct the full content path so the import targets the + // correct file/folder rather than the entire save_path directory. var pathParts = fileName.Split('/'); - var subfolder = pathParts.Length > 1 ? pathParts[0] : string.Empty; - - // Construct output path - var outputPath = !string.IsNullOrEmpty(subfolder) - ? System.IO.Path.Combine(savePath, subfolder) - : savePath; + string outputPath; + if (pathParts.Length > 1) + { + // Multi-file torrent: use the top-level subfolder + outputPath = System.IO.Path.Combine(savePath, pathParts[0]); + } + else + { + // Single-file torrent: use the full file path + outputPath = System.IO.Path.Combine(savePath, fileName); + } // Apply remote path mapping result.OutputPath = await _pathMappingService.TranslatePathAsync(client.Id, outputPath); @@ -993,14 +1001,22 @@ public async Task GetImportItemAsync( return result; } - // Extract the first subdirectory from file path (qBittorrent uses / separator even on Windows) + // For multi-file torrents, files are inside a subfolder (e.g. "FolderName/file.mkv"). + // For single-file torrents, the file has no directory component (e.g. "file.m4b"). + // In both cases, construct the full content path so the import targets the + // correct file/folder rather than the entire save_path directory. var pathParts = fileName.Split('/'); - var subfolder = pathParts.Length > 1 ? pathParts[0] : string.Empty; - - // Construct output path - var outputPath = !string.IsNullOrEmpty(subfolder) - ? System.IO.Path.Combine(savePath, subfolder) - : savePath; + string outputPath; + if (pathParts.Length > 1) + { + // Multi-file torrent: use the top-level subfolder + outputPath = System.IO.Path.Combine(savePath, pathParts[0]); + } + else + { + // Single-file torrent: use the full file path + outputPath = System.IO.Path.Combine(savePath, fileName); + } // ✅ Apply remote path mapping result.ContentPath = await _pathMappingService.TranslatePathAsync(client.Id, outputPath); diff --git a/listenarr.api/Services/DownloadMonitorService.cs b/listenarr.api/Services/DownloadMonitorService.cs index cd22bc35..27f23faa 100644 --- a/listenarr.api/Services/DownloadMonitorService.cs +++ b/listenarr.api/Services/DownloadMonitorService.cs @@ -687,16 +687,17 @@ private Task PollQBittorrentAsync( var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; _logger.LogInformation("Polling qBittorrent client {ClientName} at {BaseUrl}", client.Name, baseUrl); - // Create a new HttpClient with cookie support for this session + // Create an HttpClient with its own CookieContainer so the qBittorrent + // SID cookie from login is stored and sent with subsequent requests. + // The factory "DownloadClient" has UseCookies=false which breaks qBit auth. var cookieJar = new System.Net.CookieContainer(); - var handler = new HttpClientHandler + using var handler = new HttpClientHandler { CookieContainer = cookieJar, UseCookies = true, AutomaticDecompression = System.Net.DecompressionMethods.All }; using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - _logger.LogInformation("Created HttpClient with cookie support for qbittorrent polling. BaseAddress={BaseAddress}", http.BaseAddress); // Login using var loginData = new FormUrlEncodedContent(new[] @@ -991,21 +992,23 @@ await HandleFailedDownloadAsync( _completionCandidates[dl.Id] = DateTime.UtcNow; _logger.LogInformation("Download {DownloadId} observed as complete candidate (qBittorrent). Torrent: {TorrentName}, Path: {Path}. Waiting for stability window.", dl.Id, matched.Name, completionPath); - - // Update download status to Completed in database so it stops being re-added to candidates + + // Update progress but do NOT set status to Completed yet. + // Setting Completed here races with DownloadProcessingBackgroundService + // which picks up Completed downloads and starts importing before the + // stability window expires. Keep status as Downloading until finalization. try { - dl.Status = DownloadStatus.Completed; dl.Progress = 100M; dbContext.Downloads.Update(dl); await dbContext.SaveChangesAsync(cancellationToken); - _logger.LogDebug("Updated download {DownloadId} status to Completed in database", dl.Id); + _logger.LogDebug("Updated download {DownloadId} progress to 100%% in database (status remains {Status})", dl.Id, dl.Status); } catch (Exception ex2) { - _logger.LogWarning(ex2, "Failed to update download {DownloadId} status to Completed", dl.Id); + _logger.LogWarning(ex2, "Failed to update download {DownloadId} progress", dl.Id); } - + // Broadcast candidate so UI can surface it immediately _ = BroadcastCandidateUpdateAsync(dl, true, cancellationToken); continue; @@ -1388,6 +1391,22 @@ private async Task FinalizeDownloadAsync(Download download, string clientPath, D } var settings = await configService.GetApplicationSettingsAsync(); + + // When OutputPath is not configured, fall back to the first root folder path + if (string.IsNullOrWhiteSpace(settings.OutputPath)) + { + var rootFolderService = scope.ServiceProvider.GetService(); + if (rootFolderService != null) + { + var rootFolders = await rootFolderService.GetAllAsync(); + if (rootFolders.Count > 0) + { + settings.OutputPath = rootFolders[0].Path; + _logger.LogInformation("OutputPath not configured, using first root folder: {OutputPath}", settings.OutputPath); + } + } + } + _logger.LogDebug("Application settings: OutputPath='{OutputPath}', EnableMetadataProcessing={EnableMetadata}, CompletedFileAction={Action}", settings.OutputPath, settings.EnableMetadataProcessing, settings.CompletedFileAction); diff --git a/listenarr.api/Services/DownloadProcessingBackgroundService.cs b/listenarr.api/Services/DownloadProcessingBackgroundService.cs index 78fbda4b..9fe28984 100644 --- a/listenarr.api/Services/DownloadProcessingBackgroundService.cs +++ b/listenarr.api/Services/DownloadProcessingBackgroundService.cs @@ -231,13 +231,20 @@ private async Task EnqueueCompletedDownloadsAsync(CancellationToken cancellation } // Use V2 pattern: Call GetImportItem to resolve the accurate path - // Build a basic QueueItem from the download data + // Build a basic QueueItem from the download data. + // Prefer ClientContentPath (the torrent's content_path, i.e. the actual + // file/folder) over DownloadPath (save_path, i.e. the download directory). + // Using save_path for single-file torrents would resolve to the entire + // downloads directory and import every file in it. + var clientContentPath = dl.Metadata?.TryGetValue("ClientContentPath", out var ccp) == true + ? ccp?.ToString() + : null; var preliminaryItem = new QueueItem { Id = dl.Id, Title = dl.Title ?? "Unknown", Status = "completed", - ContentPath = dl.FinalPath ?? dl.DownloadPath, + ContentPath = dl.FinalPath ?? clientContentPath ?? dl.DownloadPath, DownloadClientId = dl.DownloadClientId }; diff --git a/listenarr.api/Services/DownloadService.cs b/listenarr.api/Services/DownloadService.cs index 9f4b0409..a0486f12 100644 --- a/listenarr.api/Services/DownloadService.cs +++ b/listenarr.api/Services/DownloadService.cs @@ -2340,16 +2340,26 @@ public async Task> GetQueueAsync() try { var clientDownloads = listenarrDownloads.Where(d => d.DownloadClientId == client.Id).ToList(); - + + // SAFETY: Skip purging when the client returned 0 queue items but we have + // active downloads tracked. This prevents accidental deletion when the client + // is temporarily unreachable (GetQueueAsync returns empty list on network errors). + if (clientQueue.Count == 0 && clientDownloads.Any()) + { + _logger.LogWarning("Skipping orphan purge for client {ClientName}: client returned 0 queue items but {Count} downloads are tracked. Client may be temporarily unreachable.", + client.Name, clientDownloads.Count); + continue; + } + // Build set of all client item IDs (both original client IDs and normalized DB IDs) var allClientItemIds = new HashSet(StringComparer.OrdinalIgnoreCase); - + // Add all normalized (mapped) IDs from the processed queue foreach (var mapped in mappedFiltered) { allClientItemIds.Add(mapped.Id); } - + // Also add original client queue IDs (torrent hashes, etc) foreach (var item in clientQueue) {