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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NzbWebDAV.Config;
using NzbWebDAV.Database;
using NzbWebDAV.Queue;
Expand All @@ -14,14 +15,105 @@ ConfigManager configManager
) : SabApiController.BaseController(httpContext, configManager)
{
public async Task<RemoveFromQueueResponse> RemoveFromQueue(RemoveFromQueueRequest request)
{
// Accept SAB style: value=id1,id2,... (existing request.NzoId already holds "value")
var raw = request.NzoId ?? string.Empty;
var idStrings = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

// Parse to GUIDs (ignore invalid entries silently)
var ids = idStrings
.Select(s => Guid.TryParse(s, out var g) ? g : (Guid?)null)
.Where(g => g.HasValue)
.Select(g => g!.Value)
.ToList();

if (ids.Count == 0)
return new RemoveFromQueueResponse() { Status = true }; // nothing to do, stay success for SAB parity

await using var tx = await dbClient.Ctx.Database.BeginTransactionAsync(HttpContext.RequestAborted);

// Single round-trip: load then remove in bulk
var items = await dbClient.Ctx.QueueItems
.Where(q => ids.Contains(q.Id))
.ToListAsync(HttpContext.RequestAborted);

if (items.Count > 0)
{
await queueManager.RemoveQueueItemAsync(request.NzoId, dbClient);
return new RemoveFromQueueResponse() { Status = true };
dbClient.Ctx.QueueItems.RemoveRange(items);
await dbClient.Ctx.SaveChangesAsync(HttpContext.RequestAborted);
}

await tx.CommitAsync(HttpContext.RequestAborted);

return new RemoveFromQueueResponse() { Status = true };
}


protected override async Task<IActionResult> Handle()
{
var request = new RemoveFromQueueRequest(httpContext);
return Ok(await RemoveFromQueue(request));
// NEW: collect multiple IDs from query (SAB passes "value") or from body (nzoIds)
var rawValue = HttpContext.Request.Query["value"].ToString(); // SAB style: value=ID1,ID2
var bodyIds = Array.Empty<string>();

// If we also accept JSON body like { "nzoIds": ["id1","id2"] }
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
var obj = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string[]>>(body);
if (obj != null && obj.TryGetValue("nzoIds", out var arr) && arr != null)
bodyIds = arr;
}
}
catch { /* ignore body parse errors, we fall back to query */ }

// Merge + normalize
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// SAB-style comma-separated "value"
if (!string.IsNullOrWhiteSpace(rawValue))
{
foreach (var part in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
ids.Add(part);
}

// Body-provided nzoIds
foreach (var id in bodyIds)
if (!string.IsNullOrWhiteSpace(id))
ids.Add(id);

// Back-compat: if the old single-param was "nzo_id" or "nzoId"
var single = HttpContext.Request.Query["nzo_id"].ToString();
if (string.IsNullOrWhiteSpace(single))
single = HttpContext.Request.Query["nzoId"].ToString();
if (!string.IsNullOrWhiteSpace(single))
ids.Add(single);

if (ids.Count == 0)
return BadRequest("No nzo_id(s) provided.");

// ---- ONE database operation: bulk delete
// Assuming EF Core and a DbContext like _db with a QueueItems DbSet that has NzoId:
// Here, QueueItem.Id is a Guid, but SAB passes string IDs, so parse as needed
var guidIds = new List<Guid>();
foreach (var id in ids)
{
if (Guid.TryParse(id, out var guid))
guidIds.Add(guid);
}

var items = await dbClient.Ctx.QueueItems
.Where(q => guidIds.Contains(q.Id))
.ToListAsync();

if (items.Count == 0)
return Ok(new { removed = 0 });

dbClient.Ctx.QueueItems.RemoveRange(items);
await dbClient.Ctx.SaveChangesAsync(); // single transaction

return Ok(new { removed = items.Count, ids = ids.ToArray() });
}
}
17 changes: 17 additions & 0 deletions frontend/app/clients/backend-client.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,23 @@ class BackendClient {
return await response.json();
}

// NEW: bulk remove — send all IDs in one call
public async removeFromQueueBulk(nzoIds: string[]): Promise<{ removed: number; ids: string[] }>
{
const url = process.env.BACKEND_URL + '/api?mode=queue&name=delete';
const apiKey = process.env.FRONTEND_BACKEND_API_KEY || "";
const response = await fetch(url, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ nzoIds })
});
if (!response.ok) throw new Error(`Bulk remove failed: ${response.status}`);
return await response.json();
}

public async removeFromHistory(nzo_id: string, del_completed_files: boolean): Promise<any> {
let url = process.env.BACKEND_URL
+ '/api?mode=history&name=delete'
Expand Down
75 changes: 73 additions & 2 deletions frontend/app/routes/queue/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { backendClient, type HistoryResponse, type QueueResponse } from "~/clien
import { EmptyQueue } from "./components/empty-queue/empty-queue";
import { HistoryTable } from "./components/history-table/history-table";
import { QueueTable } from "./components/queue-table/queue-table";
import { Form } from "react-router";
import { Button, ButtonGroup } from "react-bootstrap";


type BodyProps = {
loaderData: { queue: QueueResponse, history: HistoryResponse },
Expand Down Expand Up @@ -46,6 +49,31 @@ function Body({ loaderData, actionData }: BodyProps) {
{actionData?.error}
</Alert>
}
{/* Bulk-clear controls */}
<Form method="post" className="mb-3" onSubmit={(e) => {
const target = e.nativeEvent.submitter as HTMLButtonElement | null;
const action = target?.value ?? "";
const label = action === "all" ? "all items"
: action === "tv" ? "all TV items"
: action === "movies" ? "all Movies items"
: "items";
if (!window.confirm(`Are you sure you want to clear ${label} from the queue?`)) {
e.preventDefault();
}
}}>
<input type="hidden" name="__intent" value="bulk-clear" />
<ButtonGroup>
<Button variant="outline-danger" type="submit" name="clear" value="all">
Clear All
</Button>
<Button variant="outline-warning" type="submit" name="clear" value="tv">
Clear TV
</Button>
<Button variant="outline-primary" type="submit" name="clear" value="movies">
Clear Movies
</Button>
</ButtonGroup>
</Form>
{queue.slots.length > 0 ? <QueueTable queue={queue} /> : <EmptyQueue />}
</div>
</div>
Expand All @@ -69,13 +97,56 @@ export async function action({ request }: Route.ActionArgs) {
let user = session.get("user");
if (!user) return redirect("/login");

const formData = await request.formData();
const intent = formData.get("__intent");

// ---- Bulk-clear queue (Clear All / Clear TV / Clear Movies) ----
if (intent === "bulk-clear") {
const clear = (formData.get("clear") || "").toString().toLowerCase();

if (clear === "all" || clear === "tv" || clear === "movies") {
try {
// get the current queue
const queue = await backendClient.getQueue();

// defensive checks
const slots = Array.isArray(queue?.slots) ? queue.slots : [];

// Filter by category if needed
const wanted = clear === "all"
? slots
: slots.filter(s => (s?.cat || "").toString().toLowerCase() === (clear === "tv" ? "tv" : "movies"));

// Collect wanted IDs once
const nzoIds = wanted
.map(s => s?.nzo_id ?? (s as any)?.nzoId ?? (s as any)?.id)
.filter(Boolean) as string[];

if (nzoIds.length === 0) return redirect("/queue");

// Single round-trip to backend; backend does one DB transaction
try {
await backendClient.removeFromQueueBulk(nzoIds);
} catch (err) {
console.error("bulk removeFromQueue failed", err);
}

// Redirect back to queue so loader refreshes the list
return redirect("/queue");
} catch (error) {
return { error: "Error clearing queue items." };
}
}
}

// ---- Handle NZB file upload ----
try {
const formData = await request.formData();
const nzbFile = formData.get("nzbFile");
if (nzbFile instanceof File) {
await backendClient.addNzb(nzbFile);
return redirect("/queue");
} else {
return { error: "Error uploading nzb." }
return { error: "Error uploading nzb." };
}
} catch (error) {
if (error instanceof Error) {
Expand Down