feat: add channel categories and improved creator detection

- Add category field to TelegramChannel (filterable tag like STL, PDF, D&D)
- Category column in channels table with edit via dropdown menu
- Improved creator extraction: filename patterns + channel title fallback
- extractCreatorFromChannelTitle strips [Completed], (Paid), emoji, etc.
- Fix ArchiveType in PackageListItem and PackageRow for new types
- Add Prisma migration for category column
This commit is contained in:
admin
2026-03-21 20:37:44 +01:00
parent 53a76a8136
commit 36a7e3d5f4
10 changed files with 126 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "telegram_channels" ADD COLUMN "category" VARCHAR(64);
-- CreateIndex
CREATE INDEX "telegram_channels_category_idx" ON "telegram_channels"("category");

View File

@@ -421,6 +421,7 @@ model TelegramChannel {
type ChannelType type ChannelType
isForum Boolean @default(false) isForum Boolean @default(false)
isActive Boolean @default(false) isActive Boolean @default(false)
category String? @db.VarChar(64)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -428,6 +429,7 @@ model TelegramChannel {
packages Package[] packages Package[]
@@index([type, isActive]) @@index([type, isActive])
@@index([category])
@@map("telegram_channels") @@map("telegram_channels")
} }

View File

@@ -12,7 +12,7 @@ export interface PackageRow {
fileName: string; fileName: string;
fileSize: string; fileSize: string;
contentHash: string; contentHash: string;
archiveType: "ZIP" | "RAR"; archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
fileCount: number; fileCount: number;
isMultipart: boolean; isMultipart: boolean;
hasPreview: boolean; hasPreview: boolean;

View File

@@ -8,6 +8,7 @@ import {
ArrowDownToLine, ArrowDownToLine,
ArrowUpFromLine, ArrowUpFromLine,
RefreshCcw, RefreshCcw,
Tag,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ interface ChannelColumnsProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void; onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
onRescan: (id: string) => void; onRescan: (id: string) => void;
onSetCategory: (id: string, category: string | null) => void;
} }
export function getChannelColumns({ export function getChannelColumns({
@@ -32,6 +34,7 @@ export function getChannelColumns({
onDelete, onDelete,
onSetType, onSetType,
onRescan, onRescan,
onSetCategory,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] { }: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [ return [
{ {
@@ -63,6 +66,18 @@ export function getChannelColumns({
</Badge> </Badge>
), ),
}, },
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => {
const category = row.original.category;
return category ? (
<Badge variant="outline">{category}</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
);
},
},
{ {
accessorKey: "isActive", accessorKey: "isActive",
header: "Status", header: "Status",
@@ -132,6 +147,15 @@ export function getChannelColumns({
Rescan Channel Rescan Channel
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem
onClick={() => {
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
if (cat !== null) onSetCategory(row.original.id, cat || null);
}}
>
<Tag className="mr-2 h-3.5 w-3.5" />
Set Category
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onToggleActive(row.original.id)} onClick={() => onToggleActive(row.original.id)}
> >

View File

@@ -10,6 +10,7 @@ import {
deleteChannel, deleteChannel,
toggleChannelActive, toggleChannelActive,
setChannelType, setChannelType,
setChannelCategory,
rescanChannel, rescanChannel,
} from "../actions"; } from "../actions";
import { DataTable } from "@/components/shared/data-table"; import { DataTable } from "@/components/shared/data-table";
@@ -50,6 +51,13 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
}); });
}, },
onRescan: (id) => setRescanId(id), onRescan: (id) => setRescanId(id),
onSetCategory: (id, category) => {
startTransition(async () => {
const result = await setChannelCategory(id, category);
if (result.success) toast.success(category ? `Category set to "${category}"` : "Category removed");
else toast.error(result.error);
});
},
}); });
const { table } = useDataTable({ const { table } = useDataTable({

View File

@@ -259,6 +259,25 @@ export async function deleteChannel(id: string): Promise<ActionResult> {
} }
} }
export async function setChannelCategory(
id: string,
category: string | null
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
try {
await prisma.telegramChannel.update({
where: { id },
data: { category: category?.trim() || null },
});
revalidatePath("/telegram");
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update category" };
}
}
export async function setChannelType( export async function setChannelType(
id: string, id: string,
type: "SOURCE" | "DESTINATION" type: "SOURCE" | "DESTINATION"

View File

@@ -42,6 +42,7 @@ export async function listChannels() {
title: c.title, title: c.title,
type: c.type, type: c.type,
isActive: c.isActive, isActive: c.isActive,
category: c.category,
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
accountCount: c._count.accountMaps, accountCount: c._count.accountMaps,
packageCount: c._count.packages, packageCount: c._count.packages,

View File

@@ -3,7 +3,7 @@ export interface PackageListItem {
fileName: string; fileName: string;
fileSize: string; // BigInt serialized as string fileSize: string; // BigInt serialized as string
contentHash: string; contentHash: string;
archiveType: "ZIP" | "RAR"; archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
fileCount: number; fileCount: number;
isMultipart: boolean; isMultipart: boolean;
hasPreview: boolean; hasPreview: boolean;

View File

@@ -1,21 +1,71 @@
/** /**
* Extract a creator name from common archive file naming patterns. * Extract a creator name from common archive file naming patterns.
* *
* Priority in the worker: topic name > filename extraction. * Priority in the worker: topic name > filename extraction > channel title > null.
* This is the fallback when no forum topic name is available.
* *
* Patterns handled (split on ` - `): * Patterns handled:
* "Mammoth Factory - 2026-01.zip" → "Mammoth Factory" * "Mammoth Factory - 2026-01.zip" → "Mammoth Factory"
* "Artist Name - Pack Title.part01.rar" → "Artist Name" * "Artist Name - Pack Title.part01.rar" → "Artist Name"
* "ArtistName_PackTitle.zip" → null (ambiguous)
* "some_random_file.zip" → null * "some_random_file.zip" → null
*/ */
export function extractCreatorFromFileName(fileName: string): string | null { export function extractCreatorFromFileName(fileName: string): string | null {
// Strip archive extensions (.zip, .rar, .part01.rar, .z01, etc.) // Strip archive/document extensions
const bare = fileName.replace(/(\.(part\d+\.rar|z\d{2}|zip|rar))+$/i, ""); const bare = fileName.replace(
/(\.(part\d+\.rar|z\d{2}|zip|rar|7z|pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd))+$/i,
""
);
const idx = bare.indexOf(" - "); // Pattern 1: "Creator - Title" (most common)
if (idx <= 0) return null; const dashIdx = bare.indexOf(" - ");
if (dashIdx > 0) {
const creator = bare.slice(0, idx).trim(); const creator = bare.slice(0, dashIdx).trim();
return creator.length > 0 ? creator : null; if (creator.length > 1) return creator;
}
// Pattern 2: "Creator_Title" with underscores where first segment looks like a name
// Only match if the first segment has a space or capital letter pattern suggesting a name
const underscoreIdx = bare.indexOf("_");
if (underscoreIdx > 2) {
const candidate = bare.slice(0, underscoreIdx).trim();
// Accept if it contains a space (multi-word) or starts with upper + has lower (proper name)
if (candidate.includes(" ") || /^[A-Z][a-z]/.test(candidate)) {
return candidate;
}
}
return null;
}
/**
* Extract a creator name from a Telegram channel title.
* Strips common suffixes like "[Completed]", "(Paid)", dates, etc.
*/
export function extractCreatorFromChannelTitle(title: string): string | null {
let clean = title
// Remove bracketed suffixes: [Completed], [Open], [Closed], etc.
.replace(/\s*\[.*?\]\s*/g, " ")
// Remove parenthesized suffixes: (Paid), (partial upload...), etc.
.replace(/\s*\(.*?\)\s*/g, " ")
// Remove common emoji
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, "")
.trim();
// If there's a " - " separator, take the first part as creator
const dashIdx = clean.indexOf(" - ");
if (dashIdx > 0) {
clean = clean.slice(0, dashIdx).trim();
}
// Too generic or too short
if (clean.length < 2) return null;
// Skip overly generic channel names
const generic = [
"3d printing", "stl", "free stl", "stl zone", "stl forest", "stl all",
"marvel stl", "dc stl", "star wars stl", "pokemon stl",
];
if (generic.includes(clean.toLowerCase())) return null;
return clean;
} }

View File

@@ -36,7 +36,7 @@ import { isChatForum, getForumTopicList, getTopicMessages } from "./tdlib/topics
import { matchPreviewToArchive } from "./preview/match.js"; import { matchPreviewToArchive } from "./preview/match.js";
import { groupArchiveSets } from "./archive/multipart.js"; import { groupArchiveSets } from "./archive/multipart.js";
import type { ArchiveSet } from "./archive/multipart.js"; import type { ArchiveSet } from "./archive/multipart.js";
import { extractCreatorFromFileName } from "./archive/creator.js"; import { extractCreatorFromFileName, extractCreatorFromChannelTitle } from "./archive/creator.js";
import { hashParts } from "./archive/hash.js"; import { hashParts } from "./archive/hash.js";
import { readZipCentralDirectory } from "./archive/zip-reader.js"; import { readZipCentralDirectory } from "./archive/zip-reader.js";
import { readRarContents } from "./archive/rar-reader.js"; import { readRarContents } from "./archive/rar-reader.js";
@@ -968,8 +968,11 @@ async function processOneArchiveSet(
previewMsgId = matchedPhoto.id; previewMsgId = matchedPhoto.id;
} }
// ── Resolve creator: topic name > filename extraction > null ── // ── Resolve creator: topic name > filename extraction > channel title > null ──
const creator = topicCreator ?? extractCreatorFromFileName(archiveName) ?? null; const creator = topicCreator
?? extractCreatorFromFileName(archiveName)
?? extractCreatorFromChannelTitle(channelTitle)
?? null;
// ── Indexing ── // ── Indexing ──
await updateRunActivity(runId, { await updateRunActivity(runId, {