#{book.series.position} )} + {(hasEbook || hasAudiobook) && ( + + {hasEbook && 📖} + {hasAudiobook && 🎧} + + )} {book.title} {book.authors.map(a => a.name).join(', ')} - - {book.year && {book.year}} - - {book.formats.map(f => ( - - {f.toUpperCase()} - - ))} + {book.year && ( + + {book.year} - + )} diff --git a/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx index 92cdff1..6987e84 100644 --- a/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx +++ b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx @@ -15,10 +15,12 @@ function makeBook(overrides: Partial = {}): Book { formats: ['epub'], color: '#6366f1', genres: ['Science Fiction'], - authors: [{ id: 1, name: 'Frank Herbert' }], + authors: [{ id: 1, name: 'Frank Herbert', bio: null, bornYear: null, imageUrl: null, slug: null, role: 'Author' }], coverUrl: null, isbn: null, hardcoverId: null, + editions: [], + localFileFormats: [], ...overrides, } } @@ -34,10 +36,20 @@ describe('BookCard', () => { expect(screen.getByText('Frank Herbert')).toBeInTheDocument() }) - it('renders format chips', () => { - render() - expect(screen.getByText('EPUB')).toBeInTheDocument() - expect(screen.getByText('MOBI')).toBeInTheDocument() + it('renders ebook badge when ebook file is present', () => { + render() + expect(screen.getByTitle('Ebook in library')).toBeInTheDocument() + }) + + it('renders audiobook badge when audiobook file is present', () => { + render() + expect(screen.getByTitle('Audiobook in library')).toBeInTheDocument() + }) + + it('renders no file badges when no local files', () => { + render() + expect(screen.queryByTitle('Ebook in library')).not.toBeInTheDocument() + expect(screen.queryByTitle('Audiobook in library')).not.toBeInTheDocument() }) it('renders series position pill when series exists', () => { diff --git a/PageManager.Web/src/pages/BookDetail/BookDetail.tsx b/PageManager.Web/src/pages/BookDetail/BookDetail.tsx index 4438b9f..4873d78 100644 --- a/PageManager.Web/src/pages/BookDetail/BookDetail.tsx +++ b/PageManager.Web/src/pages/BookDetail/BookDetail.tsx @@ -1,29 +1,43 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd' +import { Button, Divider, Flex, Popconfirm, Select, Skeleton, Tag, Tooltip, Typography, message } from 'antd' import { ArrowLeftOutlined, AudioOutlined, BankOutlined, CalendarOutlined, DeleteOutlined, + DownOutlined, EditOutlined, FileTextOutlined, + FolderOutlined, LinkOutlined, ReadOutlined, + SearchOutlined, + StarFilled, + StarOutlined, TabletOutlined, + TagsOutlined, } from '@ant-design/icons' -import type { Book, BookFile, Edition, ReadingFormat } from '../../types' +import type { Book, BookFile, Edition, FileFormat, ReadingFormat, WantedBook } from '../../types' + +const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf'] +const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac'] import { fetchBook } from '../../api/books' -import { assignFile, deleteFile, fetchBookFiles } from '../../api/files' +import { assignFile, deleteFile, fetchBookFiles, organizeFile, writeFileMetadata, writeBookMetadata } from '../../api/files' +import { addWanted, fetchWanted, removeWanted } from '../../api/wanted' import s from './BookDetail.module.css' export default function BookDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const [book, setBook] = useState(null) - const [loading, setLoading] = useState(true) - const [files, setFiles] = useState([]) + const [book, setBook] = useState(null) + const [loading, setLoading] = useState(true) + const [files, setFiles] = useState([]) + const [editionsOpen, setEditionsOpen] = useState(false) + const [writingAll, setWritingAll] = useState(false) + const [wanted, setWanted] = useState(null) + const [toggling, setToggling] = useState(false) useEffect(() => { if (!id) return @@ -31,13 +45,36 @@ export default function BookDetail() { fetchBook(Number(id)) .then(b => { setBook(b) - return fetchBookFiles(b.id) + return Promise.all([ + fetchBookFiles(b.id), + fetchWanted().then(ws => ws.find(w => w.bookId === b.id) ?? null), + ]) }) - .then(setFiles) + .then(([fs, w]) => { setFiles(fs); setWanted(w) }) .catch(() => setBook(null)) .finally(() => setLoading(false)) }, [id]) + async function handleToggleMonitor() { + if (!book) return + setToggling(true) + try { + if (wanted) { + await removeWanted(wanted.id) + setWanted(null) + message.success('Removed from monitoring.') + } else { + const w = await addWanted(book.id) + setWanted(w) + message.success('Book is now monitored.') + } + } catch { + message.error('Failed to update monitoring.') + } finally { + setToggling(false) + } + } + function handleUnlink(fileId: number) { assignFile(fileId, null, null).then(updated => setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) @@ -48,6 +85,43 @@ export default function BookDetail() { deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId))) } + function handleAssignEdition(fileId: number, bookId: number | null, editionId: number | null) { + assignFile(fileId, bookId, editionId).then(updated => + setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) + ) + } + + function handleOrganize(fileId: number) { + organizeFile(fileId).then(result => { + if (result.moved) + setFiles(fs => fs.map(f => f.id === fileId + ? { ...f, path: result.newRelativePath, filename: result.newFilename } + : f + )) + }) + } + + function handleWriteMetadata(fileId: number) { + return writeFileMetadata(fileId).then(r => { + if (r.success) message.success(r.message) + else message.error(r.message) + }) + } + + function handleWriteAllMetadata() { + if (!book) return + setWritingAll(true) + writeBookMetadata(book.id) + .then(r => { + const failed = r.results.filter(x => !x.success) + if (failed.length === 0) + message.success(`Metadata written to ${r.results.length} file(s).`) + else + message.warning(`${r.results.length - failed.length} succeeded, ${failed.length} failed.`) + }) + .finally(() => setWritingAll(false)) + } + return ( @@ -62,13 +136,28 @@ export default function BookDetail() { {book && ( - } - onClick={() => navigate(`/metadata?bookId=${book.id}`)} - > - Edit Metadata - + <> + + : } + loading={toggling} + onClick={handleToggleMonitor} + /> + + } + onClick={() => navigate(`/import?tab=search&q=${encodeURIComponent(book.title)}`)} + > + Find Downloads + + } + onClick={() => navigate(`/metadata?bookId=${book.id}`)} + > + Edit Metadata + + > )} @@ -132,6 +221,31 @@ export default function BookDetail() { ))} )} + + {book.localFileFormats.length > 0 && ( + + {book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && ( + + + Ebook + + )} + {book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && ( + + + Audiobook + + )} + + )} @@ -152,23 +266,55 @@ export default function BookDetail() { ) return filtered.length > 0 ? ( - - Editions from Hardcover ({filtered.length}) - - - {filtered.map(ed => ( - - ))} - + setEditionsOpen(o => !o)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + background: 'none', border: 'none', cursor: 'pointer', padding: 0, + marginBottom: editionsOpen ? 10 : 0, + }} + > + + Editions from Hardcover ({filtered.length}) + + + + {editionsOpen && ( + + {filtered.map(ed => ( + + ))} + + )} ) : null })()} {/* Files */} - - Files {files.length > 0 && `(${files.length})`} - + + + Files {files.length > 0 && `(${files.length})`} + + {files.length > 0 && ( + + } + loading={writingAll} + onClick={handleWriteAllMetadata} + > + Write Metadata + + + )} + {files.length === 0 ? ( No files linked to this book. @@ -179,8 +325,14 @@ export default function BookDetail() { + !ed.language || ed.language === 'English' || ed.language === 'Latvian' + )} onUnlink={handleUnlink} onDelete={handleDeleteFile} + onAssignEdition={handleAssignEdition} + onOrganize={handleOrganize} + onWriteMetadata={handleWriteMetadata} /> ))} @@ -271,18 +423,42 @@ function formatBytes(bytes: number): string { return `${bytes} B` } +function editionLabel(ed: Edition): string { + const parts: string[] = [] + if (ed.editionFormat) parts.push(ed.editionFormat) + else if (ed.readingFormat) parts.push(ed.readingFormat) + if (ed.releaseYear) parts.push(String(ed.releaseYear)) + if (ed.isbn) parts.push(`ISBN ${ed.isbn}`) + else if (ed.asin) parts.push(`ASIN ${ed.asin}`) + return parts.join(' · ') || `Edition #${ed.id}` +} + function FileRow({ file, + editions, onUnlink, onDelete, + onAssignEdition, + onOrganize, + onWriteMetadata, }: { file: BookFile + editions: Edition[] onUnlink: (id: number) => void onDelete: (id: number) => void + onAssignEdition: (fileId: number, bookId: number | null, editionId: number | null) => void + onOrganize: (id: number) => void + onWriteMetadata: (id: number) => void }) { + const [writing, setWriting] = useState(false) + + function handleWrite() { + setWriting(true) + onWriteMetadata(file.id).finally(() => setWriting(false)) + } return ( - - + + {file.format} @@ -296,13 +472,42 @@ function FileRow({ {formatBytes(file.sizeBytes)} - - 0 && ( + } - onClick={() => onUnlink(file.id)} - title="Unlink from book" + allowClear + placeholder="Edition" + value={file.editionId ?? undefined} + onChange={(val: number | undefined) => + onAssignEdition(file.id, file.bookId ?? null, val ?? null) + } + style={{ width: 200, fontSize: 12 }} + options={editions.map(ed => ({ value: ed.id, label: editionLabel(ed) }))} /> + )} + + + } + loading={writing} + onClick={handleWrite} + /> + + + } + onClick={() => onOrganize(file.id)} + /> + + + } + onClick={() => onUnlink(file.id)} + /> + = { folder: , @@ -21,16 +37,481 @@ const SOURCE_ICONS: Record = { url: , } -export default function Import() { - const [queue, setQueue] = useState([]) - const [sources, setSources] = useState([]) - const [unmatched, setUnmatched] = useState([]) - const [scanning, setScanning] = useState(false) +function formatBytes(b: number) { + if (b === 0) return '—' + if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB` + if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB` + return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB` +} + +// ── Downloads tab ──────────────────────────────────────────────────────────── + +function DownloadsTab() { + const [downloads, setDownloads] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchDownloads().then(setDownloads).finally(() => setLoading(false)) + const id = setInterval(() => fetchDownloads().then(setDownloads), 10_000) + return () => clearInterval(id) + }, []) + + async function handleCancel(id: string) { + try { + await cancelDownload(id) + setDownloads(ds => ds.filter(d => d.id !== id)) + } catch { + message.error('Failed to cancel download.') + } + } + + const active = downloads.filter(d => d.status === 'downloading' || d.status === 'queued') + const finished = downloads.filter(d => d.status === 'completed' || d.status === 'failed') + + if (loading) { + return ( + + } /> + + ) + } + + if (downloads.length === 0) { + return ( + + No downloads. + + ) + } + + return ( + + {active.length > 0 && ( + + Active + + {active.map(d => ( + + ))} + + + )} + {finished.length > 0 && ( + + History + + {finished.map(d => ( + + ))} + + + )} + + ) +} + +function DownloadRow({ download: d, onCancel }: { download: Download; onCancel: (id: string) => void }) { + const pct = d.sizeBytes > 0 ? Math.round((d.downloadedBytes / d.sizeBytes) * 100) : 0 + const statusColor: Record = { + downloading: '#6750A4', + queued: '#fa8c16', + completed: '#52c41a', + failed: '#ff4d4f', + } + + return ( + + + {d.status} + + {d.bookTitle ?? d.filename} + + + {formatBytes(d.sizeBytes)} + + {(d.status === 'downloading' || d.status === 'queued') && ( + } + danger + onClick={() => onCancel(d.id)} + /> + )} + + {d.status === 'downloading' && ( + + )} + {d.status === 'failed' && d.error && ( + {d.error} + )} + + ) +} + +// ── Search tab ─────────────────────────────────────────────────────────────── + +function SearchTab({ initialQuery }: { initialQuery?: string }) { + const [query, setQuery] = useState(initialQuery ?? '') + const [type, setType] = useState<'ebook' | 'audiobook'>('ebook') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [adding, setAdding] = useState(null) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (initialQuery) doSearch(initialQuery, type) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function doSearch(q: string, t: 'ebook' | 'audiobook') { + if (!q.trim()) { setResults([]); return } + setLoading(true) + searchTorrents(q, t) + .then(setResults) + .catch(() => { message.error('Search failed.'); setResults([]) }) + .finally(() => setLoading(false)) + } + + function handleQueryChange(q: string) { + setQuery(q) + if (timerRef.current) clearTimeout(timerRef.current) + if (!q.trim()) { setResults([]); return } + timerRef.current = setTimeout(() => doSearch(q, type), 500) + } + + function handleTypeChange(t: 'ebook' | 'audiobook') { + setType(t) + if (query.trim()) doSearch(query, t) + } + + async function handleDownload(r: TorrentSearchResult) { + if (!r.magnet || adding) return + setAdding(r.magnet) + try { + await addDownload(r.magnet) + message.success(`Added "${r.title}" to downloads.`) + } catch { + message.error('Failed to add download.') + } finally { + setAdding(null) + } + } + + return ( + + + } + placeholder="Search for ebooks or audiobooks…" + value={query} + onChange={e => handleQueryChange(e.target.value)} + allowClear + style={{ flex: 1 }} + autoFocus + /> + + + + {loading && ( + + } /> + + )} + + {!loading && query.trim() && results.length === 0 && ( + + No results for "{query}" + + )} + + {!loading && !query.trim() && ( + + Start typing to search indexers + + )} + + {!loading && results.map((r, i) => ( + + + + + {r.title} + + + {r.seeders} seeders · {r.leechers} leechers · {formatBytes(r.sizeBytes ?? 0)} · {r.indexer} + + + } + type="primary" + ghost + disabled={!r.magnet || adding === r.magnet} + loading={adding === r.magnet} + onClick={() => handleDownload(r)} + > + Download + + + + ))} + + ) +} + +// ── Wanted tab ─────────────────────────────────────────────────────────────── + +function WantedTab() { + const [wanted, setWanted] = useState([]) + const [loading, setLoading] = useState(true) + const [searching, setSearching] = useState(null) + const [removing, setRemoving] = useState(null) + const [searchResults, setSearchResults] = useState<{ id: number; results: TorrentSearchResult[] } | null>(null) + const [addModalOpen, setAddModalOpen] = useState(false) + const [books, setBooks] = useState([]) + const [addBookId, setAddBookId] = useState(null) + const [addFormat, setAddFormat] = useState(undefined) + const [addMinSeeders, setAddMinSeeders] = useState(1) + const [adding, setAdding] = useState(false) + + useEffect(() => { + fetchWanted().then(setWanted).finally(() => setLoading(false)) + fetchBooks().then(setBooks) + }, []) + + async function handleRemove(id: number) { + setRemoving(id) + try { + await removeWanted(id) + setWanted(ws => ws.filter(w => w.id !== id)) + } catch { + message.error('Failed to remove.') + } finally { + setRemoving(null) + } + } + + async function handleSearchNow(id: number) { + setSearching(id) + try { + const results = await searchNow(id) + setSearchResults({ id, results }) + } catch { + message.error('Search failed.') + } finally { + setSearching(null) + } + } + + async function handleAdd() { + if (!addBookId) return + setAdding(true) + try { + const entry = await addWanted(addBookId, addFormat, addMinSeeders) + setWanted(ws => { + const idx = ws.findIndex(w => w.bookId === addBookId) + if (idx >= 0) { const copy = [...ws]; copy[idx] = entry; return copy } + return [...ws, entry] + }) + setAddModalOpen(false) + setAddBookId(null) + setAddFormat(undefined) + setAddMinSeeders(1) + } catch { + message.error('Failed to add.') + } finally { + setAdding(false) + } + } + + const statusColor: Record = { + wanted: '#6750A4', + downloading: '#fa8c16', + found: '#52c41a', + } + + if (loading) { + return ( + + } /> + + ) + } + + return ( + <> + + } onClick={() => setAddModalOpen(true)}> + Monitor book + + + + {wanted.length === 0 && ( + + No monitored books. + + )} + + + {wanted.map(w => ( + + + {w.status} + + + {w.bookTitle} + + + {w.bookAuthors.join(', ')} + {w.formatPreference ? ` · ${w.formatPreference}` : ''} + {` · ≥${w.minSeeders} seeders`} + + + : } + disabled={searching !== null} + onClick={() => handleSearchNow(w.id)} + > + Search now + + : } + disabled={removing !== null} + onClick={() => handleRemove(w.id)} + /> + + + {searchResults?.id === w.id && searchResults.results.length > 0 && ( + + + {searchResults.results.length} result(s) found: + + {searchResults.results.slice(0, 3).map((r, i) => ( + + · {r.title} ({r.seeders} seeders, {formatBytes(r.sizeBytes ?? 0)}) + + ))} + + )} + {searchResults?.id === w.id && searchResults.results.length === 0 && ( + + No results found. + + )} + + ))} + + + { setAddModalOpen(false); setAddBookId(null); setAddFormat(undefined); setAddMinSeeders(1) }} + onOk={handleAdd} + okText="Monitor" + okButtonProps={{ disabled: !addBookId, loading: adding }} + > + + + Book + + (option?.label as string ?? '').toLowerCase().includes(input.toLowerCase()) + } + options={books.map(b => ({ + value: b.id, + label: `${b.title}${b.authors.length ? ` — ${b.authors[0].name}` : ''}`, + }))} + /> + + + Format preference (optional) + setAddFormat(v)} + options={[ + { label: 'EPUB', value: 'epub' }, + { label: 'MOBI', value: 'mobi' }, + { label: 'M4B', value: 'm4b' }, + { label: 'MP3', value: 'mp3' }, + ]} + /> + + + Minimum seeders + setAddMinSeeders(Number(e.target.value) || 1)} + style={{ width: 120 }} + /> + + + + > + ) +} + +// ── Sources tab ────────────────────────────────────────────────────────────── + +function SourcesTab() { + const [sources, setSources] = useState([]) + const [scanning, setScanning] = useState(false) + const [unmatched, setUnmatched] = useState([]) + + // Match modal state + const [matchingFile, setMatchingFile] = useState(null) + const [matchTab, setMatchTab] = useState<'library' | 'hardcover'>('library') + const [books, setBooks] = useState([]) + const [bookSearch, setBookSearch] = useState('') + const [assigning, setAssigning] = useState(false) + const [hcQuery, setHcQuery] = useState('') + const [hcResults, setHcResults] = useState([]) + const [hcLoading, setHcLoading] = useState(false) + const [hcAdding, setHcAdding] = useState(null) + const [hcAdded, setHcAdded] = useState>(new Set()) + const hcTimerRef = useRef | null>(null) useEffect(() => { - fetchQueue().then(setQueue) fetchSources().then(setSources) fetchUnmatchedFiles().then(setUnmatched) + fetchBooks().then(setBooks) }, []) function handleScan() { @@ -40,16 +521,6 @@ export default function Import() { .finally(() => setScanning(false)) } - function handleRetry(id: string) { - retryQueueItem(id).then(() => - setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i)) - ) - } - - function handleRemove(id: string) { - removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id))) - } - function toggleSource(id: string) { const current = sources.find(s => s.id === id) if (!current) return @@ -60,55 +531,84 @@ export default function Import() { ) } - const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued') - const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed') + function openMatchModal(file: BookFile) { + setMatchingFile(file) + setMatchTab('library') + setBookSearch('') + setHcQuery('') + setHcResults([]) + setHcAdded(new Set()) + } + + function closeMatchModal() { + setMatchingFile(null) + setBookSearch('') + setHcQuery('') + setHcResults([]) + } + + function handleAssign(book: Book) { + if (!matchingFile) return + setAssigning(true) + assignFile(matchingFile.id, book.id, null) + .then(() => { + setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id)) + closeMatchModal() + }) + .finally(() => setAssigning(false)) + } + + function handleHcQueryChange(q: string) { + setHcQuery(q) + if (hcTimerRef.current) clearTimeout(hcTimerRef.current) + if (!q.trim()) { setHcResults([]); return } + hcTimerRef.current = setTimeout(() => { + setHcLoading(true) + searchHardcover(q) + .then(setHcResults) + .catch(() => setHcResults([])) + .finally(() => setHcLoading(false)) + }, 400) + } + + async function handleHcAddAndMatch(result: HardcoverSearchResult) { + if (!matchingFile || hcAdding !== null || hcAdded.has(result.id)) return + setHcAdding(result.id) + try { + const book = await addBookFromHardcover(result.id) + setHcAdded(prev => new Set(prev).add(result.id)) + await assignFile(matchingFile.id, book.id, null) + setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id)) + closeMatchModal() + } finally { + setHcAdding(null) + } + } + + const filteredBooks = useMemo(() => { + const q = bookSearch.toLowerCase() + if (!q) return books + return books.filter(b => + b.title.toLowerCase().includes(q) || + b.authors.some(a => a.name.toLowerCase().includes(q)) + ) + }, [books, bookSearch]) return ( - - {/* Left column */} - - - Drop files - { - console.log('file:', file.name) - return false - }} - style={{ padding: '8px 0' }} - > - - - Drop EPUB, MOBI, PDF files here - - or click to browse - - - - + <> + Sources {sources.map(src => ( {SOURCE_ICONS[src.type] ?? } @@ -121,16 +621,13 @@ export default function Import() { toggleSource(src.id)} - aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`} size="small" /> ))} } - style={{ marginTop: 12 }} + type="dashed" icon={} style={{ marginTop: 12 }} onClick={() => {}} > Add source @@ -141,55 +638,10 @@ export default function Import() { Scan - } - loading={scanning} - onClick={handleScan} - > + } loading={scanning} onClick={handleScan}> Scan sources now - - - {/* Right column */} - - {active.length > 0 && ( - - - Downloading - - - - {active.map(item => ( - - ))} - - - )} - - {finished.length > 0 && ( - - History - - {finished.map(item => ( - - ))} - - - )} - - {queue.length === 0 && unmatched.length === 0 && ( - - No recent activity. - - )} {unmatched.length > 0 && ( @@ -200,28 +652,239 @@ export default function Import() { {unmatched.map(f => ( - + {f.format} - - {f.filename} - + + {f.filename} + {f.path !== f.filename && ( + + {f.path} + + )} + + } onClick={() => openMatchModal(f)}> + Match + ))} )} + + {/* Manual match modal */} + + Match file to book + {matchingFile && ( + + {matchingFile.filename} + + )} + + } + open={matchingFile !== null} + onCancel={closeMatchModal} + footer={null} + width={520} + > + setMatchTab(v as 'library' | 'hardcover')} + options={[ + { label: 'Library', value: 'library' }, + { label: 'Search Hardcover', value: 'hardcover' }, + ]} + style={{ marginBottom: 12 }} + /> + + {matchTab === 'library' && ( + <> + } + placeholder="Search by title or author…" + value={bookSearch} + onChange={e => setBookSearch(e.target.value)} + style={{ marginBottom: 12 }} + autoFocus + /> + + {filteredBooks.length === 0 && ( + + No books found. + + )} + {filteredBooks.map(b => ( + handleAssign(b)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', + border: 'none', borderRadius: 6, background: 'transparent', + cursor: assigning ? 'not-allowed' : 'pointer', textAlign: 'left', width: '100%', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f0ff')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + + {b.coverUrl && ( + + )} + + + {b.title} + + {b.authors.map(a => a.name).join(', ')} + {b.year ? ` · ${b.year}` : ''} + + + + ))} + + > + )} + + {matchTab === 'hardcover' && ( + <> + } + placeholder="Search Hardcover…" + value={hcQuery} + onChange={e => handleHcQueryChange(e.target.value)} + allowClear + style={{ marginBottom: 12 }} + autoFocus + /> + + {hcLoading && ( + + } /> + + )} + {!hcLoading && !hcQuery.trim() && ( + + Start typing to search Hardcover + + )} + {!hcLoading && hcQuery.trim() && hcResults.length === 0 && ( + + No results for "{hcQuery}" + + )} + {!hcLoading && hcResults.map(r => { + const isAdded = hcAdded.has(r.id) + const isAdding = hcAdding === r.id + return ( + handleHcAddAndMatch(r)} + role="button" + tabIndex={0} + onKeyDown={e => { if (e.key === 'Enter') handleHcAddAndMatch(r) }} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '10px 8px', borderRadius: 6, + cursor: isAdded ? 'default' : 'pointer', + background: isAdded ? '#f6f0ff' : 'transparent', + opacity: isAdding ? 0.6 : 1, transition: 'background 150ms', + }} + onMouseEnter={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)' }} + onMouseLeave={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent' }} + > + + {r.title} + + {r.authors.join(', ')}{r.year ? ` · ${r.year}` : ''} + + + + {isAdding ? : isAdded ? : } + + + ) + })} + + > + )} + + > + ) +} + +// ── Main page ──────────────────────────────────────────────────────────────── + +export default function Import() { + const [searchParams] = useSearchParams() + const initialTab = searchParams.get('tab') ?? 'downloads' + const initialSearch = searchParams.get('q') ?? undefined + const [activeTab, setActiveTab] = useState(initialTab) + + const items = [ + { + key: 'downloads', + label: ( + Downloads + ), + children: ( + + + + ), + }, + { + key: 'search', + label: ( + Search + ), + children: ( + + + + ), + }, + { + key: 'wanted', + label: ( + Wanted + ), + children: ( + + + + ), + }, + { + key: 'sources', + label: ( + Sources + ), + children: ( + + + + ), + }, + ] + + return ( + + ) } diff --git a/PageManager.Web/src/types/index.ts b/PageManager.Web/src/types/index.ts index 898a0eb..df43d29 100644 --- a/PageManager.Web/src/types/index.ts +++ b/PageManager.Web/src/types/index.ts @@ -61,6 +61,8 @@ export interface Book { isbn: string | null hardcoverId: number | null editions: Edition[] + /** Distinct formats of BookFile records actually present in the library. */ + localFileFormats: FileFormat[] } export interface QueueItem { @@ -108,6 +110,49 @@ export interface ImportSource { enabled: boolean } +export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' +export type DownloadSourceType = 'manual' | 'torrent' | 'localscan' + +export interface Download { + id: string + filename: string + sizeBytes: number + downloadedBytes: number + status: DownloadStatus + sourceType: DownloadSourceType + torrentHash: string | null + bookId: number | null + bookTitle: string | null + error: string | null + addedAt: string +} + +export type WantedStatus = 'wanted' | 'downloading' | 'found' +export type FileFormatPreference = 'epub' | 'mobi' | 'm4b' | 'mp3' | 'aac' | 'flac' + +export interface WantedBook { + id: number + bookId: number + bookTitle: string + bookAuthors: string[] + bookCoverUrl: string | null + addedAt: string + status: WantedStatus + formatPreference: FileFormatPreference | null + minSeeders: number +} + +export interface TorrentSearchResult { + title: string + magnet: string | null + downloadUrl: string | null + sizeBytes: number | null + seeders: number + leechers: number + indexer: string + publishDate: string | null +} + export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac' export interface BookFile { diff --git a/compose.yaml b/compose.yaml index 4544429..14e21e1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,15 +1,4 @@ services: - postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: pagemanager - POSTGRES_USER: pm - POSTGRES_PASSWORD: pm - volumes: - - pgdata:/var/lib/postgresql/data - ports: - - "5432:5432" - pagemanager.api: image: pagemanager.api build: @@ -19,11 +8,14 @@ services: - "5278:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - - ConnectionStrings__Postgres=Host=postgres;Database=pagemanager;Username=pm;Password=pm + - ConnectionStrings__Postgres=${POSTGRES_CONNECTION_STRING} + - Torrent__QBittorrentUrl=http://qbittorrent:8080 + - Torrent__SavePath=/data/books/incoming volumes: - books:/data/books + - audiobooks:/data/audiobooks depends_on: - - postgres + - qbittorrent pagemanager.web: image: pagemanager.web @@ -35,6 +27,35 @@ services: depends_on: - pagemanager.api + # Optional: self-hosted qBittorrent (comment out if using an external instance) + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + - WEBUI_PORT=8080 + volumes: + - qbt-config:/config + - books:/data/books + ports: + - "6881:6881" + - "6881:6881/udp" + - "8090:8080" # WebUI on host port 8090 to avoid conflict with pagemanager.web + + # Optional: Prowlarr indexer aggregator (comment out if not needed) + # prowlarr: + # image: lscr.io/linuxserver/prowlarr:latest + # environment: + # - PUID=1000 + # - PGID=1000 + # - TZ=Etc/UTC + # volumes: + # - prowlarr-config:/config + # ports: + # - "9696:9696" + volumes: - pgdata: books: + audiobooks: + qbt-config: