Files
pyTorrent/pytorrent/static/js/torrentDetails.js
Mateusz Gruszczyński f8eddd6fd5 mobile torrent details
2026-05-24 13:40:54 +02:00

2 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ') || '<span class=\"text-muted\">-</span>';\n const ratioGroup=t.ratio_group ? `<span class=\"badge text-bg-info\">${esc(t.ratio_group)}</span>` : '<span class=\"text-muted\">Not assigned</span>';\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])=>`<div class=\"general-stat\"><b>${label}</b><span>${value}</span></div>`).join('');\n $('detailPane').innerHTML=`\n <section class=\"general-summary\">\n <div class=\"general-summary-main\">\n <div class=\"general-title-row\"><h6>${esc(t.name||'-')}</h6><span class=\"badge text-bg-${statusClass}\">${esc(t.status||'-')}</span></div>\n <div class=\"general-path\"><b>Directory</b><span>${esc(t.path||'-')}</span></div>\n <div class=\"general-path\"><b>Full data path</b><span>${esc(fullPath)}</span></div>\n </div>\n <div class=\"general-summary-side\"><b>Hash</b><code>${esc(t.hash||'-')}</code></div>\n </section>\n <div class=\"general-grid\">${cards}</div>\n <div class=\"general-meta\"><div><b>Labels</b><span>${labels}</span></div><div><b>Ratio rule</b><span>${ratioGroup}</span></div><div><b>Message</b><span>${esc(t.message||'-')}</span></div></div>`;\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 `<select class=\"form-select form-select-sm file-priority\" data-index=\"${esc(f.index)}\"><option value=\"0\" ${p===0?\"selected\":\"\"}>Skip</option><option value=\"1\" ${p===1?\"selected\":\"\"}>Normal</option><option value=\"2\" ${p===2?\"selected\":\"\"}>High</option></select>`; }\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){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).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 openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\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]) => `<div class=\"media-info-card\"><b>${esc(label)}</b><span>${mediaInfoValue(value)}</span></div>`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `<tr><th>${esc(field.key)}</th><td>${esc(field.value)}</td></tr>`).join('');\n if(rows) return `<div class=\"media-info-section\"><h6>Detected metadata</h6><div class=\"responsive-table-wrap\"><table class=\"table table-sm detail-table media-info-table\"><tbody>${rows}</tbody></table></div></div>`;\n const raw = (info.raw || []).slice(0, 80).map(line => `<li>${esc(line)}</li>`).join('');\n return `<div class=\"media-info-section\"><h6>Raw parser output</h6><ul class=\"media-info-raw\">${raw || '<li>No metadata was returned for the sampled part of this file.</li>'}</ul></div>`;\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 = `<div class=\"modal-dialog modal-xl modal-dialog-scrollable\"><div class=\"modal-content\"><div class=\"modal-header\"><div><h5 class=\"modal-title\"><i class=\"fa-solid fa-circle-info\"></i> File info</h5><div id=\"mediaInfoSubtitle\" class=\"media-info-subtitle\"></div></div><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div id=\"mediaInfoBody\" class=\"modal-body\"><div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading file info...</div></div></div></div>`;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\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 ? `<div class=\"media-info-note\"><i class=\"fa-solid fa-scissors\"></i> Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.</div>` : '';\n return `${note}<pre class=\"media-info-text-preview\">${text || 'No text content was returned.'}</pre>`;\n }\n function renderImagePreview(info){\n if(info.error){\n return `<div class=\"media-info-image-empty\"><i class=\"fa-regular fa-image\"></i><div><b>Image preview unavailable</b><span>${esc(info.error)}</span></div><button class=\"btn btn-sm btn-outline-secondary file-download-one\" type=\"button\" data-index=\"${esc(info.index)}\"><i class=\"fa-solid fa-download\"></i> Download image</button></div>`;\n }\n return `<figure class=\"media-info-image-preview\"><img src=\"${esc(info.data_url || '')}\" alt=\"${esc(info.path || 'Image preview')}\" loading=\"lazy\"><figcaption>${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}</figcaption></figure>`;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = `<button class=\"btn btn-sm btn-outline-secondary file-download-one\" type=\"button\" data-index=\"${esc(info.index)}\"><i class=\"fa-solid fa-download\"></i> Download PDF</button>`;\n const openButton = src ? `<a class=\"btn btn-sm btn-outline-primary\" href=\"${esc(src)}\" target=\"_blank\" rel=\"noopener noreferrer\"><i class=\"fa-solid fa-up-right-from-square\"></i> Open in new tab</a>` : '';\n if(!src){\n return `<div class=\"media-info-pdf-empty\"><i class=\"fa-regular fa-file-pdf\"></i><div><b>PDF preview unavailable</b><span>Missing temporary app link for inline preview.</span></div>${downloadButton}</div>`;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `<div class=\"media-info-pdf-toolbar\"><div><b>PDF preview</b><span>${esc(size)} · rendered by your browser${esc(expires)}</span></div><div class=\"media-info-pdf-actions\">${openButton}${downloadButton}</div></div><div class=\"media-info-pdf-viewer\"><object data=\"${esc(src)}\" type=\"application/pdf\" aria-label=\"${title}\"><div class=\"media-info-pdf-empty\"><i class=\"fa-regular fa-file-pdf\"></i><div><b>Inline PDF preview is not available</b><span>Your browser blocked the embedded viewer. Open it in a new tab or download the file.</span></div>${openButton}${downloadButton}</div></object></div>`;\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 = `<div class=\"media-info-overview\">${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}</div>${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 = `<div class=\"media-info-overview\">${mediaInfoSummaryCards(info)}</div>${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `<div class=\"alert alert-warning mb-0\"><b>File info unavailable</b><div>${esc(info.error)}</div></div>`;\n return;\n }\n body.innerHTML = `<div class=\"media-info-overview\">${mediaInfoSummaryCards(info)}</div>${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading file info...</div>';\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 = `<div class=\"alert alert-danger mb-0\"><b>File info failed</b><div>${esc(e.message)}</div></div>`;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return `<button class=\"btn btn-xs ${stateClass} file-media-info\" type=\"button\" data-index=\"${esc(f.index)}\" title=\"${esc(title)}\"${disabled}><i class=\"fa-solid fa-circle-info\"></i></button>`;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`<tr data-file-index=\"${esc(f.index)}\"><td class=\"sel\"><input class=\"file-check\" type=\"checkbox\" data-index=\"${esc(f.index)}\"></td><td class=\"path\" title=\"${esc(f.path)}\">${esc(f.path)}</td><td>${esc(f.size_h)}</td><td>${progressBar(f.progress ?? 0, 'file-progress')}</td><td><span class=\"badge ${priorityClass(f.priority)}\">${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}</span></td><td>${renderFilePrioritySelect(f)}</td><td><div class=\"file-row-actions\">${renderFileInfoButton(f)}<button class=\"btn btn-xs btn-outline-secondary file-download-one\" type=\"button\" data-index=\"${esc(f.index)}\" title=\"Download\"><i class=\"fa-solid fa-download\"></i></button></div></td></tr>`).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`<div class=\"files-toolbar\"><div class=\"files-action-strip\" role=\"group\" aria-label=\"File actions\"><div class=\"files-action-section\"><span class=\"files-action-label\">Priority</span><button class=\"btn btn-sm btn-outline-secondary file-priority-bulk\" type=\"button\" data-priority=\"0\"><i class=\"fa-solid fa-ban\"></i> Skip selected</button><button class=\"btn btn-sm btn-outline-primary file-priority-bulk\" type=\"button\" data-priority=\"1\"><i class=\"fa-solid fa-bars\"></i> Normal selected</button><button class=\"btn btn-sm btn-outline-success file-priority-bulk\" type=\"button\" data-priority=\"2\"><i class=\"fa-solid fa-arrow-up\"></i> High selected</button></div><span class=\"files-action-separator\" aria-hidden=\"true\"></span><div class=\"files-action-section\"><span class=\"files-action-label\">Download</span><button class=\"btn btn-sm btn-outline-secondary file-download-selected\" type=\"button\"><i class=\"fa-solid fa-download\"></i> Download selected</button><button class=\"btn btn-sm btn-outline-secondary file-download-zip\" type=\"button\"><i class=\"fa-solid fa-file-zipper\"></i> Download all ZIP</button><button class=\"btn btn-sm btn-outline-secondary file-tree-refresh\" type=\"button\"><i class=\"fa-solid fa-folder-tree\"></i> Tree</button></div></div><span class=\"small text-muted\">Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.</span></div><div id=\"fileTreePanel\" class=\"file-tree-panel d-none\"></div><div class=\"responsive-table-wrap\"><table class=\"table table-sm detail-table file-priority-table\"><thead><tr><th><input id=\"fileSelectAll\" type=\"checkbox\"></th><th>Path</th><th>Size</th><th>Done</th><th>Priority</th><th>Set priority</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan=\"7\" class=\"empty\">No files.</td></tr>'}</tbody></table></div>`;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `<li><span class=\"file-tree-file\" data-index=\"${esc(node.index)}\"><i class=\"fa-regular fa-file\"></i> ${esc(node.name||node.path)} <small>${esc(node.size_h||'')}</small></span></li>`;\n return `<li><details open><summary><i class=\"fa-regular fa-folder-open\"></i> ${esc(node.name||'Files')} <small>${esc(node.size_h||'')}</small> <span class=\"file-tree-actions\"><button class=\"btn btn-xs btn-outline-secondary folder-priority\" data-path=\"${esc(node.path||'')}\" data-priority=\"0\"><i class=\"fa-solid fa-ban\"></i> Skip</button><button class=\"btn btn-xs btn-outline-primary folder-priority\" data-path=\"${esc(node.path||'')}\" data-priority=\"1\"><i class=\"fa-solid fa-bars\"></i> Normal</button><button class=\"btn btn-xs btn-outline-success folder-priority\" data-path=\"${esc(node.path||'')}\" data-priority=\"2\"><i class=\"fa-solid fa-arrow-up\"></i> High</button></span></summary><ul>${children}</ul></details></li>`;\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='<span class=\"spinner-border spinner-border-sm\"></span> 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=`<ul class=\"file-tree-root\">${fileTreeNode(j.tree||{})}</ul>`; }\n catch(e){ box.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`; }\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 `<button class=\"${cls}\" type=\"button\" data-first-chunk=\"${esc(cell.first_chunk)}\" data-last-chunk=\"${esc(cell.last_chunk)}\" title=\"${esc(chunkCellTitle(cell))}\" aria-label=\"${esc(chunkCellTitle(cell))}\"><span style=\"height:${pct}%\"></span></button>`;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`<span class=\"chunk-legend-item\"><i class=\"chunk-dot chunk-${key}\"></i>${label} <b>${esc(summary?.[key]??0)}</b></span>`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => `<option value=\"${esc(value)}\" ${chunkFilterMode === value ? 'selected' : ''}>${esc(label)}</option>`).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => `<option value=\"${esc(value)}\" ${chunkDensityMode === value ? 'selected' : ''}>${esc(cfg.label)}</option>`).join('');\n return `<div class=\"chunk-controls\"><label><span>Filter</span><select id=\"chunkFilterMode\" class=\"form-select form-select-sm\">${filters}</select></label><label><span>Density</span><select id=\"chunkDensityMode\" class=\"form-select form-select-sm\">${densities}</select></label></div>`;\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?'<span class=\"badge text-bg-warning\">grouped for performance</span>':'';\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])=>`<div class=\"chunk-stat\"><b>${esc(label)}</b><span>${esc(value)}</span></div>`).join('');\n pane.innerHTML=`\n <div class=\"chunks-panel\">\n <div class=\"chunks-toolbar\">\n <div class=\"chunks-title\"><i class=\"fa-solid fa-grip\"></i> Chunks ${grouped}</div>\n <div class=\"chunks-actions\">\n <button class=\"btn btn-sm btn-outline-primary chunk-action-recheck\" type=\"button\"><i class=\"fa-solid fa-shield-halved\"></i> Recheck torrent</button>\n <button class=\"btn btn-sm btn-outline-success chunk-action-prioritize\" type=\"button\"><i class=\"fa-solid fa-arrow-up\"></i> High priority files for selection</button>\n <button class=\"btn btn-sm btn-outline-secondary chunk-refresh\" type=\"button\"><i class=\"fa-solid fa-rotate\"></i> Refresh</button>\n </div>\n </div>\n <div class=\"chunk-stats\">${meta}</div>\n <div class=\"chunk-tools-row\">\n <div class=\"chunk-legend\">${renderChunkLegend(chunks.summary||{})}</div>\n ${renderChunkControls()}\n </div>\n <div id=\"chunkSelectionInfo\" class=\"chunk-selection-info\"></div>\n <div class=\"chunk-grid\" data-density=\"${esc(chunkDensityMode)}\" style=\"--chunk-count:${Math.max(1, Math.min(96, cells.length || 1))}\">${cells.map(chunkCellMarkup).join('') || '<div class=\"empty\">No chunk cells for this filter.</div>'}</div>\n </div>`;\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('<span class=\"badge text-bg-success\">enc</span>');\n if(p.incoming) badges.push('<span class=\"badge text-bg-info\">in</span>');\n if(p.snubbed) badges.push('<span class=\"badge text-bg-warning\">snub</span>');\n if(p.banned) badges.push('<span class=\"badge text-bg-danger\">ban</span>');\n return badges.join(' ') || '<span class=\"text-muted\">-</span>';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `<span class=\"peer-host\" title=\"${esc(host)}\">${esc(host)}</span>`;\n if(p.host_pending) return '<span class=\"text-muted\">resolving</span>';\n return '<span class=\"text-muted\">-</span>';\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),`<span class=\"peer-ip\">${esc(p.ip)}<a class=\"peer-ip-link\" href=\"https://ipinfo.io/${encodeURIComponent(p.ip||'')}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open IP info\"><i class=\"fa-solid fa-link\"></i></a></span>`];\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 [`<span class=\"text-muted\">#${idx}</span>`, `<span class=\"tracker-url-text\">${url || '<span class=\"text-muted\">-</span>'}</span>`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class=\"tracker-actions\"><button class=\"btn btn-xs btn-outline-danger tracker-delete\" data-index=\"${idx}\"${deleteDisabled}><i class=\"fa-solid fa-trash\"></i> Delete</button></div>`];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`<div class=\"tracker-toolbar\"><div class=\"input-group input-group-sm\"><input id=\"trackerAddUrl\" class=\"form-control tracker-add-input\" placeholder=\"https://tracker.example/announce\"><button id=\"trackerAddBtn\" class=\"btn btn-outline-primary\"><i class=\"fa-solid fa-plus\"></i> Add tracker</button></div><button id=\"trackerReannounceBtn\" class=\"btn btn-sm btn-outline-primary\"><i class=\"fa-solid fa-bullhorn\"></i> Reannounce</button></div>${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class=\"text-muted\">-</span>','<span class=\"text-muted\">No trackers.</span>','','','','','' ]], '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\n function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `<div class=\"mobile-details-stat\"><b>${esc(label)}</b><span>${mobileDetailValue(value)}</span></div>`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `<span class=\"peer-ip\">${esc(p.ip || '-')}<a class=\"peer-ip-link\" href=\"https://ipinfo.io/${encodeURIComponent(p.ip || '')}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open IP info\"><i class=\"fa-solid fa-link\"></i></a></span>`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, '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 }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '<div class=\"mobile-details-empty\">No peers returned by rTorrent.</div>';\n return responsiveTable(headers, rows, 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `<div class=\"file-row-actions\">${renderFileInfoButton(file)}<button class=\"btn btn-xs btn-outline-secondary file-download-one\" type=\"button\" data-index=\"${esc(file.index)}\" title=\"Download\"><i class=\"fa-solid fa-download\"></i></button></div>`;\n return [\n `<span class=\"mobile-details-file-path\" title=\"${esc(file.path || file.name || '-')}\">${esc(file.path || file.name || '-')}</span>`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `<span class=\"badge ${priorityClass(file.priority)}\">${esc(priority)}</span>`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '<div class=\"mobile-details-empty\">No files returned by rTorrent.</div>';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `<li class=\"mobile-details-tracker\"><b>${esc(t.url || '-')}</b><span>Seeds / Peers: ${esc(trackerSeedsPeers(t))}</span></li>`;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `<h6><i class=\"fa-solid ${esc(icon)}\"></i> ${esc(title)}${meta ? `<small>${esc(meta)}</small>` : ''}</h6>`;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `<details class=\"mobile-details-section mobile-details-collapsible\"><summary>${titleMarkup}</summary>${body}</details>`;\n }\n return `<section class=\"mobile-details-section\">${titleMarkup}${body}</section>`;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `<div class=\"modal-dialog modal-fullscreen-sm-down modal-lg modal-dialog-scrollable\"><div class=\"modal-content\"><div class=\"modal-header\"><div><h5 id=\"mobileDetailsTitle\" class=\"modal-title\"><i class=\"fa-solid fa-circle-info\"></i> Torrent details</h5><div id=\"mobileDetailsSubtitle\" class=\"mobile-details-subtitle\"></div></div><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div id=\"mobileDetailsBody\" class=\"modal-body\"><div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading torrent details...</div></div></div></div>`;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '<li class=\"mobile-details-empty\">No trackers returned by rTorrent.</li>';\n const generalBody = `<section class=\"mobile-details-summary\"><div class=\"mobile-details-name\">${esc(t.name || '-')}</div><div class=\"mobile-details-path\"><b>Path</b><span>${esc(fullPath)}</span></div><div class=\"mobile-details-hash\"><b>Hash</b><code>${esc(t.hash || '-')}</code></div></section><div class=\"mobile-details-stats\">${mobileDetailsStatCards(t)}</div>`;\n const messageBody = `<div class=\"mobile-details-message\">${esc(t.message || 'No message.')}</div>`;\n const errorBox = failures.length ? `<div class=\"alert alert-warning mobile-details-warning\"><b>Partial details loaded</b><div>${esc(failures.join(' | '))}</div></div>` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`)}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', `<ul class=\"mobile-details-list\">${trackerList}</ul>`, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {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 return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = '<i class=\"fa-solid fa-circle-info\"></i> Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading peers, files and trackers...</div>';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `<div class=\"alert alert-danger mb-0\"><b>Details failed</b><div>${esc(e.message)}</div></div>`;\n }\n }\n\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`<pre class=\"torrent-log-message\">${esc(t.message||'No logs')}</pre>`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`<div class=\"loading-line\"><span class=\"spinner-border spinner-border-sm\"></span> Loading ${esc(tab)}...</div>`;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`<div class=\"text-danger\">${esc(e.message)}</div>`;\n }\n }\n";