fix(downloads): a YouTube clip outside the catalog takes its title from the fetch
It was queued as "Clip (clip)" and stayed so: nothing renamed it once yt-dlp had answered.
This commit is contained in:
@@ -907,9 +907,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _clip_source(job_id: int, asset_id: int, spec: dict, staging: Path) -> tuple[Path, float]:
|
def _clip_source(
|
||||||
"""The file a clip is cut from and where in it the clip starts. A YouTube clip fetches ONLY its
|
job_id: int, asset_id: int, spec: dict, staging: Path
|
||||||
range into `staging` (so it starts at 0 there); a Plex clip reads the library file in place."""
|
) -> 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":
|
if spec["source"] == "youtube":
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
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,
|
max_bytes=remaining,
|
||||||
section=(spec["start_s"], spec["end_s"]),
|
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:
|
with SessionLocal() as db:
|
||||||
item = db.execute(
|
item = db.execute(
|
||||||
select(PlexItem).where(PlexItem.rating_key == spec["ref"])
|
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
|
src = plex_paths.local_media_path(db, item.file_path) if item else None
|
||||||
if src is None:
|
if src is None:
|
||||||
raise StatedFailure("The library file is not reachable from here.")
|
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:
|
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():
|
if staging.exists():
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
staging.mkdir(parents=True, exist_ok=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 {}
|
data = probe.probe_json(src) or {}
|
||||||
audio_track = spec.get("audio_track")
|
audio_track = spec.get("audio_track")
|
||||||
if audio_track is not None and audio_track >= clipmod.audio_track_count(data):
|
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.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
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
|
title = asset.title if asset else None
|
||||||
finalized = service.finalize_if_running(
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
|
||||||
)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ def enqueue(
|
|||||||
return job
|
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
|
# 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
|
# `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.
|
# 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]
|
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||||
if not asset.title:
|
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.uploader = src_asset.uploader
|
||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
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."""
|
rather than as a job that fails in the queue a minute later."""
|
||||||
if spec["source"] == "youtube":
|
if spec["source"] == "youtube":
|
||||||
video = db.get(Video, spec["ref"])
|
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}
|
return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None}
|
||||||
channel = db.get(Channel, video.channel_id)
|
channel = db.get(Channel, video.channel_id)
|
||||||
return {
|
return {
|
||||||
@@ -346,7 +346,7 @@ def enqueue_clip(
|
|||||||
ref = clipmod.asset_ref(user_id, spec)[:512]
|
ref = clipmod.asset_ref(user_id, spec)[:512]
|
||||||
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
|
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
|
||||||
if not asset.title:
|
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.uploader = facts["uploader"]
|
||||||
asset.thumbnail_url = facts["thumbnail_url"]
|
asset.thumbnail_url = facts["thumbnail_url"]
|
||||||
asset.duration_s = round(clipmod.duration(spec))
|
asset.duration_s = round(clipmod.duration(spec))
|
||||||
|
|||||||
@@ -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()
|
job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json()
|
||||||
row = db.get(DownloadJob, job["id"])
|
row = db.get(DownloadJob, job["id"])
|
||||||
assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s"
|
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)",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user