Add release blocklist feature

Introduce a per-request release blocklist to auto-block permanently failing releases and provide admin management. Changes include:

- Database: add BlockedRelease model (blocked_releases) to Prisma schema with unique (requestId, releaseKey) and indexes; documented in backend database docs.
- Service & utils: new blocklist.service, release-key and filter helpers for normalization and matching; processors updated to emit auto-blocks (monitor-download, organize-files, search processors, RSS).
- HTTP API: add admin endpoints GET/DELETE /api/admin/blocklist, DELETE /api/admin/blocklist/[id], and GET /api/admin/blocklist/by-request/[requestId].
- Admin UI: new /admin/blocklist page and numerous React components (toolbar, filters, table, rows, pagination, skeleton, chips, date picker) with URL-driven state hook and per-row unblock UX.
- Tests: add unit/integration tests for service, routes, utils, and updated processor tests.

The blocklist is idempotent (upsert), filters search results before ranking (interactive search shows badges only), and admin-only APIs require auth. This commit wires docs, API, DB, frontend and tests for the new feature.
This commit is contained in:
kikootwo
2026-05-18 12:15:51 -04:00
parent fb0445d95f
commit b1492fc32e
41 changed files with 4098 additions and 12 deletions
@@ -0,0 +1,73 @@
/**
* Component: Blocked Results Filter Tests
* Documentation: documentation/backend/database.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const getBlocklistForRequestMock = vi.fn();
vi.mock('@/lib/services/blocklist.service', () => ({
getBlocklistForRequest: getBlocklistForRequestMock,
}));
describe('filterBlockedResults', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns input unchanged when results array is empty', async () => {
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
const { kept, blockedCount } = await filterBlockedResults('req-1', []);
expect(kept).toEqual([]);
expect(blockedCount).toBe(0);
// Empty results should short-circuit before hitting the DB.
expect(getBlocklistForRequestMock).not.toHaveBeenCalled();
});
it('returns input unchanged when blocklist is empty', async () => {
getBlocklistForRequestMock.mockResolvedValueOnce([]);
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
const results = [{ title: 'Some Release' }];
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
expect(kept).toBe(results);
expect(blockedCount).toBe(0);
});
it('removes results that match a blocked release name case-insensitively', async () => {
getBlocklistForRequestMock.mockResolvedValueOnce([
{ releaseKey: 'foo bar [m4b]', releaseHash: null },
]);
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
const { kept, blockedCount } = await filterBlockedResults('req-1', [
{ title: ' FOO BAR [M4B] ' },
{ title: 'Other Release' },
]);
expect(kept).toEqual([{ title: 'Other Release' }]);
expect(blockedCount).toBe(1);
});
it('removes results that match by infoHash even when title differs', async () => {
getBlocklistForRequestMock.mockResolvedValueOnce([
{ releaseKey: 'something else', releaseHash: 'abc123' },
]);
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
const { kept, blockedCount } = await filterBlockedResults('req-1', [
{ title: 'Different Title', infoHash: 'abc123' },
{ title: 'Keep Me', infoHash: 'zzz999' },
]);
expect(kept).toEqual([{ title: 'Keep Me', infoHash: 'zzz999' }]);
expect(blockedCount).toBe(1);
});
it('does not filter by hash when the result has no infoHash', async () => {
getBlocklistForRequestMock.mockResolvedValueOnce([
{ releaseKey: 'unrelated', releaseHash: 'abc123' },
]);
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
const results = [{ title: 'No Hash Result' }];
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
expect(kept).toEqual(results);
expect(blockedCount).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Component: Release Key Normalizer Tests
* Documentation: documentation/backend/database.md
*/
import { describe, expect, it } from 'vitest';
import { normalizeReleaseKey } from '@/lib/utils/release-key';
describe('normalizeReleaseKey', () => {
it('lowercases ASCII characters', () => {
expect(normalizeReleaseKey('SomeReleaseName')).toBe('somereleasename');
});
it('trims leading and trailing whitespace', () => {
expect(normalizeReleaseKey(' hello ')).toBe('hello');
});
it('combines trim and lowercase', () => {
expect(normalizeReleaseKey(' MIXED.Case Release ')).toBe('mixed.case release');
});
it('preserves internal whitespace', () => {
expect(normalizeReleaseKey('the templar legacy')).toBe('the templar legacy');
});
it('returns empty string for empty input', () => {
expect(normalizeReleaseKey('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeReleaseKey(' ')).toBe('');
});
it('passes through unicode characters (with native lowercasing)', () => {
expect(normalizeReleaseKey('Éclair')).toBe('éclair');
});
it('is idempotent — normalizing a normalized value is a no-op', () => {
const once = normalizeReleaseKey(' Some Release ');
expect(normalizeReleaseKey(once)).toBe(once);
});
it('treats different-case variants of the same release as the same key', () => {
expect(normalizeReleaseKey('THE.TEMPLAR.LEGACY')).toBe(
normalizeReleaseKey('the.templar.legacy')
);
expect(normalizeReleaseKey(' The.Templar.Legacy ')).toBe(
normalizeReleaseKey('the.templar.legacy')
);
});
});