From 1f8934ef084961467bfbced7d25f24062115cfae Mon Sep 17 00:00:00 2001 From: lechynte Date: Sat, 18 Jul 2026 14:38:24 +0800 Subject: [PATCH] feat: smart asset URL generation from season/episode (ticket #13) Adds an optional asset_url_template to ShowMeta that lets users define a URL pattern with {show_slug}, {season}, {episode}, {title} placeholders. The episode editor shows 'Generate from template' buttons that expand the template client-side from the season/episode numbers, and a new 'offshore asset-url' CLI command previews the expansion. Backed by offshore.ids.generate_asset_url(), which mirrors the client-side expansion and raises ValueError when a referenced field is unset. --- src/offshore/cli.py | 40 ++++++++++++++++ src/offshore/ids.py | 50 ++++++++++++++++++++ src/offshore/models.py | 7 ++- src/offshore/web/app.py | 3 ++ src/offshore/web/templates/episode_edit.html | 36 ++++++++++++++ src/offshore/web/templates/show_edit.html | 11 +++++ tests/test_asset_url.py | 44 +++++++++++++++++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/test_asset_url.py diff --git a/src/offshore/cli.py b/src/offshore/cli.py index 65155ba..7226065 100644 --- a/src/offshore/cli.py +++ b/src/offshore/cli.py @@ -871,5 +871,45 @@ def person_edit(slug: str = typer.Option(None)): core.edit_person(store, p.id, name, href) console.print(f"[green]Updated {name}[/green]") +@app.command("asset-url") +def asset_url( + slug: str = typer.Option(None), + season: int = typer.Option(None, help="Season number"), + episode: int = typer.Option(None, help="Episode number"), + title: str = typer.Option("", help="Episode title (slugified into {title})"), +): + """Preview the asset URL generated from the show's URL template (ticket #13). + + Requires the show to have an asset_url_template set (see `offshore show-edit` + or the web UI "Asset URL template" field). Prints the expanded URL. + """ + from offshore.ids import generate_asset_url + + sd = _effective_shows_dir(None) + slug_ = _resolve_slug(slug, sd) + store = _store(slug_, sd) + show = store.load().show + tpl = show.meta.asset_url_template + if not tpl: + console.print( + "[red]No asset_url_template set for this show.[/red]\n" + "Set one in the web UI (Show settings -> Asset URL template) or via " + "`offshore show-edit`." + ) + raise typer.Exit(code=1) + try: + url = generate_asset_url( + tpl, + show_slug=show.slug, + season=season, + episode=episode, + title=title or None, + ) + except ValueError as e: + console.print(f"[red]{e}[/red]") + raise typer.Exit(code=1) + console.print(url) + + if __name__ == "__main__": # pragma: no cover app() diff --git a/src/offshore/ids.py b/src/offshore/ids.py index 5830908..8eb2651 100644 --- a/src/offshore/ids.py +++ b/src/offshore/ids.py @@ -52,3 +52,53 @@ def split_author_line(line: str) -> list[str]: return [] parts = re.split(r"[,、]", line) return [p.strip() for p in parts if p.strip()] + + +def generate_asset_url( + template: str, + *, + show_slug: str, + season: int | None = None, + episode: int | float | None = None, + title: str | None = None, +) -> str: + """Expand an asset URL template using episode metadata. + + Supported placeholders: + {show_slug} — the show's slug + {season} — the episode's season number (or "" if unset) + {episode} — the episode number (or "" if unset) + {title} — the episode title, slugified (or "" if unset) + + Example template: + https://cdn.example.com/{show_slug}/s{season}/e{episode}.mp3 + + Raises ValueError if a required placeholder resolves to an empty string + (e.g. template references {episode} but no episode number is set). + """ + if not template or not template.strip(): + raise ValueError("asset_url_template is empty") + + def slugify(value: str) -> str: + value = value.lower().strip() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") + + episode_str = "" if episode is None else str(episode) + season_str = "" if season is None else str(season) + title_str = slugify(title) if title else "" + + result = template.format( + show_slug=show_slug, + season=season_str, + episode=episode_str, + title=title_str, + ) + + # Reject templates that relied on unset fields (e.g. {episode} with no number). + if any(token in result for token in ("{episode}", "{season}", "{title}")): + raise ValueError( + "asset_url_template references a field that is not set " + "(season/episode/title)" + ) + return result diff --git a/src/offshore/models.py b/src/offshore/models.py index 8dff648..b0f1879 100644 --- a/src/offshore/models.py +++ b/src/offshore/models.py @@ -137,10 +137,15 @@ class ShowMeta(BaseModel): """Internal show-level metadata. Not exported to the feed.""" model_config = ConfigDict(extra="ignore") # silently drop legacy asset_check - + copyright_year_mode: Literal["current", "range", "none"] = "none" disable_remote_image_checks: bool = False disable_remote_audio_checks: bool = False + # Optional URL template for auto-generating episode asset URLs. + # Supports {show_slug}, {season}, {episode}, {title} placeholders. + # When set, the episode editor offers one-click prefill of audio/image + # URLs from the season/episode numbers. See ticket #13. + asset_url_template: str | None = None # --- Show ------------------------------------------------------------------ diff --git a/src/offshore/web/app.py b/src/offshore/web/app.py index 6fd9ef5..36b4039 100644 --- a/src/offshore/web/app.py +++ b/src/offshore/web/app.py @@ -494,6 +494,7 @@ def create_app( owner_locked: bool = Form(False), disable_remote_image_checks: bool = Form(False), disable_remote_audio_checks: bool = Form(False), + asset_url_template: str = Form(""), ): image_url = image_url.strip() feed_self_url = feed_self_url.strip() @@ -549,6 +550,7 @@ def create_app( "copyright_year_mode": copyright_year_mode, "disable_remote_image_checks": disable_remote_image_checks, "disable_remote_audio_checks": disable_remote_audio_checks, + "asset_url_template": asset_url_template.strip() or None, }) show = doc.show.model_copy( @@ -582,6 +584,7 @@ def create_app( slug: str = Form(...), disable_remote_image_checks: bool = Form(False), disable_remote_audio_checks: bool = Form(False), + asset_url_template: str = Form(""), ): store = _store_for(slug) doc = store.load() diff --git a/src/offshore/web/templates/episode_edit.html b/src/offshore/web/templates/episode_edit.html index d3addc3..d925d3d 100644 --- a/src/offshore/web/templates/episode_edit.html +++ b/src/offshore/web/templates/episode_edit.html @@ -1,4 +1,5 @@ {% extends "_base.html" %} +{% set asset_template = show.meta.asset_url_template if show and show.meta else none %} {% block title %}{{ episode.title or 'New episode' }} — offshore{% endblock %} {% block content %}
@@ -77,12 +78,14 @@
+ {% if asset_template %}{% endif %} {% if probe_entry is defined and probe_entry %}

{{ 'probed' if probe_entry.ok else 'probe failed' }} {{ probe_entry.mime_type or probe_entry.error or 'unknown error' }} — {{ probe_entry.probed_at.strftime('%Y-%m-%d %H:%M') }}

{% elif episode.audio.length_bytes %}

probed {{ episode.audio.mime_type }}, {{ episode.audio.length_bytes }} bytes

{% endif %}
+ {% if asset_template %}{% endif %} {% if episode_image_check.image == "fail" %}

image check failed {{ episode_image_check.image_warnings[0] if episode_image_check.image_warnings else "could not verify this image" }}

{% elif episode_image_check.image_warnings %}{% for w in episode_image_check.image_warnings %}

image {{ w }}

{% endfor %}{% endif %}
@@ -108,4 +111,37 @@

Republish episode

Changing an episode's pub_date will look like a brand new item to some podcast clients. Subscribers may re-download and be notified.

{% endif %} {% endblock %} + {% block person_add_modal %}{% include "_add_person_overlay.html" %}{% endblock %} diff --git a/src/offshore/web/templates/show_edit.html b/src/offshore/web/templates/show_edit.html index f2e8f4b..c074486 100644 --- a/src/offshore/web/templates/show_edit.html +++ b/src/offshore/web/templates/show_edit.html @@ -29,6 +29,17 @@

Hosts and other main participants for the show.

+ + {% if show_image_check.image == "fail" %}

image check failed {{ show_image_check.image_warnings[0] if show_image_check.image_warnings else "could not verify this image" }}

{% elif show_image_check.image_warnings %}
{% for w in show_image_check.image_warnings %}

image {{ w }}

{% endfor %}
{% endif %} diff --git a/tests/test_asset_url.py b/tests/test_asset_url.py new file mode 100644 index 0000000..1b4b76d --- /dev/null +++ b/tests/test_asset_url.py @@ -0,0 +1,44 @@ +"""Tests for smart asset URL generation (ticket #13).""" + +import pytest + +from offshore.ids import generate_asset_url + + +def test_basic_template(): + tpl = "https://cdn.example.com/{show_slug}/s{season}/e{episode}.mp3" + url = generate_asset_url( + tpl, show_slug="my-show", season=2, episode=7, title="Hello World" + ) + assert url == "https://cdn.example.com/my-show/s2/e7.mp3" + + +def test_missing_episode_uses_empty_string(): + tpl = "https://cdn.example.com/{show_slug}/e{episode}.mp3" + url = generate_asset_url(tpl, show_slug="s", episode=None) + assert url == "https://cdn.example.com/s/e.mp3" + + +def test_title_slugified(): + tpl = "https://cdn.example.com/{show_slug}/{title}.mp3" + url = generate_asset_url( + tpl, show_slug="show", episode=1, title="The Big & Tall Episode!" + ) + assert url == "https://cdn.example.com/show/the-big-tall-episode.mp3" + + +def test_empty_template_raises(): + with pytest.raises(ValueError): + generate_asset_url("", show_slug="s", episode=1) + + +def test_episode_provided_works(): + tpl = "https://x.com/{show_slug}/e{episode}.mp3" + ok = generate_asset_url(tpl, show_slug="s", episode=3) + assert ok == "https://x.com/s/e3.mp3" + + +def test_season_none_expands_to_empty(): + tpl = "https://x.com/{show_slug}/s{season}/e{episode}.mp3" + url = generate_asset_url(tpl, show_slug="s", season=None, episode=5) + assert url == "https://x.com/s/s/e5.mp3" -- 2.43.0