2 lines
42 KiB
JavaScript
2 lines
42 KiB
JavaScript
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";
|