feat(sources): site kinds and capabilities (IA-2)
site.kind: pt / public_bt / rss_feed / torznab. Each kind declares the acquisition modes it may support (search, list, rss, detail, login, userinfo); a site's effective capabilities = kind ∩ site definition. torznab only defines TorznabSiteConfig and cannot be added yet. Migration 0055: generic_rss sites become rss_feed, the rest pt; feeds without a site are grouped by host into new custom RSS source sites (cursor window and conditional headers cleared so the next fetch writes releases). User DB read-only dry-run: 0 orphan feeds. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f0e1c77e35
commit
89d2c81de6
6 files changed
+317
-12
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
|
||||
|
||||
// 0055:site.kind;generic_rss 站点 → rss_feed;不属于任何站点的 RSS 按主机归入「自定义 RSS 源」站点。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("0055 站点类型迁移", () => {
|
||||
const schema = `ia2_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const admin = async (q: string) => {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
await c.query(q).finally(() => c.end());
|
||||
};
|
||||
let db: Kysely<DB>;
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
|
||||
});
|
||||
|
||||
it("存量站点定类型;孤立 RSS 按主机建站点、清游标窗口;回滚只删列", async () => {
|
||||
await admin(`create schema ${schema}`);
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
await sql`insert into site (key, name, adapter, base_url) values
|
||||
('audiences', '观众', 'nexusphp', 'https://audiences.example/'),
|
||||
('myrss', '旧自定义', 'generic_rss', 'https://old.example/'),
|
||||
('rss-nyaa-si', '占用', 'generic_rss', 'https://taken.example/')`.execute(db);
|
||||
const cursor = JSON.stringify({ window: { lastGuidSet: ["a"], lastFetchedAt: "2026-09-01T00:00:00Z" } });
|
||||
await sql`insert into feed_source (site_id, name, url_display, etag, cursor) values
|
||||
(null, 'A', 'https://nyaa.si/?page=rss&q=frieren', 'W/"1"', ${cursor}::jsonb),
|
||||
(null, 'B', 'https://nyaa.si/?page=rss&q=dune', null, ${cursor}::jsonb),
|
||||
(null, 'C', 'https://www.example.org/feed?passkey=[REDACTED]', null, '{}'::jsonb),
|
||||
((select id from site where key = 'audiences'), 'D', 'https://audiences.example/rss', 'W/"2"', ${cursor}::jsonb)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const sites = await sql<{ key: string; name: string; kind: string; base_url: string; search_default: boolean }>`
|
||||
select key, name, kind, base_url, search_default from site order by key`.execute(db);
|
||||
expect(sites.rows).toEqual([
|
||||
{ key: "audiences", name: "观众", kind: "pt", base_url: "https://audiences.example/", search_default: true },
|
||||
{ key: "myrss", name: "旧自定义", kind: "rss_feed", base_url: "https://old.example/", search_default: true },
|
||||
{
|
||||
key: "rss-example-org",
|
||||
name: "www.example.org",
|
||||
kind: "rss_feed",
|
||||
base_url: "https://www.example.org",
|
||||
search_default: false,
|
||||
},
|
||||
{ key: "rss-nyaa-si", name: "占用", kind: "rss_feed", base_url: "https://taken.example/", search_default: true },
|
||||
{ key: "rss-nyaa-si-2", name: "nyaa.si", kind: "rss_feed", base_url: "https://nyaa.si", search_default: false },
|
||||
]);
|
||||
const feeds = await sql<{ name: string; key: string; etag: string | null; cursor: Record<string, unknown> }>`
|
||||
select f.name, s.key, f.etag, f.cursor from feed_source f join site s on s.id = f.site_id order by f.name`.execute(
|
||||
db,
|
||||
);
|
||||
expect(feeds.rows.map((f) => [f.name, f.key])).toEqual([
|
||||
["A", "rss-nyaa-si-2"],
|
||||
["B", "rss-nyaa-si-2"],
|
||||
["C", "rss-example-org"],
|
||||
["D", "audiences"],
|
||||
]);
|
||||
// 迁移过来的 RSS 清掉窗口与条件请求头(下次抓取重新写成资源);原本属于站点的不动
|
||||
expect(feeds.rows[0]).toMatchObject({ etag: null, cursor: {} });
|
||||
expect(feeds.rows[3]!.etag).toBe('W/"2"');
|
||||
expect(feeds.rows[3]!.cursor).toHaveProperty("window");
|
||||
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
const cols = await sql<{ n: number }>`select count(*)::int as n from information_schema.columns
|
||||
where table_schema = ${schema} and table_name = 'site' and column_name = 'kind'`.execute(db);
|
||||
expect(cols.rows[0]!.n).toBe(0);
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user