Skip to content
Merged
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
24 changes: 0 additions & 24 deletions frontend/src/components/config/MetadataConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,30 +315,6 @@ export function MetadataConfigSection({
</span>
</div>
</label>

<label className="label cursor-pointer items-start justify-start gap-4">
<input
type="checkbox"
className="checkbox checkbox-error checkbox-sm mt-1 shrink-0"
checked={formData.delete_completed_nzb ?? false}
disabled={isReadOnly}
onChange={(e) => handleCheckboxChange("delete_completed_nzb", e.target.checked)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="whitespace-normal break-words font-bold text-xs">
Aggressive Cleanup
</span>
<div className="badge badge-error badge-xs shrink-0 font-black text-[8px] uppercase">
Dangerous
</div>
</div>
<span className="mt-1 block whitespace-normal break-words text-base-content/50 text-xs leading-relaxed">
Delete original NZB immediately after metadata generation. Cannot re-scan without
re-upload.
</span>
</div>
</label>
</div>
</div>

Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/config/WorkersConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,33 @@ export function ImportConfigSection({
</span>
</div>
</label>

<div className="divider text-base-content/70" />

<label className="label cursor-pointer items-start justify-start gap-4">
<input
type="checkbox"
className="checkbox checkbox-error checkbox-sm mt-1 shrink-0"
checked={formData.delete_completed_nzb ?? false}
disabled={isReadOnly}
onChange={(e) => handleInputChange("delete_completed_nzb", e.target.checked)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="whitespace-normal break-words font-bold text-xs">
Delete NZB After Import
</span>
<div className="badge badge-error badge-xs shrink-0 font-black text-[8px] uppercase">
Dangerous
</div>
</div>
<span className="mt-1 block whitespace-normal break-words text-base-content/50 text-xs leading-relaxed">
Delete the original NZB file from disk once the import completes successfully. The
queue entry is retained, but downloading the NZB from the queue will no longer be
possible.
</span>
</div>
</label>
</div>
</div>

Expand Down
39 changes: 36 additions & 3 deletions frontend/src/pages/QueuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Pagination } from "../components/ui/Pagination";
import { PathDisplay } from "../components/ui/PathDisplay";
import { StatusBadge } from "../components/ui/StatusBadge";
import { useConfirm } from "../contexts/ModalContext";
import { useToast } from "../contexts/ToastContext";
import {
useAddTestQueueItem,
useBulkCancelQueueItems,
Expand Down Expand Up @@ -130,6 +131,7 @@ export function QueuePage() {
const addTestQueueItem = useAddTestQueueItem();
const regenerateSymlinks = useRegenerateSymlinks();
const { confirmDelete, confirmAction } = useConfirm();
const { showToast } = useToast();

const handleDelete = useCallback(
async (id: number) => {
Expand Down Expand Up @@ -166,10 +168,36 @@ export function QueuePage() {
[confirmAction, cancelItem],
);

const handleDownload = async (id: number) => {
const handleDownload = async (id: number, status?: string) => {
try {
const response = await fetch(`/api/queue/${id}/download`);
if (!response.ok) throw new Error("Failed to download NZB file");
if (!response.ok) {
let title = "Download Failed";
let message = `Server returned ${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as {
error?: { message?: string; details?: string };
};
if (body?.error?.message) {
title = body.error.message;
message = body.error.details || "";
}
} catch {
// Non-JSON error body — fall back to status text.
}
// For completed items, a missing file almost always means the server
// cleaned it up post-import (delete_completed_nzb). Soften the toast.
if (response.status === 404 && status === "completed") {
showToast({
type: "info",
title: "NZB file already removed",
message: "This NZB was cleaned up after successful import.",
});
return;
}
showToast({ type: "error", title, message });
return;
}
const contentDisposition = response.headers.get("Content-Disposition");
const filenameMatch = contentDisposition?.match(/filename[^;=\n]*=["']?([^"'\n]*)["']?/);
const filename = filenameMatch?.[1] || `queue-${id}.nzb`;
Expand All @@ -184,6 +212,11 @@ export function QueuePage() {
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download NZB:", error);
showToast({
type: "error",
title: "Download Failed",
message: error instanceof Error ? error.message : "Network error",
});
}
};

Expand Down Expand Up @@ -944,7 +977,7 @@ export function QueuePage() {
<li>
<button
type="button"
onClick={() => handleDownload(item.id)}
onClick={() => handleDownload(item.id, item.status)}
>
<Download className="h-4 w-4" />
Download NZB
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export interface ImportConfig {
rename_to_nzb_name?: boolean;
filter_sample_files?: boolean;
failed_item_retention_hours?: number | null;
delete_completed_nzb?: boolean;
}

// Log configuration
Expand Down
67 changes: 38 additions & 29 deletions internal/api/queue_handlers.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package api

import (
"compress/gzip"
"fmt"
"html"
"io"
Expand All @@ -19,9 +18,25 @@ import (
"github.com/javi11/altmount/internal/database"
internalerrors "github.com/javi11/altmount/internal/errors"
"github.com/javi11/altmount/internal/importer/utils/nzbtrim"
"github.com/javi11/altmount/internal/nzbfile"
"github.com/javi11/altmount/internal/nzblnk"
)

// removeQueueNzbFiles deletes the on-disk NZB files for every non-empty path.
// Missing files are ignored; other errors are logged so the caller can still
// report the DB deletion as successful.
func (s *Server) removeQueueNzbFiles(c *fiber.Ctx, paths []string) {
for _, p := range paths {
if p == "" {
continue
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
slog.WarnContext(c.Context(), "Failed to delete NZB file after queue removal",
"path", p, "error", err)
}
}
}

// transformQueueError transforms specific errors to user-friendly messages
func transformQueueError(err *string) string {
if err == nil {
Expand Down Expand Up @@ -232,6 +247,8 @@ func (s *Server) handleDeleteQueue(c *fiber.Ctx) error {
return RespondInternalError(c, "Failed to delete queue item", err.Error())
}

s.removeQueueNzbFiles(c, []string{item.NzbPath})

if s.progressBroadcaster != nil {
s.progressBroadcaster.BroadcastQueueChanged()
}
Expand Down Expand Up @@ -439,12 +456,13 @@ func (s *Server) handleGetQueueHistoricalStats(c *fiber.Ctx) error {
// @Security ApiKeyAuth
// @Router /queue/completed [delete]
func (s *Server) handleClearCompletedQueue(c *fiber.Ctx) error {
// Clear completed items
count, err := s.queueRepo.ClearCompletedQueueItems(c.Context())
paths, count, err := s.queueRepo.ClearCompletedQueueItems(c.Context())
if err != nil {
return RespondInternalError(c, "Failed to clear completed queue items", err.Error())
}

s.removeQueueNzbFiles(c, paths)

if s.progressBroadcaster != nil {
s.progressBroadcaster.BroadcastQueueChanged()
}
Expand All @@ -464,12 +482,13 @@ func (s *Server) handleClearCompletedQueue(c *fiber.Ctx) error {
// @Security ApiKeyAuth
// @Router /queue/failed [delete]
func (s *Server) handleClearFailedQueue(c *fiber.Ctx) error {
// Clear failed items
count, err := s.queueRepo.ClearFailedQueueItems(c.Context())
paths, count, err := s.queueRepo.ClearFailedQueueItems(c.Context())
if err != nil {
return RespondInternalError(c, "Failed to clear failed queue items", err.Error())
}

s.removeQueueNzbFiles(c, paths)

if s.progressBroadcaster != nil {
s.progressBroadcaster.BroadcastQueueChanged()
}
Expand All @@ -489,12 +508,13 @@ func (s *Server) handleClearFailedQueue(c *fiber.Ctx) error {
// @Security ApiKeyAuth
// @Router /queue/pending [delete]
func (s *Server) handleClearPendingQueue(c *fiber.Ctx) error {
// Clear pending items
count, err := s.queueRepo.ClearPendingQueueItems(c.Context())
paths, count, err := s.queueRepo.ClearPendingQueueItems(c.Context())
if err != nil {
return RespondInternalError(c, "Failed to clear pending queue items", err.Error())
}

s.removeQueueNzbFiles(c, paths)

if s.progressBroadcaster != nil {
s.progressBroadcaster.BroadcastQueueChanged()
}
Expand Down Expand Up @@ -542,6 +562,8 @@ func (s *Server) handleDeleteQueueBulk(c *fiber.Ctx) error {
return RespondInternalError(c, "Failed to delete queue items", err.Error())
}

s.removeQueueNzbFiles(c, result.DeletedPaths)

if s.progressBroadcaster != nil {
s.progressBroadcaster.BroadcastQueueChanged()
}
Expand Down Expand Up @@ -1330,39 +1352,26 @@ func (s *Server) handleDownloadNZB(c *fiber.Ctx) error {
return RespondNotFound(c, "Queue item", "")
}

// Check if NZB file exists
if _, err := os.Stat(item.NzbPath); os.IsNotExist(err) {
resolved, err := nzbfile.ResolveOnDisk(item.NzbPath)
if err != nil {
return RespondNotFound(c, "NZB file", "The NZB file no longer exists on disk")
}

// Strip .gz suffix from the download filename so clients receive a plain .nzb
filename := filepath.Base(item.NzbPath)
if strings.HasSuffix(strings.ToLower(filename), ".nzb.gz") {
filename = strings.TrimSuffix(filename, ".gz")
}
c.Set("Content-Type", "application/x-nzb")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", nzbfile.PlainFilename(resolved)))

// For gzip-compressed NZBs, decompress on-the-fly before sending
if strings.HasSuffix(strings.ToLower(item.NzbPath), ".nzb.gz") {
f, err := os.Open(item.NzbPath)
if nzbfile.IsGzipped(resolved) {
rc, err := nzbfile.Open(resolved)
if err != nil {
return RespondInternalError(c, "Failed to open NZB file", err.Error())
}
defer f.Close()
defer rc.Close()

gr, err := gzip.NewReader(f)
if err != nil {
return RespondInternalError(c, "Failed to decompress NZB file", err.Error())
}
defer gr.Close()

data, err := io.ReadAll(gr)
if err != nil {
if _, err := io.Copy(c.Response().BodyWriter(), rc); err != nil {
return RespondInternalError(c, "Failed to read NZB content", err.Error())
}
return c.Send(data)
return nil
}

return c.SendFile(item.NzbPath)
return c.SendFile(resolved)
}
14 changes: 11 additions & 3 deletions internal/api/system_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,16 @@ func (s *Server) handleSystemCleanup(c *fiber.Ctx) error {

// Clean up queue items
if !req.DryRun {
queueItemsRemoved, err = s.queueRepo.ClearCompletedQueueItems(c.Context())
var paths []string
paths, queueItemsRemoved, err = s.queueRepo.ClearCompletedQueueItems(c.Context())
if err != nil {
return c.Status(500).JSON(fiber.Map{
"success": false,
"message": "Failed to cleanup queue items",
"details": err.Error(),
})
}
s.removeQueueNzbFiles(c, paths)
} else {
// For dry run, we could count what would be removed
// For now, we'll just return 0
Expand Down Expand Up @@ -335,11 +337,17 @@ func (s *Server) handleResetSystemStats(c *fiber.Ctx) error {

// Optional: Clear completed/failed queue items too if requested
if resetQueue {
if _, err := s.queueRepo.ClearCompletedQueueItems(ctx); err != nil {
completedPaths, _, err := s.queueRepo.ClearCompletedQueueItems(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to clear completed queue items during reset", "error", err)
} else {
s.removeQueueNzbFiles(c, completedPaths)
}
if _, err := s.queueRepo.ClearFailedQueueItems(ctx); err != nil {
failedPaths, _, err := s.queueRepo.ClearFailedQueueItems(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to clear failed queue items during reset", "error", err)
} else {
s.removeQueueNzbFiles(c, failedPaths)
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,21 @@ type ImportConfig struct {
RenameToNzbName *bool `yaml:"rename_to_nzb_name" mapstructure:"rename_to_nzb_name" json:"rename_to_nzb_name,omitempty"`
FilterSampleFiles *bool `yaml:"filter_sample_files" mapstructure:"filter_sample_files" json:"filter_sample_files,omitempty"`
FailedItemRetentionHours *int `yaml:"failed_item_retention_hours" mapstructure:"failed_item_retention_hours" json:"failed_item_retention_hours,omitempty"`
DeleteCompletedNzb *bool `yaml:"delete_completed_nzb" mapstructure:"delete_completed_nzb" json:"delete_completed_nzb,omitempty"`
}

// ShouldDeleteCompletedNzb returns whether the NZB file should be removed from
// disk after a successful import. Reads from ImportConfig first, falling back
// to the legacy MetadataConfig field for back-compatibility with older config
// files (the setting was moved from metadata.* to import.*).
func (c *Config) ShouldDeleteCompletedNzb() bool {
if c.Import.DeleteCompletedNzb != nil {
return *c.Import.DeleteCompletedNzb
}
if c.Metadata.DeleteCompletedNzb != nil {
return *c.Metadata.DeleteCompletedNzb
}
return false
}

// LogConfig represents logging configuration with rotation support
Expand Down
3 changes: 3 additions & 0 deletions internal/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type BulkOperationResult struct {
DeletedCount int
ProcessingCount int
FailedIDs []int64
// DeletedPaths holds the nzb_path values for rows that were actually removed,
// so callers can clean up the corresponding files on disk.
DeletedPaths []string
}

// QueueStats represents statistics about the import queue
Expand Down
10 changes: 7 additions & 3 deletions internal/database/queue_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ func (r *QueueRepository) RemoveFromQueueBulk(ctx context.Context, ids []int64)

err := r.withQueueTransaction(ctx, func(txRepo *QueueRepository) error {
for _, id := range ids {
// Check status first - we can't delete processing items
// Check status + path in a single query - we can't delete processing items
var status QueueStatus
checkQuery := `SELECT status FROM import_queue WHERE id = ?`
err := txRepo.db.QueryRowContext(ctx, checkQuery, id).Scan(&status)
var nzbPath string
checkQuery := `SELECT status, nzb_path FROM import_queue WHERE id = ?`
err := txRepo.db.QueryRowContext(ctx, checkQuery, id).Scan(&status, &nzbPath)
if err != nil {
if err == sql.ErrNoRows {
continue // Already gone, ignore
Expand All @@ -66,6 +67,9 @@ func (r *QueueRepository) RemoveFromQueueBulk(ctx context.Context, ids []int64)
return fmt.Errorf("failed to delete item %d: %w", id, err)
}
result.DeletedCount++
if nzbPath != "" {
result.DeletedPaths = append(result.DeletedPaths, nzbPath)
}
}
return nil
})
Expand Down
Loading
Loading