Files
pyTorrent/pytorrent/static/js/api.js
T
Mateusz Gruszczyński e1b5822a59 fixes
2026-06-20 17:41:25 +02:00

2 lines
16 KiB
JavaScript

export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } if(action==='profile_transfer'){ openProfileTransferModal(); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function profileTransferTargetRow(profile){\n const name=profile.name||('rTorrent '+profile.id);\n const stats=profile.runtime_stats||null;\n const meta=[];\n if(stats){\n if(stats.torrent_count!==undefined) meta.push(`${stats.torrent_count} torrents`);\n if(stats.total_size_h) meta.push(stats.total_size_h);\n if(stats.seeding_count!==undefined || stats.downloading_count!==undefined) meta.push(`${stats.seeding_count||0} seeding / ${stats.downloading_count||0} downloading`);\n }\n return `<button class=\"profile-transfer-card\" type=\"button\" data-profile-id=\"${esc(profile.id)}\" data-profile-name=\"${esc(name)}\"><span><i class=\"fa-solid fa-server\"></i><b>${esc(name)}</b></span><small>#${esc(profile.id)}${meta.length?' \u00b7 '+esc(meta.join(' \u00b7 ')):''}</small></button>`;\n }\n function selectedTorrentSummaryRows(hashes){\n const rows=hashes.slice(0,6).map(h=>{\n const t=torrents.get(h)||{};\n return `<div><i class=\"fa-solid fa-file-lines\"></i><span>${esc(t.name||h)}</span><small>${esc(t.size_h||'')}</small></div>`;\n }).join('');\n const more=hashes.length>6?`<small>+${hashes.length-6} more</small>`:'';\n return rows+more;\n }\n function selectedTorrentBytes(hashes){\n return hashes.reduce((sum,h)=>sum+Number((torrents.get(h)||{}).size||0),0);\n }\n function humanBytes(bytes){\n let value=Number(bytes||0); const units=['B','KB','MB','GB','TB','PB']; let idx=0;\n while(value>=1024 && idx<units.length-1){ value/=1024; idx++; }\n return `${value.toFixed(value>=10||idx===0?0:1)} ${units[idx]}`;\n }\n function setProfileTransferPermission(message, tone='muted'){\n const el=$('profileTransferPermissionNote');\n if(!el) return;\n el.className=`profile-transfer-permission form-text mt-2 text-${tone}`;\n el.textContent=message;\n }\n function setProfileTransferDiskInfo(html){\n const el=$('profileTransferDiskInfo');\n if(el) el.innerHTML=html;\n }\n function renderProfileTransferPathHints(paths=[]){\n const box=$('profileTransferPathHints');\n if(!box) return;\n const clean=[...new Set((paths||[]).map(p=>String(p||'').trim()).filter(Boolean))];\n box.innerHTML=clean.length?clean.map(p=>`<button class=\"btn btn-xs btn-outline-secondary profile-transfer-root\" type=\"button\" data-path=\"${esc(p)}\"><i class=\"fa-solid fa-folder\"></i> ${esc(p)}</button>`).join(' '):'<span class=\"text-muted\">No cached target roots.</span>';\n }\n function setProfileTransferTargetPath(path, overwrite=false){\n const input=$('profileTransferTargetPath');\n if(input && (overwrite || !input.value.trim())) input.value=path||'';\n }\n function profileTransferPayloadBase(){\n return {\n target_profile_id:Number($('profileTransferTargetId')?.value||0),\n move_data:!!($('profileTransferMoveData')?.checked),\n label_mode:$('profileTransferLabelMode')?.value||'none',\n label_value:$('profileTransferLabelValue')?.value||'',\n post_action:$('profileTransferPostAction')?.value||'current',\n target_path:($('profileTransferTargetPath')?.value||'').trim()\n };\n }\n async function validateProfileTransferSelection(){\n const payload=profileTransferPayloadBase();\n const selectedSize=selectedTorrentBytes(selectedHashes());\n if(!payload.target_profile_id){\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n return null;\n }\n setProfileTransferPermission(payload.move_data?'Checking data-move permissions...':'Only torrent metadata will be moved. Data files stay in the current location.', 'muted');\n try{\n const j=await post('/api/torrents/profile_transfer/validate',payload);\n const disk=j.disk||{};\n const free=Number(disk.free||0);\n const enough=!selectedSize || !free || free>=selectedSize;\n const diskTone=disk.ok?(enough?'success':'warning'):'warning';\n setProfileTransferTargetPath(j.target_path||'', false);\n renderProfileTransferPathHints(j.target_allowed_roots||[]);\n setProfileTransferDiskInfo(`<div class=\"text-${diskTone}\"><b>Destination:</b> ${esc(j.target_path||'-')}</div><div><b>Free:</b> ${esc(disk.free_h||'-')} / ${esc(disk.total_h||'-')} \u00b7 <b>Selected:</b> ${esc(humanBytes(selectedSize))}</div>${disk.warning?`<div class=\"small text-muted\">${esc(disk.warning)}</div>`:''}`);\n if(payload.move_data && j.move_data_allowed){\n setProfileTransferPermission(enough?'Data move is allowed for the destination path.':'Data move is allowed, but free space may be lower than selected torrent size.', enough?'success':'warning');\n } else if(payload.move_data){\n setProfileTransferPermission(`Data move is not allowed, so only torrent metadata will be moved. ${j.move_data_downgrade_reason||''}`.trim(), 'warning');\n }\n return j;\n }catch(e){\n setProfileTransferPermission(`Cannot validate data move. Only torrent metadata will be moved. ${e.message}`, 'warning');\n setProfileTransferDiskInfo('<span class=\"text-warning\">Destination disk space unavailable.</span>');\n return {move_data_allowed:false,error:e.message};\n }\n }\n function selectedTransferDefaultAction(hashes){\n const states=hashes.map(h=>String((torrents.get(h)||{}).status||'').toLowerCase());\n const active=states.filter(Boolean);\n if(!active.length) return 'current';\n if(active.every(s=>s.includes('stop'))) return 'stop';\n if(active.every(s=>s.includes('pause'))) return 'pause';\n if(active.every(s=>s.includes('check'))) return 'check';\n if(active.every(s=>s.includes('seed') || s.includes('download') || s.includes('queue'))) return 'start';\n return 'current';\n }\n\n async function openProfileTransferModal(){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const list=$('profileTransferList');\n const count=$('profileTransferCount');\n const torrentList=$('profileTransferTorrentList');\n if(count) count.textContent=String(hashes.length);\n if(torrentList) torrentList.innerHTML=selectedTorrentSummaryRows(hashes);\n if(list) list.innerHTML='<span class=\"spinner-border spinner-border-sm me-2\"></span>Loading profiles...';\n setProfileTransferPermission('Choose a target profile. Torrent metadata can be moved without data-file write permission.');\n setProfileTransferDiskInfo('Select a target profile to see destination disk space.');\n renderProfileTransferPathHints([]);\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const activeId=Number(j.active?.id || window.PYTORRENT?.activeProfile || activeProfileId || 0);\n const targets=(j.profiles||[]).filter(p=>Number(p.id)!==activeId && p.can_write!==false);\n if(list) list.innerHTML=targets.map(profileTransferTargetRow).join('') || '<div class=\"text-muted small\">No other writable profile is available.</div>';\n if($('profileTransferTargetId')) $('profileTransferTargetId').value='';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n if($('profileTransferMoveData')) $('profileTransferMoveData').checked=false;\n if($('profileTransferLabelMode')) $('profileTransferLabelMode').value='none';\n if($('profileTransferLabelValue')) { $('profileTransferLabelValue').value=''; $('profileTransferLabelValue').classList.add('d-none'); }\n if($('profileTransferPostAction')) $('profileTransferPostAction').value=selectedTransferDefaultAction(hashes);\n new bootstrap.Modal($('profileTransferModal')).show();\n }catch(e){\n if(list) list.innerHTML=`<div class=\"text-danger small\">${esc(e.message)}</div>`;\n new bootstrap.Modal($('profileTransferModal')).show();\n }\n }\n async function submitProfileTransfer(){\n const hashes=selectedHashes();\n const payload={hashes,...profileTransferPayloadBase()};\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(!payload.target_profile_id) return toast('Choose target profile.', 'warning');\n const btn=$('profileTransferBtn');\n buttonBusy(btn,true);\n try{\n const j=await post('/api/torrents/profile_transfer',payload);\n markQueuedJobs(j, hashes, 'profile_transfer');\n const parts=Number(j.bulk_parts||1);\n const downgraded=j.transfer_move_data_downgraded?' Data files cannot be moved with the selected destination, so only torrent metadata was queued.':'';\n const targetName=document.querySelector('.profile-transfer-card.active b')?.textContent || 'selected profile';\n toast(`Profile transfer queued for ${hashes.length} torrent${hashes.length===1?'':'s'} to ${targetName} (${parts} job${parts===1?'':'s'}).${downgraded}`,'success');\n bootstrap.Modal.getInstance($('profileTransferModal'))?.hide();\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n $('profileTransferList')?.addEventListener('click',e=>{\n const btn=e.target.closest('.profile-transfer-card');\n if(!btn) return;\n document.querySelectorAll('.profile-transfer-card').forEach(x=>x.classList.remove('active'));\n btn.classList.add('active');\n if($('profileTransferTargetId')) $('profileTransferTargetId').value=btn.dataset.profileId||'';\n if($('profileTransferTargetPath')) $('profileTransferTargetPath').value='';\n validateProfileTransferSelection();\n });\n $('profileTransferMoveData')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferTargetPath')?.addEventListener('input',()=>{ clearTimeout(window.__profileTransferPathTimer); window.__profileTransferPathTimer=setTimeout(validateProfileTransferSelection, 350); });\n $('profileTransferPathHints')?.addEventListener('click',e=>{ const btn=e.target.closest('.profile-transfer-root'); if(!btn) return; setProfileTransferTargetPath(btn.dataset.path||'', true); validateProfileTransferSelection(); });\n $('profileTransferPostAction')?.addEventListener('change',validateProfileTransferSelection);\n $('profileTransferLabelMode')?.addEventListener('change',()=>{\n const custom=$('profileTransferLabelMode')?.value==='custom';\n $('profileTransferLabelValue')?.classList.toggle('d-none', !custom);\n });\n $('profileTransferBtn')?.addEventListener('click',submitProfileTransfer);\n\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class=\"fi fi-${esc(code)}\"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class=\"table table-sm detail-table${cls}\"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }\n function responsiveTable(headers,rows,extraClass=''){ return `<div class=\"responsive-table-wrap\">${table(headers,rows,extraClass)}</div>`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n";