diff --git a/docs/sources.md b/docs/sources.md
new file mode 100644
index 0000000..c85342a
--- /dev/null
+++ b/docs/sources.md
@@ -0,0 +1,47 @@
+# Abos & Quellen (navigation phase 2)
+
+One place to manage every subscription: podcasts, YouTube channels, audiobooks,
+news sources and blogs. The everyday views (Neu, Warteschlange, Nachrichten,
+Blogs, Hörbücher) show content; adding, changing and removing sources happens
+here (their own small entry points lead here).
+
+## Where
+
+`DioraNav` section `sources` ("Abos & Quellen", icon ⚙), panel `#tab-sources`,
+`group: 'more'`, `footer: true`:
+- not in `groups()` (no tab of its own on a phone);
+- in `sections()` with `footer: true`: the sidebar puts it at the bottom, above
+ "Menü"; the phone's app menu has an entry; diora Reader's bar leaves it out;
+- the web page's bar: at the end (wide), as ⚙ (narrow).
+
+## The page (`static/js/sources.js`, diora-web, every platform)
+
+- **Hinzufügen**: one field for an address or search words.
+ - a YouTube address → its channel (`/podcasts/search/?q=`), as Podcast or Hörbuch;
+ - another address → `/feeds/discover/`: every feed found, with the kind it
+ looks like (`suggested_kind`: `podcast` when most entries carry sound or
+ video, else `news`/`blog`) and whether it's subscribed already
+ (`subscribed` as a feed, `podcast_subscribed`); the kind can be changed:
+ Podcast · Hörbuch · Nachrichten · Blog;
+ - words → the podcast directory (`/podcasts/search/`).
+ Podcast/Hörbuch subscribe with `POST /podcasts/feeds/add/` (then
+ `settings {kind: 'audiobook'}`), Nachrichten/Blog with `POST /feeds/sources/add/`.
+- **The list**: all sources, filter chips Alle · Podcasts · YouTube · Hörbücher ·
+ Nachrichten · Blogs · Probleme (fetching fails), and a search over the names.
+- **A source's actions** (tapping it): open its content, its settings (the
+ existing editors), change its kind (Podcast ↔ Hörbuch, Nachrichten ↔ Blog),
+ unsubscribe.
+- **Import & Export**: OPML for podcasts and feeds.
+
+## Hooks it uses (each optional: without it, it goes to the section instead)
+
+| Hook | From | Does |
+|------|------|------|
+| `__dioraPodcastFeedSettings(feedId)` | native-podcast.js | opens the podcast's settings sheet |
+| `__dioraPodcastOpenFeed(feedId)` | native-podcast.js | shows the podcast's episode list (Podcasts section) |
+| `__dioraFeedsEdit(sourceId)` | feeds.js | opens the source's editor (Nachrichten/Blogs, manage view) |
+| `__dioraFeedsStale()` | feeds.js | its state is old: load again when shown |
+| `__dioraAudiobookOpen(feedId)` | audiobooks.js | opens the book |
+
+The host calls it uses: `podcastState`, `podcastFeedSettings`, `podcastRefresh`
+(after subscribing), `podcastFeedRemoved` (after unsubscribing).
diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js
index 817602b..840e44c 100644
--- a/e2e/navigation.spec.js
+++ b/e2e/navigation.spec.js
@@ -14,7 +14,7 @@ test('wide: every section in the bar under its group, each shows its panel and v
const wide = page.locator('#tabs .nav-wide');
await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']);
await expect(wide.locator('[data-nav]')).toHaveText(
- ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus']);
+ ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Abos & Quellen']);
await wide.locator('[data-nav="blogs"]').click();
await expect(page).toHaveURL(/#blogs$/);
@@ -33,7 +33,7 @@ test('narrow: three groups, the sections as a row on top, each group remembers i
await page.setViewportSize({ width: 390, height: 800 });
await page.goto('/#news');
const narrow = page.locator('#tabs .nav-narrow');
- await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören']);
+ await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '⚙']);
await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/);
const segs = page.locator('#nav-segments');
await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']);
@@ -59,3 +59,16 @@ test('old links and direct panel switches land in their section', async ({ page
await page.reload();
expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered
});
+
+test('Abos & Quellen: one page for every subscription, reached from the feeds too', async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await page.goto('/#news');
+ await page.locator('#tab-feeds [data-act="manage"]').first().click();
+ await expect(page).toHaveURL(/#sources$/);
+ const tab = page.locator('#tab-sources');
+ await expect(tab.locator('.src-h1')).toHaveText('Abos & Quellen');
+ await expect(tab.locator('.src-add-q')).toBeVisible();
+ await expect(tab.locator('.src-chip.on')).toContainText('Nachrichten'); // came from the news
+ await tab.locator('[data-filter="all"]').click();
+ await expect(tab.locator('.src-chip.on')).toContainText('Alle');
+});
diff --git a/feeds/tests.py b/feeds/tests.py
index 5464e39..2ec8086 100644
--- a/feeds/tests.py
+++ b/feeds/tests.py
@@ -395,3 +395,29 @@ class FolderAndProgressTest(TestCase):
self.assertEqual(r, {'progress': 0.4, 'read': False})
self.assertEqual(self.client.get('/feeds/blogs/').json()['items'][0]['progress'], 0.4)
self.assertTrue(self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.95}).json()['read'])
+
+
+class DiscoverKindTest(TestCase):
+ """Abos & Quellen: a feed whose entries carry sound is offered as a podcast."""
+
+ def setUp(self):
+ self.u = User.objects.create_user('u', password='pw12345678')
+ self.client.force_login(self.u)
+
+ def discover(self, items):
+ xml = ('X' + ''.join(
+ f'- Folge {i}g{i}{extra}
' for i, extra in enumerate(items)) +
+ '')
+ with mock.patch('feeds.views._probe', lambda url: (('https://ex.org/feed', 'X', feedparser.parse(xml)), None)):
+ r = self.client.post('/feeds/discover/', json.dumps({'url': 'https://ex.org/feed'}),
+ content_type='application/json')
+ return r.json()['feeds'][0]
+
+ def test_sound_files_make_a_podcast(self):
+ enc = ''
+ f = self.discover([enc] * 4)
+ self.assertEqual(f['suggested_kind'], 'podcast')
+ self.assertFalse(f['podcast_subscribed'])
+
+ def test_text_entries_stay_news_or_a_blog(self):
+ self.assertIn(self.discover([''] * 4)['suggested_kind'], ('news', 'blog'))
diff --git a/feeds/views.py b/feeds/views.py
index 055b4b0..3f2efa0 100644
--- a/feeds/views.py
+++ b/feeds/views.py
@@ -241,20 +241,33 @@ def discover(request):
found = [feed]
except requests.RequestException:
return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502)
+ from podcasts.models import PodcastFeed
out = []
for u, title, parsed in found:
entries = parsed.entries[:5]
teaser = sum(1 for e in entries if fetch.is_teaser(fetch.parse_entry(e, u)['content']))
+ # Most entries with a sound or video file: a podcast (Abos & Quellen offers it as one).
+ media = sum(1 for e in parsed.entries[:10] if _has_media(e))
+ podcast = media and media * 2 >= min(len(parsed.entries), 10)
out.append({
'url': u, 'title': title,
'subscribed': Source.objects.filter(user=request.user, url=u).exists(),
+ 'podcast_subscribed': PodcastFeed.objects.filter(user=request.user, rss_url=u).exists(),
'samples': [fetch.plain(e.get('title') or '') for e in entries],
# Many entries a day suggests news, few long ones a blog.
- 'suggested_kind': Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
+ 'suggested_kind': 'podcast' if podcast
+ else Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
})
return JsonResponse({'feeds': out})
+def _has_media(entry):
+ for enc in entry.get('enclosures') or []:
+ if (enc.get('type') or '').lower().startswith(('audio/', 'video/')):
+ return True
+ return False
+
+
def _place(user, kind, category, folders, **more):
"""Where a new source goes: news into a section (category), a blog into
folders (the category of an OPML folder or the add dialog is its folder)."""
diff --git a/static/js/app/20-tabs.js b/static/js/app/20-tabs.js
index 6ea6a78..fb1d079 100644
--- a/static/js/app/20-tabs.js
+++ b/static/js/app/20-tabs.js
@@ -4,7 +4,7 @@
// Tabs
// ---------------------------------------------------------------------------
-const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks'];
+const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// ---------------------------------------------------------------------------
diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js
index 29a3fca..b6cd249 100644
--- a/static/js/audiobooks.js
+++ b/static/js/audiobooks.js
@@ -505,6 +505,8 @@
// Home's "Weiterhören" card (home.js) plays the book from where it was left.
window.__dioraAudiobookGo = feedId => goOn(feedId);
+ // Abos & Quellen (sources.js): open a book.
+ window.__dioraAudiobookOpen = feedId => openBook(Number(feedId));
// ---- following the player ------------------------------------------------------------
diff --git a/static/js/feeds.js b/static/js/feeds.js
index 431f322..c86d83a 100644
--- a/static/js/feeds.js
+++ b/static/js/feeds.js
@@ -824,6 +824,13 @@
return load();
}
if (act === 'manage') {
+ // Managing every subscription has its place (Abos & Quellen, docs/sources.md).
+ const nav = window.DioraNav;
+ if (nav && nav.sections().some(s => s.id === 'sources')) {
+ try { localStorage.setItem('diora_sources_filter', st.view === 'blogs' ? 'blog' : 'news'); } catch (err) {}
+ nav.go('sources');
+ return;
+ }
st.view = 'manage';
st.found = null;
window.scrollTo(0, 0);
@@ -834,6 +841,7 @@
try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; }
st.editing = null;
st.opml = null;
+ if (st.fromSources && window.DioraNav) { st.fromSources = false; window.DioraNav.go('sources'); return; }
return load();
}
if (act === 'seen') {
@@ -994,6 +1002,24 @@
// ---- showing the tab
let shownAt = 0;
+ // Abos & Quellen (sources.js): a source's editor (null: the management page itself, with
+ // the OPML import), and "the sources changed, load again".
+ window.__dioraFeedsEdit = async function (id) {
+ if (!st.state) await loadState().catch(() => {});
+ const src = id == null ? null : sources().find(x => x.id === Number(id));
+ const section = src && src.kind === 'blog' ? 'blogs' : 'news';
+ if (window.DioraNav) window.DioraNav.go(section); else showTab('feeds');
+ st.view = 'manage';
+ st.editing = src ? src.id : null;
+ st.found = null;
+ st.fromSources = true;
+ window.scrollTo(0, 0);
+ render();
+ };
+ window.__dioraFeedsStale = function () {
+ st.state = null;
+ shownAt = 0;
+ };
// Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the
// tab is shown; its own switch is then hidden.
window.__dioraFeedsView = function (view) {
@@ -1054,7 +1080,7 @@
const dock = document.createElement('div');
dock.id = 'nui-dock';
dock.innerHTML = '';
diff --git a/static/js/nav.js b/static/js/nav.js
index 6918fa1..b0af37b 100644
--- a/static/js/nav.js
+++ b/static/js/nav.js
@@ -25,6 +25,9 @@
{id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'},
// The focus timer: the web page's own (the apps never had it).
{id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true},
+ // Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app
+ // menu on a phone — not a tab of its own.
+ {id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true},
];
const GROUPS = [
{id: 'home', label: 'Home', icon: '⌂'},
@@ -46,19 +49,19 @@
if (s.account && !authed) return false;
if (s.webOnly && nativeUi()) return false;
if (s.needsPodcasts && !podcastHost()) return false;
- if (readerApp() && s.group !== 'lesen') return false; // diora Reader: reading only
+ if (readerApp() && s.group !== 'lesen' && s.id !== 'sources') return false; // diora Reader: reading only
if (noRead() && s.group === 'lesen') return false; // TV: no reading
return true;
}
function sections() {
- return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon}));
+ return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, footer: !!s.footer}));
}
function groups() {
const list = sections();
return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon,
- sections: list.filter(s => s.group === g.id).map(s => s.id)})).filter(g => g.sections.length);
+ sections: list.filter(s => s.group === g.id && !s.footer).map(s => s.id)})).filter(g => g.sections.length);
}
function load(key) {
@@ -74,7 +77,7 @@
// A section of `group`: the one used last there, else its first.
function lastOf(group) {
- const list = sections().filter(s => s.group === group);
+ const list = sections().filter(s => s.group === group && !s.footer);
const last = load('diora_nav_last_' + group);
const found = list.filter(s => s.id === last)[0];
return (found || list[0] || {}).id || null;
@@ -99,7 +102,7 @@
current = id;
const s = byId[id];
save('diora_nav_section', id);
- save('diora_nav_last_' + s.group, id);
+ if (!s.footer) save('diora_nav_last_' + s.group, id);
syncHash();
render();
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
@@ -188,15 +191,19 @@
function render() {
const cur = current && byId[current];
const gs = groups();
+ const foot = sections().filter(s => s.footer);
if (tabs && !nativeUi()) {
// Wide: every section under its group; narrow: the groups (their sections on top).
tabs.innerHTML =
'
' + gs.map(g => g.id === 'home'
? button('home', 'Home', 'tab-btn', current === 'home')
: '' + esc(g.label) + '' +
- g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + '
' +
+ g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') +
+ (foot.length ? '' : '') +
+ '' +
'' + gs.map(g =>
- button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') + '
';
+ button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +
+ foot.map(s => button(s.id, s.icon, 'tab-btn nav-icon', current === s.id)).join('') + '';
}
if (segs) {
const g = cur ? gs.filter(x => x.id === cur.group)[0] : null;
@@ -224,6 +231,9 @@
tabs.addEventListener('click', onClick);
}
render();
+ // The section was restored while the page was still loading (99-init.js), before the
+ // areas' scripts (feeds.js, audiobooks.js …) were there to fill it: once more, now.
+ if (current) go(current);
}
function onClick(e) {
@@ -257,6 +267,8 @@
style.textContent = [
'#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }',
'#tabs .nav-narrow { display: none; }',
+ '#tabs .nav-foot { margin-left: auto; }',
+ '#tabs .nav-narrow .nav-icon { margin-left: auto; }',
'#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
'@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }',
'#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }',
diff --git a/static/js/sources.js b/static/js/sources.js
new file mode 100644
index 0000000..8f049cc
--- /dev/null
+++ b/static/js/sources.js
@@ -0,0 +1,481 @@
+/*
+ * Abos & Quellen (docs/sources.md): every subscription in one place — podcasts, YouTube
+ * channels, audiobooks, news sources, blogs. One field adds any of them (an address or
+ * search words), one list shows them all, a source's actions open its content, its
+ * settings (the existing editors, through their hooks), change its kind or unsubscribe.
+ * Runs in the web page, the desktop app and the Android apps (diora Reader: news and
+ * blogs only, there are no podcasts). Chrome-61-safe: no ?. / ??.
+ */
+(function () {
+ 'use strict';
+ const tab = document.getElementById('tab-sources');
+ if (!tab) return;
+
+ const st = {
+ filter: 'all',
+ q: '',
+ feeds: null, // /feeds/state/ sources
+ adding: null, // {loading, error, found: [candidate…]}
+ open: null, // key of the source whose actions are shown
+ busy: false,
+ };
+ try {
+ const f = localStorage.getItem('diora_sources_filter');
+ if (f) st.filter = f;
+ } catch (e) {}
+
+ const FILTERS = [
+ ['all', 'Alle'], ['podcast', 'Podcasts'], ['youtube', 'YouTube'], ['audiobook', 'Hörbücher'],
+ ['news', 'Nachrichten'], ['blog', 'Blogs'], ['problem', 'Probleme'],
+ ];
+ const KIND_LABEL = {podcast: 'Podcast', youtube: 'YouTube', audiobook: 'Hörbuch', news: 'Nachrichten', blog: 'Blog'};
+
+ // ---- helpers -------------------------------------------------------------------------
+
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
+ ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
+ }
+
+ function host() {
+ const n = window.DioraNative;
+ if (n && typeof n.podcastState === 'function') return n;
+ return window.DioraPodcastHost || null;
+ }
+
+ function podcastFeeds() {
+ const h = host();
+ if (!h) return [];
+ try { return (JSON.parse(h.podcastState()) || {}).feeds || []; } catch (e) { return []; }
+ }
+
+ async function api(path, body) {
+ const opts = {credentials: 'same-origin', cache: 'no-store'};
+ if (body !== undefined) {
+ opts.method = 'POST';
+ opts.headers = {'Content-Type': 'application/json'};
+ opts.body = JSON.stringify(body);
+ }
+ const r = await fetch(path, opts);
+ const d = await r.json().catch(() => null);
+ if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
+ return d;
+ }
+
+ function toast(text) {
+ let t = document.getElementById('src-toast');
+ if (!t) {
+ t = document.createElement('div');
+ t.id = 'src-toast';
+ document.body.appendChild(t);
+ }
+ t.textContent = text;
+ t.classList.add('src-show');
+ clearTimeout(toast.timer);
+ toast.timer = setTimeout(() => t.classList.remove('src-show'), 2800);
+ }
+
+ const isYouTubeFeed = url => /^https?:\/\/(www\.)?youtube\.com\/feeds\/videos\.xml/.test(url || '');
+ const isYouTubeAddress = s => /^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(s.trim());
+ const looksLikeAddress = s => !/\s/.test(s.trim()) && /\.[a-z]{2,}/i.test(s);
+
+ function go(id) {
+ if (window.DioraNav) window.DioraNav.go(id);
+ else if (typeof showTab === 'function') showTab(id);
+ }
+
+ // ---- all sources as one list -----------------------------------------------------------
+
+ function rows() {
+ const out = [];
+ podcastFeeds().forEach(f => {
+ const type = f.kind === 'audiobook' ? 'audiobook' : isYouTubeFeed(f.rss_url) ? 'youtube' : 'podcast';
+ out.push({
+ key: 'p' + f.id, type, id: f.id, title: f.title || f.rss_url, sub: f.author || '',
+ art: f.artwork_url || '', url: f.rss_url,
+ meta: (f.episode_count ? f.episode_count + ' Folgen' : '') + (f.new_count ? ' · ' + f.new_count + ' neu' : ''),
+ problem: (f.error_count || 0) >= 3 ? (f.last_error || 'Abruf schlägt fehl') : '',
+ });
+ });
+ (st.feeds || []).forEach(s => {
+ out.push({
+ key: 'f' + s.id, type: s.kind === 'news' ? 'news' : 'blog', id: s.id, title: s.title || s.url,
+ sub: s.kind === 'news' ? (s.category || 'Allgemein') : (s.folders || []).join(', '),
+ art: s.icon_url || '', url: s.url,
+ meta: s.unread ? s.unread + ' ungelesen' : '',
+ problem: (s.error_count || 0) >= 3 ? (s.last_error || 'Abruf schlägt fehl') : '',
+ });
+ });
+ return out.sort((a, b) => a.title.localeCompare(b.title, 'de', {sensitivity: 'base'}));
+ }
+
+ function shown(list) {
+ const q = st.q.trim().toLowerCase();
+ return list.filter(r =>
+ (st.filter === 'all' || (st.filter === 'problem' ? !!r.problem : r.type === st.filter)) &&
+ (!q || (r.title + ' ' + r.sub + ' ' + r.url).toLowerCase().indexOf(q) >= 0));
+ }
+
+ // ---- drawing ---------------------------------------------------------------------------
+
+ function render() {
+ const all = rows();
+ const list = shown(all);
+ const count = id => id === 'all' ? all.length
+ : id === 'problem' ? all.filter(r => r.problem).length : all.filter(r => r.type === id).length;
+ let h = '';
+ h += '
Abos & Quellen
';
+ h += addHtml();
+ h += '
' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0]))
+ .map(f => '' +
+ esc(f[1]) + ' ' + count(f[0]) + '').join('') + '
';
+ h += '
';
+ if (st.feeds === null && !all.length) h += '
Wird geladen …
';
+ else if (!list.length) h += '
' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '
';
+ h += '
' + list.map(rowHtml).join('') + '
';
+ h += ioHtml();
+ h += '
';
+ const q = tab.querySelector('.src-q');
+ const focused = q && document.activeElement === q;
+ tab.innerHTML = h;
+ if (focused) {
+ const n = tab.querySelector('.src-q');
+ n.focus();
+ n.setSelectionRange(n.value.length, n.value.length);
+ }
+ }
+
+ function addHtml() {
+ const a = st.adding;
+ let h = '';
+ if (!a) return h;
+ if (a.error) h += '' + esc(a.error) + '
';
+ if (a.found && a.found.length) {
+ h += '' + a.found.map((c, n) =>
+ '
' + art(c.art, c.title, 'src-art') +
+ '
' + esc(c.title) + '
' +
+ '
' + esc(c.sub || c.url) + '
' +
+ (c.samples && c.samples.length ? '
' + c.samples.slice(0, 2).map(esc).join(' · ') + '
' : '') +
+ (c.subscribed ? '
✓ schon abonniert
' :
+ '
' + c.kinds.map(k =>
+ '' +
+ (k === c.kind ? '+ ' : '') + esc(KIND_LABEL[k]) + '').join('') + '
') +
+ '
').join('') + '
';
+ }
+ return h;
+ }
+
+ function art(url, title, cls) {
+ const u = String(url || '').replace(/^http:/, 'https:');
+ return u ? '
'
+ : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + '';
+ }
+
+ function rowHtml(r) {
+ const open = st.open === r.key;
+ let h = '' +
+ art(r.art, r.title, 'src-art') + '' + esc(r.title) + '' +
+ '' + esc(KIND_LABEL[r.type]) + '' +
+ (r.sub ? ' ' + esc(r.sub) : '') + (r.meta ? ' · ' + esc(r.meta) : '') + '' +
+ (r.problem ? '⚠ ' + esc(r.problem) + '' : '') + '';
+ if (open) h += actionsHtml(r);
+ return h + '
';
+ }
+
+ function actionsHtml(r) {
+ const a = (act, label, cls) => '' + label + '';
+ const list = [];
+ if (r.type === 'audiobook') list.push(a('open', '▶ Hörbuch öffnen'));
+ else if (r.type === 'news' || r.type === 'blog') list.push(a('open', r.type === 'news' ? 'Nachrichten lesen' : 'Blog lesen'));
+ else list.push(a('open', 'Folgen ansehen'));
+ list.push(a('settings', 'Einstellungen …'));
+ if (r.type === 'podcast' || r.type === 'youtube') list.push(a('to-audiobook', 'Als Hörbuch führen'));
+ if (r.type === 'audiobook') list.push(a('to-podcast', 'Als Podcast führen'));
+ if (r.type === 'news') list.push(a('to-blog', 'Als Blog führen'));
+ if (r.type === 'blog') list.push(a('to-news', 'Als Nachrichten führen'));
+ list.push(a('remove', 'Abbestellen', 'src-danger'));
+ return '' + esc(r.url) + '
' + list.join('') + '
';
+ }
+
+ function ioHtml() {
+ const h = host();
+ const web = !window.DioraNative;
+ return 'Import & Export
' +
+ (h ? '' : '') +
+ (h && web ? 'Podcasts als OPML' : '') +
+ 'Feeds aus OPML …' +
+ (web ? 'Feeds als OPML' : '') +
+ 'Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).
';
+ }
+
+ // ---- adding --------------------------------------------------------------------------------
+
+ async function find(query) {
+ const q = query.trim();
+ if (!q) return;
+ st.adding = {query: q, loading: true};
+ render();
+ try {
+ let found = [];
+ const podcasts = !!host();
+ if (isYouTubeAddress(q) && podcasts) {
+ const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
+ found = (d.results || []).map(r => ({title: r.title, sub: 'YouTube', url: r.rss_url, art: r.artwork_url,
+ kind: 'podcast', kinds: ['podcast', 'audiobook'], subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
+ if (!found.length) throw new Error('Dort ist kein YouTube-Kanal zu finden.');
+ } else if (looksLikeAddress(q)) {
+ const d = await api('/feeds/discover/', {url: q});
+ found = (d.feeds || []).map(f => {
+ const kind = f.suggested_kind === 'podcast' && !podcasts ? 'blog' : f.suggested_kind;
+ return {title: f.title, url: f.url, samples: f.samples, kind,
+ kinds: podcasts ? ['podcast', 'audiobook', 'news', 'blog'] : ['news', 'blog'],
+ subscribed: kind === 'podcast' || kind === 'audiobook' ? f.podcast_subscribed : f.subscribed};
+ });
+ } else if (podcasts) {
+ const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
+ found = (d.results || []).filter(r => r.rss_url).slice(0, 12).map(r => ({title: r.title, sub: r.author,
+ url: r.rss_url, art: r.artwork_url, kind: 'podcast', kinds: ['podcast', 'audiobook'],
+ subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
+ if (!found.length) throw new Error('Kein Podcast dieses Namens gefunden.');
+ } else {
+ throw new Error('Bitte die Adresse einer Seite oder eines Feeds eingeben.');
+ }
+ st.adding = {query: q, found};
+ } catch (e) {
+ st.adding = {query: q, error: navigator.onLine ? (e.message || 'Hat nicht geklappt.') : 'Das braucht eine Verbindung.'};
+ }
+ render();
+ }
+
+ async function subscribe(c, kind) {
+ if (st.busy) return;
+ st.busy = true;
+ toast('Wird abonniert …');
+ try {
+ if (kind === 'podcast' || kind === 'audiobook') {
+ const d = await api('/podcasts/feeds/add/', {rss_url: c.url, title: c.title});
+ if (kind === 'audiobook') await api('/podcasts/feeds/' + d.feed_id + '/settings/', {kind: 'audiobook'});
+ refreshPodcasts();
+ toast('„' + (d.title || c.title) + '“ abonniert' + (kind === 'audiobook' ? ' (Hörbuch)' : ''));
+ } else {
+ await api('/feeds/sources/add/', {url: c.url, title: c.title, kind});
+ if (window.__dioraFeedsStale) window.__dioraFeedsStale();
+ toast('„' + c.title + '“ abonniert');
+ await loadFeeds();
+ }
+ c.subscribed = true;
+ } catch (e) {
+ toast(navigator.onLine ? 'Das ließ sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
+ }
+ st.busy = false;
+ render();
+ }
+
+ function refreshPodcasts() {
+ const h = host();
+ // The server read the new feed already: the host only needs its state again.
+ if (h && typeof h.podcastRefresh === 'function') h.podcastRefresh(false);
+ // The host's state comes a moment later.
+ setTimeout(render, 1500);
+ setTimeout(render, 4000);
+ }
+
+ // ---- a source's actions -----------------------------------------------------------------------
+
+ async function act(name, r) {
+ const h = host();
+ if (name === 'open') {
+ if (r.type === 'audiobook') {
+ go('audiobooks');
+ if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(r.id);
+ } else if (r.type === 'news' || r.type === 'blog') {
+ go(r.type === 'news' ? 'news' : 'blogs');
+ } else if (window.__dioraPodcastOpenFeed) {
+ window.__dioraPodcastOpenFeed(r.id);
+ } else {
+ go('podcasts');
+ }
+ return;
+ }
+ if (name === 'settings') {
+ if (r.type === 'news' || r.type === 'blog') {
+ if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(r.id);
+ else go(r.type === 'news' ? 'news' : 'blogs');
+ } else if (window.__dioraPodcastFeedSettings) {
+ window.__dioraPodcastFeedSettings(r.id);
+ } else if (window.__dioraPodcastOpenFeed) {
+ window.__dioraPodcastOpenFeed(r.id);
+ } else {
+ go('podcasts');
+ }
+ return;
+ }
+ if (name === 'to-audiobook' || name === 'to-podcast') {
+ if (!h) return;
+ h.podcastFeedSettings(r.id, JSON.stringify({kind: name === 'to-audiobook' ? 'audiobook' : 'podcast'}));
+ toast(name === 'to-audiobook' ? '„' + r.title + '“ ist jetzt ein Hörbuch' : '„' + r.title + '“ ist wieder ein Podcast');
+ st.open = null;
+ render();
+ setTimeout(render, 600);
+ return;
+ }
+ if (name === 'to-blog' || name === 'to-news') {
+ try {
+ await api('/feeds/sources/' + r.id + '/', {kind: name === 'to-blog' ? 'blog' : 'news'});
+ if (window.__dioraFeedsStale) window.__dioraFeedsStale();
+ await loadFeeds();
+ } catch (e) { toast('Das braucht eine Verbindung.'); }
+ return;
+ }
+ if (name === 'remove') {
+ const ok = typeof customConfirm === 'function'
+ ? await customConfirm('„' + r.title + '“ abbestellen?', {danger: true})
+ : window.confirm('„' + r.title + '“ abbestellen?');
+ if (!ok) return;
+ try {
+ if (r.type === 'news' || r.type === 'blog') {
+ await api('/feeds/sources/' + r.id + '/delete/', {});
+ if (window.__dioraFeedsStale) window.__dioraFeedsStale();
+ await loadFeeds();
+ } else {
+ await api('/podcasts/feeds/' + r.id + '/remove/', {});
+ if (h && typeof h.podcastFeedRemoved === 'function') h.podcastFeedRemoved();
+ setTimeout(render, 1500);
+ }
+ st.open = null;
+ toast('„' + r.title + '“ abbestellt');
+ } catch (e) { toast('Das braucht eine Verbindung.'); }
+ render();
+ }
+ }
+
+ async function importPodcasts(file) {
+ const form = new FormData();
+ form.append('file', file);
+ toast('Wird übernommen …');
+ try {
+ const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form, credentials: 'same-origin'});
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || r.status);
+ toast((d.added || 0) + ' Podcasts übernommen');
+ refreshPodcasts();
+ } catch (e) {
+ toast('Die Datei ließ sich nicht übernehmen.');
+ }
+ }
+
+ // ---- events ---------------------------------------------------------------------------------
+
+ tab.addEventListener('submit', e => {
+ const form = e.target.closest('[data-form="add"]');
+ if (!form) return;
+ e.preventDefault();
+ find(form.q.value);
+ });
+ tab.addEventListener('input', e => {
+ if (!e.target.classList.contains('src-q')) return;
+ st.q = e.target.value;
+ render();
+ });
+ tab.addEventListener('change', e => {
+ const io = e.target.dataset && e.target.dataset.io;
+ if (io === 'podcasts' && e.target.files && e.target.files[0]) importPodcasts(e.target.files[0]);
+ });
+ tab.addEventListener('click', e => {
+ const t = e.target.closest('button, label');
+ if (!t) return;
+ if (t.dataset.filter) {
+ st.filter = t.dataset.filter;
+ try { localStorage.setItem('diora_sources_filter', st.filter); } catch (err) {}
+ render();
+ } else if (t.dataset.row) {
+ st.open = st.open === t.dataset.row ? null : t.dataset.row;
+ render();
+ } else if (t.dataset.act) {
+ const r = rows().filter(x => x.key === t.dataset.key)[0];
+ if (r) act(t.dataset.act, r);
+ } else if (t.dataset.sub != null) {
+ const c = st.adding && st.adding.found && st.adding.found[Number(t.dataset.sub)];
+ if (c) subscribe(c, t.dataset.kind);
+ } else if (t.dataset.io === 'feeds-opml') {
+ if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(null);
+ else go('news');
+ }
+ });
+
+ // ---- loading, showing --------------------------------------------------------------------------
+
+ async function loadFeeds() {
+ try {
+ const d = await api('/feeds/state/');
+ st.feeds = d.sources || [];
+ } catch (e) {
+ if (st.feeds === null) st.feeds = [];
+ }
+ render();
+ }
+
+ function show() {
+ // Another view may have chosen what to show (feeds.js: its own kind).
+ try {
+ const f = localStorage.getItem('diora_sources_filter');
+ if (f && FILTERS.some(x => x[0] === f)) st.filter = f;
+ } catch (e) {}
+ render();
+ loadFeeds();
+ }
+
+ const showTab = window.showTab;
+ if (typeof showTab === 'function') {
+ window.showTab = function (name) {
+ const r = showTab.apply(this, arguments);
+ if (name === 'sources') show();
+ return r;
+ };
+ }
+ if (tab.style.display !== 'none') show();
+
+ // ---- styles --------------------------------------------------------------------------------------
+
+ const style = document.createElement('style');
+ style.textContent = [
+ '#tab-sources .src { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
+ '#tab-sources .src-h1 { font-size: 18px; margin: 4px 0 12px; }',
+ '#tab-sources .src-add { display: flex; gap: 8px; }',
+ '#tab-sources .src-add-q, #tab-sources .src-q { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
+ '#tab-sources .src-q { width: 100%; margin: 8px 0 4px; }',
+ '#tab-sources .src-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; margin: 4px 6px 0 0; }',
+ '#tab-sources .src-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
+ '#tab-sources .src-danger { color: var(--accent, #e63946); }',
+ '#tab-sources .src-btn[disabled] { opacity: .5; }',
+ '#tab-sources .src-found { margin: 10px 0 4px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
+ '#tab-sources .src-cand { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
+ '#tab-sources .src-cand:last-child { border-bottom: none; }',
+ '#tab-sources .src-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 4px; }',
+ '#tab-sources .src-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
+ '#tab-sources .src-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
+ '#tab-sources .src-count { opacity: .7; font-size: 12px; }',
+ '#tab-sources .src-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
+ '#tab-sources .src-main { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
+ '#tab-sources .src-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
+ '#tab-sources .src-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
+ '#tab-sources .src-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); }',
+ '#tab-sources .src-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
+ '#tab-sources .src-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
+ '#tab-sources .src-meta, #tab-sources .src-note { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }',
+ '#tab-sources .src-url { white-space: nowrap; margin-bottom: 4px; }',
+ '#tab-sources .src-warn { color: var(--accent, #e63946); font-size: 12px; }',
+ '#tab-sources .src-kind { display: inline-block; padding: 0 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); color: var(--fg, #eee); font-size: 11px; }',
+ '#tab-sources .src-kind-audiobook, #tab-sources .src-kind-youtube { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
+ '#tab-sources .src-actions { padding: 0 0 12px 50px; }',
+ '#tab-sources .src-io { margin-top: 22px; }',
+ '#tab-sources .src-io summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
+ '#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
+ '#src-toast.src-show { opacity: 1; }',
+ 'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }',
+ ].join('\n');
+ document.head.appendChild(style);
+})();
diff --git a/static/js/sw.js b/static/js/sw.js
index 98d1f66..3b2f9bc 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v116';
+const CACHE = 'diora-v117';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
@@ -42,6 +42,7 @@ const SHELL = [
'/static/js/feeds.js',
'/static/js/audiobooks.js',
'/static/js/nav.js',
+ '/static/js/sources.js',
'/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css',
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
index 0dda5ff..c082c04 100644
--- a/static/podcasts/native-podcast.js
+++ b/static/podcasts/native-podcast.js
@@ -1,4 +1,4 @@
-// Copied from diora-android 26f5019 2026-10-16: web-native/static/native-podcast.js
+// Copied from diora-android 50c6f9c 2026-10-16: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@@ -105,6 +105,9 @@
return Math.max(n, total - Math.max(0, sent - n));
}
const feeds = () => state.feeds || [];
+ // "Abos & Quellen" (diora-web docs/sources.md, sources.js): where subscriptions are managed, if the page has it.
+ const hasSources = () => !!window.DioraNav && DioraNav.sections().some(s => s.id === 'sources');
+ const toSources = () => DioraNav.go('sources');
// Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here.
const isBook = f => !!f && f.kind === 'audiobook';
const podcastFeeds = () => feeds().filter(f => !isBook(f));
@@ -494,7 +497,8 @@
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
- ]),
+ hasSources() && {icon: '⚙', label: 'Abos & Quellen', hint: 'alle Abos an einem Ort: Podcasts, YouTube, Nachrichten, Blogs', action: toSources},
+ ].filter(Boolean)),
{icon: '▤', label: 'Hörstatistik', action: openStats},
]);
}
@@ -1805,7 +1809,10 @@
}
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
- listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)]));
+ listTools([
+ hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
+ button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
+ ].filter(Boolean)));
root.append(head);
const list = el('div', 'nui-pod-list');
for (const f of items) list.append(feedRow(f));
@@ -1813,6 +1820,22 @@
if (books) root.append(booksHint());
}
+ // For Abos & Quellen (sources.js): a podcast's settings sheet, its episode list.
+ window.__dioraPodcastFeedSettings = function (feedId) {
+ const f = feeds().find(x => x.id === Number(feedId));
+ if (!f) return false;
+ feedSettingsSheet(f);
+ return true;
+ };
+ window.__dioraPodcastOpenFeed = function (feedId) {
+ const f = feeds().find(x => x.id === Number(feedId));
+ if (!f) return false;
+ if (window.DioraNav) DioraNav.go('podcasts');
+ else if (typeof showTab === 'function') showTab('podcasts');
+ openFeed(f);
+ return true;
+ };
+
async function openFeed(f) {
feedView = {feed: f, episodes: null};
selected = null;
@@ -2542,6 +2565,12 @@
search(input.value.trim());
});
root.append(form);
+ if (hasSources()) {
+ const more = el('button', 'nui-pod-hint nui-pod-link', 'Alles abonnieren und verwalten – auch YouTube, Nachrichten, Blogs: Abos & Quellen ›');
+ more.type = 'button';
+ more.addEventListener('click', toSources);
+ root.append(more);
+ }
const s = searchResults;
if (!s) {
empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' +
diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css
index dede26a..52389e4 100644
--- a/static/podcasts/podcast-ui.css
+++ b/static/podcasts/podcast-ui.css
@@ -1,4 +1,4 @@
-/* Copied from diora-android 26f5019 2026-10-16: the podcast parts of web-native/static/native.css
+/* Copied from diora-android 50c6f9c 2026-10-16: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@@ -196,6 +196,8 @@ html.nui-one-player #nui-mini { display: none !important; }
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
/* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */
.nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; }
+/* A hint that leads somewhere (Abos & Quellen). */
+button.nui-pod-link { display: block; width: 100%; border: none; background: none; text-align: left; cursor: pointer; padding: 4px 2px 12px; }
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; }
.nui-pod-list { display: flex; flex-direction: column; }
diff --git a/templates/radio/player.html b/templates/radio/player.html
index dcfef80..afcba7e 100644
--- a/templates/radio/player.html
+++ b/templates/radio/player.html
@@ -265,6 +265,8 @@
{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %}
{% if user.is_authenticated %}{% endif %}
+{% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %}
+{% if user.is_authenticated %}{% endif %}
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
@@ -387,4 +389,5 @@
+
{% endblock %}