Added support for bittorrent

This commit is contained in:
2026-03-28 17:36:25 +02:00
parent 5acde17a53
commit 4f7036ca27
45 changed files with 3383 additions and 225 deletions
@@ -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<Book | null>(null)
const [loading, setLoading] = useState(true)
const [files, setFiles] = useState<BookFile[]>([])
const [book, setBook] = useState<Book | null>(null)
const [loading, setLoading] = useState(true)
const [files, setFiles] = useState<BookFile[]>([])
const [editionsOpen, setEditionsOpen] = useState(false)
const [writingAll, setWritingAll] = useState(false)
const [wanted, setWanted] = useState<WantedBook | null>(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 (
<div className={s.page}>
<div className={s.content}>
@@ -62,13 +136,28 @@ export default function BookDetail() {
</Button>
<div style={{ flex: 1 }} />
{book && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
>
Edit Metadata
</Button>
<>
<Tooltip title={wanted ? 'Monitored — click to unmonitor' : 'Monitor for downloads'}>
<Button
icon={wanted ? <StarFilled style={{ color: '#6750A4' }} /> : <StarOutlined />}
loading={toggling}
onClick={handleToggleMonitor}
/>
</Tooltip>
<Button
icon={<SearchOutlined />}
onClick={() => navigate(`/import?tab=search&q=${encodeURIComponent(book.title)}`)}
>
Find Downloads
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
>
Edit Metadata
</Button>
</>
)}
</Flex>
@@ -132,6 +221,31 @@ export default function BookDetail() {
))}
</Flex>
)}
{book.localFileFormats.length > 0 && (
<Flex gap={8} align="center">
{book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && (
<Flex gap={6} align="center" style={{
padding: '3px 10px', borderRadius: 20,
background: '#f0f5ff', border: '1px solid #adc6ff',
fontSize: 13, color: '#2f54eb',
}}>
<TabletOutlined />
<span>Ebook</span>
</Flex>
)}
{book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && (
<Flex gap={6} align="center" style={{
padding: '3px 10px', borderRadius: 20,
background: '#f9f0ff', border: '1px solid #d3adf7',
fontSize: 13, color: '#722ed1',
}}>
<AudioOutlined />
<span>Audiobook</span>
</Flex>
)}
</Flex>
)}
</div>
</Flex>
@@ -152,23 +266,55 @@ export default function BookDetail() {
)
return filtered.length > 0 ? (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Editions from Hardcover ({filtered.length})
</Typography.Title>
<div className={s.editionList}>
{filtered.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
<button
onClick={() => setEditionsOpen(o => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
marginBottom: editionsOpen ? 10 : 0,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
Editions from Hardcover ({filtered.length})
</Typography.Title>
<DownOutlined
style={{
fontSize: 11, color: 'rgba(0,0,0,.45)',
transition: 'transform .2s',
transform: editionsOpen ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</button>
{editionsOpen && (
<div className={s.editionList}>
{filtered.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
)}
</section>
) : null
})()}
{/* Files */}
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
Files {files.length > 0 && `(${files.length})`}
</Typography.Title>
<Flex align="center" gap={8} style={{ marginBottom: 10 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
Files {files.length > 0 && `(${files.length})`}
</Typography.Title>
{files.length > 0 && (
<Tooltip title="Write metadata to all files">
<Button
size="small"
icon={<TagsOutlined />}
loading={writingAll}
onClick={handleWriteAllMetadata}
>
Write Metadata
</Button>
</Tooltip>
)}
</Flex>
{files.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
No files linked to this book.
@@ -179,8 +325,14 @@ export default function BookDetail() {
<FileRow
key={f.id}
file={f}
editions={book.editions.filter(ed =>
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
)}
onUnlink={handleUnlink}
onDelete={handleDeleteFile}
onAssignEdition={handleAssignEdition}
onOrganize={handleOrganize}
onWriteMetadata={handleWriteMetadata}
/>
))}
</div>
@@ -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 (
<Flex gap={12} align="center" className={s.editionRow}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
<Flex gap={12} align="center" className={s.editionRow} style={{ flexWrap: 'wrap' }}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0, flexShrink: 0 }}>
{file.format}
</Tag>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -296,13 +472,42 @@ function FileRow({
{formatBytes(file.sizeBytes)}
</Typography.Text>
</div>
<Flex gap={4}>
<Button
{editions.length > 0 && (
<Select
size="small"
icon={<LinkOutlined />}
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) }))}
/>
)}
<Flex gap={4}>
<Tooltip title="Write metadata to file">
<Button
size="small"
icon={<TagsOutlined />}
loading={writing}
onClick={handleWrite}
/>
</Tooltip>
<Tooltip title="Move to canonical library path">
<Button
size="small"
icon={<FolderOutlined />}
onClick={() => onOrganize(file.id)}
/>
</Tooltip>
<Tooltip title="Unlink from book">
<Button
size="small"
icon={<LinkOutlined />}
onClick={() => onUnlink(file.id)}
/>
</Tooltip>
<Popconfirm
title="Remove this file record?"
description="The file on disk is not deleted."