diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 337f165..2249343 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -16,11 +16,9 @@ from .config import ( SOCKETIO_CORS_ALLOWED_ORIGINS, ) from .db import init_db -from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets -from .utils import file_md5 +from .services.frontend_assets import asset_path, bootstrap_css_path, static_hash, validate_offline_assets 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: @@ -78,17 +76,15 @@ def create_app() -> Flask: @app.context_processor def static_helpers(): + def current_static_hash() -> str: + return static_hash(Path(app.static_folder or "")) + def static_url(filename: str) -> str: path = Path(app.static_folder or "") / filename try: - stat = path.stat() - key = (filename, stat.st_mtime_ns, stat.st_size) - version = _static_md5_cache.get(key) - if not version: - _static_md5_cache.clear() - version = file_md5(path) - _static_md5_cache[key] = version - return url_for("static", filename=filename, v=version) + path.stat() + # Note: A single static hash keeps module imports, CSS and local libraries on the same cache version. + return url_for("static", filename=filename, v=current_static_hash()) except OSError: return url_for("static", filename=filename) @@ -104,6 +100,7 @@ def create_app() -> Flask: "static_url": static_url, "frontend_asset_url": frontend_asset_url, "bootstrap_theme_url": bootstrap_theme_url, + "static_hash": current_static_hash, } @app.after_request diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 32e810c..9cb2d28 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -1359,6 +1359,9 @@ }, "settings": { "$ref": "#/components/schemas/SmartQueueSettings" + }, + "surge_refill_remaining_seconds": { + "type": "integer" } }, "type": "object" @@ -1422,6 +1425,17 @@ "stop_batch_size": { "minimum": 1, "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" @@ -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" + } + } + } + } + } + } + } + } } } } diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index 66a81bf..93994da 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -1,7 +1,10 @@ from __future__ import annotations from ._shared import * +from flask import current_app +from pathlib import Path from ..services import operation_logs +from ..services.frontend_assets import static_hash @bp.get("/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") def health_check(): # Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring. diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index 93638ab..a1a75cc 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -188,3 +188,36 @@ def validate_offline_assets() -> None: "Run: ./scripts/download_frontend_libs.py or ./install.sh\n" 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 diff --git a/pytorrent/static/js/app.js b/pytorrent/static/js/app.js index 52e52e9..134eea8 100644 --- a/pytorrent/static/js/app.js +++ b/pytorrent/static/js/app.js @@ -1,186 +1,120 @@ -import { stateCoreSource } from './stateCore.js'; -import { columnStateSource } from './columnState.js'; -import { runtimeStateSource } from './runtimeState.js'; -import { sharedUiSource } from './sharedUi.js'; -import { torrentFilterHelpersSource } from './torrentFilterHelpers.js'; -import { torrentFilterUiSource } from './torrentFilterUi.js'; -import { torrentTrackerFiltersSource } from './torrentTrackerFilters.js'; -import { torrentTableStateSource } from './torrentTableState.js'; -import { torrentActionStateSource } from './torrentActionState.js'; -import { torrentRowRendererSource } from './torrentRowRenderer.js'; -import { torrentTableRendererSource } from './torrentTableRenderer.js'; -import { mobileSource } from './mobile.js'; -import { messagesSource } from './messages.js'; -import { torrentAddSource } from './torrentAdd.js'; -import { apiSource } from './api.js'; -import { createTorrentSource } from './createTorrent.js'; -import { torrentGeneralDetailsSource } from './torrentGeneralDetails.js'; -import { torrentFileDetailsSource } from './torrentFileDetails.js'; -import { torrentChunkDetailsSource } from './torrentChunkDetails.js'; -import { torrentPeerDetailsSource } from './torrentPeerDetails.js'; -import { torrentTrackerDetailsSource } from './torrentTrackerDetails.js'; -import { mobileTorrentDetailsSource } from './mobileTorrentDetails.js'; -import { torrentDetailsLoaderSource } from './torrentDetailsLoader.js'; -import { pathPickerToolsSource } from './pathPickerTools.js'; -import { columnManagerSource } from './columnManager.js'; -import { jobToolsSource } from './jobTools.js'; -import { labelToolsSource } from './labelTools.js'; -import { ratioToolsSource } from './ratioTools.js'; -import { rssToolsSource } from './rssTools.js'; -import { backupToolsSource } from './backupTools.js'; -import { smartQueueSource } from './smartQueue.js'; -import { rtorrentConfigSource } from './rtorrentConfig.js'; -import { appearancePreferencesSource } from './appearancePreferences.js'; -import { peerRefreshSource } from './peerRefresh.js'; -import { automationRulesSource } from './automationRules.js'; -import { cleanupToolsSource } from './cleanupTools.js'; -import { appDiagnosticsSource } from './appDiagnostics.js'; -import { footerPreferencesSource } from './footerPreferences.js'; -import { liveSpeedStatsSource } from './liveSpeedStats.js'; -import { statusBarSource } from './statusBar.js'; -import { preferencesToolsSource } from './preferencesTools.js'; -import { diskMonitorSource } from './diskMonitor.js'; -import { portCheckActionsSource } from './portCheckActions.js'; -import { appStatusSource } from './appStatus.js'; -import { torrentStatsSource } from './torrentStats.js'; -import { toolUiHelpersSource } from './toolUiHelpers.js'; -import { authUsersSource } from './authUsers.js'; -import { plannerToolsUiSource } from './plannerToolsUi.js'; -import { plannerSpeedControlsSource } from './plannerSpeedControls.js'; -import { plannerSettingsSource } from './plannerSettings.js'; -import { plannerPreviewHistorySource } from './plannerPreviewHistory.js'; -import { plannerActionsSource } from './plannerActions.js'; -import { smartViewsSource } from './smartViews.js'; -import { notificationCenterSource } from './notificationCenter.js'; -import { diagnosticsDashboardSource } from './diagnosticsDashboard.js'; -import { dashboardToolsSource } from './dashboardTools.js'; -import { operationLogsSource } from './operationLogs.js'; -import { pollerSettingsSource } from './pollerSettings.js'; -import { toolsModalSource } from './toolsModal.js'; -import { toolPaneEventsSource } from './toolPaneEvents.js'; -import { rssEventsSource } from './rssEvents.js'; -import { smartQueueEventsSource } from './smartQueueEvents.js'; -import { backupCleanupRtconfigEventsSource } from './backupCleanupRtconfigEvents.js'; -import { automationEventsSource } from './automationEvents.js'; -import { labelSmartEventsSource } from './labelSmartEvents.js'; -import { torrentSelectionEventsSource } from './torrentSelectionEvents.js'; -import { torrentTableEventsSource } from './torrentTableEvents.js'; -import { preferenceEventsSource } from './preferenceEvents.js'; -import { keyboardEventsSource } from './keyboardEvents.js'; -import { speedLimitControlsSource } from './speedLimitControls.js'; -import { themeMobileControlsSource } from './themeMobileControls.js'; -import { jobSettingsSource } from './jobSettings.js'; -import { profileListSource } from './profileList.js'; -import { profileFormSource } from './profileForm.js'; -import { profileActionsSource } from './profileActions.js'; -import { profileSelectionSource } from './profileSelection.js'; -import { realtimeChartsSource } from './realtimeCharts.js'; -import { trafficHistoryDataSource } from './trafficHistoryData.js'; -import { trafficChartRendererSource } from './trafficChartRenderer.js'; -import { initialSnapshotSource } from './initialSnapshot.js'; -import { footerStatusRefreshSource } from './footerStatusRefresh.js'; -import { systemStatsSocketSource } from './systemStatsSocket.js'; -import { mobileSelectEventsSource } from './mobileSelectEvents.js'; -import { bootstrapRuntimeSource } from './bootstrapRuntime.js'; - -export const moduleSources = [ - stateCoreSource, - 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, +const staticImportVersion = encodeURIComponent(String(window.PYTORRENT?.staticHash || 'dev')); +const versionedImport = (path) => import(`${path}?v=${staticImportVersion}`); +const moduleImportSpecs = [ + ['./stateCore.js', 'stateCoreSource'], + ['./columnState.js', 'columnStateSource'], + ['./runtimeState.js', 'runtimeStateSource'], + ['./sharedUi.js', 'sharedUiSource'], + ['./torrentFilterHelpers.js', 'torrentFilterHelpersSource'], + ['./torrentFilterUi.js', 'torrentFilterUiSource'], + ['./torrentTrackerFilters.js', 'torrentTrackerFiltersSource'], + ['./torrentTableState.js', 'torrentTableStateSource'], + ['./torrentActionState.js', 'torrentActionStateSource'], + ['./torrentRowRenderer.js', 'torrentRowRendererSource'], + ['./torrentTableRenderer.js', 'torrentTableRendererSource'], + ['./mobile.js', 'mobileSource'], + ['./messages.js', 'messagesSource'], + ['./torrentAdd.js', 'torrentAddSource'], + ['./api.js', 'apiSource'], + ['./createTorrent.js', 'createTorrentSource'], + ['./torrentGeneralDetails.js', 'torrentGeneralDetailsSource'], + ['./torrentFileDetails.js', 'torrentFileDetailsSource'], + ['./torrentChunkDetails.js', 'torrentChunkDetailsSource'], + ['./torrentPeerDetails.js', 'torrentPeerDetailsSource'], + ['./torrentTrackerDetails.js', 'torrentTrackerDetailsSource'], + ['./mobileTorrentDetails.js', 'mobileTorrentDetailsSource'], + ['./torrentDetailsLoader.js', 'torrentDetailsLoaderSource'], + ['./pathPickerTools.js', 'pathPickerToolsSource'], + ['./columnManager.js', 'columnManagerSource'], + ['./jobTools.js', 'jobToolsSource'], + ['./labelTools.js', 'labelToolsSource'], + ['./ratioTools.js', 'ratioToolsSource'], + ['./rssTools.js', 'rssToolsSource'], + ['./backupTools.js', 'backupToolsSource'], + ['./smartQueue.js', 'smartQueueSource'], + ['./rtorrentConfig.js', 'rtorrentConfigSource'], + ['./appearancePreferences.js', 'appearancePreferencesSource'], + ['./peerRefresh.js', 'peerRefreshSource'], + ['./automationRules.js', 'automationRulesSource'], + ['./cleanupTools.js', 'cleanupToolsSource'], + ['./appDiagnostics.js', 'appDiagnosticsSource'], + ['./footerPreferences.js', 'footerPreferencesSource'], + ['./liveSpeedStats.js', 'liveSpeedStatsSource'], + ['./statusBar.js', 'statusBarSource'], + ['./preferencesTools.js', 'preferencesToolsSource'], + ['./diskMonitor.js', 'diskMonitorSource'], + ['./portCheckActions.js', 'portCheckActionsSource'], + ['./appStatus.js', 'appStatusSource'], + ['./torrentStats.js', 'torrentStatsSource'], + ['./toolUiHelpers.js', 'toolUiHelpersSource'], + ['./authUsers.js', 'authUsersSource'], + ['./plannerToolsUi.js', 'plannerToolsUiSource'], + ['./plannerSpeedControls.js', 'plannerSpeedControlsSource'], + ['./plannerSettings.js', 'plannerSettingsSource'], + ['./plannerPreviewHistory.js', 'plannerPreviewHistorySource'], + ['./plannerActions.js', 'plannerActionsSource'], + ['./smartViews.js', 'smartViewsSource'], + ['./notificationCenter.js', 'notificationCenterSource'], + ['./diagnosticsDashboard.js', 'diagnosticsDashboardSource'], + ['./dashboardTools.js', 'dashboardToolsSource'], + ['./operationLogs.js', 'operationLogsSource'], + ['./pollerSettings.js', 'pollerSettingsSource'], + ['./toolsModal.js', 'toolsModalSource'], + ['./toolPaneEvents.js', 'toolPaneEventsSource'], + ['./rssEvents.js', 'rssEventsSource'], + ['./smartQueueEvents.js', 'smartQueueEventsSource'], + ['./backupCleanupRtconfigEvents.js', 'backupCleanupRtconfigEventsSource'], + ['./automationEvents.js', 'automationEventsSource'], + ['./labelSmartEvents.js', 'labelSmartEventsSource'], + ['./torrentSelectionEvents.js', 'torrentSelectionEventsSource'], + ['./torrentTableEvents.js', 'torrentTableEventsSource'], + ['./preferenceEvents.js', 'preferenceEventsSource'], + ['./keyboardEvents.js', 'keyboardEventsSource'], + ['./speedLimitControls.js', 'speedLimitControlsSource'], + ['./themeMobileControls.js', 'themeMobileControlsSource'], + ['./jobSettings.js', 'jobSettingsSource'], + ['./profileList.js', 'profileListSource'], + ['./profileForm.js', 'profileFormSource'], + ['./profileActions.js', 'profileActionsSource'], + ['./profileSelection.js', 'profileSelectionSource'], + ['./realtimeCharts.js', 'realtimeChartsSource'], + ['./trafficHistoryData.js', 'trafficHistoryDataSource'], + ['./trafficChartRenderer.js', 'trafficChartRendererSource'], + ['./initialSnapshot.js', 'initialSnapshotSource'], + ['./footerStatusRefresh.js', 'footerStatusRefreshSource'], + ['./systemStatsSocket.js', 'systemStatsSocketSource'], + ['./mobileSelectEvents.js', 'mobileSelectEventsSource'], + ['./bootstrapRuntime.js', 'bootstrapRuntimeSource'], ]; -export function buildRuntimeSource(){ - return `(() => {\n${moduleSources.join('\n')}\n})();\n`; +export let moduleSources = []; +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(){ - const runtimeSource = buildRuntimeSource(); +export async function 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. // `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(); + 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.'; + }); } diff --git a/pytorrent/static/js/bootstrapRuntime.js b/pytorrent/static/js/bootstrapRuntime.js index 57c142d..1932350 100644 --- a/pytorrent/static/js/bootstrapRuntime.js +++ b/pytorrent/static/js/bootstrapRuntime.js @@ -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"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 14a7ae4..7463925 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -2979,17 +2979,15 @@ body.mobile-mode .mobile-filter-bar { gap: 0.3rem; } -.smart-surge-refill-card .smart-refill-controls { - grid-template-columns: minmax(84px, 0.6fr) minmax(110px, 1fr) minmax(110px, 1fr); - width: min(450px, 100%); +.smart-surge-cooldown-card { + background: rgba(var(--bs-info-rgb), 0.08); } -.smart-refill-switch { - align-content: end; -} - -.smart-refill-switch .form-check-input { - margin: 0; +.smart-surge-refill-controls { + display: grid; + grid-template-columns: repeat(2, minmax(110px, 1fr)); + gap: 0.55rem; + width: min(330px, 100%); } .disk-monitor-shell { display: grid; @@ -3065,6 +3063,7 @@ body.mobile-mode .mobile-filter-bar { flex-direction: column; } + .smart-surge-refill-controls, .disk-monitor-shell { grid-template-columns: 1fr; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 9e46009..e8cc39c 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -326,7 +326,7 @@