mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: pattern/creator grouping, notification UI, failure alerts
Pattern grouping (Signal 3): - Extract YYYY-MM dates, month names, and project prefixes from filenames - Auto-group packages sharing the same pattern within a channel - Groups created with groupingSource=AUTO_PATTERN Creator grouping (Signal 4): - Auto-group 3+ ungrouped packages from the same creator within a channel - Runs after pattern grouping as lowest-priority automatic signal Notification UI: - Add NotificationBell component to header with unread badge - Popover panel shows recent notifications with severity icons - Mark individual or all notifications as read - Polls every 30 seconds for updates Failure notifications: - Upload/download failures now create SystemNotification records - Visible in the notification bell alongside hash mismatch alerts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
src/app/api/notifications/read/route.ts
Normal file
26
src/app/api/notifications/read/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
} from "@/data/notification.queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const id = body.id as string | undefined;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await markNotificationRead(id);
|
||||||
|
} else {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
27
src/app/api/notifications/route.ts
Normal file
27
src/app/api/notifications/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
getRecentNotifications,
|
||||||
|
getUnreadNotificationCount,
|
||||||
|
} from "@/data/notification.queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [notifications, unreadCount] = await Promise.all([
|
||||||
|
getRecentNotifications(30),
|
||||||
|
getUnreadNotificationCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serialized = notifications.map((n) => ({
|
||||||
|
...n,
|
||||||
|
createdAt: n.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications: serialized, unreadCount });
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { UserMenu } from "./user-menu";
|
import { UserMenu } from "./user-menu";
|
||||||
import { MobileSidebar } from "./mobile-sidebar";
|
import { MobileSidebar } from "./mobile-sidebar";
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
const routeTitles: Record<string, string> = {
|
||||||
"/dashboard": "Dashboard",
|
"/dashboard": "Dashboard",
|
||||||
@@ -38,7 +39,8 @@ export function Header() {
|
|||||||
|
|
||||||
<h1 className="text-lg font-semibold">{title}</h1>
|
<h1 className="text-lg font-semibold">{title}</h1>
|
||||||
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<NotificationBell />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
183
src/components/layout/notification-bell.tsx
Normal file
183
src/components/layout/notification-bell.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Bell, AlertTriangle, AlertCircle, Info, CheckCircle2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
severity: "INFO" | "WARNING" | "ERROR";
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityIcon = {
|
||||||
|
INFO: Info,
|
||||||
|
WARNING: AlertTriangle,
|
||||||
|
ERROR: AlertCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityColor = {
|
||||||
|
INFO: "text-blue-400",
|
||||||
|
WARNING: "text-orange-400",
|
||||||
|
ERROR: "text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNotifications(data.notifications ?? []);
|
||||||
|
setUnreadCount(data.unreadCount ?? 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore fetch errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll every 30 seconds + on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
const interval = setInterval(fetchNotifications, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
// Refresh when popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) fetchNotifications();
|
||||||
|
}, [open, fetchNotifications]);
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notifications/read", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(id: string) {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notifications/read", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
|
||||||
|
);
|
||||||
|
setUnreadCount((c) => Math.max(0, c - 1));
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60_000);
|
||||||
|
if (diffMin < 1) return "just now";
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
return `${diffDay}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative h-9 w-9">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96 p-0" align="end">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">Notifications</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-[400px]">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-8 w-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">All clear!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{notifications.map((n) => {
|
||||||
|
const Icon = severityIcon[n.severity] ?? Info;
|
||||||
|
const color = severityColor[n.severity] ?? "text-muted-foreground";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
className={`flex w-full gap-3 px-4 py-3 text-left hover:bg-muted/50 transition-colors ${
|
||||||
|
!n.isRead ? "bg-muted/20" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => !n.isRead && handleMarkRead(n.id)}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 mt-0.5 shrink-0 ${color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={`text-sm truncate ${!n.isRead ? "font-medium" : ""}`}>
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
{!n.isRead && (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||||
|
{n.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
{formatTime(n.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/data/notification.queries.ts
Normal file
37
src/data/notification.queries.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getUnreadNotificationCount(): Promise<number> {
|
||||||
|
return prisma.systemNotification.count({
|
||||||
|
where: { isRead: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentNotifications(limit = 20) {
|
||||||
|
return prisma.systemNotification.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
severity: true,
|
||||||
|
title: true,
|
||||||
|
message: true,
|
||||||
|
isRead: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: string) {
|
||||||
|
return prisma.systemNotification.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead() {
|
||||||
|
return prisma.systemNotification.updateMany({
|
||||||
|
where: { isRead: false },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -608,3 +608,25 @@ export async function createTimeWindowGroup(input: {
|
|||||||
|
|
||||||
return group.id;
|
return group.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAutoGroup(input: {
|
||||||
|
sourceChannelId: string;
|
||||||
|
name: string;
|
||||||
|
packageIds: string[];
|
||||||
|
groupingSource: "AUTO_TIME" | "AUTO_PATTERN" | "AUTO_ZIP" | "AUTO_CAPTION";
|
||||||
|
}): Promise<string> {
|
||||||
|
const group = await db.packageGroup.create({
|
||||||
|
data: {
|
||||||
|
sourceChannelId: input.sourceChannelId,
|
||||||
|
name: input.name,
|
||||||
|
groupingSource: input.groupingSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.package.updateMany({
|
||||||
|
where: { id: { in: input.packageIds } },
|
||||||
|
data: { packageGroupId: group.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return group.id;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Client } from "tdl";
|
import type { Client } from "tdl";
|
||||||
import type { TelegramPhoto } from "./preview/match.js";
|
import type { TelegramPhoto } from "./preview/match.js";
|
||||||
import { downloadPhotoThumbnail } from "./tdlib/download.js";
|
import { downloadPhotoThumbnail } from "./tdlib/download.js";
|
||||||
import { createOrFindPackageGroup, linkPackagesToGroup, createTimeWindowGroup } from "./db/queries.js";
|
import { createOrFindPackageGroup, linkPackagesToGroup, createTimeWindowGroup, createAutoGroup } from "./db/queries.js";
|
||||||
import { config } from "./util/config.js";
|
import { config } from "./util/config.js";
|
||||||
import { childLogger } from "./util/logger.js";
|
import { childLogger } from "./util/logger.js";
|
||||||
import { db } from "./db/client.js";
|
import { db } from "./db/client.js";
|
||||||
@@ -150,6 +150,144 @@ export async function processTimeWindowGroups(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group ungrouped packages that share a date pattern (YYYY-MM, YYYY_MM, etc.)
|
||||||
|
* or project slug extracted from their filenames.
|
||||||
|
*/
|
||||||
|
export async function processPatternGroups(
|
||||||
|
sourceChannelId: string,
|
||||||
|
indexedPackages: IndexedPackageRef[]
|
||||||
|
): Promise<void> {
|
||||||
|
const ungrouped = await db.package.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: indexedPackages.map((p) => p.packageId) },
|
||||||
|
packageGroupId: null,
|
||||||
|
},
|
||||||
|
select: { id: true, fileName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ungrouped.length < 2) return;
|
||||||
|
|
||||||
|
// Group by extracted pattern
|
||||||
|
const patternMap = new Map<string, typeof ungrouped>();
|
||||||
|
for (const pkg of ungrouped) {
|
||||||
|
const pattern = extractPattern(pkg.fileName);
|
||||||
|
if (!pattern) continue;
|
||||||
|
const group = patternMap.get(pattern) ?? [];
|
||||||
|
group.push(pkg);
|
||||||
|
patternMap.set(pattern, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pattern, members] of patternMap) {
|
||||||
|
if (members.length < 2) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupId = await createAutoGroup({
|
||||||
|
sourceChannelId,
|
||||||
|
name: pattern,
|
||||||
|
packageIds: members.map((m) => m.id),
|
||||||
|
groupingSource: "AUTO_PATTERN",
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ groupId, pattern, memberCount: members.length },
|
||||||
|
"Created pattern-based group"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, pattern }, "Failed to create pattern group");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a grouping pattern from a filename.
|
||||||
|
* Matches: YYYY-MM, YYYY_MM, "Month Year", or a project prefix before common separators.
|
||||||
|
* Returns null if no usable pattern found.
|
||||||
|
*/
|
||||||
|
function extractPattern(fileName: string): string | null {
|
||||||
|
// Strip extension for matching
|
||||||
|
const name = fileName.replace(/\.(zip|rar|7z|pdf|stl)(\.\d+)?$/i, "");
|
||||||
|
|
||||||
|
// Match YYYY-MM or YYYY_MM patterns
|
||||||
|
const dateMatch = name.match(/(\d{4})[\-_](\d{2})/);
|
||||||
|
if (dateMatch) {
|
||||||
|
return `${dateMatch[1]}-${dateMatch[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match "Month Year" patterns (e.g., "January 2025", "Jan 2025")
|
||||||
|
const months = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)";
|
||||||
|
const monthYearMatch = name.match(new RegExp(`(${months})\\s*(\\d{4})`, "i"));
|
||||||
|
if (monthYearMatch) {
|
||||||
|
const monthStr = monthYearMatch[1].toLowerCase().slice(0, 3);
|
||||||
|
const monthNum = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"].indexOf(monthStr) + 1;
|
||||||
|
if (monthNum > 0) {
|
||||||
|
return `${monthYearMatch[2]}-${String(monthNum).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match project prefix: text before " - ", " – ", or "(". Must be at least 5 chars.
|
||||||
|
const prefixMatch = name.match(/^(.{5,}?)(?:\s*[\-–]\s|\s*\()/);
|
||||||
|
if (prefixMatch) {
|
||||||
|
return prefixMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group ungrouped packages that share the same creator within a channel.
|
||||||
|
* Only groups if there are 3+ packages from the same creator (to avoid
|
||||||
|
* over-grouping when a creator only has a couple files).
|
||||||
|
*/
|
||||||
|
export async function processCreatorGroups(
|
||||||
|
sourceChannelId: string,
|
||||||
|
indexedPackages: IndexedPackageRef[]
|
||||||
|
): Promise<void> {
|
||||||
|
const ungrouped = await db.package.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: indexedPackages.map((p) => p.packageId) },
|
||||||
|
packageGroupId: null,
|
||||||
|
creator: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true, fileName: true, creator: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ungrouped.length < 3) return;
|
||||||
|
|
||||||
|
// Group by creator
|
||||||
|
const creatorMap = new Map<string, typeof ungrouped>();
|
||||||
|
for (const pkg of ungrouped) {
|
||||||
|
if (!pkg.creator) continue;
|
||||||
|
const key = pkg.creator.toLowerCase();
|
||||||
|
const group = creatorMap.get(key) ?? [];
|
||||||
|
group.push(pkg);
|
||||||
|
creatorMap.set(key, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, members] of creatorMap) {
|
||||||
|
if (members.length < 3) continue;
|
||||||
|
|
||||||
|
const creatorName = members[0].creator!;
|
||||||
|
const name = findCommonPrefix(members.map((m) => m.fileName)) || creatorName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupId = await createAutoGroup({
|
||||||
|
sourceChannelId,
|
||||||
|
name,
|
||||||
|
packageIds: members.map((m) => m.id),
|
||||||
|
groupingSource: "AUTO_PATTERN",
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ groupId, creator: creatorName, memberCount: members.length },
|
||||||
|
"Created creator-based group"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, creator: creatorName }, "Failed to create creator group");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the longest common prefix among a list of filenames,
|
* Find the longest common prefix among a list of filenames,
|
||||||
* trimming trailing separators and partial words.
|
* trimming trailing separators and partial words.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { readRarContents } from "./archive/rar-reader.js";
|
|||||||
import { read7zContents } from "./archive/sevenz-reader.js";
|
import { read7zContents } from "./archive/sevenz-reader.js";
|
||||||
import { byteLevelSplit, concatenateFiles } from "./archive/split.js";
|
import { byteLevelSplit, concatenateFiles } from "./archive/split.js";
|
||||||
import { uploadToChannel } from "./upload/channel.js";
|
import { uploadToChannel } from "./upload/channel.js";
|
||||||
import { processAlbumGroups, processTimeWindowGroups, type IndexedPackageRef } from "./grouping.js";
|
import { processAlbumGroups, processTimeWindowGroups, processPatternGroups, processCreatorGroups, type IndexedPackageRef } from "./grouping.js";
|
||||||
import { db } from "./db/client.js";
|
import { db } from "./db/client.js";
|
||||||
import type { TelegramAccount, TelegramChannel } from "@prisma/client";
|
import type { TelegramAccount, TelegramChannel } from "@prisma/client";
|
||||||
import type { Client } from "tdl";
|
import type { Client } from "tdl";
|
||||||
@@ -777,6 +777,22 @@ async function processArchiveSets(
|
|||||||
partCount: archiveSet.parts.length,
|
partCount: archiveSet.parts.length,
|
||||||
accountId: ctx.accountId,
|
accountId: ctx.accountId,
|
||||||
});
|
});
|
||||||
|
// Also create a persistent notification
|
||||||
|
await db.systemNotification.create({
|
||||||
|
data: {
|
||||||
|
type: inferSkipReason(errMsg) === "UPLOAD_FAILED" ? "UPLOAD_FAILED" : "DOWNLOAD_FAILED",
|
||||||
|
severity: "WARNING",
|
||||||
|
title: `Failed to process ${archiveSet.parts[0].fileName}`,
|
||||||
|
message: errMsg,
|
||||||
|
context: {
|
||||||
|
fileName: archiveSet.parts[0].fileName,
|
||||||
|
sourceChannelId: ctx.channel.id,
|
||||||
|
sourceMessageId: Number(archiveSet.parts[0].id),
|
||||||
|
channelTitle: ctx.channelTitle,
|
||||||
|
reason: inferSkipReason(errMsg),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — don't fail the run if skip recording fails
|
// Best-effort — don't fail the run if skip recording fails
|
||||||
}
|
}
|
||||||
@@ -794,6 +810,12 @@ async function processArchiveSets(
|
|||||||
|
|
||||||
// Time-window grouping for remaining ungrouped packages
|
// Time-window grouping for remaining ungrouped packages
|
||||||
await processTimeWindowGroups(channel.id, indexedPackageRefs);
|
await processTimeWindowGroups(channel.id, indexedPackageRefs);
|
||||||
|
|
||||||
|
// Pattern-based grouping (date patterns, project slugs)
|
||||||
|
await processPatternGroups(channel.id, indexedPackageRefs);
|
||||||
|
|
||||||
|
// Creator-based grouping (3+ files from same creator)
|
||||||
|
await processCreatorGroups(channel.id, indexedPackageRefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxProcessedId;
|
return maxProcessedId;
|
||||||
|
|||||||
Reference in New Issue
Block a user