Podcast-trait admin-UI og utvidet RSS-metadata (oppgave 30.2)
Dedikert admin-UI for podcast-trait med riktige skjemafelt: - iTunes Author, Category (med underkategori-dropdown), Language - Explicit-avkrysning, Redirect Feed URL - Erstatter generisk nøkkel/verdi-editor for podcast-traitet RSS-utvidelser: - itunes:category støtter nå nested subcategory-element - itunes:new-feed-url for feed-migrasjon via redirect_feed - Oppdatert både maskinrommet og synops-rss CLI-verktøy
This commit is contained in:
parent
84396dc805
commit
6aeb8aa783
5 changed files with 294 additions and 48 deletions
200
frontend/src/lib/components/traits/PodcastTraitAdmin.svelte
Normal file
200
frontend/src/lib/components/traits/PodcastTraitAdmin.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Dedicated admin UI for podcast trait configuration.
|
||||
* Provides proper form fields for iTunes/podcast metadata
|
||||
* instead of generic key-value editing.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
config: Record<string, unknown>;
|
||||
onchange: (config: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
let { config, onchange }: Props = $props();
|
||||
|
||||
// Local state initialized from config
|
||||
let itunesAuthor = $state((config.itunes_author as string) ?? '');
|
||||
let itunesCategory = $state((config.itunes_category as string) ?? '');
|
||||
let itunesSubcategory = $state((config.itunes_subcategory as string) ?? '');
|
||||
let explicit = $state((config.explicit as boolean) ?? false);
|
||||
let language = $state((config.language as string) ?? 'no');
|
||||
let redirectFeed = $state((config.redirect_feed as string) ?? '');
|
||||
|
||||
// iTunes category tree (Apple Podcasts standard)
|
||||
const itunesCategories: Record<string, string[]> = {
|
||||
'Arts': ['Books', 'Design', 'Fashion & Beauty', 'Food', 'Performing Arts', 'Visual Arts'],
|
||||
'Business': ['Careers', 'Entrepreneurship', 'Investing', 'Management', 'Marketing', 'Non-Profit'],
|
||||
'Comedy': ['Comedy Interviews', 'Improv', 'Stand-Up'],
|
||||
'Education': ['Courses', 'How To', 'Language Learning', 'Self-Improvement'],
|
||||
'Fiction': ['Comedy Fiction', 'Drama', 'Science Fiction'],
|
||||
'Government': [],
|
||||
'Health & Fitness': ['Alternative Health', 'Fitness', 'Medicine', 'Mental Health', 'Nutrition', 'Sexuality'],
|
||||
'History': [],
|
||||
'Kids & Family': ['Education for Kids', 'Parenting', 'Pets & Animals', 'Stories for Kids'],
|
||||
'Leisure': ['Animation & Manga', 'Automotive', 'Aviation', 'Crafts', 'Games', 'Hobbies', 'Home & Garden', 'Video Games'],
|
||||
'Music': ['Music Commentary', 'Music History', 'Music Interviews'],
|
||||
'News': ['Business News', 'Daily News', 'Entertainment News', 'News Commentary', 'Politics', 'Sports News', 'Tech News'],
|
||||
'Religion & Spirituality': ['Buddhism', 'Christianity', 'Hinduism', 'Islam', 'Judaism', 'Religion', 'Spirituality'],
|
||||
'Science': ['Astronomy', 'Chemistry', 'Earth Sciences', 'Life Sciences', 'Mathematics', 'Natural Sciences', 'Nature', 'Physics', 'Social Sciences'],
|
||||
'Society & Culture': ['Documentary', 'Personal Journals', 'Philosophy', 'Places & Travel', 'Relationships'],
|
||||
'Sports': ['Baseball', 'Basketball', 'Cricket', 'Fantasy Sports', 'Football', 'Golf', 'Hockey', 'Rugby', 'Running', 'Soccer', 'Swimming', 'Tennis', 'Volleyball', 'Wilderness', 'Wrestling'],
|
||||
'Technology': [],
|
||||
'True Crime': [],
|
||||
'TV & Film': ['After Shows', 'Film History', 'Film Interviews', 'Film Reviews', 'TV Reviews'],
|
||||
};
|
||||
|
||||
const categoryNames = Object.keys(itunesCategories).sort();
|
||||
|
||||
const subcategories = $derived(
|
||||
itunesCategory ? (itunesCategories[itunesCategory] ?? []) : []
|
||||
);
|
||||
|
||||
// Common podcast languages (ISO 639-1)
|
||||
const languages = [
|
||||
{ code: 'no', label: 'Norsk' },
|
||||
{ code: 'nb', label: 'Norsk bokmal' },
|
||||
{ code: 'nn', label: 'Norsk nynorsk' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'sv', label: 'Svenska' },
|
||||
{ code: 'da', label: 'Dansk' },
|
||||
{ code: 'fi', label: 'Suomi' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'fr', label: 'Francais' },
|
||||
{ code: 'es', label: 'Espanol' },
|
||||
{ code: 'pt', label: 'Portugues' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'ja', label: 'Japanese' },
|
||||
{ code: 'zh', label: 'Chinese' },
|
||||
{ code: 'ko', label: 'Korean' },
|
||||
];
|
||||
|
||||
// Emit changes to parent whenever any field changes
|
||||
function emitConfig() {
|
||||
const updated: Record<string, unknown> = {};
|
||||
if (itunesAuthor.trim()) updated.itunes_author = itunesAuthor.trim();
|
||||
if (itunesCategory) updated.itunes_category = itunesCategory;
|
||||
if (itunesSubcategory) updated.itunes_subcategory = itunesSubcategory;
|
||||
if (explicit) updated.explicit = true;
|
||||
if (language && language !== 'no') updated.language = language;
|
||||
else if (language === 'no') updated.language = language;
|
||||
if (redirectFeed.trim()) updated.redirect_feed = redirectFeed.trim();
|
||||
onchange(updated);
|
||||
}
|
||||
|
||||
// Reset subcategory when category changes
|
||||
function handleCategoryChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
itunesCategory = target.value;
|
||||
// Clear subcategory if not valid for new category
|
||||
if (!itunesCategories[itunesCategory]?.includes(itunesSubcategory)) {
|
||||
itunesSubcategory = '';
|
||||
}
|
||||
emitConfig();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- iTunes Author -->
|
||||
<div>
|
||||
<label for="podcast-author" class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Forfatter / iTunes Author
|
||||
</label>
|
||||
<input
|
||||
id="podcast-author"
|
||||
type="text"
|
||||
bind:value={itunesAuthor}
|
||||
oninput={emitConfig}
|
||||
placeholder="Navn pa podcasten eller personen bak"
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-0.5 text-[11px] text-gray-400">Vises som forfatter i Apple Podcasts og andre apper.</p>
|
||||
</div>
|
||||
|
||||
<!-- iTunes Category -->
|
||||
<div>
|
||||
<label for="podcast-category" class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Kategori / iTunes Category
|
||||
</label>
|
||||
<select
|
||||
id="podcast-category"
|
||||
value={itunesCategory}
|
||||
onchange={handleCategoryChange}
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Velg kategori...</option>
|
||||
{#each categoryNames as cat (cat)}
|
||||
<option value={cat}>{cat}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- iTunes Subcategory (if available) -->
|
||||
{#if subcategories.length > 0}
|
||||
<div>
|
||||
<label for="podcast-subcategory" class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Underkategori
|
||||
</label>
|
||||
<select
|
||||
id="podcast-subcategory"
|
||||
bind:value={itunesSubcategory}
|
||||
onchange={emitConfig}
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Ingen underkategori</option>
|
||||
{#each subcategories as sub (sub)}
|
||||
<option value={sub}>{sub}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label for="podcast-language" class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Sprak
|
||||
</label>
|
||||
<select
|
||||
id="podcast-language"
|
||||
bind:value={language}
|
||||
onchange={emitConfig}
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
{#each languages as lang (lang.code)}
|
||||
<option value={lang.code}>{lang.label} ({lang.code})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Explicit -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="podcast-explicit"
|
||||
type="checkbox"
|
||||
bind:checked={explicit}
|
||||
onchange={emitConfig}
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="podcast-explicit" class="text-xs font-medium text-gray-600">
|
||||
Eksplisitt innhold (iTunes Explicit)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Redirect Feed -->
|
||||
<div>
|
||||
<label for="podcast-redirect" class="block text-xs font-medium text-gray-600 mb-1">
|
||||
Redirect-feed (valgfritt)
|
||||
</label>
|
||||
<input
|
||||
id="podcast-redirect"
|
||||
type="url"
|
||||
bind:value={redirectFeed}
|
||||
oninput={emitConfig}
|
||||
placeholder="https://ny-host.example.com/feed.xml"
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-0.5 text-[11px] text-gray-400">
|
||||
Brukes for a flytte podcasten til en annen plattform. Setter itunes:new-feed-url i RSS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { updateNode } from '$lib/api';
|
||||
import { traitCatalog } from '$lib/traits';
|
||||
import PodcastTraitAdmin from './PodcastTraitAdmin.svelte';
|
||||
|
||||
interface Props {
|
||||
collectionId: string;
|
||||
|
|
@ -85,6 +86,10 @@
|
|||
workingTraits = { ...workingTraits, [trait]: config };
|
||||
}
|
||||
|
||||
function handleDedicatedConfigChange(trait: string, newConfig: Record<string, unknown>) {
|
||||
workingTraits = { ...workingTraits, [trait]: newConfig };
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving = true;
|
||||
error = null;
|
||||
|
|
@ -157,7 +162,14 @@
|
|||
<!-- Per-trait config editor -->
|
||||
{#if editingTrait === trait}
|
||||
<div class="mt-2 border-t border-gray-200 pt-2">
|
||||
<!-- Existing config entries -->
|
||||
{#if trait === 'podcast'}
|
||||
<!-- Dedicated podcast config UI -->
|
||||
<PodcastTraitAdmin
|
||||
config={workingTraits[trait]}
|
||||
onchange={(c) => handleDedicatedConfigChange(trait, c)}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Generic key-value editor -->
|
||||
{#each Object.entries(workingTraits[trait]) as [key, value] (key)}
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<span class="text-xs text-gray-600">
|
||||
|
|
@ -194,6 +206,7 @@
|
|||
Legg til
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ struct RssTraitConfig {
|
|||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[allow(dead_code)]
|
||||
struct PodcastTraitConfig {
|
||||
itunes_author: Option<String>,
|
||||
itunes_category: Option<String>,
|
||||
itunes_subcategory: Option<String>,
|
||||
explicit: Option<bool>,
|
||||
language: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
@ -460,11 +460,19 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
|
|||
}
|
||||
|
||||
if let Some(ref category) = pc.itunes_category {
|
||||
if let Some(ref subcategory) = pc.itunes_subcategory {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:category text=\"{}\">\n <itunes:category text=\"{}\"/>\n </itunes:category>\n",
|
||||
xml_escape(category),
|
||||
xml_escape(subcategory)
|
||||
));
|
||||
} else {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:category text=\"{}\"/>\n",
|
||||
xml_escape(category)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let explicit = pc.explicit.unwrap_or(false);
|
||||
xml.push_str(&format!(
|
||||
|
|
@ -481,6 +489,16 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
|
|||
|
||||
xml.push_str(" <itunes:type>episodic</itunes:type>\n");
|
||||
|
||||
// itunes:new-feed-url for feed migration
|
||||
if let Some(ref redirect_url) = pc.redirect_feed {
|
||||
if !redirect_url.is_empty() {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:new-feed-url>{}</itunes:new-feed-url>\n",
|
||||
xml_escape(redirect_url)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Podcasting 2.0: locked
|
||||
xml.push_str(" <podcast:locked>no</podcast:locked>\n");
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -423,8 +423,7 @@ prøveimport-flyt.
|
|||
|
||||
### RSS og metadata
|
||||
- [x] 30.1 iTunes/Podcasting 2.0 RSS-tags: utvid synops-rss med `<itunes:*>` og `<podcast:*>` namespace. Tags fra samlingens podcast-trait metadata (author, category, explicit, language). Podcast:transcript og podcast:chapters fra eksisterende edges.
|
||||
- [~] 30.2 Podcast-trait metadata: utvid podcast-trait med iTunes-felt (itunes_category, itunes_author, explicit, language, redirect_feed). Admin-UI for å redigere.
|
||||
> Påbegynt: 2026-03-18T23:15
|
||||
- [x] 30.2 Podcast-trait metadata: utvid podcast-trait med iTunes-felt (itunes_category, itunes_author, explicit, language, redirect_feed). Admin-UI for å redigere.
|
||||
|
||||
### Statistikk
|
||||
- [ ] 30.3 `synops-stats` CLI: parse Caddy access-logger for /media/*-requests. Aggreger nedlastinger per episode per dag. IAB-regler: filtrer bots (user-agent), unik IP per 24t. Output: JSON med episode_id, date, downloads, unique_listeners. `--write` lagrer i PG.
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ struct RssTraitConfig {
|
|||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[allow(dead_code)]
|
||||
struct PodcastTraitConfig {
|
||||
itunes_author: Option<String>,
|
||||
itunes_category: Option<String>,
|
||||
itunes_subcategory: Option<String>,
|
||||
explicit: Option<bool>,
|
||||
language: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
@ -539,11 +539,19 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
|
|||
}
|
||||
|
||||
if let Some(ref category) = pc.itunes_category {
|
||||
if let Some(ref subcategory) = pc.itunes_subcategory {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:category text=\"{}\">\n <itunes:category text=\"{}\"/>\n </itunes:category>\n",
|
||||
xml_escape(category),
|
||||
xml_escape(subcategory)
|
||||
));
|
||||
} else {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:category text=\"{}\"/>\n",
|
||||
xml_escape(category)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let explicit = pc.explicit.unwrap_or(false);
|
||||
xml.push_str(&format!(
|
||||
|
|
@ -559,9 +567,17 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
|
|||
));
|
||||
}
|
||||
|
||||
xml.push_str(" <itunes:type>episodic</itunes:type>\n");
|
||||
|
||||
// itunes:new-feed-url for feed migration
|
||||
if let Some(ref redirect_url) = pc.redirect_feed {
|
||||
if !redirect_url.is_empty() {
|
||||
xml.push_str(&format!(
|
||||
" <itunes:type>episodic</itunes:type>\n"
|
||||
" <itunes:new-feed-url>{}</itunes:new-feed-url>\n",
|
||||
xml_escape(redirect_url)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Podcasting 2.0: locked
|
||||
xml.push_str(" <podcast:locked>no</podcast:locked>\n");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue