12 Commits

Author SHA1 Message Date
55bdf3c890 feat: add migration for skipped_packages table
All checks were successful
continuous-integration/drone/push Build is passing
Hand-written migration matching the SkippedPackage model in schema.prisma.
Will be applied automatically by docker-entrypoint.sh via `prisma migrate deploy`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:27:25 +01:00
5506c7d91b fix: resolve build errors in retry actions and package detail query
- Replace BigInt literal `1n` with `BigInt(1)` for ES target compatibility
- Add default matchedFileCount/matchedByContent to getPackageById return

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:40:14 +01:00
5a3550fa10 feat: add skipped/failed packages tab to STL files page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:36:39 +01:00
ad3d42a997 feat: add skipped/failed packages table UI components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:24:32 +01:00
dd0d246a77 feat: highlight matching files in package drawer when opened from search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:22:50 +01:00
dcc1c97053 feat: add retry server actions for skipped/failed packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:22:31 +01:00
71c3228e44 feat: add query functions for listing skipped/failed packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:19:42 +01:00
094001f9f7 feat: show file match count badge in search results
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:19:40 +01:00
0faacc214b feat: return per-package file match counts from searchPackages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:17:42 +01:00
d53e581623 feat: record skipped/failed archives in database for UI visibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:16:12 +01:00
780e6200d8 feat: add SkippedPackage model for tracking skipped/failed archives
Adds SkipReason enum (SIZE_LIMIT, DOWNLOAD_FAILED, EXTRACT_FAILED,
UPLOAD_FAILED) and SkippedPackage model with unique constraint on
(sourceChannelId, sourceMessageId). Reverse relations added to
TelegramAccount and TelegramChannel.

Note: Run `npx prisma migrate dev --name add-skipped-packages` when
database is available to create the migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:11:56 +01:00
9642adaba7 feat: raise default ingestion size limit from 4GB to 200GB
Multipart archives where individual parts fit under Telegram's 2GB limit
but total size exceeds 4GB were being silently skipped. These can now be
processed up to 200GB total, with each part uploading directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:01:41 +01:00
14 changed files with 790 additions and 81 deletions

View File

@@ -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;

View File

@@ -409,9 +409,10 @@ model TelegramAccount {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
channelMaps AccountChannelMap[]
ingestionRuns IngestionRun[]
fetchRequests ChannelFetchRequest[]
channelMaps AccountChannelMap[]
ingestionRuns IngestionRun[]
fetchRequests ChannelFetchRequest[]
skippedPackages SkippedPackage[]
@@index([isActive])
@@map("telegram_accounts")
@@ -428,8 +429,9 @@ model TelegramChannel {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accountMaps AccountChannelMap[]
packages Package[]
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
// ───────────────────────────────────────

View File

@@ -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,12 +80,22 @@ 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">
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
{row.original.isMultipart && (
<Badge variant="outline" className="text-[10px] shrink-0">
Multi
</Badge>
<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">
Multi
</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>
),

View File

@@ -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,36 +502,45 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
depth={0}
search={search}
defaultOpen={true}
highlightTerm={highlightTerm}
/>
))}
</>
) : (
<>
{/* Flat list for archives without folders */}
{filtered.map((file) => (
<div
key={file.id}
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50 transition-colors"
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm truncate" title={file.path}>
{file.fileName}
</p>
{filtered.map((file) => {
const isHighlighted = highlightTerm ? fileMatchesHighlight(file, highlightTerm) : false;
return (
<div
key={file.id}
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">
<p className="text-sm truncate" title={file.path}>
{file.fileName}
</p>
</div>
{file.extension && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${getExtBadgeClass(file.extension)}`}
>
.{file.extension}
</Badge>
)}
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatBytes(file.uncompressedSize)}
</span>
</div>
{file.extension && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${getExtBadgeClass(file.extension)}`}
>
.{file.extension}
</Badge>
)}
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatBytes(file.uncompressedSize)}
</span>
</div>
))}
);
})}
</>
)}

View 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,
},
];
}

View 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>
);
}

View File

@@ -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,39 +151,63 @@ export function StlTable({
<IngestionStatus initialStatus={ingestionStatus} />
</PageHeader>
<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" />
<Input
placeholder="Search packages or files..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{availableTags.length > 0 && (
<Select value={activeTag || "all"} onValueChange={updateTagFilter}>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="All Tags" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tags</SelectItem>
{availableTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DataTableViewOptions table={table} />
</div>
<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>
<DataTable
table={table}
emptyMessage="No packages found. Archives will appear here after ingestion."
/>
<DataTablePagination table={table} totalCount={totalCount} />
<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" />
<Input
placeholder="Search packages or files..."
value={searchValue}
onChange={(e) => updateSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{availableTags.length > 0 && (
<Select value={activeTag || "all"} onValueChange={updateTagFilter}>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="All Tags" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tags</SelectItem>
{availableTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DataTableViewOptions table={table} />
</div>
<DataTable
table={table}
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>
);

View File

@@ -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" };
}
}

View File

@@ -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}
/>
);
}

View File

@@ -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();
}

View File

@@ -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: {

View File

@@ -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 },
});
}

View File

@@ -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 ?? "",

View File

@@ -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)`,