first commit

This commit is contained in:
root
2026-05-19 13:43:37 +00:00
commit 9dcd0abd7d
107 changed files with 33622 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="14" y="20" width="36" height="30" rx="8" fill="#f8fafc" stroke="#0f172a" stroke-width="4"></rect>
<rect x="22" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
<rect x="36" y="30" width="6" height="6" rx="3" fill="#0f172a"></rect>
<path d="M25 42h14" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
<path d="M32 20V10" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
<circle cx="32" cy="8" r="4" fill="#0f172a"></circle>
<path d="M14 34H8M56 34h-6" stroke="#0f172a" stroke-width="4" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 647 B

View File

@@ -0,0 +1 @@
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 toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); 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); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\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 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 toast('Download started','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toast('No torrents selected','warning');\n if(list.length===1) return downloadResponse(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file`,{},`${list[0]}.torrent`,'Preparing .torrent...').catch(e=>toast(e.message,'danger'));\n return downloadResponse('/api/torrents/torrent-files.zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({hashes:list})},'pytorrent-torrents.zip','Preparing torrent ZIP...').catch(e=>toast(e.message,'danger'));\n }\n";

View File

@@ -0,0 +1,44 @@
import { stateSource } from './state.js';
import { torrentsSource } from './torrents.js';
import { apiSource } from './api.js';
import { createTorrentSource } from './createTorrent.js';
import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js';
import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js';
import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js';
import { dashboardSource } from './dashboard.js';
import { chartsSource } from './charts.js';
import { bootstrapSource } from './bootstrap.js';
export const moduleSources = [
stateSource,
torrentsSource,
apiSource,
createTorrentSource,
torrentDetailsSource,
modalsSource,
rssSource,
smartQueueSource,
plannerSource,
dashboardSource,
pollerSource,
chartsSource,
bootstrapSource,
];
export function buildRuntimeSource(){
return `(() => {\n${moduleSources.join('\n')}\n})();\n`;
}
export function startApp(){
const runtimeSource = buildRuntimeSource();
// Keep the original shared lexical scope while loading the source from smaller ES modules.
// `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
return Function('io', runtimeSource)(window.io);
}
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
startApp();
}

1
pytorrent/static/js/bootstrap.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export const createTorrentSource = " function isCreateTorrentTabActive(){\n return $('createTorrentPane')?.classList.contains('active');\n }\n function syncAddAndCreateActions(){\n const createActive = isCreateTorrentTabActive();\n $('addBtn')?.classList.toggle('d-none', !!createActive);\n $('createTorrentBtn')?.classList.toggle('d-none', !createActive);\n }\n function createTorrentPayload(){\n const fd = new FormData();\n fd.append('source_path', $('createSourcePath')?.value || '');\n fd.append('trackers', $('createTrackers')?.value || '');\n fd.append('comment', $('createComment')?.value || '');\n fd.append('source', $('createSourceName')?.value || '');\n fd.append('piece_size_kib', $('createPieceSize')?.value || '256');\n fd.append('private', $('createPrivate')?.checked ? '1' : '0');\n fd.append('share', $('createShare')?.checked ? '1' : '0');\n fd.append('label', $('createLabel')?.value || '');\n return fd;\n }\n function downloadCreatedTorrent(blob,name){\n const obj = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = obj;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(()=>URL.revokeObjectURL(obj), 1000);\n }\n async function createTorrentFromModal(){\n const btn = $('createTorrentBtn');\n const info = $('createTorrentInfo');\n buttonBusy(btn, true);\n setBusy(true, 'Creating torrent...');\n if(info) info.textContent = 'Creating .torrent file...';\n try{\n const res = await fetch('/api/torrents/create', {method: 'POST', body: createTorrentPayload()});\n if(!res.ok){\n const j = await res.json().catch(()=>({}));\n throw new Error(j.error || `Create failed (${res.status})`);\n }\n const name = filenameFromResponse(res, 'created.torrent');\n const message = res.headers.get('X-PyTorrent-Create-Message') || 'Torrent created';\n const blob = await res.blob();\n downloadCreatedTorrent(blob, name);\n if(info) info.textContent = message;\n toast(message, 'success');\n }catch(e){\n if(info) info.textContent = e.message;\n toast(e.message, 'danger');\n }finally{\n setBusy(false);\n buttonBusy(btn, false);\n }\n }\n $('addModal')?.addEventListener('shown.bs.modal', syncAddAndCreateActions);\n document.querySelectorAll('#addModal [data-bs-toggle=\"pill\"]').forEach(tab => tab.addEventListener('shown.bs.tab', syncAddAndCreateActions));\n $('createTorrentBtn')?.addEventListener('click', createTorrentFromModal);\n";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),`<button class=\"btn btn-xs btn-outline-primary rss-edit-feed\" data-feed='${esc(JSON.stringify(f))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-feed\" data-id=\"${esc(f.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">Rules</h6>${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),`<button class=\"btn btn-xs btn-outline-primary rss-edit-rule\" data-rule='${esc(JSON.stringify(r))}'><i class=\"fa-solid fa-pen-to-square\"></i> Edit</button> <button class=\"btn btn-xs btn-outline-danger rss-delete-rule\" data-id=\"${esc(r.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`]))}<h6 class=\"mt-3\">RSS log</h6>${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n const rows=j.backups||[];\n fillBackupSettings(j.auto||{});\n if($('backupManager')) $('backupManager').innerHTML=responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n\n";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4181
pytorrent/static/styles.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
../../data/tracker_favicons