diff --git a/Dockerfile b/Dockerfile index 04c01dc..d8eba07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,10 @@ RUN useradd --create-home --shell /bin/bash mediarip WORKDIR /app COPY backend/ ./ +# Inject version from build arg (set by CI from git tag) +ARG APP_VERSION=dev +RUN echo "__version__ = \"${APP_VERSION}\"" > app/__version__.py + # Copy built frontend into backend static dir COPY --from=frontend-builder /build/frontend/dist ./static diff --git a/backend/app/__version__.py b/backend/app/__version__.py new file mode 100644 index 0000000..9f96004 --- /dev/null +++ b/backend/app/__version__.py @@ -0,0 +1,2 @@ +# Auto-generated at build time. Fallback for local dev. +__version__ = "dev" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 4c3371e..2a79620 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -289,16 +289,27 @@ async def update_job_progress( speed: str | None = None, eta: str | None = None, filename: str | None = None, + filesize: int | None = None, ) -> None: """Update live progress fields for a running download.""" - await db.execute( - """ - UPDATE jobs - SET progress_percent = ?, speed = ?, eta = ?, filename = ? - WHERE id = ? - """, - (progress_percent, speed, eta, filename, job_id), - ) + if filesize is not None: + await db.execute( + """ + UPDATE jobs + SET progress_percent = ?, speed = ?, eta = ?, filename = ?, filesize = ? + WHERE id = ? + """, + (progress_percent, speed, eta, filename, filesize, job_id), + ) + else: + await db.execute( + """ + UPDATE jobs + SET progress_percent = ?, speed = ?, eta = ?, filename = ? + WHERE id = ? + """, + (progress_percent, speed, eta, filename, job_id), + ) await db.commit() diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 4df3e60..78f5b06 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -20,7 +20,10 @@ try: except ImportError: # pragma: no cover _yt_dlp_version = "unknown" -_APP_VERSION = "0.1.0" +try: + from app.__version__ import __version__ as _APP_VERSION +except ImportError: # pragma: no cover + _APP_VERSION = "dev" @router.get("/health") diff --git a/backend/app/services/download.py b/backend/app/services/download.py index b526ea2..2f7c3b1 100644 --- a/backend/app/services/download.py +++ b/backend/app/services/download.py @@ -430,6 +430,23 @@ class DownloadService: relative_fn = str(abs_path.relative_to(out_dir)) except ValueError: relative_fn = abs_path.name + + # Capture filesize from metadata + file_size = info.get("filesize") or info.get("filesize_approx") + if file_size: + asyncio.run_coroutine_threadsafe( + update_job_progress( + self._db, job_id, 0, None, None, relative_fn, + filesize=int(file_size), + ), + self._loop, + ).result(timeout=10) + self._broker.publish(session_id, { + "event": "job_update", + "data": {"job_id": job_id, "status": "downloading", + "percent": 0, "filename": relative_fn, + "filesize": int(file_size)}, + }) else: relative_fn = None diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 95ed27a..f50650d 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -63,11 +63,11 @@ class TestHealthEndpoint: assert isinstance(data["queue_depth"], int) and data["queue_depth"] >= 0 @pytest.mark.anyio - async def test_health_version_is_semver(self, client): + async def test_health_version_format(self, client): resp = await client.get("/api/health") version = resp.json()["version"] - parts = version.split(".") - assert len(parts) == 3, f"Expected semver, got {version}" + # In Docker: semver (e.g. "1.1.4"). Locally: "dev". + assert version == "dev" or len(version.split(".")) == 3, f"Unexpected version: {version}" @pytest.mark.anyio async def test_health_queue_depth_reflects_active_jobs(self, db): diff --git a/frontend/src/components/DownloadTable.vue b/frontend/src/components/DownloadTable.vue index 2b210e2..92fbd8e 100644 --- a/frontend/src/components/DownloadTable.vue +++ b/frontend/src/components/DownloadTable.vue @@ -112,6 +112,13 @@ function isCompleted(job: Job): boolean { return job.status === 'completed' } +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + /** Infer whether the job is audio or video from quality/filename. */ function isAudioJob(job: Job): boolean { if (job.quality === 'bestaudio') return true @@ -184,6 +191,9 @@ async function clearJob(jobId: string): Promise { Speed{{ sortIndicator('speed') }} + + Size + ETA{{ sortIndicator('eta') }} @@ -227,6 +237,10 @@ async function clearJob(jobId: string): Promise { {{ job.speed }} + + {{ formatSize(job.filesize) }} + + {{ job.eta }} diff --git a/frontend/src/stores/downloads.ts b/frontend/src/stores/downloads.ts index 584d3a8..6fdb925 100644 --- a/frontend/src/stores/downloads.ts +++ b/frontend/src/stores/downloads.ts @@ -96,6 +96,7 @@ export const useDownloadsStore = defineStore('downloads', () => { if (event.speed !== null) existing.speed = event.speed if (event.eta !== null) existing.eta = event.eta if (event.filename !== null) existing.filename = event.filename + if (event.filesize) existing.filesize = event.filesize if (event.error_message) existing.error_message = event.error_message // Trigger reactivity by re-setting the map entry jobs.value.set(event.job_id, { ...existing }) @@ -111,7 +112,7 @@ export const useDownloadsStore = defineStore('downloads', () => { quality: null, output_template: null, filename: event.filename ?? null, - filesize: null, + filesize: event.filesize ?? null, progress_percent: event.percent, speed: event.speed ?? null, eta: event.eta ?? null,