diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index f8c94d10..478cb428 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -907,9 +907,12 @@ def _process_edit(job_id: int, asset_id: int) -> None: shutil.rmtree(staging, ignore_errors=True) -def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple[Path, float]: - """The file a clip is cut from and where in it the clip starts. A YouTube clip fetches ONLY its - range into `staging` (so it starts at 0 there); a Plex clip reads the library file in place.""" +def _clip_source( + job_id: int, asset_id: int, spec: dict, staging: Path +) -> tuple[Path, float, str | None]: + """The file a clip is cut from, where in it the clip starts, and the source's title when only + the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at + 0 there); a Plex clip reads the library file in place.""" if spec["source"] == "youtube": with SessionLocal() as db: job = db.get(DownloadJob, job_id) @@ -929,7 +932,9 @@ def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple max_bytes=remaining, section=(spec["start_s"], spec["end_s"]), ) - return _produced_file(_media_info(dict(info)), staging), 0.0 + info = _media_info(dict(info)) + title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info)) + return _produced_file(info, staging), 0.0, title with SessionLocal() as db: item = db.execute( select(PlexItem).where(PlexItem.rating_key == spec["ref"]) @@ -937,7 +942,7 @@ def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple src = plex_paths.local_media_path(db, item.file_path) if item else None if src is None: raise StatedFailure("The library file is not reachable from here.") - return src, spec["start_s"] + return src, spec["start_s"], None def _process_clip(job_id: int, asset_id: int) -> None: @@ -954,7 +959,7 @@ def _process_clip(job_id: int, asset_id: int) -> None: if staging.exists(): shutil.rmtree(staging, ignore_errors=True) staging.mkdir(parents=True, exist_ok=True) - src, start = _clip_source(job_id, asset_id, spec, staging) + src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging) data = probe.probe_json(src) or {} audio_track = spec.get("audio_track") if audio_track is not None and audio_track >= clipmod.audio_track_count(data): @@ -1008,10 +1013,17 @@ def _process_clip(job_id: int, asset_id: int) -> None: asset.error = None asset.last_access_at = datetime.now(timezone.utc) asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days) + fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None} + # A video outside the catalog was queued as "Clip (clip)": the fetch has its title now. + placeholder = service.clip_title(None, None) + if asset and fetched_title and asset.title == placeholder: + named = service.clip_title(fetched_title, None) + asset.title = named + if job_row := db.get(DownloadJob, job_id): + if job_row.display_name == placeholder: + fields["display_name"] = named[:DISPLAY_TEXT_MAX] title = asset.title if asset else None - finalized = service.finalize_if_running( - db, job_id, status=JobStatus.DONE, progress=100, phase=None - ) + finalized = service.finalize_if_running(db, job_id, **fields) db.commit() if finalized: _notify_download_done(db, user_id, title, job_id, kind="edit") diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index f2c4ef63..98e4f4ba 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -210,7 +210,7 @@ def enqueue( return job -def _clip_title(source_title: str | None, display_name: str | None) -> str: +def clip_title(source_title: str | None, display_name: str | None) -> str: # Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through # `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped # rather than run through `display_text` — blank here means "no title", not None. @@ -252,7 +252,7 @@ def enqueue_edit( ref = f"{user_id}:{src_asset.id}"[:512] asset = get_or_create_asset(db, "edit", ref, sig) if not asset.title: - asset.title = _clip_title(src_asset.title, display_name) + asset.title = clip_title(src_asset.title, display_name) asset.uploader = src_asset.uploader asset.thumbnail_url = src_asset.thumbnail_url asset.upload_date = src_asset.upload_date @@ -294,7 +294,7 @@ def _clip_source_facts(db: Session, spec: dict) -> dict: rather than as a job that fails in the queue a minute later.""" if spec["source"] == "youtube": video = db.get(Video, spec["ref"]) - if video is None: # not in the catalog: the worker fills the title from yt-dlp's answer + if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker) return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None} channel = db.get(Channel, video.channel_id) return { @@ -346,7 +346,7 @@ def enqueue_clip( ref = clipmod.asset_ref(user_id, spec)[:512] asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec)) if not asset.title: - asset.title = _clip_title(facts["title"], display_name) + asset.title = clip_title(facts["title"], display_name) asset.uploader = facts["uploader"] asset.thumbnail_url = facts["thumbnail_url"] asset.duration_s = round(clipmod.duration(spec)) diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 1913bb89..8c7451be 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -421,3 +421,22 @@ def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_ job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json() row = db.get(DownloadJob, job["id"]) assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s" + + +def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch( + db, make_user, as_user, worker, monkeypatch +): + def extract(job_id, asset_id, url, spec, staging, adapter, **kw): + f = Path(staging) / "zyxwvutsrqp.mp4" + f.write_bytes(b"range") + return {"requested_downloads": [{"filepath": str(f)}], "title": "Found On YouTube"} + + monkeypatch.setattr(pipeline, "_extract_with_fallback", extract) + job = _clip(as_user(make_user()), source="youtube", ref="zyxwvutsrqp").json() + assert job["display_name"] == "Clip (clip)" + job, asset = worker["run"](job) + assert (job.status, asset.title, job.display_name) == ( + "done", + "Found On YouTube (clip)", + "Found On YouTube (clip)", + )