Dynamic app version from git tag + file size display in queue

Version:
- New app/__version__.py with 'dev' fallback for local dev
- Dockerfile injects APP_VERSION build arg from CI tag
- Health endpoint and footer now show actual release version
- Test updated to accept 'dev' in non-Docker environments

File size:
- Capture filesize/filesize_approx from yt-dlp extract_info
- Write to DB via update_job_progress and broadcast via SSE
- New 'Size' column in download table (hidden on mobile)
- formatSize helper: bytes → human-readable (KB/MB/GB)
- Frontend store picks up filesize from SSE events
This commit is contained in:
xpltd 2026-03-21 23:45:48 -05:00
parent 6f20d29151
commit 04f7fd09f3
8 changed files with 65 additions and 13 deletions

View file

@ -45,6 +45,10 @@ RUN useradd --create-home --shell /bin/bash mediarip
WORKDIR /app WORKDIR /app
COPY backend/ ./ 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 built frontend into backend static dir
COPY --from=frontend-builder /build/frontend/dist ./static COPY --from=frontend-builder /build/frontend/dist ./static

View file

@ -0,0 +1,2 @@
# Auto-generated at build time. Fallback for local dev.
__version__ = "dev"

View file

@ -289,8 +289,19 @@ async def update_job_progress(
speed: str | None = None, speed: str | None = None,
eta: str | None = None, eta: str | None = None,
filename: str | None = None, filename: str | None = None,
filesize: int | None = None,
) -> None: ) -> None:
"""Update live progress fields for a running download.""" """Update live progress fields for a running download."""
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( await db.execute(
""" """
UPDATE jobs UPDATE jobs

View file

@ -20,7 +20,10 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_yt_dlp_version = "unknown" _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") @router.get("/health")

View file

@ -430,6 +430,23 @@ class DownloadService:
relative_fn = str(abs_path.relative_to(out_dir)) relative_fn = str(abs_path.relative_to(out_dir))
except ValueError: except ValueError:
relative_fn = abs_path.name 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: else:
relative_fn = None relative_fn = None

View file

@ -63,11 +63,11 @@ class TestHealthEndpoint:
assert isinstance(data["queue_depth"], int) and data["queue_depth"] >= 0 assert isinstance(data["queue_depth"], int) and data["queue_depth"] >= 0
@pytest.mark.anyio @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") resp = await client.get("/api/health")
version = resp.json()["version"] version = resp.json()["version"]
parts = version.split(".") # In Docker: semver (e.g. "1.1.4"). Locally: "dev".
assert len(parts) == 3, f"Expected semver, got {version}" assert version == "dev" or len(version.split(".")) == 3, f"Unexpected version: {version}"
@pytest.mark.anyio @pytest.mark.anyio
async def test_health_queue_depth_reflects_active_jobs(self, db): async def test_health_queue_depth_reflects_active_jobs(self, db):

View file

@ -112,6 +112,13 @@ function isCompleted(job: Job): boolean {
return job.status === 'completed' 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. */ /** Infer whether the job is audio or video from quality/filename. */
function isAudioJob(job: Job): boolean { function isAudioJob(job: Job): boolean {
if (job.quality === 'bestaudio') return true if (job.quality === 'bestaudio') return true
@ -184,6 +191,9 @@ async function clearJob(jobId: string): Promise<void> {
<th class="col-speed sortable hide-mobile" @click="toggleSort('speed')"> <th class="col-speed sortable hide-mobile" @click="toggleSort('speed')">
Speed{{ sortIndicator('speed') }} Speed{{ sortIndicator('speed') }}
</th> </th>
<th class="col-size hide-mobile">
Size
</th>
<th class="col-eta sortable hide-mobile" @click="toggleSort('eta')"> <th class="col-eta sortable hide-mobile" @click="toggleSort('eta')">
ETA{{ sortIndicator('eta') }} ETA{{ sortIndicator('eta') }}
</th> </th>
@ -227,6 +237,10 @@ async function clearJob(jobId: string): Promise<void> {
<span v-if="job.speed" class="mono">{{ job.speed }}</span> <span v-if="job.speed" class="mono">{{ job.speed }}</span>
<span v-else class="text-muted"></span> <span v-else class="text-muted"></span>
</td> </td>
<td class="col-size hide-mobile">
<span v-if="job.filesize" class="mono">{{ formatSize(job.filesize) }}</span>
<span v-else class="text-muted"></span>
</td>
<td class="col-eta hide-mobile"> <td class="col-eta hide-mobile">
<span v-if="job.eta" class="mono">{{ job.eta }}</span> <span v-if="job.eta" class="mono">{{ job.eta }}</span>
<span v-else class="text-muted"></span> <span v-else class="text-muted"></span>

View file

@ -96,6 +96,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
if (event.speed !== null) existing.speed = event.speed if (event.speed !== null) existing.speed = event.speed
if (event.eta !== null) existing.eta = event.eta if (event.eta !== null) existing.eta = event.eta
if (event.filename !== null) existing.filename = event.filename 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 if (event.error_message) existing.error_message = event.error_message
// Trigger reactivity by re-setting the map entry // Trigger reactivity by re-setting the map entry
jobs.value.set(event.job_id, { ...existing }) jobs.value.set(event.job_id, { ...existing })
@ -111,7 +112,7 @@ export const useDownloadsStore = defineStore('downloads', () => {
quality: null, quality: null,
output_template: null, output_template: null,
filename: event.filename ?? null, filename: event.filename ?? null,
filesize: null, filesize: event.filesize ?? null,
progress_percent: event.percent, progress_percent: event.percent,
speed: event.speed ?? null, speed: event.speed ?? null,
eta: event.eta ?? null, eta: event.eta ?? null,