feat(downloads): ASCII-slug names, universal browser playability, force re-download
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.
S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.
S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
profile (an explicit vcodec / non-mp4 custom profile opts out).
S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.
Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
This commit is contained in:
@@ -20,7 +20,7 @@ from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import formats, quota, service, storage
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -448,6 +448,48 @@ def resume_download(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/redownload")
|
||||
def redownload_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
|
||||
|
||||
The SAME job row is kept, so any public share link (DownloadLink → job_id) keeps working and
|
||||
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
|
||||
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
|
||||
Useful after a format/naming fix: the old file is deleted and re-fetched under the current rules
|
||||
(the format-sig bump alone re-points this to a fresh asset)."""
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.job_kind == "edit":
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
|
||||
# Drop this job's hold on its current asset (deletes the file if it was the last holder), then
|
||||
# resolve the asset for the current spec and force a genuinely fresh fetch even on a cache hit.
|
||||
_release_asset(db, job)
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
sig = formats.format_sig(spec)
|
||||
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
||||
if asset.status == "ready":
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
asset.rel_path = None
|
||||
asset.status = "pending"
|
||||
service.populate_from_catalog(db, asset)
|
||||
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
job.asset_id = asset.id
|
||||
job.status = "queued"
|
||||
job.progress = 0
|
||||
job.phase = None
|
||||
job.error = None
|
||||
job.speed_bps = None
|
||||
job.eta_s = None
|
||||
db.commit()
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel")
|
||||
def cancel_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
|
||||
Reference in New Issue
Block a user