Cleanup in js #16

Merged
gru merged 11 commits from cleanup_in_js into master 2026-06-02 23:02:29 +02:00
8 changed files with 217 additions and 198 deletions
Showing only changes of commit 68d8ddc8d7 - Show all commits
+8 -11
View File
@@ -16,11 +16,9 @@ from .config import (
SOCKETIO_CORS_ALLOWED_ORIGINS, SOCKETIO_CORS_ALLOWED_ORIGINS,
) )
from .db import init_db from .db import init_db
from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets from .services.frontend_assets import asset_path, bootstrap_css_path, static_hash, validate_offline_assets
from .utils import file_md5
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading") socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
_static_md5_cache: dict[tuple, str] = {}
def _wants_json_response() -> bool: def _wants_json_response() -> bool:
@@ -78,17 +76,15 @@ def create_app() -> Flask:
@app.context_processor @app.context_processor
def static_helpers(): def static_helpers():
def current_static_hash() -> str:
return static_hash(Path(app.static_folder or ""))
def static_url(filename: str) -> str: def static_url(filename: str) -> str:
path = Path(app.static_folder or "") / filename path = Path(app.static_folder or "") / filename
try: try:
stat = path.stat() path.stat()
key = (filename, stat.st_mtime_ns, stat.st_size) # Note: A single static hash keeps module imports, CSS and local libraries on the same cache version.
version = _static_md5_cache.get(key) return url_for("static", filename=filename, v=current_static_hash())
if not version:
_static_md5_cache.clear()
version = file_md5(path)
_static_md5_cache[key] = version
return url_for("static", filename=filename, v=version)
except OSError: except OSError:
return url_for("static", filename=filename) return url_for("static", filename=filename)
@@ -104,6 +100,7 @@ def create_app() -> Flask:
"static_url": static_url, "static_url": static_url,
"frontend_asset_url": frontend_asset_url, "frontend_asset_url": frontend_asset_url,
"bootstrap_theme_url": bootstrap_theme_url, "bootstrap_theme_url": bootstrap_theme_url,
"static_hash": current_static_hash,
} }
@app.after_request @app.after_request
+46
View File
@@ -1359,6 +1359,9 @@
}, },
"settings": { "settings": {
"$ref": "#/components/schemas/SmartQueueSettings" "$ref": "#/components/schemas/SmartQueueSettings"
},
"surge_refill_remaining_seconds": {
"type": "integer"
} }
}, },
"type": "object" "type": "object"
@@ -1422,6 +1425,17 @@
"stop_batch_size": { "stop_batch_size": {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"surge_refill_enabled": {
"type": "boolean"
},
"surge_refill_interval_minutes": {
"type": "integer",
"minimum": 1
},
"surge_refill_batch_size": {
"type": "integer",
"minimum": 1
} }
}, },
"type": "object" "type": "object"
@@ -7479,6 +7493,38 @@
} }
} }
} }
},
"/api/static_hash": {
"get": {
"tags": [
"System"
],
"summary": "Get static asset version hash",
"description": "Returns the current hash for app static assets. Clients can compare it with window.PYTORRENT.staticHash and reload when it changes.",
"responses": {
"200": {
"description": "Static asset hash",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"static_hash": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
}
}
}
} }
} }
} }
+10
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from flask import current_app
from pathlib import Path
from ..services import operation_logs from ..services import operation_logs
from ..services.frontend_assets import static_hash
@bp.get("/system/disk") @bp.get("/system/disk")
def system_disk(): def system_disk():
@@ -46,6 +49,13 @@ def system_status():
@bp.get("/static_hash")
def static_hash_get():
# Note: The frontend uses this lightweight version to detect changed static assets on mobile browsers.
value = static_hash(Path(current_app.static_folder or ""))
return ok({"static_hash": value, "version": value})
@bp.get("/health") @bp.get("/health")
def health_check(): def health_check():
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring. # Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
+33
View File
@@ -188,3 +188,36 @@ def validate_offline_assets() -> None:
"Run: ./scripts/download_frontend_libs.py or ./install.sh\n" "Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
f"Missing files:\n{preview}{extra}" f"Missing files:\n{preview}{extra}"
) )
_STATIC_HASH_CACHE: dict[tuple[str, int], str] = {}
def static_hash(static_root: Path | None = None) -> str:
"""Return one short hash for all app static files.
Note: This value is used as the shared browser-cache version, so any static file
change invalidates app.js imports, CSS and local frontend assets together.
"""
import hashlib
root = static_root or (BASE_DIR / "pytorrent" / "static")
files = [path for path in root.rglob("*") if path.is_file() and "tracker_favicons" not in path.parts]
fingerprint = f"{root}:{sum(path.stat().st_mtime_ns for path in files)}:{sum(path.stat().st_size for path in files)}"
cached = _STATIC_HASH_CACHE.get((fingerprint, len(files)))
if cached:
return cached
digest = hashlib.sha256()
for path in sorted(files):
rel = path.relative_to(root).as_posix()
stat = path.stat()
digest.update(rel.encode("utf-8"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(str(stat.st_mtime_ns).encode("ascii"))
try:
digest.update(path.read_bytes())
except OSError:
continue
value = digest.hexdigest()[:16]
_STATIC_HASH_CACHE.clear()
_STATIC_HASH_CACHE[(fingerprint, len(files))] = value
return value
+109 -175
View File
@@ -1,186 +1,120 @@
import { stateCoreSource } from './stateCore.js'; const staticImportVersion = encodeURIComponent(String(window.PYTORRENT?.staticHash || 'dev'));
import { columnStateSource } from './columnState.js'; const versionedImport = (path) => import(`${path}?v=${staticImportVersion}`);
import { runtimeStateSource } from './runtimeState.js'; const moduleImportSpecs = [
import { sharedUiSource } from './sharedUi.js'; ['./stateCore.js', 'stateCoreSource'],
import { torrentFilterHelpersSource } from './torrentFilterHelpers.js'; ['./columnState.js', 'columnStateSource'],
import { torrentFilterUiSource } from './torrentFilterUi.js'; ['./runtimeState.js', 'runtimeStateSource'],
import { torrentTrackerFiltersSource } from './torrentTrackerFilters.js'; ['./sharedUi.js', 'sharedUiSource'],
import { torrentTableStateSource } from './torrentTableState.js'; ['./torrentFilterHelpers.js', 'torrentFilterHelpersSource'],
import { torrentActionStateSource } from './torrentActionState.js'; ['./torrentFilterUi.js', 'torrentFilterUiSource'],
import { torrentRowRendererSource } from './torrentRowRenderer.js'; ['./torrentTrackerFilters.js', 'torrentTrackerFiltersSource'],
import { torrentTableRendererSource } from './torrentTableRenderer.js'; ['./torrentTableState.js', 'torrentTableStateSource'],
import { mobileSource } from './mobile.js'; ['./torrentActionState.js', 'torrentActionStateSource'],
import { messagesSource } from './messages.js'; ['./torrentRowRenderer.js', 'torrentRowRendererSource'],
import { torrentAddSource } from './torrentAdd.js'; ['./torrentTableRenderer.js', 'torrentTableRendererSource'],
import { apiSource } from './api.js'; ['./mobile.js', 'mobileSource'],
import { createTorrentSource } from './createTorrent.js'; ['./messages.js', 'messagesSource'],
import { torrentGeneralDetailsSource } from './torrentGeneralDetails.js'; ['./torrentAdd.js', 'torrentAddSource'],
import { torrentFileDetailsSource } from './torrentFileDetails.js'; ['./api.js', 'apiSource'],
import { torrentChunkDetailsSource } from './torrentChunkDetails.js'; ['./createTorrent.js', 'createTorrentSource'],
import { torrentPeerDetailsSource } from './torrentPeerDetails.js'; ['./torrentGeneralDetails.js', 'torrentGeneralDetailsSource'],
import { torrentTrackerDetailsSource } from './torrentTrackerDetails.js'; ['./torrentFileDetails.js', 'torrentFileDetailsSource'],
import { mobileTorrentDetailsSource } from './mobileTorrentDetails.js'; ['./torrentChunkDetails.js', 'torrentChunkDetailsSource'],
import { torrentDetailsLoaderSource } from './torrentDetailsLoader.js'; ['./torrentPeerDetails.js', 'torrentPeerDetailsSource'],
import { pathPickerToolsSource } from './pathPickerTools.js'; ['./torrentTrackerDetails.js', 'torrentTrackerDetailsSource'],
import { columnManagerSource } from './columnManager.js'; ['./mobileTorrentDetails.js', 'mobileTorrentDetailsSource'],
import { jobToolsSource } from './jobTools.js'; ['./torrentDetailsLoader.js', 'torrentDetailsLoaderSource'],
import { labelToolsSource } from './labelTools.js'; ['./pathPickerTools.js', 'pathPickerToolsSource'],
import { ratioToolsSource } from './ratioTools.js'; ['./columnManager.js', 'columnManagerSource'],
import { rssToolsSource } from './rssTools.js'; ['./jobTools.js', 'jobToolsSource'],
import { backupToolsSource } from './backupTools.js'; ['./labelTools.js', 'labelToolsSource'],
import { smartQueueSource } from './smartQueue.js'; ['./ratioTools.js', 'ratioToolsSource'],
import { rtorrentConfigSource } from './rtorrentConfig.js'; ['./rssTools.js', 'rssToolsSource'],
import { appearancePreferencesSource } from './appearancePreferences.js'; ['./backupTools.js', 'backupToolsSource'],
import { peerRefreshSource } from './peerRefresh.js'; ['./smartQueue.js', 'smartQueueSource'],
import { automationRulesSource } from './automationRules.js'; ['./rtorrentConfig.js', 'rtorrentConfigSource'],
import { cleanupToolsSource } from './cleanupTools.js'; ['./appearancePreferences.js', 'appearancePreferencesSource'],
import { appDiagnosticsSource } from './appDiagnostics.js'; ['./peerRefresh.js', 'peerRefreshSource'],
import { footerPreferencesSource } from './footerPreferences.js'; ['./automationRules.js', 'automationRulesSource'],
import { liveSpeedStatsSource } from './liveSpeedStats.js'; ['./cleanupTools.js', 'cleanupToolsSource'],
import { statusBarSource } from './statusBar.js'; ['./appDiagnostics.js', 'appDiagnosticsSource'],
import { preferencesToolsSource } from './preferencesTools.js'; ['./footerPreferences.js', 'footerPreferencesSource'],
import { diskMonitorSource } from './diskMonitor.js'; ['./liveSpeedStats.js', 'liveSpeedStatsSource'],
import { portCheckActionsSource } from './portCheckActions.js'; ['./statusBar.js', 'statusBarSource'],
import { appStatusSource } from './appStatus.js'; ['./preferencesTools.js', 'preferencesToolsSource'],
import { torrentStatsSource } from './torrentStats.js'; ['./diskMonitor.js', 'diskMonitorSource'],
import { toolUiHelpersSource } from './toolUiHelpers.js'; ['./portCheckActions.js', 'portCheckActionsSource'],
import { authUsersSource } from './authUsers.js'; ['./appStatus.js', 'appStatusSource'],
import { plannerToolsUiSource } from './plannerToolsUi.js'; ['./torrentStats.js', 'torrentStatsSource'],
import { plannerSpeedControlsSource } from './plannerSpeedControls.js'; ['./toolUiHelpers.js', 'toolUiHelpersSource'],
import { plannerSettingsSource } from './plannerSettings.js'; ['./authUsers.js', 'authUsersSource'],
import { plannerPreviewHistorySource } from './plannerPreviewHistory.js'; ['./plannerToolsUi.js', 'plannerToolsUiSource'],
import { plannerActionsSource } from './plannerActions.js'; ['./plannerSpeedControls.js', 'plannerSpeedControlsSource'],
import { smartViewsSource } from './smartViews.js'; ['./plannerSettings.js', 'plannerSettingsSource'],
import { notificationCenterSource } from './notificationCenter.js'; ['./plannerPreviewHistory.js', 'plannerPreviewHistorySource'],
import { diagnosticsDashboardSource } from './diagnosticsDashboard.js'; ['./plannerActions.js', 'plannerActionsSource'],
import { dashboardToolsSource } from './dashboardTools.js'; ['./smartViews.js', 'smartViewsSource'],
import { operationLogsSource } from './operationLogs.js'; ['./notificationCenter.js', 'notificationCenterSource'],
import { pollerSettingsSource } from './pollerSettings.js'; ['./diagnosticsDashboard.js', 'diagnosticsDashboardSource'],
import { toolsModalSource } from './toolsModal.js'; ['./dashboardTools.js', 'dashboardToolsSource'],
import { toolPaneEventsSource } from './toolPaneEvents.js'; ['./operationLogs.js', 'operationLogsSource'],
import { rssEventsSource } from './rssEvents.js'; ['./pollerSettings.js', 'pollerSettingsSource'],
import { smartQueueEventsSource } from './smartQueueEvents.js'; ['./toolsModal.js', 'toolsModalSource'],
import { backupCleanupRtconfigEventsSource } from './backupCleanupRtconfigEvents.js'; ['./toolPaneEvents.js', 'toolPaneEventsSource'],
import { automationEventsSource } from './automationEvents.js'; ['./rssEvents.js', 'rssEventsSource'],
import { labelSmartEventsSource } from './labelSmartEvents.js'; ['./smartQueueEvents.js', 'smartQueueEventsSource'],
import { torrentSelectionEventsSource } from './torrentSelectionEvents.js'; ['./backupCleanupRtconfigEvents.js', 'backupCleanupRtconfigEventsSource'],
import { torrentTableEventsSource } from './torrentTableEvents.js'; ['./automationEvents.js', 'automationEventsSource'],
import { preferenceEventsSource } from './preferenceEvents.js'; ['./labelSmartEvents.js', 'labelSmartEventsSource'],
import { keyboardEventsSource } from './keyboardEvents.js'; ['./torrentSelectionEvents.js', 'torrentSelectionEventsSource'],
import { speedLimitControlsSource } from './speedLimitControls.js'; ['./torrentTableEvents.js', 'torrentTableEventsSource'],
import { themeMobileControlsSource } from './themeMobileControls.js'; ['./preferenceEvents.js', 'preferenceEventsSource'],
import { jobSettingsSource } from './jobSettings.js'; ['./keyboardEvents.js', 'keyboardEventsSource'],
import { profileListSource } from './profileList.js'; ['./speedLimitControls.js', 'speedLimitControlsSource'],
import { profileFormSource } from './profileForm.js'; ['./themeMobileControls.js', 'themeMobileControlsSource'],
import { profileActionsSource } from './profileActions.js'; ['./jobSettings.js', 'jobSettingsSource'],
import { profileSelectionSource } from './profileSelection.js'; ['./profileList.js', 'profileListSource'],
import { realtimeChartsSource } from './realtimeCharts.js'; ['./profileForm.js', 'profileFormSource'],
import { trafficHistoryDataSource } from './trafficHistoryData.js'; ['./profileActions.js', 'profileActionsSource'],
import { trafficChartRendererSource } from './trafficChartRenderer.js'; ['./profileSelection.js', 'profileSelectionSource'],
import { initialSnapshotSource } from './initialSnapshot.js'; ['./realtimeCharts.js', 'realtimeChartsSource'],
import { footerStatusRefreshSource } from './footerStatusRefresh.js'; ['./trafficHistoryData.js', 'trafficHistoryDataSource'],
import { systemStatsSocketSource } from './systemStatsSocket.js'; ['./trafficChartRenderer.js', 'trafficChartRendererSource'],
import { mobileSelectEventsSource } from './mobileSelectEvents.js'; ['./initialSnapshot.js', 'initialSnapshotSource'],
import { bootstrapRuntimeSource } from './bootstrapRuntime.js'; ['./footerStatusRefresh.js', 'footerStatusRefreshSource'],
['./systemStatsSocket.js', 'systemStatsSocketSource'],
export const moduleSources = [ ['./mobileSelectEvents.js', 'mobileSelectEventsSource'],
stateCoreSource, ['./bootstrapRuntime.js', 'bootstrapRuntimeSource'],
columnStateSource,
runtimeStateSource,
sharedUiSource,
torrentFilterHelpersSource,
torrentFilterUiSource,
torrentTrackerFiltersSource,
torrentTableStateSource,
torrentActionStateSource,
torrentRowRendererSource,
torrentTableRendererSource,
mobileSource,
messagesSource,
torrentAddSource,
apiSource,
createTorrentSource,
torrentGeneralDetailsSource,
torrentFileDetailsSource,
torrentChunkDetailsSource,
torrentPeerDetailsSource,
torrentTrackerDetailsSource,
mobileTorrentDetailsSource,
torrentDetailsLoaderSource,
pathPickerToolsSource,
columnManagerSource,
jobToolsSource,
labelToolsSource,
ratioToolsSource,
rssToolsSource,
backupToolsSource,
smartQueueSource,
rtorrentConfigSource,
appearancePreferencesSource,
peerRefreshSource,
automationRulesSource,
cleanupToolsSource,
appDiagnosticsSource,
footerPreferencesSource,
liveSpeedStatsSource,
statusBarSource,
preferencesToolsSource,
diskMonitorSource,
portCheckActionsSource,
appStatusSource,
torrentStatsSource,
toolUiHelpersSource,
authUsersSource,
plannerToolsUiSource,
plannerSpeedControlsSource,
plannerSettingsSource,
plannerPreviewHistorySource,
plannerActionsSource,
smartViewsSource,
notificationCenterSource,
diagnosticsDashboardSource,
dashboardToolsSource,
operationLogsSource,
pollerSettingsSource,
toolsModalSource,
toolPaneEventsSource,
rssEventsSource,
smartQueueEventsSource,
backupCleanupRtconfigEventsSource,
automationEventsSource,
labelSmartEventsSource,
torrentSelectionEventsSource,
torrentTableEventsSource,
preferenceEventsSource,
keyboardEventsSource,
speedLimitControlsSource,
themeMobileControlsSource,
jobSettingsSource,
profileListSource,
profileFormSource,
profileActionsSource,
profileSelectionSource,
realtimeChartsSource,
trafficHistoryDataSource,
trafficChartRendererSource,
initialSnapshotSource,
footerStatusRefreshSource,
systemStatsSocketSource,
mobileSelectEventsSource,
bootstrapRuntimeSource,
]; ];
export function buildRuntimeSource(){ export let moduleSources = [];
return `(() => {\n${moduleSources.join('\n')}\n})();\n`; let moduleSourcesPromise = null;
async function loadModuleSources(){
if(moduleSourcesPromise) return moduleSourcesPromise;
moduleSourcesPromise = Promise.all(moduleImportSpecs.map(([path]) => versionedImport(path))).then((modules) => {
moduleSources = modules.map((mod, index) => mod[moduleImportSpecs[index][1]]);
return moduleSources;
});
return moduleSourcesPromise;
} }
export function startApp(){ export async function buildRuntimeSource(){
const runtimeSource = buildRuntimeSource(); const sources = await loadModuleSources();
return `(() => {\n${sources.join('\n')}\n})();\n`;
}
export async function startApp(){
const runtimeSource = await buildRuntimeSource();
// Keep the original shared lexical scope while loading the source from smaller ES modules. // 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. // `io` is passed explicitly so Socket.IO remains available inside the generated runtime.
return Function('io', runtimeSource)(window.io); return Function('io', runtimeSource)(window.io);
} }
if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){ if(typeof window !== 'undefined' && !window.PYTORRENT_DISABLE_AUTOSTART){
startApp(); startApp().catch((error) => {
console.error('pyTorrent frontend failed to start', error);
const loaderText = document.getElementById('initialLoaderText');
if(loaderText) loaderText.textContent = 'Frontend failed to start. Reload the page or clear browser cache.';
});
} }
+1 -1
View File
@@ -1 +1 @@
export const bootstrapRuntimeSource = " updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n"; export const bootstrapRuntimeSource = " async function checkStaticAssetVersion(){ try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(checkStaticAssetVersion, 120000);\n window.addEventListener('focus', checkStaticAssetVersion);\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\\n";
+8 -9
View File
@@ -2979,17 +2979,15 @@ body.mobile-mode .mobile-filter-bar {
gap: 0.3rem; gap: 0.3rem;
} }
.smart-surge-refill-card .smart-refill-controls { .smart-surge-cooldown-card {
grid-template-columns: minmax(84px, 0.6fr) minmax(110px, 1fr) minmax(110px, 1fr); background: rgba(var(--bs-info-rgb), 0.08);
width: min(450px, 100%);
} }
.smart-refill-switch { .smart-surge-refill-controls {
align-content: end; display: grid;
} grid-template-columns: repeat(2, minmax(110px, 1fr));
gap: 0.55rem;
.smart-refill-switch .form-check-input { width: min(330px, 100%);
margin: 0;
} }
.disk-monitor-shell { .disk-monitor-shell {
display: grid; display: grid;
@@ -3065,6 +3063,7 @@ body.mobile-mode .mobile-filter-bar {
flex-direction: column; flex-direction: column;
} }
.smart-surge-refill-controls,
.disk-monitor-shell { .disk-monitor-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
File diff suppressed because one or more lines are too long