mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +00:00
Compare commits
12 Commits
9bc9271f11
...
55bdf3c890
| Author | SHA1 | Date | |
|---|---|---|---|
| 55bdf3c890 | |||
| 5506c7d91b | |||
| 5a3550fa10 | |||
| ad3d42a997 | |||
| dd0d246a77 | |||
| dcc1c97053 | |||
| 71c3228e44 | |||
| 094001f9f7 | |||
| 0faacc214b | |||
| d53e581623 | |||
| 780e6200d8 | |||
| 9642adaba7 |
@@ -0,0 +1,35 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SkipReason" AS ENUM ('SIZE_LIMIT', 'DOWNLOAD_FAILED', 'EXTRACT_FAILED', 'UPLOAD_FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "skipped_packages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"fileSize" BIGINT NOT NULL,
|
||||
"reason" "SkipReason" NOT NULL,
|
||||
"errorMessage" TEXT,
|
||||
"sourceChannelId" TEXT NOT NULL,
|
||||
"sourceMessageId" BIGINT NOT NULL,
|
||||
"sourceTopicId" BIGINT,
|
||||
"isMultipart" BOOLEAN NOT NULL DEFAULT false,
|
||||
"partCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "skipped_packages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "skipped_packages_sourceChannelId_sourceMessageId_key" ON "skipped_packages"("sourceChannelId", "sourceMessageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "skipped_packages_reason_idx" ON "skipped_packages"("reason");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "skipped_packages_accountId_idx" ON "skipped_packages"("accountId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "skipped_packages" ADD CONSTRAINT "skipped_packages_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "skipped_packages" ADD CONSTRAINT "skipped_packages_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "telegram_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -412,6 +412,7 @@ model TelegramAccount {
|
||||
channelMaps AccountChannelMap[]
|
||||
ingestionRuns IngestionRun[]
|
||||
fetchRequests ChannelFetchRequest[]
|
||||
skippedPackages SkippedPackage[]
|
||||
|
||||
@@index([isActive])
|
||||
@@map("telegram_accounts")
|
||||
@@ -430,6 +431,7 @@ model TelegramChannel {
|
||||
|
||||
accountMaps AccountChannelMap[]
|
||||
packages Package[]
|
||||
skippedPackages SkippedPackage[]
|
||||
|
||||
@@index([type, isActive])
|
||||
@@index([category])
|
||||
@@ -686,6 +688,39 @@ model ArchiveExtractRequest {
|
||||
@@map("archive_extract_requests")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────
|
||||
// Skipped/Failed Archives
|
||||
// ───────────────────────────────────────
|
||||
|
||||
enum SkipReason {
|
||||
SIZE_LIMIT
|
||||
DOWNLOAD_FAILED
|
||||
EXTRACT_FAILED
|
||||
UPLOAD_FAILED
|
||||
}
|
||||
|
||||
model SkippedPackage {
|
||||
id String @id @default(cuid())
|
||||
fileName String
|
||||
fileSize BigInt
|
||||
reason SkipReason
|
||||
errorMessage String?
|
||||
sourceChannelId String
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
sourceMessageId BigInt
|
||||
sourceTopicId BigInt?
|
||||
isMultipart Boolean @default(false)
|
||||
partCount Int @default(1)
|
||||
accountId String
|
||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sourceChannelId, sourceMessageId])
|
||||
@@index([reason])
|
||||
@@index([accountId])
|
||||
@@map("skipped_packages")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────
|
||||
// Purchased Kickstarters
|
||||
// ───────────────────────────────────────
|
||||
|
||||
@@ -23,12 +23,15 @@ export interface PackageRow {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
matchedFileCount: number;
|
||||
matchedByContent: boolean;
|
||||
}
|
||||
|
||||
interface PackageColumnsProps {
|
||||
onViewFiles: (pkg: PackageRow) => void;
|
||||
onSetCreator: (pkg: PackageRow) => void;
|
||||
onSetTags: (pkg: PackageRow) => void;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -62,6 +65,7 @@ export function getPackageColumns({
|
||||
onViewFiles,
|
||||
onSetCreator,
|
||||
onSetTags,
|
||||
searchTerm,
|
||||
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -76,7 +80,8 @@ export function getPackageColumns({
|
||||
accessorKey: "fileName",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
|
||||
{row.original.isMultipart && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
@@ -84,6 +89,15 @@ export function getPackageColumns({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{searchTerm && row.original.matchedByContent && (
|
||||
<button
|
||||
className="text-[11px] text-amber-500 hover:text-amber-400 hover:underline cursor-pointer mt-0.5"
|
||||
onClick={() => onViewFiles(row.original)}
|
||||
>
|
||||
{row.original.matchedFileCount.toLocaleString()} file match{row.original.matchedFileCount !== 1 ? "es" : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ interface PackageFilesDrawerProps {
|
||||
pkg: PackageRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
highlightTerm?: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -81,6 +82,15 @@ function getExtBadgeClass(ext: string | null): string {
|
||||
return EXTENSION_COLORS[ext.toLowerCase()] ?? "bg-zinc-500/15 text-zinc-400 border-zinc-500/30";
|
||||
}
|
||||
|
||||
function fileMatchesHighlight(file: FileItem, term: string): boolean {
|
||||
if (!term) return false;
|
||||
const lower = term.toLowerCase();
|
||||
return (
|
||||
file.fileName.toLowerCase().includes(lower) ||
|
||||
file.path.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree structure from flat file paths.
|
||||
*/
|
||||
@@ -120,11 +130,13 @@ function TreeNodeView({
|
||||
depth,
|
||||
search,
|
||||
defaultOpen,
|
||||
highlightTerm,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
search: string;
|
||||
defaultOpen: boolean;
|
||||
highlightTerm?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
@@ -137,10 +149,22 @@ function TreeNodeView({
|
||||
});
|
||||
}, [node.children]);
|
||||
|
||||
// If searching, force all open
|
||||
const hasHighlightedDescendant = useMemo(() => {
|
||||
if (!highlightTerm) return false;
|
||||
function check(n: TreeNode): boolean {
|
||||
if (n.file && fileMatchesHighlight(n.file, highlightTerm!)) return true;
|
||||
for (const child of n.children.values()) {
|
||||
if (check(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return check(node);
|
||||
}, [node, highlightTerm]);
|
||||
|
||||
// If searching or has highlighted descendants, force all open
|
||||
useEffect(() => {
|
||||
if (search) setOpen(true);
|
||||
}, [search]);
|
||||
if (search || hasHighlightedDescendant) setOpen(true);
|
||||
}, [search, hasHighlightedDescendant]);
|
||||
|
||||
if (node.isFolder && node.children.size > 0) {
|
||||
return (
|
||||
@@ -177,6 +201,7 @@ function TreeNodeView({
|
||||
depth={depth + 1}
|
||||
search={search}
|
||||
defaultOpen={depth < 1} // Auto-expand first 2 levels
|
||||
highlightTerm={highlightTerm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -185,9 +210,15 @@ function TreeNodeView({
|
||||
|
||||
// File node
|
||||
if (node.file) {
|
||||
const isHighlighted = highlightTerm ? fileMatchesHighlight(node.file, highlightTerm) : false;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/50 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
|
||||
isHighlighted
|
||||
? "bg-amber-500/15 hover:bg-amber-500/20"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
style={{ paddingLeft: `${Math.max(0, depth) * 16 + 4}px` }}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -223,7 +254,7 @@ function countFiles(node: TreeNode): number {
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDrawerProps) {
|
||||
export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: PackageFilesDrawerProps) {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -471,16 +502,24 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
depth={0}
|
||||
search={search}
|
||||
defaultOpen={true}
|
||||
highlightTerm={highlightTerm}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flat list for archives without folders */}
|
||||
{filtered.map((file) => (
|
||||
{filtered.map((file) => {
|
||||
const isHighlighted = highlightTerm ? fileMatchesHighlight(file, highlightTerm) : false;
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-2 py-1.5 transition-colors",
|
||||
isHighlighted
|
||||
? "bg-amber-500/15 hover:bg-amber-500/20"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -500,7 +539,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
{formatBytes(file.uncompressedSize)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
135
src/app/(app)/stls/_components/skipped-columns.tsx
Normal file
135
src/app/(app)/stls/_components/skipped-columns.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export interface SkippedRow {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED";
|
||||
errorMessage: string | null;
|
||||
sourceChannel: { id: string; title: string };
|
||||
isMultipart: boolean;
|
||||
partCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
const bytes = Number(bytesStr);
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<SkippedRow["reason"], { label: string; variant: "default" | "destructive" | "outline" | "secondary" }> = {
|
||||
SIZE_LIMIT: { label: "Size Limit", variant: "secondary" },
|
||||
DOWNLOAD_FAILED: { label: "Download Failed", variant: "destructive" },
|
||||
EXTRACT_FAILED: { label: "Extract Failed", variant: "destructive" },
|
||||
UPLOAD_FAILED: { label: "Upload Failed", variant: "destructive" },
|
||||
};
|
||||
|
||||
export function getSkippedColumns({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: (row: SkippedRow) => void;
|
||||
}): ColumnDef<SkippedRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
|
||||
{row.original.isMultipart && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{row.original.partCount} parts
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBytes(row.original.fileSize)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reason",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Reason" />,
|
||||
cell: ({ row }) => {
|
||||
const { label, variant } = REASON_LABELS[row.original.reason];
|
||||
return <Badge variant={variant} className="text-[10px]">{label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "errorMessage",
|
||||
header: "Error",
|
||||
cell: ({ row }) => {
|
||||
const msg = row.original.errorMessage;
|
||||
if (!msg) return <span className="text-sm text-muted-foreground">{"\u2014"}</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[200px] block cursor-help">
|
||||
{msg}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
<p className="text-xs break-all">{msg}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "channel",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[160px] block">
|
||||
{row.original.sourceChannel.title}
|
||||
</span>
|
||||
),
|
||||
accessorFn: (row) => row.sourceChannel.title,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Skipped" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onRetry(row.original)}
|
||||
title="Retry this package"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
77
src/app/(app)/stls/_components/skipped-packages-tab.tsx
Normal file
77
src/app/(app)/stls/_components/skipped-packages-tab.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getSkippedColumns, type SkippedRow } from "./skipped-columns";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retrySkippedPackageAction, retryAllSkippedPackagesAction } from "../actions";
|
||||
|
||||
interface SkippedPackagesTabProps {
|
||||
data: SkippedRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function SkippedPackagesTab({
|
||||
data,
|
||||
pageCount,
|
||||
totalCount,
|
||||
}: SkippedPackagesTabProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const columns = getSkippedColumns({
|
||||
onRetry: (row) => {
|
||||
startTransition(async () => {
|
||||
const result = await retrySkippedPackageAction(row.id);
|
||||
if (result.success) {
|
||||
toast.success(`"${row.fileName}" queued for retry`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{totalCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const result = await retryAllSkippedPackagesAction();
|
||||
if (result.success) {
|
||||
toast.success(`All ${totalCount} skipped packages queued for retry`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
Retry All ({totalCount})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
table={table}
|
||||
emptyMessage="No skipped or failed packages."
|
||||
/>
|
||||
<DataTablePagination table={table} totalCount={totalCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getPackageColumns, type PackageRow } from "./package-columns";
|
||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
||||
import { IngestionStatus } from "./ingestion-status";
|
||||
import { SkippedPackagesTab } from "./skipped-packages-tab";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
@@ -20,7 +21,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
import type { SkippedRow } from "./skipped-columns";
|
||||
import { updatePackageCreator, updatePackageTags } from "../actions";
|
||||
|
||||
interface StlTableProps {
|
||||
@@ -29,6 +33,10 @@ interface StlTableProps {
|
||||
totalCount: number;
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
availableTags: string[];
|
||||
searchTerm: string;
|
||||
skippedData: SkippedRow[];
|
||||
skippedPageCount: number;
|
||||
skippedTotalCount: number;
|
||||
}
|
||||
|
||||
export function StlTable({
|
||||
@@ -37,6 +45,10 @@ export function StlTable({
|
||||
totalCount,
|
||||
ingestionStatus,
|
||||
availableTags,
|
||||
searchTerm,
|
||||
skippedData,
|
||||
skippedPageCount,
|
||||
skippedTotalCount,
|
||||
}: StlTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -75,8 +87,25 @@ export function StlTable({
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const activeTab = searchParams.get("tab") ?? "packages";
|
||||
|
||||
const updateTab = useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value === "packages") {
|
||||
params.delete("tab");
|
||||
} else {
|
||||
params.set("tab", value);
|
||||
}
|
||||
params.set("page", "1");
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getPackageColumns({
|
||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||
searchTerm,
|
||||
onSetCreator: (pkg) => {
|
||||
const value = prompt("Enter creator name:", pkg.creator ?? "");
|
||||
if (value === null) return;
|
||||
@@ -122,6 +151,20 @@ export function StlTable({
|
||||
<IngestionStatus initialStatus={ingestionStatus} />
|
||||
</PageHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={updateTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="packages">Packages</TabsTrigger>
|
||||
<TabsTrigger value="skipped" className="gap-1.5">
|
||||
Skipped / Failed
|
||||
{skippedTotalCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] ml-1">
|
||||
{skippedTotalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="packages" className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -155,6 +198,16 @@ export function StlTable({
|
||||
emptyMessage="No packages found. Archives will appear here after ingestion."
|
||||
/>
|
||||
<DataTablePagination table={table} totalCount={totalCount} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="skipped">
|
||||
<SkippedPackagesTab
|
||||
data={skippedData}
|
||||
pageCount={skippedPageCount}
|
||||
totalCount={skippedTotalCount}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PackageFilesDrawer
|
||||
pkg={viewPkg}
|
||||
@@ -162,6 +215,7 @@ export function StlTable({
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewPkg(null);
|
||||
}}
|
||||
highlightTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,3 +177,148 @@ export async function setPreviewFromExtract(
|
||||
return { success: false, error: "Failed to set preview from archive image" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrySkippedPackageAction(
|
||||
id: string
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const skipped = await prisma.skippedPackage.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!skipped) return { success: false, error: "Skipped package not found" };
|
||||
|
||||
// Find the AccountChannelMap and reset watermark if needed
|
||||
const mapping = await prisma.accountChannelMap.findUnique({
|
||||
where: {
|
||||
accountId_channelId: {
|
||||
accountId: skipped.accountId,
|
||||
channelId: skipped.sourceChannelId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (mapping) {
|
||||
const targetId = skipped.sourceMessageId - BigInt(1);
|
||||
|
||||
// Only reset if the watermark is past this message
|
||||
if (mapping.lastProcessedMessageId && mapping.lastProcessedMessageId >= skipped.sourceMessageId) {
|
||||
await prisma.accountChannelMap.update({
|
||||
where: { id: mapping.id },
|
||||
data: { lastProcessedMessageId: targetId },
|
||||
});
|
||||
}
|
||||
|
||||
// Also reset TopicProgress if this was a forum topic message
|
||||
if (skipped.sourceTopicId) {
|
||||
const topicProgress = await prisma.topicProgress.findFirst({
|
||||
where: {
|
||||
accountChannelMapId: mapping.id,
|
||||
topicId: skipped.sourceTopicId,
|
||||
},
|
||||
});
|
||||
if (topicProgress && topicProgress.lastProcessedMessageId && topicProgress.lastProcessedMessageId >= skipped.sourceMessageId) {
|
||||
await prisma.topicProgress.update({
|
||||
where: { id: topicProgress.id },
|
||||
data: { lastProcessedMessageId: targetId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the skip record
|
||||
await prisma.skippedPackage.delete({ where: { id } });
|
||||
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to retry skipped package" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryAllSkippedPackagesAction(
|
||||
reason?: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED"
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (reason) where.reason = reason;
|
||||
|
||||
const skippedItems = await prisma.skippedPackage.findMany({ where });
|
||||
|
||||
if (skippedItems.length === 0) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
// Group by (accountId, channelId) to find minimum messageId per channel
|
||||
const channelResets = new Map<string, { mappingKey: { accountId: string; channelId: string }; minMessageId: bigint; topicResets: Map<bigint, bigint> }>();
|
||||
|
||||
for (const item of skippedItems) {
|
||||
const key = `${item.accountId}:${item.sourceChannelId}`;
|
||||
const existing = channelResets.get(key);
|
||||
const targetId = item.sourceMessageId - BigInt(1);
|
||||
|
||||
if (!existing) {
|
||||
const topicResets = new Map<bigint, bigint>();
|
||||
if (item.sourceTopicId) {
|
||||
topicResets.set(item.sourceTopicId, targetId);
|
||||
}
|
||||
channelResets.set(key, {
|
||||
mappingKey: { accountId: item.accountId, channelId: item.sourceChannelId },
|
||||
minMessageId: targetId,
|
||||
topicResets,
|
||||
});
|
||||
} else {
|
||||
if (targetId < existing.minMessageId) {
|
||||
existing.minMessageId = targetId;
|
||||
}
|
||||
if (item.sourceTopicId) {
|
||||
const existingTopic = existing.topicResets.get(item.sourceTopicId);
|
||||
if (!existingTopic || targetId < existingTopic) {
|
||||
existing.topicResets.set(item.sourceTopicId, targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset watermarks
|
||||
for (const reset of channelResets.values()) {
|
||||
const mapping = await prisma.accountChannelMap.findUnique({
|
||||
where: { accountId_channelId: reset.mappingKey },
|
||||
});
|
||||
if (!mapping) continue;
|
||||
|
||||
if (mapping.lastProcessedMessageId && mapping.lastProcessedMessageId > reset.minMessageId) {
|
||||
await prisma.accountChannelMap.update({
|
||||
where: { id: mapping.id },
|
||||
data: { lastProcessedMessageId: reset.minMessageId },
|
||||
});
|
||||
}
|
||||
|
||||
// Reset topic progress
|
||||
for (const [topicId, targetId] of reset.topicResets) {
|
||||
const topicProgress = await prisma.topicProgress.findFirst({
|
||||
where: { accountChannelMapId: mapping.id, topicId },
|
||||
});
|
||||
if (topicProgress && topicProgress.lastProcessedMessageId && topicProgress.lastProcessedMessageId > targetId) {
|
||||
await prisma.topicProgress.update({
|
||||
where: { id: topicProgress.id },
|
||||
data: { lastProcessedMessageId: targetId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all matching skip records
|
||||
await prisma.skippedPackage.deleteMany({ where });
|
||||
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to retry skipped packages" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags } from "@/lib/telegram/queries";
|
||||
import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries";
|
||||
import { StlTable } from "./_components/stl-table";
|
||||
|
||||
interface Props {
|
||||
@@ -20,9 +20,10 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
const search = (params.search as string) ?? "";
|
||||
const creator = (params.creator as string) || undefined;
|
||||
const tag = (params.tag as string) || undefined;
|
||||
const tab = (params.tab as string) ?? "packages";
|
||||
|
||||
// Fetch packages, ingestion status, and available tags in parallel
|
||||
const [result, ingestionStatus, availableTags] = await Promise.all([
|
||||
// Fetch packages, ingestion status, tags, and skipped count in parallel
|
||||
const [result, ingestionStatus, availableTags, skippedCount] = await Promise.all([
|
||||
search
|
||||
? searchPackages({
|
||||
query: search,
|
||||
@@ -40,8 +41,14 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
}),
|
||||
getIngestionStatus(),
|
||||
getAllPackageTags(),
|
||||
countSkippedPackages(),
|
||||
]);
|
||||
|
||||
// Fetch skipped packages only if on that tab
|
||||
const skippedResult = tab === "skipped"
|
||||
? await listSkippedPackages({ page, limit: perPage })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StlTable
|
||||
data={result.items}
|
||||
@@ -49,6 +56,10 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
totalCount={result.pagination.total}
|
||||
ingestionStatus={ingestionStatus}
|
||||
availableTags={availableTags}
|
||||
searchTerm={search}
|
||||
skippedData={skippedResult?.items ?? []}
|
||||
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}
|
||||
skippedTotalCount={skippedCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PackageDetail,
|
||||
PackageFileItem,
|
||||
IngestionAccountStatus,
|
||||
SkippedPackageItem,
|
||||
} from "./types";
|
||||
|
||||
export async function listPackages(options: {
|
||||
@@ -57,6 +58,8 @@ export async function listPackages(options: {
|
||||
tags: pkg.tags,
|
||||
indexedAt: pkg.indexedAt.toISOString(),
|
||||
sourceChannel: pkg.sourceChannel,
|
||||
matchedFileCount: 0,
|
||||
matchedByContent: false,
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -106,6 +109,8 @@ export async function getPackageById(
|
||||
partCount: pkg.partCount,
|
||||
indexedAt: pkg.indexedAt.toISOString(),
|
||||
sourceChannel: pkg.sourceChannel,
|
||||
matchedFileCount: 0,
|
||||
matchedByContent: false,
|
||||
destChannel,
|
||||
destMessageId: pkg.destMessageId?.toString() ?? null,
|
||||
sourceMessageId: pkg.sourceMessageId.toString(),
|
||||
@@ -171,19 +176,22 @@ export async function searchPackages(options: {
|
||||
const q = options.query;
|
||||
|
||||
if (options.searchIn === "files" || options.searchIn === "both") {
|
||||
// Search in package files, return parent packages
|
||||
const fileMatches = await prisma.packageFile.findMany({
|
||||
// Get per-package file match counts
|
||||
const fileMatches = await prisma.packageFile.groupBy({
|
||||
by: ["packageId"],
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: q, mode: "insensitive" } },
|
||||
{ path: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: { packageId: true },
|
||||
distinct: ["packageId"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const packageIds = fileMatches.map((f) => f.packageId);
|
||||
const fileMatchMap = new Map(
|
||||
fileMatches.map((m) => [m.packageId, m._count._all])
|
||||
);
|
||||
const fileMatchedIds = fileMatches.map((f) => f.packageId);
|
||||
|
||||
const packageNameIds =
|
||||
options.searchIn === "both"
|
||||
@@ -195,7 +203,7 @@ export async function searchPackages(options: {
|
||||
).map((p) => p.id)
|
||||
: [];
|
||||
|
||||
const allIds = [...new Set([...packageIds, ...packageNameIds])];
|
||||
const allIds = [...new Set([...fileMatchedIds, ...packageNameIds])];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.package.findMany({
|
||||
@@ -234,6 +242,8 @@ export async function searchPackages(options: {
|
||||
tags: pkg.tags,
|
||||
indexedAt: pkg.indexedAt.toISOString(),
|
||||
sourceChannel: pkg.sourceChannel,
|
||||
matchedFileCount: fileMatchMap.get(pkg.id) ?? 0,
|
||||
matchedByContent: fileMatchMap.has(pkg.id),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -329,3 +339,52 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
export async function listSkippedPackages(options: {
|
||||
page: number;
|
||||
limit: number;
|
||||
reason?: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED";
|
||||
}) {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (options.reason) where.reason = options.reason;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.skippedPackage.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (options.page - 1) * options.limit,
|
||||
take: options.limit,
|
||||
include: {
|
||||
sourceChannel: { select: { id: true, title: true } },
|
||||
},
|
||||
}),
|
||||
prisma.skippedPackage.count({ where }),
|
||||
]);
|
||||
|
||||
const mapped: SkippedPackageItem[] = items.map((s) => ({
|
||||
id: s.id,
|
||||
fileName: s.fileName,
|
||||
fileSize: s.fileSize.toString(),
|
||||
reason: s.reason,
|
||||
errorMessage: s.errorMessage,
|
||||
sourceChannel: s.sourceChannel,
|
||||
sourceMessageId: s.sourceMessageId.toString(),
|
||||
isMultipart: s.isMultipart,
|
||||
partCount: s.partCount,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
items: mapped,
|
||||
pagination: {
|
||||
page: options.page,
|
||||
limit: options.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / options.limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function countSkippedPackages(): Promise<number> {
|
||||
return prisma.skippedPackage.count();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PackageListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
matchedFileCount: number;
|
||||
matchedByContent: boolean;
|
||||
}
|
||||
|
||||
export interface PackageDetail extends PackageListItem {
|
||||
@@ -40,6 +42,22 @@ export interface PackageFileItem {
|
||||
crc32: string | null;
|
||||
}
|
||||
|
||||
export interface SkippedPackageItem {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED";
|
||||
errorMessage: string | null;
|
||||
sourceChannel: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
sourceMessageId: string;
|
||||
isMultipart: boolean;
|
||||
partCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
pagination: {
|
||||
|
||||
@@ -473,3 +473,53 @@ export async function resetPackageDestination(packageId: string) {
|
||||
data: { destChannelId: null, destMessageId: null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertSkippedPackage(data: {
|
||||
fileName: string;
|
||||
fileSize: bigint;
|
||||
reason: "SIZE_LIMIT" | "DOWNLOAD_FAILED" | "EXTRACT_FAILED" | "UPLOAD_FAILED";
|
||||
errorMessage?: string;
|
||||
sourceChannelId: string;
|
||||
sourceMessageId: bigint;
|
||||
sourceTopicId?: bigint | null;
|
||||
isMultipart: boolean;
|
||||
partCount: number;
|
||||
accountId: string;
|
||||
}) {
|
||||
return db.skippedPackage.upsert({
|
||||
where: {
|
||||
sourceChannelId_sourceMessageId: {
|
||||
sourceChannelId: data.sourceChannelId,
|
||||
sourceMessageId: data.sourceMessageId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
reason: data.reason,
|
||||
errorMessage: data.errorMessage ?? null,
|
||||
fileName: data.fileName,
|
||||
fileSize: data.fileSize,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
fileName: data.fileName,
|
||||
fileSize: data.fileSize,
|
||||
reason: data.reason,
|
||||
errorMessage: data.errorMessage ?? null,
|
||||
sourceChannelId: data.sourceChannelId,
|
||||
sourceMessageId: data.sourceMessageId,
|
||||
sourceTopicId: data.sourceTopicId ?? null,
|
||||
isMultipart: data.isMultipart,
|
||||
partCount: data.partCount,
|
||||
accountId: data.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSkippedPackage(
|
||||
sourceChannelId: string,
|
||||
sourceMessageId: bigint
|
||||
) {
|
||||
return db.skippedPackage.deleteMany({
|
||||
where: { sourceChannelId, sourceMessageId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const config = {
|
||||
workerIntervalMinutes: parseInt(process.env.WORKER_INTERVAL_MINUTES ?? "60", 10),
|
||||
tempDir: process.env.WORKER_TEMP_DIR ?? "/tmp/zips",
|
||||
tdlibStateDir: process.env.TDLIB_STATE_DIR ?? "/data/tdlib",
|
||||
maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "4096", 10),
|
||||
maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "204800", 10),
|
||||
logLevel: (process.env.LOG_LEVEL ?? "info") as "debug" | "info" | "warn" | "error",
|
||||
telegramApiId: parseInt(process.env.TELEGRAM_API_ID ?? "0", 10),
|
||||
telegramApiHash: process.env.TELEGRAM_API_HASH ?? "",
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getExistingChannelsByTelegramId,
|
||||
getAccountById,
|
||||
deleteOrphanedPackageByHash,
|
||||
upsertSkippedPackage,
|
||||
deleteSkippedPackage,
|
||||
} from "./db/queries.js";
|
||||
import type { ActivityUpdate } from "./db/queries.js";
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
@@ -279,6 +281,7 @@ function createThrottledActivityUpdater(runId: string, minIntervalMs = 2000) {
|
||||
interface PipelineContext {
|
||||
client: Client;
|
||||
runId: string;
|
||||
accountId: string;
|
||||
channelTitle: string;
|
||||
channel: TelegramChannel;
|
||||
destChannelTelegramId: bigint;
|
||||
@@ -436,6 +439,7 @@ export async function runWorkerForAccount(
|
||||
const pipelineCtx: PipelineContext = {
|
||||
client,
|
||||
runId: activeRunId,
|
||||
accountId: account.id,
|
||||
channelTitle: channel.title,
|
||||
channel,
|
||||
destChannelTelegramId: destChannel.telegramId,
|
||||
@@ -729,6 +733,25 @@ async function processArchiveSets(
|
||||
{ err: setErr, baseName: archiveSets[setIdx].baseName },
|
||||
"Archive set failed, watermark will not advance past this set"
|
||||
);
|
||||
// Record the failure for visibility in the UI
|
||||
try {
|
||||
const archiveSet = archiveSets[setIdx];
|
||||
const totalSize = archiveSet.parts.reduce((sum, p) => sum + p.fileSize, 0n);
|
||||
await upsertSkippedPackage({
|
||||
fileName: archiveSet.parts[0].fileName,
|
||||
fileSize: totalSize,
|
||||
reason: "DOWNLOAD_FAILED",
|
||||
errorMessage: setErr instanceof Error ? setErr.message : String(setErr),
|
||||
sourceChannelId: ctx.channel.id,
|
||||
sourceMessageId: archiveSet.parts[0].id,
|
||||
sourceTopicId: ctx.sourceTopicId,
|
||||
isMultipart: archiveSet.isMultipart,
|
||||
partCount: archiveSet.parts.length,
|
||||
accountId: ctx.accountId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — don't fail the run if skip recording fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +821,17 @@ async function processOneArchiveSet(
|
||||
currentFileNum: setIdx + 1,
|
||||
totalFiles: totalSets,
|
||||
});
|
||||
await upsertSkippedPackage({
|
||||
fileName: archiveName,
|
||||
fileSize: totalArchiveSize,
|
||||
reason: "SIZE_LIMIT",
|
||||
sourceChannelId: channel.id,
|
||||
sourceMessageId: archiveSet.parts[0].id,
|
||||
sourceTopicId: ctx.sourceTopicId,
|
||||
isMultipart: archiveSet.isMultipart,
|
||||
partCount: archiveSet.parts.length,
|
||||
accountId: ctx.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1086,6 +1120,8 @@ async function processOneArchiveSet(
|
||||
});
|
||||
|
||||
counters.zipsIngested++;
|
||||
// Clean up any prior skip record for this archive
|
||||
await deleteSkippedPackage(channel.id, archiveSet.parts[0].id);
|
||||
|
||||
await updateRunActivity(runId, {
|
||||
currentActivity: `Ingested ${archiveName} (${entries.length} files indexed)`,
|
||||
|
||||
Reference in New Issue
Block a user