diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py
index 66d59fc..4782f05 100644
--- a/pytorrent/routes/api.py
+++ b/pytorrent/routes/api.py
@@ -62,16 +62,53 @@ def _public_ip(profile: dict | None = None, force: bool = False) -> str:
return res.read(64).decode("utf-8", "replace").strip()
-def _incoming_port(profile: dict) -> int | None:
+MAX_PORT_CHECK_CANDIDATES = 256
+
+
+def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]:
+ """Return valid incoming port candidates from rTorrent network.port_range.
+
+ Note: rTorrent may keep a range/list and pick a random port on start.
+ The old checker used only the first number, which produced false "closed"
+ results when another configured port was actually active.
+ """
+ ports: list[int] = []
+ seen: set[int] = set()
+ truncated = False
+
+ def add(port: int) -> None:
+ nonlocal truncated
+ if not 1 <= port <= 65535 or port in seen:
+ return
+ if len(ports) >= limit:
+ truncated = True
+ return
+ seen.add(port)
+ ports.append(port)
+
+ for start, end in re.findall(r"(\d{1,5})\s*-\s*(\d{1,5})", value or ""):
+ a, b = int(start), int(end)
+ if a > b:
+ a, b = b, a
+ for port in range(a, b + 1):
+ add(port)
+ if truncated:
+ break
+
+ without_ranges = re.sub(r"\d{1,5}\s*-\s*\d{1,5}", " ", value or "")
+ for item in re.findall(r"\d{1,5}", without_ranges):
+ add(int(item))
+
+ return ports, truncated
+
+
+def _incoming_ports(profile: dict) -> dict:
try:
- value = str(rtorrent.client_for(profile).call("network.port_range") or "")
+ raw_value = str(rtorrent.client_for(profile).call("network.port_range") or "")
except Exception:
- value = ""
- match = re.search(r"(\d{2,5})", value)
- if not match:
- return None
- port = int(match.group(1))
- return port if 1 <= port <= 65535 else None
+ raw_value = ""
+ ports, truncated = _parse_port_candidates(raw_value)
+ return {"ports": ports, "raw": raw_value, "truncated": truncated}
def _yougetsignal_check(public_ip: str, port: int) -> dict:
@@ -104,16 +141,40 @@ def _local_port_fallback(public_ip: str, port: int) -> dict:
return {"status": "unknown", "source": "local-fallback", "error": f"Local fallback inconclusive: {exc}"}
+def _check_ports(public_ip: str, ports: list[int], checker) -> dict:
+ checked: list[int] = []
+ first_closed: dict | None = None
+ last_result: dict = {"status": "unknown"}
+
+ for port in ports:
+ checked.append(port)
+ current = checker(public_ip, port)
+ last_result = current
+ if current.get("status") == "open":
+ current.update({"port": port, "open_port": port, "checked_ports": checked})
+ return current
+ if current.get("status") == "closed" and first_closed is None:
+ first_closed = current
+
+ result = first_closed or last_result
+ result.update({"port": ports[0] if ports else None, "open_port": None, "checked_ports": checked})
+ return result
+
+
def port_check_status(force: bool = False) -> dict:
profile = preferences.active_profile()
prefs = preferences.get_preferences()
enabled = bool((prefs or {}).get("port_check_enabled"))
if not profile:
return {"status": "unknown", "enabled": enabled, "error": "No profile"}
- port = _incoming_port(profile)
- if not port:
+
+ port_info = _incoming_ports(profile)
+ ports = port_info["ports"]
+ if not ports:
return {"status": "unknown", "enabled": enabled, "error": "Cannot read rTorrent network.port_range"}
- cache_key = f"port_check:{profile['id']}:{port}"
+
+ ports_key = ",".join(str(port) for port in ports)
+ cache_key = f"port_check:{profile['id']}:{ports_key}:{int(bool(port_info['truncated']))}"
if not force:
cached = _app_setting_get(cache_key)
if cached:
@@ -127,20 +188,31 @@ def port_check_status(force: bool = False) -> dict:
return data
except Exception:
pass
+
checked_at_epoch = time.time()
- result = {"status": "unknown", "enabled": enabled, "port": port, "checked_at_epoch": checked_at_epoch, "checked_at": _iso_from_epoch(checked_at_epoch), "cached": False}
+ result = {
+ "status": "unknown",
+ "enabled": enabled,
+ "port": ports[0],
+ "ports": ports,
+ "port_range": port_info["raw"],
+ "ports_truncated": port_info["truncated"],
+ "checked_at_epoch": checked_at_epoch,
+ "checked_at": _iso_from_epoch(checked_at_epoch),
+ "cached": False,
+ }
try:
public_ip = _public_ip(profile, force=force)
result["public_ip"] = public_ip
result["remote"] = bool(profile.get("is_remote"))
- result.update(_yougetsignal_check(public_ip, port))
+ result.update(_check_ports(public_ip, ports, _yougetsignal_check))
except Exception as exc:
result["error"] = f"YouGetSignal failed: {exc}"
try:
public_ip = result.get("public_ip") or _public_ip(profile, force=force)
result["public_ip"] = public_ip
result["remote"] = bool(profile.get("is_remote"))
- result.update(_local_port_fallback(public_ip, port))
+ result.update(_check_ports(public_ip, ports, _local_port_fallback))
except Exception as fallback_exc:
result["fallback_error"] = str(fallback_exc)
result["source"] = "none"
diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js
index 4256bd0..67d6840 100644
--- a/pytorrent/static/app.js
+++ b/pytorrent/static/app.js
@@ -299,7 +299,7 @@
async function browsePath(path){ $('pathList').innerHTML=' Loading...'; try{ const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`); const j=await res.json(); if(!j.ok) throw new Error(j.error); $('pathCurrent').value=j.path; lastPathParent=j.parent; $('pathList').innerHTML=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'No directories.
'; }catch(e){$('pathList').innerHTML=`${esc(e.message)}
`;} }
$('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markTorrentOperation(hashes,'move',j.job_id,'queued'); toast($('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));
- function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>` ${esc(label)} `).join(''); }
+ function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>` ${esc(label)} `).join(''); }
$('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); });
$('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); });
@@ -336,7 +336,9 @@
return raw;
}
function rtConfigInputValue(input){
- return normalizeRtConfigValue(input.value, input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text');
+ const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';
+ const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;
+ return normalizeRtConfigValue(value, type);
}
function rtConfigOriginalValue(input){
const key=input.dataset.key;
@@ -402,7 +404,7 @@
const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)} `:'';
const originalAttr=esc(originalValue);
const input=type==='bool'
- ? `Off On `
+ ? `${displayValue==='1'?'On':'Off'} `
: ` `;
return `${head}${esc(f.label)} ${esc(f.key)}${note} ${valueNote} ${input} `;
}).join('');
@@ -491,9 +493,9 @@
function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }
function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }
function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }
- function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const port=data.port?String(data.port):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)} `; }
+ function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)} `; }
function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }
- function portCheckDetails(data={}){ const bits=[]; if(data.port) bits.push(`Port: ${data.port}`); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }
+ function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }
function renderPortCheck(data={}){
if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;
const details=portCheckDetails(data);
@@ -531,7 +533,7 @@
}catch(e){ box.innerHTML=`${esc(e.message)}
`; }
}
- $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
+ $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
$('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();});
document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });
$('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index c7a017c..9ef82e6 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -389,11 +389,53 @@ body.mobile-mode .torrent-table { display: none; }
.column-check{padding:.35rem .5rem;border:1px solid var(--bs-border-color);border-radius:.5rem;background:var(--bs-body-bg)}
.label-filters .label-filter{font-size:.82rem;padding:.34rem .5rem;margin-bottom:.15rem}
.label-filters .label-filter i{opacity:.75;margin-right:.25rem}
-.column-manager{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:.55rem}
-.column-card{display:flex;align-items:center;gap:.5rem;padding:.55rem .65rem;border:1px solid var(--bs-border-color);border-radius:.7rem;background:rgba(var(--bs-secondary-bg-rgb),.45);cursor:pointer;user-select:none;transition:background .15s,border-color .15s,transform .15s}
-.column-card:hover{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle)}
-.column-card.active{border-color:rgba(var(--bs-primary-rgb),.55);background:var(--bs-primary-bg-subtle)}
-.column-card input{margin:0}.column-card span{display:flex;gap:.45rem;align-items:center;font-weight:600}.column-card i{opacity:.72}
+.column-manager {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ gap: .55rem;
+}
+
+.column-card {
+ display: flex;
+ align-items: center;
+ gap: .55rem;
+ margin: 0;
+ padding: .55rem .65rem;
+ border: 1px solid var(--bs-border-color);
+ border-radius: .7rem;
+ background: rgba(var(--bs-secondary-bg-rgb), .45);
+ cursor: pointer;
+ user-select: none;
+ transition: background .15s, border-color .15s, transform .15s;
+}
+
+.column-card:hover,
+.column-card.active {
+ background: var(--bs-primary-bg-subtle);
+}
+
+.column-card:hover {
+ border-color: var(--bs-primary);
+}
+
+.column-card.active {
+ border-color: rgba(var(--bs-primary-rgb), .55);
+}
+
+.column-card .form-check-input {
+ margin: 0;
+}
+
+.column-card .form-check-label {
+ display: flex;
+ align-items: center;
+ gap: .45rem;
+ font-weight: 600;
+}
+
+.column-card i {
+ opacity: .72;
+}
.path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)}
body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important}
body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px}
@@ -643,6 +685,22 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; }
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
+.rt-config-switch {
+ justify-self: end;
+ margin: 0;
+}
+
+.rt-config-switch .form-check-input {
+ margin-top: 0;
+}
+
+.rt-config-switch .form-check-label {
+ min-width: 2rem;
+ color: var(--bs-secondary-color);
+ font-size: .78rem;
+ font-weight: 700;
+}
+
.rt-config-row b {
font-size: .88rem;
}
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index 11ba711..7e818fd 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -131,21 +131,21 @@
-
+
-
+
Unlimited 100 Mbit/s 200 Mbit/s 500 Mbit/s 1 Gbit/s 2 Gbit/s
Refresh Clear finishedPending, running, done, failed, retry and cancel history.
Loading jobs...
-
+
Add new label Add
Selected labels
Saved labels
-
+
15m 1h 3h 6h 24h 7d 30d 90d