diff --git a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte
index 0e5fa91..7e74b14 100644
--- a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte
+++ b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte
@@ -89,9 +89,14 @@
);
const visibleFoldersPayload = $derived({ ...foldersPayload, folders: visibleFolders });
const visibleTableFolders = $derived(visibleFolders.slice(0, 32));
+ const visibleFolderRanks = $derived(
+ new Map(visibleFolders.map((folder, index) => [folder.prefix, String(index + 1)]))
+ );
const visibleWorkLaneGroups = $derived(
isFolderIndex ? [] : buildWorkLaneGroups(visibleTableFolders)
);
+ const visiblePendingItems = $derived(totalPendingItems(visibleFolders));
+ const visibleProjectedReclaim = $derived(totalProjectedReclaim(visibleFolders));
const nextFolder = $derived(
isFolderIndex
? (visibleFolders[0] ?? null)
@@ -198,8 +203,7 @@
}
function rowRank(folder: FolderCard): string {
- const index = visibleFolders.findIndex((candidate) => candidate.prefix === folder.prefix);
- return index >= 0 ? String(index + 1) : '—';
+ return visibleFolderRanks.get(folder.prefix) ?? '—';
}
function libraryKey(label: string): string {
@@ -531,11 +535,11 @@
Open work
- {totalPendingItems(visibleFolders).toLocaleString('en-US')}
+ {visiblePendingItems.toLocaleString('en-US')}
Projected reclaim
- {formatBytes(totalProjectedReclaim(visibleFolders))}
+ {formatBytes(visibleProjectedReclaim)}
diff --git a/frontend/src/lib/components/workstation/RouteLoadingView.svelte b/frontend/src/lib/components/workstation/RouteLoadingView.svelte
new file mode 100644
index 0000000..f6a542a
--- /dev/null
+++ b/frontend/src/lib/components/workstation/RouteLoadingView.svelte
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
{subject}
+
{error ? 'Route data failed' : message}
+
{error || 'The route is visible while Mediaforce waits on the API.'}
+
+ {#if !error}
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/lib/components/workstation/queue-workstation.ts b/frontend/src/lib/components/workstation/queue-workstation.ts
index 79839d7..d412cf9 100644
--- a/frontend/src/lib/components/workstation/queue-workstation.ts
+++ b/frontend/src/lib/components/workstation/queue-workstation.ts
@@ -229,6 +229,7 @@ export function buildQueueStatusTiles(
const readyHosts = hosts.hosts.filter((host) => host.available).length;
const encodeQueue = dashboard.encode_queue;
const scanStatus = dashboard.scan_job?.status ?? 'idle';
+ const projectedReclaim = totalProjectedReclaim(folders);
return [
{
label: 'Next work',
@@ -238,9 +239,9 @@ export function buildQueueStatusTiles(
},
{
label: 'Projected reclaim',
- value: formatBytes(totalProjectedReclaim(folders)),
+ value: formatBytes(projectedReclaim),
detail: `estimated from visible ${visibleScopeLabel}`,
- tone: totalProjectedReclaim(folders) > 0 ? 'ready' : 'idle',
+ tone: projectedReclaim > 0 ? 'ready' : 'idle',
mono: true
},
{
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 7e71d93..a6aa78b 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,8 +1,10 @@
@@ -11,3 +13,60 @@
{@render children()}
+
+{#if routeLoading}
+
+
+ Loading workstation data
+
+{/if}
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 7c8410d..02f32ec 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -1,15 +1,44 @@
Mediaforce Work
-
+{#if dashboard && foldersPayload && hosts}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts
index 011cc2a..291f5cb 100644
--- a/frontend/src/routes/+page.ts
+++ b/frontend/src/routes/+page.ts
@@ -1,20 +1,3 @@
-import { fetchJson } from '$lib/api/client';
-import type {
- DashboardFoldersPayload,
- DashboardSummaryPayload,
- HostsPayload
-} from '$lib/api/types';
-
-export async function load({ fetch }: { fetch: typeof window.fetch }) {
- const [dashboard, hosts] = await Promise.all([
- fetchJson('/api/dashboard', fetch),
- fetchJson('/api/hosts?compact=1', fetch)
- ]);
- const foldersPayload: DashboardFoldersPayload = {
- folders: dashboard.folders_preview,
- folder_cache_key: dashboard.folder_cache_key,
- catalog_empty: dashboard.catalog_empty
- };
-
- return { dashboard, foldersPayload, hosts };
+export function load() {
+ return {};
}
diff --git a/frontend/src/routes/completed/+page.svelte b/frontend/src/routes/completed/+page.svelte
index 4a13604..f599605 100644
--- a/frontend/src/routes/completed/+page.svelte
+++ b/frontend/src/routes/completed/+page.svelte
@@ -1,11 +1,28 @@
Mediaforce Completed
-
+{#if completed}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/completed/+page.ts b/frontend/src/routes/completed/+page.ts
index 7ca4c87..291f5cb 100644
--- a/frontend/src/routes/completed/+page.ts
+++ b/frontend/src/routes/completed/+page.ts
@@ -1,25 +1,3 @@
-import { fetchJson } from '$lib/api/client';
-import type { CompletedPayload } from '$lib/api/types';
-
-type SettledPayload = { data: T; error: null } | { data: null; error: string };
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : 'Request failed.';
-}
-
-async function settle(promise: Promise): Promise> {
- try {
- return { data: await promise, error: null };
- } catch (error) {
- return { data: null, error: errorMessage(error) };
- }
-}
-
-export async function load({ fetch }: { fetch: typeof window.fetch }) {
- const completed = await settle(fetchJson('/api/completed', fetch));
-
- return {
- completed: completed.data,
- loadError: completed.error
- };
+export function load() {
+ return {};
}
diff --git a/frontend/src/routes/folders/+page.svelte b/frontend/src/routes/folders/+page.svelte
index 957d26b..fbb4d5e 100644
--- a/frontend/src/routes/folders/+page.svelte
+++ b/frontend/src/routes/folders/+page.svelte
@@ -1,17 +1,41 @@
Mediaforce Folders
-
+{#if dashboard && foldersPayload && hosts}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/folders/+page.ts b/frontend/src/routes/folders/+page.ts
index 90cd5ab..291f5cb 100644
--- a/frontend/src/routes/folders/+page.ts
+++ b/frontend/src/routes/folders/+page.ts
@@ -1,16 +1,3 @@
-import { fetchJson } from '$lib/api/client';
-import type {
- DashboardFoldersPayload,
- DashboardSummaryPayload,
- HostsPayload
-} from '$lib/api/types';
-
-export async function load({ fetch }: { fetch: typeof window.fetch }) {
- const [dashboard, foldersPayload, hosts] = await Promise.all([
- fetchJson('/api/dashboard', fetch),
- fetchJson('/api/dashboard/folders', fetch),
- fetchJson('/api/hosts?compact=1', fetch)
- ]);
-
- return { dashboard, foldersPayload, hosts };
+export function load() {
+ return {};
}
diff --git a/frontend/src/routes/ops/+page.svelte b/frontend/src/routes/ops/+page.svelte
index a0f2695..58df92d 100644
--- a/frontend/src/routes/ops/+page.svelte
+++ b/frontend/src/routes/ops/+page.svelte
@@ -1,11 +1,34 @@
Mediaforce Ops
-
+{#if dashboard && hosts}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/ops/+page.ts b/frontend/src/routes/ops/+page.ts
index d702905..291f5cb 100644
--- a/frontend/src/routes/ops/+page.ts
+++ b/frontend/src/routes/ops/+page.ts
@@ -1,30 +1,3 @@
-import { fetchJson } from '$lib/api/client';
-import type { DashboardSummaryPayload, HostsPayload } from '$lib/api/types';
-
-type SettledPayload = { data: T; error: null } | { data: null; error: string };
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : 'Request failed.';
-}
-
-async function settle(promise: Promise): Promise> {
- try {
- return { data: await promise, error: null };
- } catch (error) {
- return { data: null, error: errorMessage(error) };
- }
-}
-
-export async function load({ fetch }: { fetch: typeof window.fetch }) {
- const [dashboard, hosts] = await Promise.all([
- settle(fetchJson('/api/dashboard', fetch)),
- settle(fetchJson('/api/hosts?compact=1', fetch))
- ]);
- const loadError = [dashboard.error, hosts.error].filter(Boolean).join(' · ') || null;
-
- return {
- dashboard: dashboard.data,
- hosts: hosts.data,
- loadError
- };
+export function load() {
+ return {};
}
diff --git a/mediaforce/web/app.py b/mediaforce/web/app.py
index 485d299..d83e0df 100644
--- a/mediaforce/web/app.py
+++ b/mediaforce/web/app.py
@@ -25,7 +25,7 @@
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy import delete
-from sqlalchemy import func
+from sqlalchemy import and_, func
from sqlalchemy import literal_column
from sqlalchemy import or_
from sqlalchemy import select
@@ -2620,13 +2620,31 @@ def _folder_series_context(prefix: str) -> dict[str, str] | None:
def _folder_encode_candidate_count(connection: DBClient, config: MediaforceConfig, prefix: str) -> int:
- return len(
- select_encode_candidates(
- connection,
- config,
- prefixes=[prefix],
- limit=None,
- )
+ _ = config
+ normalized_prefix = prefix.strip().strip("/")
+ prefix_filter = or_(
+ library_items.c.rel_path == normalized_prefix,
+ library_items.c.rel_path.like(_prefix_descendant_like_pattern(normalized_prefix), escape="\\"),
+ )
+ candidate_filter = or_(
+ library_items.c.status.in_(("discovered", "planned")),
+ and_(
+ library_items.c.status == "validated",
+ staged_artifacts.c.library_item_id.is_(None),
+ ),
+ )
+ return int(
+ connection.execute(
+ select(func.count())
+ .select_from(
+ library_items.outerjoin(
+ staged_artifacts,
+ staged_artifacts.c.library_item_id == library_items.c.id,
+ )
+ )
+ .where(prefix_filter)
+ .where(candidate_filter)
+ ).scalar_one()
)
diff --git a/mediaforce/web/runtime/completed_runtime.py b/mediaforce/web/runtime/completed_runtime.py
index 0adf455..1ba4913 100644
--- a/mediaforce/web/runtime/completed_runtime.py
+++ b/mediaforce/web/runtime/completed_runtime.py
@@ -11,10 +11,10 @@
from mediaforce.core.db import DBClient
from mediaforce.core.db_tables import item_events, library_items, staged_artifacts
from mediaforce.encoding.staging import safe_unlink
-from mediaforce.web.runtime.archive_cleanup import archive_cleanup_summary
FolderGroup = tuple[str, str, str, str]
ORIGINALS_REMOVED_EVENT = "originals_removed_confirmed"
+HISTORY_DETAIL_MAX_CHARS = 320
@dataclass(slots=True)
@@ -67,7 +67,7 @@ def completed_page_payload(
"folders": [asdict(folder) for folder in folders],
"completed_count": len(folders),
"folders_with_backups_count": folders_with_backups_count,
- "archive_cleanup": archive_cleanup_summary(config),
+ "archive_cleanup": completed_archive_cleanup_summary(archive_root, folders),
"history": [
asdict(event)
for event in list_completed_history_events(connection, folder_group=folder_group)
@@ -75,6 +75,17 @@ def completed_page_payload(
}
+def completed_archive_cleanup_summary(archive_root: Path | None, folders: list[CompletedFolder]) -> dict[str, Any]:
+ file_count = sum(folder.archived_backup_count for folder in folders)
+ total_size_bytes = sum(folder.archived_backup_size_bytes for folder in folders)
+ return {
+ "archive_root": str(archive_root) if archive_root is not None else "",
+ "file_count": file_count,
+ "total_size_bytes": total_size_bytes,
+ "has_cleanup": file_count > 0,
+ }
+
+
def list_completed_folders(
connection: DBClient,
*,
@@ -508,7 +519,7 @@ def _history_event_copy(event_type: str, details: dict[str, Any]) -> tuple[str,
return (
"Encoding failed",
"fail",
- _clean_text(details.get("error")) or "Encode failed before promotion.",
+ _history_detail_summary(details.get("error")) or "Encode failed before promotion.",
None,
)
if event_type == "validation_completed":
@@ -526,6 +537,14 @@ def _path_detail(path: object, fallback: str) -> str:
return cleaned or fallback
+def _history_detail_summary(value: object, max_chars: int = HISTORY_DETAIL_MAX_CHARS) -> str:
+ cleaned = _clean_text(value)
+ if len(cleaned) <= max_chars:
+ return cleaned
+ omitted = len(cleaned) - max_chars
+ return f"{cleaned[:max_chars].rstrip()} ... [{omitted:,} characters omitted]"
+
+
def _bytes_detail(value: object, fallback: str) -> str:
parsed = _int_or_none(value)
if parsed is None:
diff --git a/scripts/smoke-web-routes.mjs b/scripts/smoke-web-routes.mjs
index 9697ac8..3067679 100755
--- a/scripts/smoke-web-routes.mjs
+++ b/scripts/smoke-web-routes.mjs
@@ -310,6 +310,11 @@ async function checkRoutes(baseUrl, routeChecksForBrowser, timeoutMs) {
state: "visible",
timeout: timeoutMs,
});
+ await page.waitForFunction(
+ (expectedMarker) => document.body.innerText.includes(expectedMarker),
+ marker,
+ { timeout: timeoutMs },
+ );
const state = await page.evaluate((expectedMarker) => {
const bodyText = document.body.innerText.trim();
return {
@@ -367,6 +372,11 @@ async function checkNarrowRoutes(baseUrl, routeChecksForNarrow, timeoutMs) {
state: "visible",
timeout: timeoutMs,
});
+ await page.waitForFunction(
+ (expectedMarker) => document.body.innerText.includes(expectedMarker),
+ marker,
+ { timeout: timeoutMs },
+ );
const state = await page.evaluate((expectedMarker) => {
const bodyText = document.body.innerText.trim();
const visibleWideTables = Array.from(document.querySelectorAll("table"))
diff --git a/tests/test_encode_queue_recovery.py b/tests/test_encode_queue_recovery.py
index 19dea70..a42a134 100644
--- a/tests/test_encode_queue_recovery.py
+++ b/tests/test_encode_queue_recovery.py
@@ -3337,6 +3337,29 @@ def test_folder_studio_series_context_and_encode_candidate_count_follow_manifest
self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show/Season 2"), 0)
self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show"), 1)
+ def test_folder_encode_candidate_count_counts_sql_eligible_items_only(self) -> None:
+ candidate = self._create_source_file("candidate.mkv")
+ staged = self._create_source_file("staged.mkv")
+ sibling = self._create_source_file("sibling.mkv")
+
+ with open_db(self.config.paths.db_path) as connection:
+ candidate_id = self._insert_library_item(connection, candidate, status="validated")
+ staged_id = self._insert_library_item(connection, staged, status="validated")
+ sibling_id = self._insert_library_item(connection, sibling, status="planned")
+ for item_id, rel_path, parent_dir in (
+ (candidate_id, "tv/show_1/Season 1/candidate.mkv", "tv/show_1/Season 1"),
+ (staged_id, "tv/show_1/Season 1/staged.mkv", "tv/show_1/Season 1"),
+ (sibling_id, "tv/show-special/Season 1/sibling.mkv", "tv/show-special/Season 1"),
+ ):
+ connection.execute(
+ update(library_items)
+ .where(library_items.c.id == item_id)
+ .values(rel_path=rel_path, parent_dir=parent_dir)
+ )
+ self._insert_staged_artifact(connection, staged_id, self._staging_path("staged.mkv"))
+
+ self.assertEqual(web_app._folder_encode_candidate_count(connection, self.config, "tv/show_1"), 1)
+
def test_folder_cards_projected_reclaim_uses_known_validated_savings(self) -> None:
validated = self._create_source_file("episode-validated.mkv")
diff --git a/tests/test_tuning_runtime.py b/tests/test_tuning_runtime.py
index 824bf13..21a9980 100644
--- a/tests/test_tuning_runtime.py
+++ b/tests/test_tuning_runtime.py
@@ -1120,6 +1120,26 @@ def test_completed_page_payload_groups_promoted_folders_and_active_backups(self)
self.assertEqual(second_folder["cleanup_state"], "unknown")
self.assertEqual(second_folder["missing_backup_count"], 1)
+ def test_completed_page_payload_uses_folder_backup_counts_for_archive_summary(self) -> None:
+ from mediaforce.web import app as web_app
+
+ self._insert_promoted_artifact(
+ rel_path="tv/Example Show/Season 1/Episode 01.mkv",
+ promoted_at="2026-04-10T10:00:00+00:00",
+ archived_size_bytes=3,
+ bytes_saved=12,
+ )
+ unrelated_archive = self.config.archive_root / "unrelated" / "orphan.mkv"
+ unrelated_archive.parent.mkdir(parents=True, exist_ok=True)
+ unrelated_archive.write_text("not part of promoted state")
+
+ with open_db(self.config.paths.db_path) as connection:
+ payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group)
+
+ self.assertEqual(payload["archive_cleanup"]["file_count"], 1)
+ self.assertEqual(payload["archive_cleanup"]["total_size_bytes"], 3)
+ self.assertTrue(payload["archive_cleanup"]["has_cleanup"])
+
def test_completed_page_payload_ignores_archived_paths_outside_active_archive_root(self) -> None:
from mediaforce.web import app as web_app
@@ -1141,7 +1161,7 @@ def test_completed_page_payload_ignores_archived_paths_outside_active_archive_ro
connection.commit()
payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group)
- self.assertEqual(payload["archive_cleanup"]["file_count"], 1)
+ self.assertEqual(payload["archive_cleanup"]["file_count"], 0)
self.assertEqual(payload["folders"][0]["archived_backup_count"], 0)
self.assertEqual(payload["folders"][0]["cleanup_state"], "blocked")
self.assertEqual(payload["folders"][0]["outside_archive_root_count"], 1)
@@ -1172,6 +1192,31 @@ def test_completed_page_payload_includes_recent_history_events(self) -> None:
self.assertEqual(payload["history"][0]["prefix"], "tv/Example Show/Season 1")
self.assertEqual(payload["history"][0]["tone"], "ready")
+ def test_completed_page_payload_truncates_large_failed_history_details(self) -> None:
+ from mediaforce.web import app as web_app
+
+ self._insert_promoted_artifact(
+ rel_path="tv/Example Show/Season 1/Episode 01.mkv",
+ promoted_at="2026-04-10T10:00:00+00:00",
+ archived_size_bytes=3,
+ bytes_saved=12,
+ )
+ with open_db(self.config.paths.db_path) as connection:
+ library_item_id = connection.execute(select(library_items.c.id)).scalar_one()
+ connection.execute(
+ item_events.insert().values(
+ library_item_id=library_item_id,
+ created_at="2026-04-10T10:01:00+00:00",
+ event_type="encoding_failed",
+ details_json=json.dumps({"error": "x" * 1200}),
+ )
+ )
+ connection.commit()
+ payload = completed_page_payload(self.config, connection, folder_group=web_app._folder_group)
+
+ self.assertLessEqual(len(payload["history"][0]["detail"]), 380)
+ self.assertIn("characters omitted", payload["history"][0]["detail"])
+
def test_completed_page_payload_tolerates_missing_archive_root(self) -> None:
from mediaforce.web import app as web_app