commit a2b9713e0e5cf5db1cc3870305043665fc673b42
parent b84f4862006540065db9a701587ccdeb0768617b
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Fri, 4 Sep 2026 13:38:25 +0530
Merge pull request #13 from notamitgamer/feat/repo-stats-footer
Show total file count and total data size in footer
Diffstat:
3 files changed, 73 insertions(+), 15 deletions(-)
diff --git a/app/main.py b/app/main.py
@@ -10,7 +10,7 @@ from fastapi import FastAPI, Request, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse
from fastapi.templating import Jinja2Templates
-from .storage import is_file, list_directory, list_files_recursive, upload_temp_file, HF_REPO_ID
+from .storage import is_file, list_directory, list_files_recursive, upload_temp_file, repo_stats, HF_REPO_ID
app = FastAPI()
@@ -57,13 +57,23 @@ RAW_DOMAIN = os.getenv("RAW_DOMAIN", "raw.cdn.amit.is-a.dev")
RAW_BASE_URL = os.getenv("RAW_BASE_URL", f"https://{RAW_DOMAIN}")
RAW_PREFIX = "raw/"
+def render_context(extra: dict) -> dict:
+ """Merges page-specific context with repo-wide file count / size stats
+ (shown in the footer on every page). repo_stats() is cached, so this
+ doesn't add a fresh full-repo scan on every request."""
+ stats = repo_stats()
+ ctx = dict(extra)
+ ctx["repo_file_count"] = stats["file_count"] if stats else None
+ ctx["repo_size_str"] = stats["size_str"] if stats else None
+ return ctx
+
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: HTTPException):
# Keep the raw subdomain returning plain text errors
if request.url.hostname == RAW_DOMAIN:
return PlainTextResponse("404: File Not Found", status_code=404)
# Render UI 404 page for the main CDN domain
- return templates.TemplateResponse(request, "index.html", {"page": "404"}, status_code=404)
+ return templates.TemplateResponse(request, "index.html", render_context({"page": "404"}), status_code=404)
async def stream_raw(path: str):
hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}"
@@ -214,24 +224,24 @@ async def serve(request: Request, path: str):
return RedirectResponse(f"{RAW_BASE_URL}/{raw_path}")
if clean_path == "upload":
- return templates.TemplateResponse(request, "index.html", {"page": "upload"})
+ return templates.TemplateResponse(request, "index.html", render_context({"page": "upload"}))
if clean_path and await is_file(clean_path):
filename = clean_path.split("/")[-1]
- return templates.TemplateResponse(request, "index.html", {
+ return templates.TemplateResponse(request, "index.html", render_context({
"page": "file",
"path": clean_path,
"filename": filename,
"raw_base_url": RAW_BASE_URL
- })
+ }))
items = list_directory(clean_path)
if clean_path and not items:
raise HTTPException(status_code=404, detail="Not Found")
- return templates.TemplateResponse(request, "index.html", {
+ return templates.TemplateResponse(request, "index.html", render_context({
"page": "listing",
"path": clean_path,
"items": items,
"raw_base_url": RAW_BASE_URL
- })
+ }))
diff --git a/app/storage.py b/app/storage.py
@@ -74,14 +74,7 @@ def list_directory(path: str):
is_dir = not hasattr(item, "size")
size = getattr(item, "size", 0)
- size_str = "-"
- if not is_dir and size is not None:
- if size < 1024:
- size_str = f"{size} B"
- elif size < 1024 * 1024:
- size_str = f"{size / 1024:.1f} KB"
- else:
- size_str = f"{size / (1024 * 1024):.1f} MB"
+ size_str = format_size(size) if not is_dir else "-"
# last_commit is only populated when expand_info=True succeeded;
# otherwise this just stays "-" rather than breaking the listing.
@@ -137,6 +130,56 @@ def list_files_recursive(path: str):
)
return files
+def format_size(size_bytes) -> str:
+ """
+ Human-readable size with proper unit escalation: bytes stay whole,
+ KB/MB/GB/TB get 2 decimals, and it keeps dividing by 1024 as long as
+ the value would round up to the next unit (so 1024MB shows as 1.00GB,
+ not 1024.00MB).
+ """
+ if size_bytes is None:
+ return "-"
+ size = float(size_bytes)
+ units = ["B", "KB", "MB", "GB", "TB"]
+ idx = 0
+ while size >= 1024 and idx < len(units) - 1:
+ size /= 1024
+ idx += 1
+ if idx == 0:
+ return f"{int(size)} {units[idx]}"
+ return f"{size:.2f} {units[idx]}"
+
+# Repo-wide stats are more expensive to compute (full recursive tree scan)
+# than a single-folder listing, so they get their own longer-lived cache
+# entry rather than reusing the 60s per-folder TTL.
+REPO_STATS_CACHE_TTL = 300 # 5 minutes
+
+def repo_stats():
+ """Returns {'file_count': int, 'size_str': str} for the whole repo, cached."""
+ cache_key = "repo_stats"
+ now = time.time()
+ if cache_key in _cache and _cache[cache_key][0] > now:
+ return _cache[cache_key][1]
+
+ try:
+ items = list(api.list_repo_tree(
+ repo_id=HF_REPO_ID, path_in_repo="", repo_type="dataset", recursive=True
+ ))
+ except Exception as e:
+ print(f"[storage] repo_stats failed: {e}")
+ return None
+
+ file_count = 0
+ total_size = 0
+ for item in items:
+ if hasattr(item, "size"): # files only, skip folder entries
+ file_count += 1
+ total_size += getattr(item, "size", 0) or 0
+
+ result = {"file_count": file_count, "size_str": format_size(total_size)}
+ _cache[cache_key] = (now + REPO_STATS_CACHE_TTL, result)
+ return result
+
def upload_temp_file(temp_path: str, filename: str) -> str:
"""Uploads a local file to HF and returns the relative path."""
slug = str(uuid.uuid4())[:8]
diff --git a/app/templates/index.html b/app/templates/index.html
@@ -577,6 +577,11 @@
{% endif %}
· © 2026 · assets may move without notice
</div>
+ {% if repo_file_count is not none %}
+ <div title="Across the whole repo">
+ {{ repo_file_count }} file{{ '' if repo_file_count == 1 else 's' }} · {{ repo_size_str }} total
+ </div>
+ {% endif %}
</footer>
</div>