From 9142590c79ce26901714fcd6013c1ad5bf01f67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 21 May 2026 10:39:19 +0200 Subject: [PATCH] media info - read pdf --- pytorrent/services/rtorrent/files.py | 121 ++++++++++++++++++++++++++ pytorrent/static/js/torrentDetails.js | 2 +- pytorrent/static/styles.css | 54 ++++++++++++ requirements.txt | 1 + 4 files changed, 177 insertions(+), 1 deletion(-) diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py index d9ce750..41f66ac 100644 --- a/pytorrent/services/rtorrent/files.py +++ b/pytorrent/services/rtorrent/files.py @@ -136,10 +136,13 @@ _TEXT_PREVIEW_EXTENSIONS = { ".xml", ".yaml", ".yml", } _IMAGE_PREVIEW_EXTENSIONS = {".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} +_PDF_PREVIEW_EXTENSIONS = {".pdf"} _MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024 _MEDIA_INFO_CHUNK_BYTES = 1024 * 1024 _TEXT_PREVIEW_BYTES = 512 * 1024 _IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024 +_PDF_TEXT_BYTES = 16 * 1024 * 1024 +_PDF_TEXT_PAGES = 10 _MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples" @@ -162,6 +165,11 @@ def _image_preview_supported(path: str) -> bool: return _file_extension(path) in _IMAGE_PREVIEW_EXTENSIONS +def _pdf_preview_supported(path: str) -> bool: + # Note: PDF previews use pypdf for bounded text extraction and do not require system tools such as poppler. + return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS + + def _media_info_sample_suffix(source_path: str) -> str: suffix = LocalPath(str(source_path or "")).suffix.lower() if suffix and len(suffix) <= 16 and all(ch.isalnum() or ch in ".-_" for ch in suffix): @@ -288,6 +296,117 @@ def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_byt return result +def _pdf_imports(): + # Note: pypdf is imported lazily so non-PDF previews do not depend on it at request time. + import sys + + try: + from pypdf import PdfReader + return PdfReader + except ModuleNotFoundError as exc: + missing = str(getattr(exc, "name", "") or "pypdf") + if missing.split(".", 1)[0] == "pypdf": + raise RuntimeError( + "Python package 'pypdf' is not importable in the application runtime. " + "Install it inside the pyTorrent virtualenv and restart the service: " + "/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. " + f"Runtime: {sys.executable}." + ) from exc + raise RuntimeError( + f"pypdf is installed, but one of its Python dependencies is missing: {missing}. " + f"Runtime: {sys.executable}." + ) from exc + except Exception as exc: + raise RuntimeError( + "pypdf was found, but failed during import. " + f"Runtime: {sys.executable}. Details: {exc}" + ) from exc + + +def _pdf_file_preview( + profile: dict, + selected: dict, + remote_path: str, + max_bytes: int = _PDF_TEXT_BYTES, + max_pages: int = _PDF_TEXT_PAGES, +) -> dict: + # Note: PDF text extraction reads only bounded, reasonably sized PDF files and extracts a limited number of pages for modal UX. + size = int(selected.get("size") or 0) + result = { + **selected, + "kind": "pdf", + "parser": "pypdf", + "supported": True, + "sample_bytes": 0, + "sample_limit": int(max_bytes), + "page_limit": int(max_pages), + "partial": False, + "summary": {}, + "fields": [ + {"key": "Type", "value": "PDF text preview"}, + {"key": "Read limit", "value": human_size(max_bytes)}, + {"key": "Page limit", "value": str(max_pages)}, + ], + "raw": [], + "text": "", + } + if size > max_bytes: + result.update({ + "too_large": True, + "error": f"PDF text extraction is limited to {human_size(max_bytes)}. Download the file to read the full PDF.", + }) + return result + + PdfReader = _pdf_imports() + data = _read_file_prefix(profile, remote_path, max_bytes) + result["sample_bytes"] = len(data) + try: + from io import BytesIO + + reader = PdfReader(BytesIO(data)) + if getattr(reader, "is_encrypted", False): + try: + reader.decrypt("") + except Exception: + result.update({"error": "This PDF is encrypted and cannot be read without a password."}) + return result + pages = list(reader.pages) + page_count = len(pages) + extracted = [] + for page_number, page in enumerate(pages[:max_pages], start=1): + try: + page_text = page.extract_text() or "" + except Exception as exc: + page_text = f"[Page {page_number}: text extraction failed: {exc}]" + if page_text.strip(): + extracted.append(f"--- Page {page_number} ---\n{page_text.strip()}") + text = "\n\n".join(extracted).strip() + result.update({ + "text": text, + "page_count": page_count, + "extracted_pages": min(page_count, max_pages), + "partial": page_count > max_pages, + "summary": { + "duration": None, + "bit_rate": human_size(size) if size else None, + "compression": "PDF", + "producer": f"{min(page_count, max_pages)} / {page_count} page(s)", + "creation_date": None, + }, + "fields": result["fields"] + [ + {"key": "PDF size", "value": human_size(size)}, + {"key": "Pages", "value": str(page_count)}, + {"key": "Extracted pages", "value": str(min(page_count, max_pages))}, + ], + }) + if not text: + result["error"] = "No readable text was found in the selected PDF pages. The file may be scanned or image-based." + return result + except Exception as exc: + result.update({"error": f"Unable to read PDF text: {exc}"}) + return result + + def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]: # Note: hachoir needs a seekable file, so this writes a bounded sample into the app data directory instead of loading whole media into RAM. import tempfile @@ -422,6 +541,8 @@ def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_by return _text_file_preview(profile, selected, remote_path) if _image_preview_supported(name): return _image_file_preview(profile, selected, remote_path) + if _pdf_preview_supported(name): + return _pdf_file_preview(profile, selected, remote_path) supported = _media_info_supported(name) result = { diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js index b197d14..112b401 100644 --- a/pytorrent/static/js/torrentDetails.js +++ b/pytorrent/static/js/torrentDetails.js @@ -1 +1 @@ -export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download`,{},'file.bin','Preparing file...').catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({indexes})},`${selectedHash.slice(0,12)}-files.zip`,'Preparing ZIP...');\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
${esc(label)}${mediaInfoValue(value)}
`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
Detected metadata
${rows}
`;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
    ${label}${value}
    `).join('');\n $('detailPane').innerHTML=`\n
    \n
    \n
    ${esc(t.name||'-')}
    ${esc(t.status||'-')}
    \n
    Directory${esc(t.path||'-')}
    \n
    Full data path${esc(fullPath)}
    \n
    \n
    Hash${esc(t.hash||'-')}
    \n
    \n
    ${cards}
    \n
    Labels${labels}
    Ratio rule${ratioGroup}
    Message${esc(t.message||'-')}
    `;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download`,{},'file.bin','Preparing file...').catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({indexes})},`${selectedHash.slice(0,12)}-files.zip`,'Preparing ZIP...');\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
    ${esc(label)}${mediaInfoValue(value)}
    `).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
    Detected metadata
    ${rows}
    `;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function renderPdfPreview(info){\n const downloadButton = ``;\n if(info.too_large){\n return `
    PDF text preview unavailable${esc(info.error || 'The PDF is too large for modal text extraction.')}
    ${downloadButton}
    `;\n }\n const note = info.partial ? `
    Preview limited to ${esc(info.extracted_pages || 0)} of ${esc(info.page_count || 0)} pages. Download the file to read the full PDF.
    ` : '';\n const error = info.error ? `
    PDF note
    ${esc(info.error)}
    ` : '';\n const text = esc(info.text || 'No readable text was returned from this PDF.');\n return `${note}${error}
    ${text}
    ${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index cbd1aeb..26ef479 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -4565,3 +4565,57 @@ body, width: 9rem; } } + +.media-info-pdf-preview { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + color: var(--bs-body-color); + font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + font-size: 0.95rem; + line-height: 1.5; + margin: 0 0 1rem; + max-height: 62vh; + overflow: auto; + padding: 1rem; + white-space: pre-wrap; +} + +.media-info-pdf-empty { + align-items: center; + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.9rem; + display: grid; + gap: 1rem; + grid-template-columns: auto minmax(0, 1fr) auto; + margin-bottom: 1rem; + padding: 1rem; +} + +.media-info-pdf-empty > i { + color: var(--bs-danger); + font-size: 1.8rem; +} + +.media-info-pdf-empty b, +.media-info-pdf-empty span { + display: block; +} + +.media-info-pdf-empty span { + color: var(--bs-secondary-color); + margin-top: 0.15rem; +} + +.media-info-download-row { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +@media (max-width: 560px) { + .media-info-pdf-empty { + grid-template-columns: 1fr; + } +} diff --git a/requirements.txt b/requirements.txt index 7c3dcb2..ea1ebce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ psutil>=5.9 simple-websocket>=1.0 gunicorn>=22.0 hachoir>=3.3 +pypdf>=4.3