From 1f8934ef084961467bfbced7d25f24062115cfae Mon Sep 17 00:00:00 2001
From: lechynte <lechynte@gotrak.org>
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 %}
 <div class="flex flex-col md:flex-row items-stretch justify-between gap-8 mb-8">
@@ -77,12 +78,14 @@
   <label>Audio URL <span class="text-gray-600 dark:text-gray-400" id="audio-url-req">{% if not episode.meta.draft %}*{% endif %}</span></label>
   <div class="w-full">
     <input type="url" id="audio-url-input" data-audio-url-input name="audio_url" value="{{ '' if is_new else episode.audio.url or '' }}" placeholder="{{ 'https://example.com/episode.mp3' if is_new else '' }}" {% if not episode.meta.draft %}required{% endif %}>
+    {% if asset_template %}<button type="button" id="gen-audio-url" class="ui-button mt-2">Generate from template</button>{% endif %}
     {% if probe_entry is defined and probe_entry %}<p class="ui-muted mt-2"><span class="badge {{ 'ok' if probe_entry.ok else 'fail' }}">{{ 'probed' if probe_entry.ok else 'probe failed' }}</span> {{ probe_entry.mime_type or probe_entry.error or 'unknown error' }} — {{ probe_entry.probed_at.strftime('%Y-%m-%d %H:%M') }}</p>{% elif episode.audio.length_bytes %}<p class="ui-muted mt-2"><span class="badge ok">probed</span> {{ episode.audio.mime_type }}, {{ episode.audio.length_bytes }} bytes</p>{% endif %}
   </div>
 
   <label>Episode artwork URL</label>
   <div class="w-full">
     <input type="url" name="image_url" value="{{ episode.image_url or '' }}">
+    {% if asset_template %}<button type="button" id="gen-image-url" class="ui-button mt-2">Generate from template</button>{% endif %}
     {% if episode_image_check.image == "fail" %}<p class="ui-muted mt-2"><span class="badge fail">image check failed</span> {{ episode_image_check.image_warnings[0] if episode_image_check.image_warnings else "could not verify this image" }}</p>{% elif episode_image_check.image_warnings %}{% for w in episode_image_check.image_warnings %}<p class="ui-muted mt-2"><span class="badge warn">image</span> {{ w }}</p>{% endfor %}{% endif %}
   </div>
   <label>Episode page link</label>
@@ -108,4 +111,37 @@
 <dialog id="republish-dialog" class="backdrop:bg-black/50 p-6 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#121214] text-gray-900 dark:text-gray-100 max-w-lg w-full shadow-xl"><form method="post" action="/episodes/republish?guid={{ episode.guid|guid_query }}"><input type="hidden" name="slug" value="{{ slug }}"><h3 class="text-xl font-semibold mb-2">Republish episode</h3><p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Changing an episode's <code>pub_date</code> will look like a <strong>brand new item</strong> to some podcast clients. Subscribers may re-download and be notified.</p><label class="block font-medium mb-1">New pub_date</label><input type="datetime-local" name="new_pub_date" required value="{{ episode.pub_date.strftime('%Y-%m-%dT%H:%M') }}" class="w-full bg-transparent mb-6"><div class="flex gap-3 justify-end"><button type="button" onclick="this.closest('dialog').close()" class="ui-button">Cancel</button><button type="submit" class="ui-button ui-button-primary">I understand, republish</button></div></form></dialog>
 {% endif %}
 {% endblock %}
+<script>
+  document.addEventListener('DOMContentLoaded', () => {
+    const assetTemplate = {{ (show.meta.asset_url_template | tojson) if show and show.meta and show.meta.asset_url_template else "null" }};
+    if (assetTemplate) {
+      const seasonInput = document.querySelector('input[name="season"]');
+      const episodeInput = document.querySelector('input[name="episode_number"]');
+      const titleInput = document.querySelector('input[name="title"]');
+      function buildUrl() {
+        const ctx = {
+          show_slug: {{ (show.slug | tojson) if show else "''" }},
+          season: seasonInput && seasonInput.value ? seasonInput.value : '',
+          episode: episodeInput && episodeInput.value ? episodeInput.value : '',
+          title: titleInput && titleInput.value ? titleInput.value : '',
+        };
+        return assetTemplate
+          .replaceAll('{show_slug}', ctx.show_slug)
+          .replaceAll('{season}', ctx.season)
+          .replaceAll('{episode}', ctx.episode)
+          .replaceAll('{title}', ctx.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''));
+      }
+      const audioInput = document.getElementById('audio-url-input');
+      const genAudioBtn = document.getElementById('gen-audio-url');
+      if (genAudioBtn && audioInput) {
+        genAudioBtn.addEventListener('click', () => { audioInput.value = buildUrl(); });
+      }
+      const imageInput = document.querySelector('input[name="image_url"]');
+      const genImageBtn = document.getElementById('gen-image-url');
+      if (genImageBtn && imageInput) {
+        genImageBtn.addEventListener('click', () => { imageInput.value = buildUrl(); });
+      }
+    }
+  });
+</script>
 {% 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 @@
 
   <label>People</label><div class="w-full"><p class="ui-muted mb-2">Hosts and other main participants for the show.</p><div id="persons-editor" class="mb-2" data-person-editor data-mode="remote" data-slug="{{ slug }}" data-roster='{{ roster_json|tojson }}' data-roles='{{ person_roles|tojson }}'></div><input type="hidden" name="persons_json" id="persons-json" value='{{ persons_json|tojson }}'><button type="button" data-add-person-role="host" class="ui-button">+ people</button></div>
   <label>Cover image URL <span class="text-gray-600 dark:text-gray-400">*</span></label><input type="url" name="image_url" value="{{ show.image_url }}" required>
+  <label>Asset URL template
+    <span class="muted" style="font-size:.8rem; font-weight:normal;">
+      Optional. Auto-generate episode audio/artwork URLs from season &amp; episode numbers.
+      Placeholders: <code>{show_slug}</code> <code>{season}</code> <code>{episode}</code> <code>{title}</code>.
+      Example: <code>https://cdn.example.com/{show_slug}/s{season}/e{episode}.mp3</code>
+    </span>
+  </label>
+  <input type="text" name="asset_url_template"
+         value="{{ show.meta.asset_url_template or '' }}"
+         placeholder="https://cdn.example.com/{show_slug}/s{season}/e{episode}.mp3"
+         style="font-family:monospace;">
   {% if show_image_check.image == "fail" %}<div class="col-span-full"><p class="ui-muted mt-1"><span class="badge fail">image check failed</span> {{ show_image_check.image_warnings[0] if show_image_check.image_warnings else "could not verify this image" }}</p></div>{% elif show_image_check.image_warnings %}<div class="col-span-full">{% for w in show_image_check.image_warnings %}<p class="ui-muted mt-1"><span class="badge warn">image</span> {{ w }}</p>{% endfor %}</div>{% endif %}
   <label>Feed self URL <span class="text-gray-600 dark:text-gray-400">*</span></label><input type="url" name="feed_self_url" value="{{ show.feed_self_url }}" required>
   <label>WebSub URL</label><input type="url" name="hub_url" value="{{ show.hub_url or '' }}">
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

