From 5b1df7920d290e470f2931be817e3b529486ece1 Mon Sep 17 00:00:00 2001 From: defnone <141522332+defnone@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:53:46 +0700 Subject: [PATCH 1/3] chore: add EVENTS-DOC.MD to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index eff77f1..77ed1a0 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ server/data trakt-proxy/wrangler.jsonc trakt-proxy/.wrangler +EVENTS-DOC.MD From f5df72d76d0f09261ec0e8fd97b2ed137f70c871 Mon Sep 17 00:00:00 2001 From: defnone <141522332+defnone@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:35:25 +0700 Subject: [PATCH 2/3] chore: use single quotes in prettier --- .prettierrc.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.prettierrc.json b/.prettierrc.json index ab09b99..44b507a 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,6 @@ { + "singleQuote": true, + "jsxSingleQuote": true, "overrides": [ { "files": "*.jsonc", From 217c6ed134e27cab70b96e14171d68b16406db85 Mon Sep 17 00:00:00 2001 From: defnone <141522332+defnone@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:34:17 +0700 Subject: [PATCH 3/3] server: add event journal --- client/src/components/layout/Header.tsx | 19 +- .../src/components/settings/SettingsMenu.tsx | 16 +- client/src/routes.tsx | 11 +- client/src/routes/dashboard/journal.tsx | 605 ++++++++++++++++++ server/src/db/app/app-schema.ts | 45 ++ .../db/migrations/0003_add_event_journal.sql | 16 + .../0004_add_event_journal_state.sql | 1 + server/src/db/migrations/meta/_journal.json | 16 +- .../event-journal/event-journal.mappers.ts | 21 + .../event-journal/event-journal.port.ts | 46 ++ .../event-journal/event-journal.repo.ts | 70 ++ .../event-journal/event-journal.service.ts | 209 ++++++ .../event-journal/event-journal.types.ts | 27 + server/src/index.ts | 8 +- server/src/routes/event-journal.$id.read.ts | 43 ++ server/src/routes/event-journal.read-all.ts | 22 + server/src/routes/event-journal.ts | 36 ++ server/src/workers/download-worker.ts | 78 ++- server/src/workers/update-worker.ts | 92 ++- .../test/download-worker-copy-error.test.ts | 40 ++ .../download-worker-error-message.test.ts | 40 ++ server/test/download-worker.test.ts | 112 +++- server/test/event-journal.repo.test.ts | 147 +++++ server/test/event-journal.service.test.ts | 185 ++++++ .../test/routes/event-journal-route.test.ts | 128 ++++ .../test/update-worker-error-message.test.ts | 40 ++ server/test/update-worker.test.ts | 135 +++- 27 files changed, 2146 insertions(+), 62 deletions(-) create mode 100644 client/src/routes/dashboard/journal.tsx create mode 100644 server/src/db/migrations/0003_add_event_journal.sql create mode 100644 server/src/db/migrations/0004_add_event_journal_state.sql create mode 100644 server/src/features/event-journal/event-journal.mappers.ts create mode 100644 server/src/features/event-journal/event-journal.port.ts create mode 100644 server/src/features/event-journal/event-journal.repo.ts create mode 100644 server/src/features/event-journal/event-journal.service.ts create mode 100644 server/src/features/event-journal/event-journal.types.ts create mode 100644 server/src/routes/event-journal.$id.read.ts create mode 100644 server/src/routes/event-journal.read-all.ts create mode 100644 server/src/routes/event-journal.ts create mode 100644 server/test/event-journal.repo.test.ts create mode 100644 server/test/event-journal.service.test.ts create mode 100644 server/test/routes/event-journal-route.test.ts diff --git a/client/src/components/layout/Header.tsx b/client/src/components/layout/Header.tsx index bf0c2da..e46b74d 100644 --- a/client/src/components/layout/Header.tsx +++ b/client/src/components/layout/Header.tsx @@ -48,7 +48,8 @@ export default function Header() { onClick={() => { setStartFetch(Date.now()); navigate('/'); - }}> + }} + > {lastSync && ( @@ -64,8 +65,9 @@ export default function Header() { variant='outline' className={cn( pathname === '/discover' && - 'bg-secondary text-secondary-foreground' - )}> + 'bg-secondary text-secondary-foreground', + )} + > @@ -77,8 +79,10 @@ export default function Header() { variant='outline' onClick={() => navigate('/search')} className={cn( - pathname === '/search' && 'bg-secondary text-secondary-foreground' - )}> + pathname === '/search' && + 'bg-secondary text-secondary-foreground', + )} + > @@ -88,8 +92,9 @@ export default function Header() { onClick={() => navigate('/settings')} className={cn( pathname === '/settings' && - 'bg-secondary text-secondary-foreground' - )}> + 'bg-secondary text-secondary-foreground', + )} + > diff --git a/client/src/components/settings/SettingsMenu.tsx b/client/src/components/settings/SettingsMenu.tsx index ad8cca7..3f73e42 100644 --- a/client/src/components/settings/SettingsMenu.tsx +++ b/client/src/components/settings/SettingsMenu.tsx @@ -10,7 +10,8 @@ export default function SettingsMenu() { @@ -19,9 +20,20 @@ export default function SettingsMenu() { variant={ pathname === '/settings/credentials' ? 'secondary' : 'outline' } - className='font-bold'> + className='font-bold' + > Trackers Credentials + + ); diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 434c1b3..9e694af 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -7,11 +7,12 @@ import { lazy } from 'react'; const Dashboard = lazy(() => import('./routes/dashboard')); const Discover = lazy(() => import('./routes/dashboard/discover')); const Search = lazy(() => import('./routes/dashboard/search')); +const SettingsEventJournal = lazy(() => import('./routes/dashboard/journal')); const Settings = lazy(() => import('./routes/dashboard/settings')); const SignUp = lazy(() => import('./routes/sign-up')); const Login = lazy(() => import('./routes/login')); const CredentialsSettings = lazy( - () => import('./routes/dashboard/settings.credentials') + () => import('./routes/dashboard/settings.credentials'), ); // eslint-disable-next-line react-refresh/only-export-components @@ -36,6 +37,10 @@ export const routes = [ path: '/settings', meta: { title: 'Settings', description: '' }, }, + { + path: '/settings/event-journal', + meta: { title: 'Events Journal', description: '' }, + }, { path: '/sign-up', meta: { title: 'Sign Up', description: '' }, @@ -55,6 +60,10 @@ export default function Router() { } /> } /> } /> + } + /> } diff --git a/client/src/routes/dashboard/journal.tsx b/client/src/routes/dashboard/journal.tsx new file mode 100644 index 0000000..4f86f59 --- /dev/null +++ b/client/src/routes/dashboard/journal.tsx @@ -0,0 +1,605 @@ +import { Button } from '@/components/ui/button'; +import SettingsMenu from '@/components/settings/SettingsMenu'; +import { rpc } from '@/lib/rpc'; +import type { + EventJournalDto, + EventJournalPageDto, + EventJournalType, +} from '@server/features/event-journal/event-journal.types'; +import { + useInfiniteQuery, + useMutation, + useQueryClient, + type InfiniteData, +} from '@tanstack/react-query'; +import { LoaderCircle } from 'lucide-react'; +import { useEffect, useMemo, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +const EVENTS_PAGE_LIMIT = 30; +const EVENT_JOURNAL_QUERY_KEY = ['event-journal'] as const; +const USE_DEV_EVENT_JOURNAL = import.meta.env.DEV; + +export default function Journal() { + const queryClient = useQueryClient(); + const loadMoreRef = useRef(null); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + status, + } = useInfiniteQuery({ + queryKey: EVENT_JOURNAL_QUERY_KEY, + queryFn: async ({ pageParam }): Promise => { + if (USE_DEV_EVENT_JOURNAL) { + return getMockEventJournalPage(pageParam); + } + + const response = await rpc.api['event-journal'].$get({ + query: { + page: String(pageParam), + limit: String(EVENTS_PAGE_LIMIT), + }, + }); + const body = await response.json(); + if (!body.success || !body.data) { + throw new Error(body.message || 'Failed to load event journal'); + } + return body.data; + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.page + 1 : undefined, + }); + + const markAsReadMutation = useMutation({ + mutationFn: async (id: number): Promise => { + if (USE_DEV_EVENT_JOURNAL) { + return markMockEventAsRead(id); + } + + const response = await rpc.api['event-journal'][':id'].read.$put({ + param: { id: String(id) }, + }); + const body = await response.json(); + if (!body.success || !body.data) { + throw new Error(body.message || 'Failed to mark event as read'); + } + return body.data; + }, + onSuccess: (updatedEvent) => { + queryClient.setQueryData>( + EVENT_JOURNAL_QUERY_KEY, + (currentData) => updateReadEventInPages(currentData, updatedEvent) + ); + }, + }); + + const markAllAsReadMutation = useMutation({ + mutationFn: async (): Promise => { + if (USE_DEV_EVENT_JOURNAL) { + return markAllMockEventsAsRead(); + } + + const response = await rpc.api['event-journal']['read-all'].$put(); + const body = await response.json(); + if (!body.success || !body.data) { + throw new Error(body.message || 'Failed to mark all events as read'); + } + return body.data; + }, + onSuccess: () => { + queryClient.setQueryData>( + EVENT_JOURNAL_QUERY_KEY, + markAllReadEventsInPages + ); + }, + }); + + const events = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data] + ); + const hasUnreadEvents = events.some((event) => event.readAt === null); + + useEffect(() => { + const target = loadMoreRef.current; + if (!target || !hasNextPage) return; + + const observer = new IntersectionObserver((entries) => { + const [entry] = entries; + if (entry?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }); + + observer.observe(target); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + if (status === 'error') { + return ( +
+ + +

{error.message}

+
+ ); + } + + return ( +
+ +
+
+ {isLoading && ( + + )} + +
+
+ + {!isLoading && events.length === 0 && ( +
+ No events yet +
+ )} + +
+ {events.map((event) => ( + markAsReadMutation.mutate(id)} + /> + ))} +
+ +
+ {isFetchingNextPage && ( + + )} +
+
+ ); +} + +function JournalEventRow({ + event, + isMarkingAsRead, + onMarkAsRead, +}: { + event: EventJournalDto; + isMarkingAsRead: boolean; + onMarkAsRead: (id: number) => void; +}) { + const isUnread = event.readAt === null; + + const handleMarkAsRead = () => { + if (!isUnread || isMarkingAsRead || hasSelectedText()) return; + onMarkAsRead(event.id); + }; + + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handleMarkAsRead(); + }} + className='grid w-full grid-cols-[10px_1fr] gap-4 border-b border-zinc-800 py-7 px-2 text-left transition-colors hover:bg-zinc-900/70' + > + + {isUnread && } + + +
+ + {formatEventTitle(event.type, event.torrentTitle)} + + + +
+
+ ); +} + +function hasSelectedText(): boolean { + return window.getSelection()?.toString().trim().length ? true : false; +} + +function formatEventTitle( + type: EventJournalType, + torrentTitle: string +): string { + switch (type) { + case 'torrentTitleChanged': + return `Torrent ${torrentTitle} updated because title changed`; + case 'torrentMagnetChanged': + return `Torrent ${torrentTitle} updated because magnet changed`; + case 'torrentSyncFailed': + return `Torrent ${torrentTitle} sync failed`; + case 'torrentDownloadStarted': + return `Torrent ${torrentTitle} download started`; + case 'torrentDownloadCompleted': + return `Torrent ${torrentTitle} download completed`; + case 'torrentDownloadFailed': + return `Torrent ${torrentTitle} download failed`; + case 'torrentFileCopyStarted': + return `Torrent ${torrentTitle} file copy started`; + case 'torrentFileCopyCompleted': + return `Torrent ${torrentTitle} file copy completed`; + case 'torrentFileCopyFailed': + return `Torrent ${torrentTitle} file copy failed`; + } +} + +function formatEventDetails(event: EventJournalDto): string { + if (!event.oldValue && !event.newValue) { + return 'No value details'; + } + + if (!event.oldValue && event.newValue) { + return event.newValue; + } + + if (event.oldValue && !event.newValue) { + return event.oldValue; + } + + return `${event.oldValue ?? 'Empty value'} -> ${ + event.newValue ?? 'Empty value' + }`; +} + +function JournalEventDetails({ event }: { event: EventJournalDto }) { + if (event.oldValue && event.newValue) { + return ; + } + + return ( +

+ {formatEventDetails(event)} +

+ ); +} + +function DiffDetails({ + oldValue, + newValue, +}: { + oldValue: string; + newValue: string; +}) { + const diff = buildDiffParts(oldValue, newValue); + + return ( +
+

+ {diff.oldParts.map((part, index) => ( + + ))} +

+

+ {diff.newParts.map((part, index) => ( + + ))} +

+
+ ); +} + +function DiffPart({ + part, + variant, +}: { + part: DiffPartValue; + variant: 'added' | 'removed'; +}) { + if (!part.changed) { + return {part.value}; + } + + return ( + + {part.value} + + ); +} + +type DiffPartValue = { + value: string; + changed: boolean; +}; + +function buildDiffParts( + oldValue: string, + newValue: string +): { oldParts: DiffPartValue[]; newParts: DiffPartValue[] } { + const oldTokens = tokenizeDiffValue(oldValue); + const newTokens = tokenizeDiffValue(newValue); + const prefixLength = getCommonPrefixLength(oldTokens, newTokens); + const suffixLength = getCommonSuffixLength( + oldTokens, + newTokens, + prefixLength + ); + + return { + oldParts: createDiffParts(oldTokens, prefixLength, suffixLength), + newParts: createDiffParts(newTokens, prefixLength, suffixLength), + }; +} + +function tokenizeDiffValue(value: string): string[] { + return value.match(/\S+|\s+/g) ?? []; +} + +function getCommonPrefixLength( + oldTokens: string[], + newTokens: string[] +): number { + const maxLength = Math.min(oldTokens.length, newTokens.length); + let index = 0; + + while (index < maxLength && oldTokens[index] === newTokens[index]) { + index += 1; + } + + return index; +} + +function getCommonSuffixLength( + oldTokens: string[], + newTokens: string[], + prefixLength: number +): number { + let suffixLength = 0; + const maxLength = Math.min( + oldTokens.length - prefixLength, + newTokens.length - prefixLength + ); + + while ( + suffixLength < maxLength && + oldTokens[oldTokens.length - 1 - suffixLength] === + newTokens[newTokens.length - 1 - suffixLength] + ) { + suffixLength += 1; + } + + return suffixLength; +} + +function createDiffParts( + tokens: string[], + prefixLength: number, + suffixLength: number +): DiffPartValue[] { + const changedStart = prefixLength; + const changedEnd = tokens.length - suffixLength; + + return tokens.map((value, index) => ({ + value, + changed: index >= changedStart && index < changedEnd, + })); +} + +function updateReadEventInPages( + currentData: InfiniteData | undefined, + updatedEvent: EventJournalDto +): InfiniteData | undefined { + if (!currentData) return currentData; + + return { + ...currentData, + pages: currentData.pages.map((page) => ({ + ...page, + items: page.items.map((event) => + event.id === updatedEvent.id ? updatedEvent : event + ), + })), + }; +} + +function markAllReadEventsInPages( + currentData: InfiniteData | undefined +): InfiniteData | undefined { + if (!currentData) return currentData; + const readAt = Date.now(); + + return { + ...currentData, + pages: currentData.pages.map((page) => ({ + ...page, + items: page.items.map((event) => ({ + ...event, + readAt: event.readAt ?? readAt, + })), + })), + }; +} + +const mockEventJournalItems: EventJournalDto[] = [ + { + id: 1, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 101, + torrentTitle: 'Resident Alien', + oldValue: 'Resident Alien S03 1080p WEB-DL', + newValue: 'Resident Alien S03 1080p WEB-DL Proper', + isNotification: true, + readAt: null, + createdAt: Date.now() - 1000 * 60 * 12, + }, + { + id: 2, + type: 'torrentMagnetChanged', + state: 'info', + torrentItemId: 102, + torrentTitle: 'House M.D.', + oldValue: 'magnet:?xt=urn:btih:OLDHOUSEHASH', + newValue: 'magnet:?xt=urn:btih:NEWHOUSEHASH', + isNotification: false, + readAt: null, + createdAt: Date.now() - 1000 * 60 * 45, + }, + { + id: 3, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 103, + torrentTitle: 'Clarksons Farm', + oldValue: 'Clarksons Farm S04 E01-E04', + newValue: 'Clarksons Farm S04 E01-E08', + isNotification: false, + readAt: Date.now() - 1000 * 60 * 30, + createdAt: Date.now() - 1000 * 60 * 90, + }, + { + id: 4, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 104, + torrentTitle: 'Slow Horses', + oldValue: 'Slow Horses S04 E01-E04', + newValue: 'Slow Horses S04 E01-E06', + isNotification: true, + readAt: null, + createdAt: Date.now() - 1000 * 60 * 120, + }, + { + id: 5, + type: 'torrentSyncFailed', + state: 'error', + torrentItemId: 105, + torrentTitle: 'Severance', + oldValue: null, + newValue: 'UpdateWorker: Error on fetch data, tracker timeout', + isNotification: true, + readAt: null, + createdAt: Date.now() - 1000 * 60 * 160, + }, + { + id: 6, + type: 'torrentDownloadStarted', + state: 'info', + torrentItemId: 106, + torrentTitle: 'Foundation', + oldValue: null, + newValue: 'Download started', + isNotification: true, + readAt: null, + createdAt: Date.now() - 1000 * 60 * 170, + }, + { + id: 7, + type: 'torrentFileCopyCompleted', + state: 'info', + torrentItemId: 107, + torrentTitle: 'Silo', + oldValue: null, + newValue: + 'Copied 2 file(s)\n/media/Silo/S02E01.mkv\n/media/Silo/S02E02.mkv', + isNotification: false, + readAt: Date.now() - 1000 * 60 * 60, + createdAt: Date.now() - 1000 * 60 * 175, + }, + { + id: 8, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 108, + torrentTitle: 'Foundation', + oldValue: 'Foundation S03 2160p WEB-DL', + newValue: 'Foundation S03 2160p WEB-DL HDR', + isNotification: false, + readAt: Date.now() - 1000 * 60 * 60, + createdAt: Date.now() - 1000 * 60 * 180, + }, +]; + +function getMockEventJournalPage(page: number): EventJournalPageDto { + const startIndex = (page - 1) * EVENTS_PAGE_LIMIT; + const items = mockEventJournalItems.slice( + startIndex, + startIndex + EVENTS_PAGE_LIMIT + ); + + return { + items, + total: mockEventJournalItems.length, + page, + hasNext: startIndex + EVENTS_PAGE_LIMIT < mockEventJournalItems.length, + }; +} + +function markMockEventAsRead(id: number): EventJournalDto { + const event = mockEventJournalItems.find((item) => item.id === id); + if (!event) { + throw new Error('Event not found'); + } + + return { + ...event, + readAt: Date.now(), + }; +} + +function markAllMockEventsAsRead(): EventJournalDto[] { + const readAt = Date.now(); + return mockEventJournalItems + .filter((event) => event.readAt === null) + .map((event) => ({ + ...event, + readAt, + })); +} diff --git a/server/src/db/app/app-schema.ts b/server/src/db/app/app-schema.ts index b132b1d..4a6d7f7 100644 --- a/server/src/db/app/app-schema.ts +++ b/server/src/db/app/app-schema.ts @@ -11,6 +11,20 @@ export const controlStatuses = [ 'paused', ] as const; +export const eventJournalTypes = [ + 'torrentTitleChanged', + 'torrentMagnetChanged', + 'torrentSyncFailed', + 'torrentDownloadStarted', + 'torrentDownloadCompleted', + 'torrentDownloadFailed', + 'torrentFileCopyStarted', + 'torrentFileCopyCompleted', + 'torrentFileCopyFailed', +] as const; + +export const eventJournalStates = ['info', 'error'] as const; + export const torrentItems = sqliteTable( 'torrent_items', { @@ -70,6 +84,37 @@ export const userSettings = sqliteTable('user_settings', { .notNull(), }); +export const eventJournal = sqliteTable( + 'event_journal', + { + id: int('id').primaryKey({ autoIncrement: true }), + type: text('type', { enum: eventJournalTypes }).notNull(), + state: text('state', { enum: eventJournalStates }) + .default('info') + .notNull(), + torrentItemId: int('torrent_item_id').references(() => torrentItems.id, { + onDelete: 'set null', + }), + torrentTitle: text('torrent_title').notNull(), + oldValue: text('old_value'), + newValue: text('new_value'), + isNotification: int('is_notification', { mode: 'boolean' }) + .default(true) + .notNull(), + readAt: int('read_at'), + createdAt: int('created_at') + .default(sql`(strftime('%s', 'now') * 1000)`) + .notNull(), + }, + (t) => [ + index('event_journal_created_at_index').on(t.createdAt), + index('event_journal_read_at_index').on(t.readAt), + ], +); + +export type DbEventJournal = typeof eventJournal.$inferSelect; +export type DbEventJournalInsert = typeof eventJournal.$inferInsert; + export type DbTorrentItem = typeof torrentItems.$inferSelect; export type DbTorrentItemInsert = typeof torrentItems.$inferInsert; export type DbUserSettings = typeof userSettings.$inferSelect; diff --git a/server/src/db/migrations/0003_add_event_journal.sql b/server/src/db/migrations/0003_add_event_journal.sql new file mode 100644 index 0000000..378160d --- /dev/null +++ b/server/src/db/migrations/0003_add_event_journal.sql @@ -0,0 +1,16 @@ +CREATE TABLE `event_journal` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `type` text NOT NULL, + `torrent_item_id` integer, + `torrent_title` text NOT NULL, + `old_value` text, + `new_value` text, + `is_notification` integer DEFAULT true NOT NULL, + `read_at` integer, + `created_at` integer DEFAULT (strftime('%s', 'now') * 1000) NOT NULL, + FOREIGN KEY (`torrent_item_id`) REFERENCES `torrent_items`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `event_journal_created_at_index` ON `event_journal` (`created_at`); +--> statement-breakpoint +CREATE INDEX `event_journal_read_at_index` ON `event_journal` (`read_at`); diff --git a/server/src/db/migrations/0004_add_event_journal_state.sql b/server/src/db/migrations/0004_add_event_journal_state.sql new file mode 100644 index 0000000..ea0c8c5 --- /dev/null +++ b/server/src/db/migrations/0004_add_event_journal_state.sql @@ -0,0 +1 @@ +ALTER TABLE `event_journal` ADD `state` text DEFAULT 'info' NOT NULL; diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index ea38ef1..eccf88f 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1782107364269, "tag": "0002_add_flaresolverr_timeout", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1782110400000, + "tag": "0003_add_event_journal", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1782190200000, + "tag": "0004_add_event_journal_state", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/src/features/event-journal/event-journal.mappers.ts b/server/src/features/event-journal/event-journal.mappers.ts new file mode 100644 index 0000000..8ed1b5e --- /dev/null +++ b/server/src/features/event-journal/event-journal.mappers.ts @@ -0,0 +1,21 @@ +import type { DbEventJournal } from '@server/db/app/app-schema'; +import type { EventJournalDto } from './event-journal.types'; + +export function toEventJournalDto(row: DbEventJournal): EventJournalDto { + return { + id: row.id, + type: row.type, + state: row.state, + torrentItemId: row.torrentItemId, + torrentTitle: row.torrentTitle, + oldValue: row.oldValue, + newValue: row.newValue, + isNotification: row.isNotification, + readAt: row.readAt, + createdAt: row.createdAt, + }; +} + +export function toEventJournalDtos(rows: DbEventJournal[]): EventJournalDto[] { + return rows.map(toEventJournalDto); +} diff --git a/server/src/features/event-journal/event-journal.port.ts b/server/src/features/event-journal/event-journal.port.ts new file mode 100644 index 0000000..3b06db6 --- /dev/null +++ b/server/src/features/event-journal/event-journal.port.ts @@ -0,0 +1,46 @@ +import type { DbTorrentItem } from '@server/db/app/app-schema'; + +export type TorrentUpdateEventParams = { + torrentItem: DbTorrentItem; + oldValue: string | null; + newValue: string | null; +}; + +export type TorrentSyncFailedEventParams = { + torrentItem: DbTorrentItem; + errorMessage: string; +}; + +export type TorrentProcessEventParams = { + torrentItem: DbTorrentItem; + message?: string | null; +}; + +export type TorrentProcessFailedEventParams = { + torrentItem: DbTorrentItem; + errorMessage: string; +}; + +export interface EventJournalPort { + recordTorrentTitleChanged(params: TorrentUpdateEventParams): Promise; + recordTorrentMagnetChanged(params: TorrentUpdateEventParams): Promise; + recordTorrentSyncFailed(params: TorrentSyncFailedEventParams): Promise; + recordTorrentDownloadStarted( + params: TorrentProcessEventParams, + ): Promise; + recordTorrentDownloadCompleted( + params: TorrentProcessEventParams, + ): Promise; + recordTorrentDownloadFailed( + params: TorrentProcessFailedEventParams, + ): Promise; + recordTorrentFileCopyStarted( + params: TorrentProcessEventParams, + ): Promise; + recordTorrentFileCopyCompleted( + params: TorrentProcessEventParams, + ): Promise; + recordTorrentFileCopyFailed( + params: TorrentProcessFailedEventParams, + ): Promise; +} diff --git a/server/src/features/event-journal/event-journal.repo.ts b/server/src/features/event-journal/event-journal.repo.ts new file mode 100644 index 0000000..e88dcfc --- /dev/null +++ b/server/src/features/event-journal/event-journal.repo.ts @@ -0,0 +1,70 @@ +import db from '@server/db'; +import { + eventJournal, + type DbEventJournal, + type DbEventJournalInsert, +} from '@server/db/app/app-schema'; +import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'; +import { desc, eq, getTableColumns, isNull, sql } from 'drizzle-orm'; + +export class EventJournalRepo { + private readonly database: BunSQLiteDatabase; + + constructor(database = db) { + this.database = database; + } + + async findAll( + page: number, + limit: number, + ): Promise<{ items: DbEventJournal[]; total: number }> { + const offset: number = (page - 1) * limit; + const rows = await this.database + .select({ + ...getTableColumns(eventJournal), + total: sql`count(*) over()`, + }) + .from(eventJournal) + .orderBy(desc(eventJournal.createdAt), desc(eventJournal.id)) + .limit(limit) + .offset(offset); + + const total: number = + rows.length > 0 && + typeof (rows[0] as { total?: number }).total === 'number' + ? (rows[0] as { total: number }).total + : rows.length; + const items: DbEventJournal[] = rows.map( + ({ total: _total, ...rest }) => rest as DbEventJournal, + ); + + return { items, total }; + } + + async create( + data: DbEventJournalInsert, + ): Promise { + const [row] = await this.database + .insert(eventJournal) + .values(data) + .returning(); + return row; + } + + async markAsRead(id: number): Promise { + const [row] = await this.database + .update(eventJournal) + .set({ readAt: Date.now() }) + .where(eq(eventJournal.id, id)) + .returning(); + return row; + } + + async markAllAsRead(): Promise { + return await this.database + .update(eventJournal) + .set({ readAt: Date.now() }) + .where(isNull(eventJournal.readAt)) + .returning(); + } +} diff --git a/server/src/features/event-journal/event-journal.service.ts b/server/src/features/event-journal/event-journal.service.ts new file mode 100644 index 0000000..109e2d9 --- /dev/null +++ b/server/src/features/event-journal/event-journal.service.ts @@ -0,0 +1,209 @@ +import { EventJournalRepo } from './event-journal.repo'; +import { toEventJournalDto, toEventJournalDtos } from './event-journal.mappers'; +import type { + EventJournalDto, + EventJournalPageDto, +} from './event-journal.types'; +import type { + EventJournalPort, + TorrentProcessEventParams, + TorrentProcessFailedEventParams, + TorrentSyncFailedEventParams, + TorrentUpdateEventParams, +} from './event-journal.port'; + +export class EventJournalService implements EventJournalPort { + private readonly repo: EventJournalRepo; + + constructor(repo = new EventJournalRepo()) { + this.repo = repo; + } + + async getAll(page: number, limit: number): Promise { + const rows = await this.repo.findAll(page, limit); + return { + items: toEventJournalDtos(rows.items), + total: rows.total, + page, + hasNext: rows.total > page * limit, + }; + } + + async markAsRead(id: number): Promise { + const row = await this.repo.markAsRead(id); + return row ? toEventJournalDto(row) : null; + } + + async markAllAsRead(): Promise { + const rows = await this.repo.markAllAsRead(); + return toEventJournalDtos(rows); + } + + async recordTorrentTitleChanged( + params: TorrentUpdateEventParams, + ): Promise { + await this.createTorrentUpdateEvent({ + type: 'torrentTitleChanged', + state: 'info', + params, + isNotification: true, + }); + } + + async recordTorrentMagnetChanged( + params: TorrentUpdateEventParams, + ): Promise { + await this.createTorrentUpdateEvent({ + type: 'torrentMagnetChanged', + state: 'info', + params, + isNotification: false, + }); + } + + async recordTorrentSyncFailed( + params: TorrentSyncFailedEventParams, + ): Promise { + await this.repo.create({ + type: 'torrentSyncFailed', + state: 'error', + torrentItemId: params.torrentItem.id, + torrentTitle: params.torrentItem.title, + oldValue: null, + newValue: params.errorMessage, + isNotification: true, + }); + } + + async recordTorrentDownloadStarted( + params: TorrentProcessEventParams, + ): Promise { + await this.createTorrentProcessEvent({ + type: 'torrentDownloadStarted', + state: 'info', + params, + isNotification: true, + }); + } + + async recordTorrentDownloadCompleted( + params: TorrentProcessEventParams, + ): Promise { + await this.createTorrentProcessEvent({ + type: 'torrentDownloadCompleted', + state: 'info', + params, + isNotification: true, + }); + } + + async recordTorrentDownloadFailed( + params: TorrentProcessFailedEventParams, + ): Promise { + await this.createTorrentProcessFailedEvent({ + type: 'torrentDownloadFailed', + params, + isNotification: true, + }); + } + + async recordTorrentFileCopyStarted( + params: TorrentProcessEventParams, + ): Promise { + await this.createTorrentProcessEvent({ + type: 'torrentFileCopyStarted', + state: 'info', + params, + isNotification: false, + }); + } + + async recordTorrentFileCopyCompleted( + params: TorrentProcessEventParams, + ): Promise { + await this.createTorrentProcessEvent({ + type: 'torrentFileCopyCompleted', + state: 'info', + params, + isNotification: false, + }); + } + + async recordTorrentFileCopyFailed( + params: TorrentProcessFailedEventParams, + ): Promise { + await this.createTorrentProcessFailedEvent({ + type: 'torrentFileCopyFailed', + params, + isNotification: false, + }); + } + + private async createTorrentUpdateEvent({ + type, + state, + params, + isNotification, + }: { + type: 'torrentTitleChanged' | 'torrentMagnetChanged'; + state: 'info' | 'error'; + params: TorrentUpdateEventParams; + isNotification: boolean; + }): Promise { + await this.repo.create({ + type, + state, + torrentItemId: params.torrentItem.id, + torrentTitle: params.torrentItem.title, + oldValue: params.oldValue, + newValue: params.newValue, + isNotification, + }); + } + + private async createTorrentProcessEvent({ + type, + state, + params, + isNotification, + }: { + type: + | 'torrentDownloadStarted' + | 'torrentDownloadCompleted' + | 'torrentFileCopyStarted' + | 'torrentFileCopyCompleted'; + state: 'info'; + params: TorrentProcessEventParams; + isNotification: boolean; + }): Promise { + await this.repo.create({ + type, + state, + torrentItemId: params.torrentItem.id, + torrentTitle: params.torrentItem.title, + oldValue: null, + newValue: params.message ?? null, + isNotification, + }); + } + + private async createTorrentProcessFailedEvent({ + type, + params, + isNotification, + }: { + type: 'torrentDownloadFailed' | 'torrentFileCopyFailed'; + params: TorrentProcessFailedEventParams; + isNotification: boolean; + }): Promise { + await this.repo.create({ + type, + state: 'error', + torrentItemId: params.torrentItem.id, + torrentTitle: params.torrentItem.title, + oldValue: null, + newValue: params.errorMessage, + isNotification, + }); + } +} diff --git a/server/src/features/event-journal/event-journal.types.ts b/server/src/features/event-journal/event-journal.types.ts new file mode 100644 index 0000000..0d88452 --- /dev/null +++ b/server/src/features/event-journal/event-journal.types.ts @@ -0,0 +1,27 @@ +import type { + eventJournalStates, + eventJournalTypes, +} from '@server/db/app/app-schema'; + +export type EventJournalType = (typeof eventJournalTypes)[number]; +export type EventJournalState = (typeof eventJournalStates)[number]; + +export type EventJournalDto = { + id: number; + type: EventJournalType; + state: EventJournalState; + torrentItemId: number | null; + torrentTitle: string; + oldValue: string | null; + newValue: string | null; + isNotification: boolean; + readAt: number | null; + createdAt: number; +}; + +export type EventJournalPageDto = { + items: EventJournalDto[]; + total: number; + page: number; + hasNext: boolean; +}; diff --git a/server/src/index.ts b/server/src/index.ts index 2d9952c..1b34d19 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,9 @@ import { systemExitRoute } from './routes/system.exit'; import { trackersKinozalVerifyRoute } from './routes/trackers.kinozal.verify'; import { getUserCount } from './lib/utils'; import { torrentsPauseToggleRoute } from './routes/torrents.pause-toggle'; +import { eventJournalRoute } from './routes/event-journal'; +import { eventJournalReadRoute } from './routes/event-journal.$id.read'; +import { eventJournalReadAllRoute } from './routes/event-journal.read-all'; export const app = new Hono<{ Variables: { @@ -47,7 +50,7 @@ if (process.env.NODE_ENV !== 'production') { cors({ origin: process.env.ORIGIN || 'http://localhost:5173', allowHeaders: ['Content-Type', 'Authorization'], - allowMethods: ['POST', 'GET', 'OPTIONS', 'DELETE'], + allowMethods: ['POST', 'GET', 'OPTIONS', 'DELETE', 'PUT'], exposeHeaders: ['Content-Length'], maxAge: 600, credentials: true, @@ -81,6 +84,9 @@ export const routes = app .route('/jackett/verify', jackettVerifyRoute) .route('/flaresolverr/verify', flaresolverrVerifyRoute) .route('/trackers/kinozal/verify', trackersKinozalVerifyRoute) + .route('/event-journal', eventJournalRoute) + .route('/event-journal/read-all', eventJournalReadAllRoute) + .route('/event-journal/:id/read', eventJournalReadRoute) .route('/settings', settingsRoute) .route('/files/:id/delete', deleteFileRoute) .route('/torrents', torrentsRoute) diff --git a/server/src/routes/event-journal.$id.read.ts b/server/src/routes/event-journal.$id.read.ts new file mode 100644 index 0000000..25929b7 --- /dev/null +++ b/server/src/routes/event-journal.$id.read.ts @@ -0,0 +1,43 @@ +import { Hono } from 'hono/tiny'; +import type { ApiResponse } from 'shared/dist'; +import logger from '@server/lib/logger'; +import z from 'zod'; +import { sValidator } from '@hono/standard-validator'; +import { handleStandardValidation } from '@server/lib/validation'; +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; + +const paramSchema = z.object({ + id: z.coerce.number({ message: 'id is required' }).min(1), +}); + +export const eventJournalReadRoute = new Hono().put( + '/', + sValidator('param', paramSchema, handleStandardValidation), + async (c) => { + const { id } = c.req.valid('param'); + + try { + const data = await new EventJournalService().markAsRead(id); + if (!data) { + const response: ApiResponse = { + success: false, + message: 'Event not found', + }; + return c.json(response, 404); + } + + const response: ApiResponse = { + success: true, + data, + }; + return c.json(response); + } catch (e) { + logger.error(e); + const response: ApiResponse = { + success: false, + message: (e as Error).message, + }; + return c.json(response, 400); + } + }, +); diff --git a/server/src/routes/event-journal.read-all.ts b/server/src/routes/event-journal.read-all.ts new file mode 100644 index 0000000..829a518 --- /dev/null +++ b/server/src/routes/event-journal.read-all.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono/tiny'; +import type { ApiResponse } from 'shared/dist'; +import logger from '@server/lib/logger'; +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; + +export const eventJournalReadAllRoute = new Hono().put('/', async (c) => { + try { + const data = await new EventJournalService().markAllAsRead(); + const response: ApiResponse = { + success: true, + data, + }; + return c.json(response); + } catch (e) { + logger.error(e); + const response: ApiResponse = { + success: false, + message: (e as Error).message, + }; + return c.json(response, 400); + } +}); diff --git a/server/src/routes/event-journal.ts b/server/src/routes/event-journal.ts new file mode 100644 index 0000000..54dcbae --- /dev/null +++ b/server/src/routes/event-journal.ts @@ -0,0 +1,36 @@ +import { Hono } from 'hono/tiny'; +import type { ApiResponse } from 'shared/dist'; +import logger from '@server/lib/logger'; +import z from 'zod'; +import { sValidator } from '@hono/standard-validator'; +import { handleStandardValidation } from '@server/lib/validation'; +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; + +const schema = z.object({ + page: z.coerce.number({ message: 'page is required' }).min(1), + limit: z.coerce.number({ message: 'limit is required' }).min(1).max(100), +}); + +export const eventJournalRoute = new Hono().get( + '/', + sValidator('query', schema, handleStandardValidation), + async (c) => { + const { page, limit } = c.req.valid('query'); + + try { + const data = await new EventJournalService().getAll(page, limit); + const response: ApiResponse = { + success: true, + data, + }; + return c.json(response); + } catch (e) { + logger.error(e); + const response: ApiResponse = { + success: false, + message: (e as Error).message, + }; + return c.json(response, 400); + } + }, +); diff --git a/server/src/workers/download-worker.ts b/server/src/workers/download-worker.ts index 2f2a4db..bfd1290 100644 --- a/server/src/workers/download-worker.ts +++ b/server/src/workers/download-worker.ts @@ -7,6 +7,8 @@ import { promises as fs } from 'fs'; import { FileManagementService } from '@server/features/file-management/file-management.service'; import { TelegramAdapter } from '@server/external/adapters/telegram/telegram.adapter'; import { formatErrorMessage } from '@server/lib/error-message'; +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; +import type { EventJournalPort } from '@server/features/event-journal/event-journal.port'; // In-memory cache for the last known Transmission status per torrent item export const statusStorage = new Map(); @@ -14,6 +16,7 @@ export const statusStorage = new Map(); export class DownloadWorker { // Database repository for reading items and updating statuses private readonly repo: WorkersRepo; + private readonly eventJournal: EventJournalPort; // Internal scheduler tick (ms) private timerMs: number; // Cached user settings used during processing @@ -23,10 +26,17 @@ export class DownloadWorker { private error: string | null = null; private isProcessing = false; - constructor({ repo }: { repo?: WorkersRepo }) { + constructor({ + repo, + eventJournal, + }: { + repo?: WorkersRepo; + eventJournal?: EventJournalPort; + }) { // Default polling cadence for download processing this.timerMs = 5000; this.repo = repo || new WorkersRepo(); + this.eventJournal = eventJournal || new EventJournalService(); } // Load and cache current user settings @@ -46,12 +56,19 @@ export class DownloadWorker { }); try { await client.add(); + await this.eventJournal.recordTorrentDownloadStarted({ + torrentItem: row, + message: 'Download started', + }); } catch (e) { logger.error(`[DownloadWorker] Failed to start downloading ${row.title}`); logger.error(e); this.error = - 'DownloadWorker: Failed to start downloading, ' + - formatErrorMessage(e); + 'DownloadWorker: Failed to start downloading, ' + formatErrorMessage(e); + await this.eventJournal.recordTorrentDownloadFailed({ + torrentItem: row, + errorMessage: this.error, + }); } } @@ -67,11 +84,11 @@ export class DownloadWorker { if (status) statusStorage.set(row.id, status); } catch (e) { logger.error( - `[DownloadWorker] Failed to check download status ${row.title}: ${e}` + `[DownloadWorker] Failed to check download status ${row.title}: ${e}`, ); if (e instanceof Error && e.message.includes('Torrent not found')) { logger.error( - `[DownloadWorker] Torrent ${row.title} not found, looks like it was removed from Transmission. Mark as idle.` + `[DownloadWorker] Torrent ${row.title} not found, looks like it was removed from Transmission. Mark as idle.`, ); await this.repo.markAsIdle(row.id); statusStorage.delete(row.id); @@ -79,15 +96,23 @@ export class DownloadWorker { this.error = 'DownloadWorker: Failed to check download status, ' + formatErrorMessage(e); + await this.eventJournal.recordTorrentDownloadFailed({ + torrentItem: row, + errorMessage: this.error, + }); } const isDone = Boolean( status?.isCompleted && status.dateCompleted && - new Date(status.dateCompleted).getTime() > 0 + new Date(status.dateCompleted).getTime() > 0, ); if (isDone) { // Mark as completed to move processing to the next stage await this.repo.markAsCompleted(row.id); + await this.eventJournal.recordTorrentDownloadCompleted({ + torrentItem: row, + message: 'Download completed', + }); statusStorage.delete(row.id); } else { try { @@ -96,12 +121,15 @@ export class DownloadWorker { statusStorage.set(row.id, status); } catch (e) { logger.error( - `[DownloadWorker] Error selecting episodes for ${row.title}: ${e}` + `[DownloadWorker] Error selecting episodes for ${row.title}: ${e}`, ); if (status) logger.error(JSON.stringify(status, null, 2)); this.error = - 'DownloadWorker: Error selecting episodes, ' + - formatErrorMessage(e); + 'DownloadWorker: Error selecting episodes, ' + formatErrorMessage(e); + await this.eventJournal.recordTorrentDownloadFailed({ + torrentItem: row, + errorMessage: this.error, + }); } } } @@ -113,7 +141,7 @@ export class DownloadWorker { torrentItem: row, }); logger.info( - `[DownloadWorker] Processing completed download for ${row.title}` + `[DownloadWorker] Processing completed download for ${row.title}`, ); await this.repo.markAsProcessing(row.id); try { @@ -121,9 +149,13 @@ export class DownloadWorker { logger.error(`[DownloadWorker] Settings not found`); return; } + await this.eventJournal.recordTorrentFileCopyStarted({ + torrentItem: row, + message: 'File copy started', + }); const copyResult = await new FileManagementService().copyTrackedEpisodes( row, - this.settings + this.settings, ); const episodes = Object.keys(copyResult); const files = Object.values(copyResult); @@ -135,10 +167,14 @@ export class DownloadWorker { files: [...newFiles, ...existingFiles], trackedEpisodes: [ ...(row?.trackedEpisodes as number[]).filter( - (x) => !episodes.includes(x.toString()) + (x) => !episodes.includes(x.toString()), ), ], }); + await this.eventJournal.recordTorrentFileCopyCompleted({ + torrentItem: row, + message: formatCopiedFilesMessage(newFiles), + }); if (this.settings?.telegramId && this.settings?.botToken) new TelegramAdapter(this.settings).sendUpdate(row.title, copyResult); } catch (e) { @@ -147,6 +183,10 @@ export class DownloadWorker { this.error = 'DownloadWorker: Error processing completed download, ' + formatErrorMessage(e); + await this.eventJournal.recordTorrentFileCopyFailed({ + torrentItem: row, + errorMessage: this.error, + }); return; } if (this.settings?.deleteAfterDownload) { @@ -179,8 +219,8 @@ export class DownloadWorker { } else { logger.error( `[DownloadWorker] Unexpected error while statting ${file}: ${String( - e - )}` + e, + )}`, ); existingFiles.push(file); } @@ -193,7 +233,7 @@ export class DownloadWorker { if (missingFiles.length) { await this.repo.update(row.id, { files: existingFiles }); logger.warn( - `[DownloadWorker] Removed ${missingFiles.length} missing file(s) from DB for item ${row.title}` + `[DownloadWorker] Removed ${missingFiles.length} missing file(s) from DB for item ${row.title}`, ); } } @@ -232,7 +272,7 @@ export class DownloadWorker { } if (this.error) { logger.error( - `[DownloadWorker] Error on process ${row.id}: ${this.error}` + `[DownloadWorker] Error on process ${row.id}: ${this.error}`, ); await this.repo.update(row.id, { errorMessage: this.error }); this.error = null; @@ -265,3 +305,9 @@ export class DownloadWorker { }, this.timerMs); } } + +function formatCopiedFilesMessage(files: string[]): string { + const title = `Copied ${files.length} file(s)`; + if (!files.length) return title; + return [title, ...files].join('\n'); +} diff --git a/server/src/workers/update-worker.ts b/server/src/workers/update-worker.ts index ddf8956..c72dcb4 100644 --- a/server/src/workers/update-worker.ts +++ b/server/src/workers/update-worker.ts @@ -3,12 +3,16 @@ import { WorkersRepo } from './workers.repo'; import { TorrentItem } from '@server/features/torrent-item/torrent-item.service'; import logger from '@server/lib/logger'; import { formatErrorMessage } from '@server/lib/error-message'; +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; +import type { EventJournalPort } from '@server/features/event-journal/event-journal.port'; +import type { DbTorrentItem } from '@server/db/app/app-schema'; export class UpdateWorker { // Torrent item service instance used during current iteration private ti: TorrentItemPort | undefined; // Repository used to read settings and torrent items from DB private readonly repo: WorkersRepo; + private readonly eventJournal: EventJournalPort; // Tick for internal setInterval, ms private timerMs: number; // How often to run sync logic, ms (defaults to 60 min). Overridden by user settings. @@ -19,10 +23,17 @@ export class UpdateWorker { // Prevent concurrent runs of process() private isProcessing = false; - constructor({ repo }: { repo?: WorkersRepo }) { + constructor({ + repo, + eventJournal, + }: { + repo?: WorkersRepo; + eventJournal?: EventJournalPort; + }) { // Polling cadence: worker wakes up every timerMs and checks sync window this.timerMs = 10000; this.repo = repo || new WorkersRepo(); + this.eventJournal = eventJournal || new EventJournalService(); // Allow overriding last sync time from env (useful for tests and ops) this.lastSync = process.env.HOOP_LAST_SYNC ? parseInt(process.env.HOOP_LAST_SYNC) @@ -55,59 +66,91 @@ export class UpdateWorker { try { await Promise.all([this.ti.getById(), this.ti.fetchData()]); } catch (e) { + const errorMessage = + 'UpdateWorker: Error on fetch data, ' + formatErrorMessage(e); logger.error( - '[UpdateWorker] Error on fetch data, ' + formatErrorMessage(e) + '[UpdateWorker] Error on fetch data, ' + formatErrorMessage(e), ); - await this.repo.update(row.id, { - errorMessage: - 'UpdateWorker: Error on fetch data, ' + formatErrorMessage(e), + await this.eventJournal.recordTorrentSyncFailed({ + torrentItem: row, + errorMessage, }); + await this.repo.update(row.id, { errorMessage }); continue; } - if (!this.ti?.trackerData?.rawTitle) { + const trackerData = this.ti.trackerData; + const databaseData = this.ti.databaseData; + + if (!trackerData?.rawTitle) { + const errorMessage = + 'UpdateWorker: Error on fetch data, no tracker title found'; logger.error( - `[UpdateWorker] No tracker title found for ${this.ti?.databaseData?.title}.` + `[UpdateWorker] No tracker title found for ${databaseData?.title}.`, ); - await this.repo.update(row.id, { - errorMessage: - 'UpdateWorker: Error on fetch data, no tracker title found', + await this.eventJournal.recordTorrentSyncFailed({ + torrentItem: databaseData ?? row, + errorMessage, + }); + await this.repo.update(row.id, { errorMessage }); + continue; + } + + if (!databaseData) { + const errorMessage = + 'UpdateWorker: Error on fetch data, no database data found'; + await this.eventJournal.recordTorrentSyncFailed({ + torrentItem: row, + errorMessage, }); + await this.repo.update(row.id, { errorMessage }); continue; } // If raw title differs — tracker has a new/updated payload - if (this.ti?.trackerData?.rawTitle !== this.ti?.databaseData?.rawTitle) { + if (trackerData.rawTitle !== databaseData.rawTitle) { logger.debug( - `[UpdateWorker] Comparing: \n ${this.ti?.trackerData?.rawTitle} \n ${this.ti?.databaseData?.rawTitle}` + `[UpdateWorker] Comparing: \n ${trackerData.rawTitle} \n ${databaseData.rawTitle}`, ); + await this.eventJournal.recordTorrentTitleChanged({ + torrentItem: databaseData, + oldValue: databaseData.rawTitle, + newValue: trackerData.rawTitle, + }); // Persist new tracker data await this.ti?.addOrUpdate(); // If any tracked episode is present in the new torrent — request download - const trackedEpisodes = this.ti?.databaseData - ?.trackedEpisodes as number[]; - const haveEpisodes = this.ti?.databaseData?.haveEpisodes as number[]; + const updatedDatabaseData = this.ti.databaseData ?? databaseData; + const trackedEpisodes = toNumberArray( + updatedDatabaseData.trackedEpisodes, + ); + const haveEpisodes = toNumberArray(updatedDatabaseData.haveEpisodes); if (trackedEpisodes && trackedEpisodes.length > 0) { for (const num of trackedEpisodes) { if (haveEpisodes && haveEpisodes.includes(num)) { await this.ti?.markAsDownloadRequested(); logger.info( - `[UpdateWorker] Mark as download requested: ${this.ti?.databaseData?.title}` + `[UpdateWorker] Mark as download requested: ${updatedDatabaseData.title}`, ); break; } } } - } else if (this.ti.trackerData.magnet !== this.ti?.databaseData?.magnet) { + } else if (trackerData.magnet !== databaseData.magnet) { logger.info( - `[UpdateWorker] Magnet changed for ${this.ti?.databaseData?.title}. Updating.` + `[UpdateWorker] Magnet changed for ${databaseData.title}. Updating.`, ); + await this.eventJournal.recordTorrentMagnetChanged({ + torrentItem: databaseData, + oldValue: databaseData.magnet, + newValue: trackerData.magnet, + }); await this.ti?.addOrUpdate(); } else { logger.info( - `[UpdateWorker] No new data found for ${this.ti?.databaseData?.title}` + `[UpdateWorker] No new data found for ${databaseData.title}`, ); } if (row.errorMessage) { @@ -123,8 +166,8 @@ export class UpdateWorker { process.env.HOOP_LAST_SYNC = this.lastSync.toString(); logger.info( `[UpdateWorker] Next sync in ${new Date( - this.lastSync + this.syncInterval - ).toLocaleString()}` + this.lastSync + this.syncInterval, + ).toLocaleString()}`, ); } @@ -152,3 +195,10 @@ export class UpdateWorker { }, this.timerMs); } } + +function toNumberArray( + value: DbTorrentItem['trackedEpisodes'] | DbTorrentItem['haveEpisodes'], +): number[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is number => typeof item === 'number'); +} diff --git a/server/test/download-worker-copy-error.test.ts b/server/test/download-worker-copy-error.test.ts index c371e57..e71085d 100644 --- a/server/test/download-worker-copy-error.test.ts +++ b/server/test/download-worker-copy-error.test.ts @@ -16,6 +16,46 @@ vi.mock('@server/workers/workers.repo', () => ({ WorkersRepo: class {}, })); +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async recordTorrentTitleChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentMagnetChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentSyncFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyFailed(): Promise { + return Promise.resolve(); + } + }, +})); + // Mock adapters and services vi.mock('@server/external/adapters/transmission', () => ({ TransmissionAdapter: class { diff --git a/server/test/download-worker-error-message.test.ts b/server/test/download-worker-error-message.test.ts index 88a4857..6ab61f9 100644 --- a/server/test/download-worker-error-message.test.ts +++ b/server/test/download-worker-error-message.test.ts @@ -17,6 +17,46 @@ vi.mock('@server/workers/workers.repo', () => ({ WorkersRepo: class {}, })); +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async recordTorrentTitleChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentMagnetChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentSyncFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyFailed(): Promise { + return Promise.resolve(); + } + }, +})); + // Transmission adapter mock with configurable behavior per test const add = vi.fn(async () => undefined); const status = vi.fn( diff --git a/server/test/download-worker.test.ts b/server/test/download-worker.test.ts index 631f23c..c79f6eb 100644 --- a/server/test/download-worker.test.ts +++ b/server/test/download-worker.test.ts @@ -17,6 +17,46 @@ vi.mock('@server/workers/workers.repo', () => ({ WorkersRepo: class {}, })); +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async recordTorrentTitleChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentMagnetChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentSyncFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyFailed(): Promise { + return Promise.resolve(); + } + }, +})); + // Test fixtures const item: DbTorrentItem = { id: 1, @@ -127,6 +167,18 @@ class RepoMock { public update = vi.fn(async (_id: number, _data: unknown) => undefined); } +class EventJournalMock { + public recordTorrentTitleChanged = vi.fn(async () => undefined); + public recordTorrentMagnetChanged = vi.fn(async () => undefined); + public recordTorrentSyncFailed = vi.fn(async () => undefined); + public recordTorrentDownloadStarted = vi.fn(async () => undefined); + public recordTorrentDownloadCompleted = vi.fn(async () => undefined); + public recordTorrentDownloadFailed = vi.fn(async () => undefined); + public recordTorrentFileCopyStarted = vi.fn(async () => undefined); + public recordTorrentFileCopyCompleted = vi.fn(async () => undefined); + public recordTorrentFileCopyFailed = vi.fn(async () => undefined); +} + describe('DownloadWorker', () => { beforeEach(() => { vi.clearAllMocks(); @@ -138,14 +190,26 @@ describe('DownloadWorker', () => { it('startDownload: calls add() and logs; handles errors gracefully', async () => { const { DownloadWorker } = await import('@server/workers/download-worker'); const repo = new RepoMock(); - const worker = new DownloadWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new DownloadWorker({ + repo: repo as unknown as never, + eventJournal, + }); await worker.startDownload({ ...item }); expect(add).toHaveBeenCalledTimes(1); + expect(eventJournal.recordTorrentDownloadStarted).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + message: 'Download started', + }); add.mockRejectedValueOnce(new Error('boom')); await worker.startDownload({ ...item }); expect(add).toHaveBeenCalledTimes(2); + expect(eventJournal.recordTorrentDownloadFailed).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + errorMessage: 'DownloadWorker: Failed to start downloading, boom', + }); }); it('processDownloading: marks completed when status is done', async () => { @@ -153,7 +217,11 @@ describe('DownloadWorker', () => { '@server/workers/download-worker' ); const repo = new RepoMock(); - const worker = new DownloadWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new DownloadWorker({ + repo: repo as unknown as never, + eventJournal, + }); status.mockResolvedValueOnce({ isCompleted: true, @@ -162,6 +230,10 @@ describe('DownloadWorker', () => { await worker.processDownloading({ ...item }); expect(repo.markAsCompleted).toHaveBeenCalledWith(item.id); + expect(eventJournal.recordTorrentDownloadCompleted).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + message: 'Download completed', + }); expect(statusStorage.get(item.id)).toBeUndefined(); }); @@ -212,7 +284,11 @@ describe('DownloadWorker', () => { it('processCompletedDownload: copies files and removes from Transmission', async () => { const { DownloadWorker } = await import('@server/workers/download-worker'); const repo = new RepoMock(); - const worker = new DownloadWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new DownloadWorker({ + repo: repo as unknown as never, + eventJournal, + }); // Ensure settings is loaded by invoking process() prelude repo.findAllNeedToControl.mockResolvedValueOnce([]); @@ -223,16 +299,33 @@ describe('DownloadWorker', () => { }); await worker.process(); + copyTrackedEpisodes.mockResolvedValueOnce({ + 1: '/media/show/S01E01.mkv', + 2: '/media/show/S01E02.mkv', + }); await worker.processCompletedDownload({ ...item }); expect(repo.markAsProcessing).toHaveBeenCalledWith(item.id); + expect(eventJournal.recordTorrentFileCopyStarted).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + message: 'File copy started', + }); expect(copyTrackedEpisodes).toHaveBeenCalledTimes(1); + expect(eventJournal.recordTorrentFileCopyCompleted).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + message: + 'Copied 2 file(s)\n/media/show/S01E01.mkv\n/media/show/S01E02.mkv', + }); expect(remove).toHaveBeenCalledTimes(1); }); it('processCompletedDownload: exits early when settings are missing', async () => { const { DownloadWorker } = await import('@server/workers/download-worker'); const repo = new RepoMock(); - const worker = new DownloadWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new DownloadWorker({ + repo: repo as unknown as never, + eventJournal, + }); // Do not call process() so settings remain undefined await worker.processCompletedDownload({ ...item }); @@ -244,7 +337,11 @@ describe('DownloadWorker', () => { it('processCompletedDownload: exits early on copy error', async () => { const { DownloadWorker } = await import('@server/workers/download-worker'); const repo = new RepoMock(); - const worker = new DownloadWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new DownloadWorker({ + repo: repo as unknown as never, + eventJournal, + }); // Prepare settings repo.findAllNeedToControl.mockResolvedValueOnce([]); @@ -254,6 +351,11 @@ describe('DownloadWorker', () => { await worker.processCompletedDownload({ ...item }); expect(remove).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentFileCopyFailed).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: item.id }), + errorMessage: + 'DownloadWorker: Error processing completed download, copy failed', + }); }); it('process: dispatches by controlStatus for each row', async () => { diff --git a/server/test/event-journal.repo.test.ts b/server/test/event-journal.repo.test.ts new file mode 100644 index 0000000..d248493 --- /dev/null +++ b/server/test/event-journal.repo.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +vi.mock('@server/db', () => ({ default: {} as never })); + +import { EventJournalRepo } from '@server/features/event-journal/event-journal.repo'; +import type { + DbEventJournal, + DbEventJournalInsert, +} from '@server/db/app/app-schema'; +import type { Database } from 'bun:sqlite'; + +const selectQueue: Array>> = []; +const insertQueue: Array> = []; +const updateQueue: Array> = []; +const whereCalls: unknown[] = []; + +let lastOffset: number | null = null; +let lastLimit: number | null = null; +let lastInsertValues: DbEventJournalInsert | null = null; +let lastUpdateSet: Partial | null = null; + +const database = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + limit: vi.fn((limit: number) => { + lastLimit = limit; + return { + offset: vi.fn(async (offset: number) => { + lastOffset = offset; + return selectQueue.shift() ?? []; + }), + }; + }), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn((values: DbEventJournalInsert) => { + lastInsertValues = values; + return { + returning: vi.fn(async () => insertQueue.shift() ?? []), + }; + }), + })), + update: vi.fn(() => ({ + set: vi.fn((set: Partial) => { + lastUpdateSet = set; + return { + where: vi.fn((condition: unknown) => { + whereCalls.push(condition); + return { + returning: vi.fn(async () => updateQueue.shift() ?? []), + }; + }), + }; + }), + })), +} as const; + +const repo = new EventJournalRepo({ + ...database, + $client: {} as unknown as Database, +} as never); + +function makeEvent(override: Partial = {}): DbEventJournal { + return { + id: 1, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 3, + torrentTitle: 'Some Show', + oldValue: 'Old title', + newValue: 'New title', + isNotification: true, + readAt: null, + createdAt: 1000, + ...override, + } satisfies DbEventJournal; +} + +describe('EventJournalRepo (mocked database)', () => { + beforeEach(() => { + selectQueue.length = 0; + insertQueue.length = 0; + updateQueue.length = 0; + whereCalls.length = 0; + lastOffset = null; + lastLimit = null; + lastInsertValues = null; + lastUpdateSet = null; + }); + + it('returns paginated events with total', async () => { + selectQueue.push([ + { total: 2, ...makeEvent({ id: 1 }) }, + { total: 2, ...makeEvent({ id: 2, type: 'torrentMagnetChanged' }) }, + ]); + + const result = await repo.findAll(2, 10); + + expect(lastLimit).toBe(10); + expect(lastOffset).toBe(10); + expect(result.total).toBe(2); + expect(result.items.map((event) => event.id)).toEqual([1, 2]); + }); + + it('creates an event', async () => { + insertQueue.push([makeEvent({ id: 7 })]); + + const row = await repo.create({ + type: 'torrentMagnetChanged', + state: 'info', + torrentItemId: 7, + torrentTitle: 'Some Show', + oldValue: 'Old magnet', + newValue: 'New magnet', + isNotification: true, + }); + + expect(lastInsertValues?.type).toBe('torrentMagnetChanged'); + expect(lastInsertValues?.state).toBe('info'); + expect(lastInsertValues?.isNotification).toBe(true); + expect(row?.id).toBe(7); + }); + + it('marks an event as read', async () => { + updateQueue.push([makeEvent({ id: 9, readAt: 2000 })]); + + const row = await repo.markAsRead(9); + + expect(typeof lastUpdateSet?.readAt).toBe('number'); + expect(row?.readAt).toBe(2000); + }); + + it('marks all unread events as read', async () => { + updateQueue.push([ + makeEvent({ id: 1, readAt: 3000 }), + makeEvent({ id: 2, readAt: 3000 }), + ]); + + const rows = await repo.markAllAsRead(); + + expect(typeof lastUpdateSet?.readAt).toBe('number'); + expect(whereCalls.length).toBe(1); + expect(rows.map((row) => row.id)).toEqual([1, 2]); + }); +}); diff --git a/server/test/event-journal.service.test.ts b/server/test/event-journal.service.test.ts new file mode 100644 index 0000000..7e1604d --- /dev/null +++ b/server/test/event-journal.service.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +vi.mock('@server/db', () => ({ default: {} as never })); + +import { EventJournalService } from '@server/features/event-journal/event-journal.service'; +import type { + DbEventJournal, + DbEventJournalInsert, + DbTorrentItem, +} from '@server/db/app/app-schema'; + +class RepoMock { + public created: DbEventJournalInsert[] = []; + + async findAll(): Promise<{ items: DbEventJournal[]; total: number }> { + return { items: [], total: 0 }; + } + + async markAsRead(): Promise { + return undefined; + } + + async markAllAsRead(): Promise { + return []; + } + + async create( + data: DbEventJournalInsert, + ): Promise { + this.created.push(data); + return undefined; + } +} + +const torrentItem: DbTorrentItem = { + id: 1, + trackerId: 't-1', + rawTitle: 'Old Raw', + title: 'Some Show', + url: 'https://example.com/t?id=1', + magnet: 'magnet:?xt=urn:btih:old', + season: 1, + trackedEpisodes: [], + haveEpisodes: [], + totalEpisodes: 10, + files: null, + createdAt: Date.now(), + updatedAt: Date.now(), + transmissionId: null, + controlStatus: 'idle', + tracker: 'kinozal', + errorMessage: null, +}; + +describe('EventJournalService', () => { + let repo: RepoMock; + let service: EventJournalService; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new RepoMock(); + service = new EventJournalService(repo as never); + }); + + it('creates title change event as notification', async () => { + await service.recordTorrentTitleChanged({ + torrentItem, + oldValue: 'Old Raw', + newValue: 'New Raw', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentTitleChanged', + state: 'info', + isNotification: true, + }); + }); + + it('creates magnet change event without notification flag', async () => { + await service.recordTorrentMagnetChanged({ + torrentItem, + oldValue: 'old-magnet', + newValue: 'new-magnet', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentMagnetChanged', + state: 'info', + isNotification: false, + }); + }); + + it('creates sync failed event as error notification', async () => { + await service.recordTorrentSyncFailed({ + torrentItem, + errorMessage: 'UpdateWorker: Error on fetch data, failed', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentSyncFailed', + state: 'error', + oldValue: null, + newValue: 'UpdateWorker: Error on fetch data, failed', + isNotification: true, + }); + }); + + it('creates download started event as notification', async () => { + await service.recordTorrentDownloadStarted({ + torrentItem, + message: 'Download started', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentDownloadStarted', + state: 'info', + oldValue: null, + newValue: 'Download started', + isNotification: true, + }); + }); + + it('creates download completed event as notification', async () => { + await service.recordTorrentDownloadCompleted({ + torrentItem, + message: 'Download completed', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentDownloadCompleted', + state: 'info', + oldValue: null, + newValue: 'Download completed', + isNotification: true, + }); + }); + + it('creates download failed event as error notification', async () => { + await service.recordTorrentDownloadFailed({ + torrentItem, + errorMessage: 'DownloadWorker: Failed to start downloading, failed', + }); + + expect(repo.created[0]).toMatchObject({ + type: 'torrentDownloadFailed', + state: 'error', + oldValue: null, + newValue: 'DownloadWorker: Failed to start downloading, failed', + isNotification: true, + }); + }); + + it('creates file copy events without notification flag', async () => { + await service.recordTorrentFileCopyStarted({ + torrentItem, + message: 'File copy started', + }); + await service.recordTorrentFileCopyCompleted({ + torrentItem, + message: 'Copied 2 file(s)', + }); + await service.recordTorrentFileCopyFailed({ + torrentItem, + errorMessage: + 'DownloadWorker: Error processing completed download, failed', + }); + + expect(repo.created).toEqual([ + expect.objectContaining({ + type: 'torrentFileCopyStarted', + state: 'info', + isNotification: false, + }), + expect.objectContaining({ + type: 'torrentFileCopyCompleted', + state: 'info', + isNotification: false, + }), + expect.objectContaining({ + type: 'torrentFileCopyFailed', + state: 'error', + isNotification: false, + }), + ]); + }); +}); diff --git a/server/test/routes/event-journal-route.test.ts b/server/test/routes/event-journal-route.test.ts new file mode 100644 index 0000000..afe5367 --- /dev/null +++ b/server/test/routes/event-journal-route.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { eventJournalRoute } from '@server/routes/event-journal'; +import { eventJournalReadRoute } from '@server/routes/event-journal.$id.read'; +import { eventJournalReadAllRoute } from '@server/routes/event-journal.read-all'; +import { Hono } from 'hono/tiny'; +import type { + EventJournalDto, + EventJournalPageDto, +} from '@server/features/event-journal/event-journal.types'; + +type ApiResponse = { + success: boolean; + data?: T; + message?: string; +}; + +const { getAllMock, markAsReadMock, markAllAsReadMock } = vi.hoisted(() => { + const getAllMock = + vi.fn<(page: number, limit: number) => Promise>(); + const markAsReadMock = + vi.fn<(id: number) => Promise>(); + const markAllAsReadMock = vi.fn<() => Promise>(); + return { getAllMock, markAsReadMock, markAllAsReadMock } as const; +}); + +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async getAll(page: number, limit: number): Promise { + return await getAllMock(page, limit); + } + + async markAsRead(id: number): Promise { + return await markAsReadMock(id); + } + + async markAllAsRead(): Promise { + return await markAllAsReadMock(); + } + }, +})); + +const event: EventJournalDto = { + id: 1, + type: 'torrentTitleChanged', + state: 'info', + torrentItemId: 7, + torrentTitle: 'Some Show', + oldValue: 'Old title', + newValue: 'New title', + isNotification: true, + readAt: null, + createdAt: 1000, +}; + +function mountRoute(path: string, route: Hono) { + const app = new Hono(); + app.route(path, route); + return app; +} + +describe('eventJournalRoute', () => { + beforeEach(() => { + getAllMock.mockReset(); + markAsReadMock.mockReset(); + markAllAsReadMock.mockReset(); + }); + + it('returns paginated journal events', async () => { + getAllMock.mockResolvedValueOnce({ + items: [event], + total: 1, + page: 1, + hasNext: false, + }); + + const response = await eventJournalRoute.request('/?page=1&limit=30'); + const body = (await response.json()) as ApiResponse; + + expect(response.status).toBe(200); + expect(getAllMock).toHaveBeenCalledWith(1, 30); + expect(body.data?.items[0]?.torrentTitle).toBe('Some Show'); + }); + + it('marks event as read', async () => { + const readEvent: EventJournalDto = { ...event, readAt: 2000 }; + markAsReadMock.mockResolvedValueOnce(readEvent); + const app = mountRoute('/event-journal/:id/read', eventJournalReadRoute); + + const response = await app.request('/event-journal/1/read', { + method: 'PUT', + }); + const body = (await response.json()) as ApiResponse; + + expect(response.status).toBe(200); + expect(markAsReadMock).toHaveBeenCalledWith(1); + expect(body.data?.readAt).toBe(2000); + }); + + it('returns not found when event does not exist', async () => { + markAsReadMock.mockResolvedValueOnce(null); + const app = mountRoute('/event-journal/:id/read', eventJournalReadRoute); + + const response = await app.request('/event-journal/99/read', { + method: 'PUT', + }); + const body = (await response.json()) as ApiResponse; + + expect(response.status).toBe(404); + expect(body.message).toBe('Event not found'); + }); + + it('marks all events as read', async () => { + const readEvents: EventJournalDto[] = [ + { ...event, readAt: 3000 }, + { ...event, id: 2, readAt: 3000 }, + ]; + markAllAsReadMock.mockResolvedValueOnce(readEvents); + + const response = await eventJournalReadAllRoute.request('/', { + method: 'PUT', + }); + const body = (await response.json()) as ApiResponse; + + expect(response.status).toBe(200); + expect(markAllAsReadMock).toHaveBeenCalledTimes(1); + expect(body.data?.map((item) => item.id)).toEqual([1, 2]); + }); +}); diff --git a/server/test/update-worker-error-message.test.ts b/server/test/update-worker-error-message.test.ts index e2afbff..3621cb9 100644 --- a/server/test/update-worker-error-message.test.ts +++ b/server/test/update-worker-error-message.test.ts @@ -15,6 +15,46 @@ vi.mock('@server/workers/workers.repo', () => ({ WorkersRepo: class {}, })); +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async recordTorrentTitleChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentMagnetChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentSyncFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyFailed(): Promise { + return Promise.resolve(); + } + }, +})); + // Mock TorrentItem service with controlled behavior per test file let mode: 'throwFetch' | 'noTitle' | 'success' = 'success'; diff --git a/server/test/update-worker.test.ts b/server/test/update-worker.test.ts index e3bffbc..b40ef2b 100644 --- a/server/test/update-worker.test.ts +++ b/server/test/update-worker.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import type { DbTorrentItem, DbUserSettings } from '@server/db/app/app-schema'; import type { TorrentDataResult } from '@server/external/adapters/tracker-data'; import type { TorrentItemPort } from '@server/features/torrent-item/torrent-item.port'; +import type { EventJournalPort } from '@server/features/event-journal/event-journal.port'; import type { PagedResult, TorrentItemDto, @@ -29,6 +30,46 @@ vi.mock('@server/workers/workers.repo', () => { }; }); +vi.mock('@server/features/event-journal/event-journal.service', () => ({ + EventJournalService: class { + async recordTorrentTitleChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentMagnetChanged(): Promise { + return Promise.resolve(); + } + + async recordTorrentSyncFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentDownloadFailed(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyStarted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyCompleted(): Promise { + return Promise.resolve(); + } + + async recordTorrentFileCopyFailed(): Promise { + return Promise.resolve(); + } + }, +})); + // Test doubles and fixtures const baseItem: DbTorrentItem = { id: 1, @@ -76,6 +117,7 @@ let lastTI: | null = null; let nextTrackerData: TorrentDataResult | null = null; let nextDatabaseData: DbTorrentItem | null = null; +let nextFetchError: Error | null = null; // Mock implementation of TorrentItem service vi.mock('@server/features/torrent-item/torrent-item.service', () => { @@ -85,12 +127,15 @@ vi.mock('@server/features/torrent-item/torrent-item.service', () => { public addOrUpdateMock = vi.fn(); public markAsDownloadRequestedMock = vi.fn(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(_: any) { - lastTI = this as unknown as typeof lastTI extends infer T ? T : never; + constructor(_: { id: number; url: string; trackerId: string }) { + captureTorrentItem(this); } async fetchData(): Promise { + if (nextFetchError) { + throw nextFetchError; + } + // Use values arranged by the test this.trackerData = nextTrackerData; return Promise.resolve(); @@ -165,6 +210,15 @@ vi.mock('@server/features/torrent-item/torrent-item.service', () => { return { TorrentItem: MockTI }; }); +function captureTorrentItem( + item: TorrentItemPort & { + addOrUpdateMock: ReturnType; + markAsDownloadRequestedMock: ReturnType; + }, +): void { + lastTI = item; +} + // Lightweight repo mock class RepoMock { public findSettings = vi.fn(async () => settings); @@ -174,10 +228,23 @@ class RepoMock { public update = vi.fn(async (_id: number, _data: unknown) => undefined); } +class EventJournalMock implements EventJournalPort { + public recordTorrentTitleChanged = vi.fn(async () => undefined); + public recordTorrentMagnetChanged = vi.fn(async () => undefined); + public recordTorrentSyncFailed = vi.fn(async () => undefined); + public recordTorrentDownloadStarted = vi.fn(async () => undefined); + public recordTorrentDownloadCompleted = vi.fn(async () => undefined); + public recordTorrentDownloadFailed = vi.fn(async () => undefined); + public recordTorrentFileCopyStarted = vi.fn(async () => undefined); + public recordTorrentFileCopyCompleted = vi.fn(async () => undefined); + public recordTorrentFileCopyFailed = vi.fn(async () => undefined); +} + describe('UpdateWorker.process', () => { beforeEach(() => { vi.clearAllMocks(); lastTI = null; + nextFetchError = null; }); afterEach(() => { @@ -187,7 +254,11 @@ describe('UpdateWorker.process', () => { it('updates item and requests download when new data and tracked episodes are available', async () => { const { UpdateWorker } = await import('@server/workers/update-worker'); const repo = new RepoMock(); - const worker = new UpdateWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new UpdateWorker({ + repo: repo as unknown as never, + eventJournal, + }); // Arrange: tracker returned a new rawTitle, DB has haveEpisodes intersecting with trackedEpisodes nextTrackerData = { @@ -229,7 +300,6 @@ describe('UpdateWorker.process', () => { nextDatabaseData = { ...baseItem, rawTitle: 'Same Raw', - // Магнит совпадает с трекером, чтобы обновление не требовалось magnet: 'MAG', trackedEpisodes: [1], haveEpisodes: [1], @@ -244,7 +314,11 @@ describe('UpdateWorker.process', () => { it('updates item without requesting download when no tracked episodes match', async () => { const { UpdateWorker } = await import('@server/workers/update-worker'); const repo = new RepoMock(); - const worker = new UpdateWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new UpdateWorker({ + repo: repo as unknown as never, + eventJournal, + }); // New data from tracker but trackedEpisodes do not intersect with haveEpisodes nextTrackerData = { @@ -265,12 +339,24 @@ describe('UpdateWorker.process', () => { expect(lastTI?.addOrUpdateMock).toHaveBeenCalledTimes(1); expect(lastTI?.markAsDownloadRequestedMock).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentTitleChanged).toHaveBeenCalledTimes(1); + expect(eventJournal.recordTorrentTitleChanged).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: 1, title: 'Some Show' }), + oldValue: 'Old Raw', + newValue: 'Brand New', + }); + expect(eventJournal.recordTorrentMagnetChanged).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentSyncFailed).not.toHaveBeenCalled(); }); it('updates item when only magnet changed', async () => { const { UpdateWorker } = await import('@server/workers/update-worker'); const repo = new RepoMock(); - const worker = new UpdateWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new UpdateWorker({ + repo: repo as unknown as never, + eventJournal, + }); nextTrackerData = { torrentId: 't-1', @@ -289,12 +375,44 @@ describe('UpdateWorker.process', () => { expect(lastTI?.addOrUpdateMock).toHaveBeenCalledTimes(1); expect(lastTI?.markAsDownloadRequestedMock).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentMagnetChanged).toHaveBeenCalledTimes(1); + expect(eventJournal.recordTorrentMagnetChanged).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: 1, title: 'Some Show' }), + oldValue: 'MAG-OLD', + newValue: 'MAG-NEW', + }); + expect(eventJournal.recordTorrentTitleChanged).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentSyncFailed).not.toHaveBeenCalled(); + }); + + it('records sync failed event when fetch data fails', async () => { + const { UpdateWorker } = await import('@server/workers/update-worker'); + const repo = new RepoMock(); + const eventJournal = new EventJournalMock(); + const worker = new UpdateWorker({ + repo: repo as unknown as never, + eventJournal, + }); + + nextDatabaseData = { ...baseItem } satisfies DbTorrentItem; + nextFetchError = new Error('tracker timeout'); + + await worker.process(); + + expect(eventJournal.recordTorrentSyncFailed).toHaveBeenCalledWith({ + torrentItem: expect.objectContaining({ id: 1, title: 'Some Show' }), + errorMessage: 'UpdateWorker: Error on fetch data, tracker timeout', + }); }); it('skips item when tracker rawTitle is missing', async () => { const { UpdateWorker } = await import('@server/workers/update-worker'); const repo = new RepoMock(); - const worker = new UpdateWorker({ repo: repo as unknown as never }); + const eventJournal = new EventJournalMock(); + const worker = new UpdateWorker({ + repo: repo as unknown as never, + eventJournal, + }); nextTrackerData = null; // simulate missing tracker data nextDatabaseData = { ...baseItem } as DbTorrentItem; @@ -303,6 +421,7 @@ describe('UpdateWorker.process', () => { expect(lastTI?.addOrUpdateMock).not.toHaveBeenCalled(); expect(lastTI?.markAsDownloadRequestedMock).not.toHaveBeenCalled(); + expect(eventJournal.recordTorrentSyncFailed).toHaveBeenCalledTimes(1); }); });