Skip to content
Closed
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
9 changes: 0 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
44 changes: 30 additions & 14 deletions listenarr.api/Services/Adapters/QbittorrentAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -868,14 +868,22 @@ public async Task<DownloadClientItem> 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);
Expand Down Expand Up @@ -993,14 +1001,22 @@ public async Task<QueueItem> 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);
Expand Down
37 changes: 28 additions & 9 deletions listenarr.api/Services/DownloadMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<IRootFolderService>();
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);

Expand Down
11 changes: 9 additions & 2 deletions listenarr.api/Services/DownloadProcessingBackgroundService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
16 changes: 13 additions & 3 deletions listenarr.api/Services/DownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2340,16 +2340,26 @@ public async Task<List<QueueItem>> 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<string>(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)
{
Expand Down