From 20ff373fca9c820dca34829a546572c01bc18548 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 24 Oct 2026 09:47:22 -0500 Subject: [PATCH] Refresh podcast details from feed --- database_functions/migration_definitions.py | 31 ++ .../services/pinepods/pinepods_service.dart | 26 + mobile/lib/ui/pinepods/podcast_details.dart | 149 +++-- rust-api/openapi.json | 59 ++ rust-api/src/database.rs | 45 +- rust-api/src/handlers/podcasts.rs | 49 ++ rust-api/src/handlers/refresh.rs | 85 ++- rust-api/src/lib.rs | 1 + rust-api/src/services/feed_metadata.rs | 508 ++++++++++++++++++ rust-api/src/services/mod.rs | 1 + rust-api/tests/integration/main.rs | 1 + rust-api/tests/integration/podcast_details.rs | 166 ++++++ web/src/pages/episode_layout.rs | 97 +++- web/src/requests/pod_req.rs | 39 ++ web/src/translations/ar.json | 5 + web/src/translations/be.json | 5 + web/src/translations/bg.json | 5 + web/src/translations/bn.json | 5 + web/src/translations/ca.json | 5 + web/src/translations/cs.json | 5 + web/src/translations/da.json | 5 + web/src/translations/de.json | 5 + web/src/translations/en.json | 5 + web/src/translations/es.json | 5 + web/src/translations/et.json | 5 + web/src/translations/eu.json | 5 + web/src/translations/fa.json | 5 + web/src/translations/fi.json | 5 + web/src/translations/fr.json | 5 + web/src/translations/gu.json | 5 + web/src/translations/he.json | 5 + web/src/translations/hi.json | 5 + web/src/translations/hr.json | 5 + web/src/translations/hu.json | 5 + web/src/translations/it.json | 5 + web/src/translations/ja.json | 5 + web/src/translations/ko.json | 5 + web/src/translations/lt.json | 5 + web/src/translations/nb.json | 5 + web/src/translations/nl.json | 5 + web/src/translations/pl.json | 5 + web/src/translations/pt-BR.json | 5 + web/src/translations/pt.json | 5 + web/src/translations/ro.json | 5 + web/src/translations/ru.json | 5 + web/src/translations/sk.json | 5 + web/src/translations/sl.json | 5 + web/src/translations/sv.json | 5 + web/src/translations/test.json | 5 + web/src/translations/tr.json | 5 + web/src/translations/uk.json | 5 + web/src/translations/vi.json | 5 + web/src/translations/zh-Hans.json | 5 + web/src/translations/zh-Hant.json | 5 + 54 files changed, 1376 insertions(+), 81 deletions(-) create mode 100644 rust-api/src/services/feed_metadata.rs create mode 100644 rust-api/tests/integration/podcast_details.rs diff --git a/database_functions/migration_definitions.py b/database_functions/migration_definitions.py index e718c1b6d..961bbb1c1 100644 --- a/database_functions/migration_definitions.py +++ b/database_functions/migration_definitions.py @@ -650,6 +650,7 @@ def migration_005_podcast_episode_tables(conn, db_type: str): ProblemNotified BOOLEAN NOT NULL DEFAULT FALSE, NextRefreshAt TIMESTAMP DEFAULT (CURRENT_TIMESTAMP + INTERVAL '5 minutes'), RefreshIntervalMinutes INT, + EditedMetadata VARCHAR(255), FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ) """) @@ -731,6 +732,7 @@ def migration_005_podcast_episode_tables(conn, db_type: str): ProblemNotified TINYINT(1) NOT NULL DEFAULT 0, NextRefreshAt DATETIME NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL 5 MINUTE), RefreshIntervalMinutes INT, + EditedMetadata VARCHAR(255), FOREIGN KEY (UserID) REFERENCES Users(UserID) ) """) @@ -7805,6 +7807,35 @@ def latest(values): return linked +@register_migration("085", "podcast_edited_metadata", "Podcasts.EditedMetadata: details the user edited, which refreshes keep; every feed is read in full once", requires=["005", "080"]) +def migration_085_podcast_edited_metadata(conn, db_type: str) -> None: + """Refreshes now keep a podcast's name, artwork, author, description and website in step with + its feed. Podcasts.EditedMetadata lists (comma-separated, by lowercase column name) the ones + the user edited by hand, which refreshes leave alone. + + The stored validators and body hashes are cleared so the next refresh reads every feed in full + once, bringing podcasts whose feed changed before this release up to date too. + """ + cursor = conn.cursor() + pg = db_type == "postgresql" + podcasts = '"Podcasts"' if pg else "Podcasts" + feeds = '"Feeds"' if pg else "Feeds" + try: + if not _column_exists(cursor, db_type, "Podcasts", "EditedMetadata"): + cursor.execute(f"ALTER TABLE {podcasts} ADD COLUMN EditedMetadata VARCHAR(255)") + not_youtube = "COALESCE(IsYouTubeChannel, FALSE) = FALSE" + for table in (podcasts, feeds): + cursor.execute( + f"UPDATE {table} SET FeedETag = NULL, FeedLastModified = NULL, FeedContentHash = NULL WHERE {not_youtube}" + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cursor.close() + + @register_migration("118", "gpodder_actions_follow_merged_episodes", "Episode actions point at the shared episode their merged copy became; the copies are deleted", requires=["082", "111"]) def migration_118_gpodder_actions_follow_merged_episodes(conn, db_type: str) -> None: """GpodderSyncEpisodeActions.EpisodeID of actions on an episode copy merged by 082 now points diff --git a/mobile/lib/services/pinepods/pinepods_service.dart b/mobile/lib/services/pinepods/pinepods_service.dart index b0ca09c88..284f77337 100644 --- a/mobile/lib/services/pinepods/pinepods_service.dart +++ b/mobile/lib/services/pinepods/pinepods_service.dart @@ -564,6 +564,32 @@ class PinepodsService { } } + // Read a podcast's feed again now and take its current details (name, + // artwork, author, description, website). [resetEdits] also drops the + // user's own edits to them. Throws with the server's message on failure. + Future refreshPodcastDetails(int podcastId, {bool resetEdits = false}) async { + if (_server == null || _apiKey == null) { + throw Exception('Not authenticated'); + } + + final url = Uri.parse('$_server/api/data/refresh_podcast_details'); + // Reading a slow feed can take longer than an ordinary request. + final response = await _client + .post( + url, + headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, + body: jsonEncode({'podcast_id': podcastId, 'reset_edits': resetEdits}), + ) + .timeout(const Duration(seconds: 90)); + if (response.statusCode != 200) { + String message = 'HTTP ${response.statusCode}'; + try { + message = (jsonDecode(response.body) as Map)['message'] as String? ?? message; + } catch (_) {} + throw Exception(message); + } + } + // Get a podcast's favorite status. Future getPodcastFavoriteStatus(int podcastId, int userId) async { if (_server == null || _apiKey == null) { diff --git a/mobile/lib/ui/pinepods/podcast_details.dart b/mobile/lib/ui/pinepods/podcast_details.dart index 80519db44..041a8188d 100644 --- a/mobile/lib/ui/pinepods/podcast_details.dart +++ b/mobile/lib/ui/pinepods/podcast_details.dart @@ -68,6 +68,9 @@ class PinepodsPodcastDetails extends StatefulWidget { class _PinepodsPodcastDetailsState extends State { final PinepodsService _pinepodsService = PinepodsService(); + // The podcast as shown; replaced when its details are read from the feed again. + late UnifiedPinepodsPodcast _podcast = widget.podcast; + bool _isRefreshingDetails = false; bool _isLoading = false; bool _isFollowing = false; bool _isFollowButtonLoading = false; @@ -134,7 +137,7 @@ class _PinepodsPodcastDetailsState extends State { Future _loadSortPreference() async { final prefs = await SharedPreferences.getInstance(); // Use podcast URL as key for per-podcast sort preference - final key = 'episode_sort_${widget.podcast.url.hashCode}'; + final key = 'episode_sort_${_podcast.url.hashCode}'; final savedSort = prefs.getString(key); if (savedSort != null && mounted) { setState(() { @@ -164,7 +167,7 @@ class _PinepodsPodcastDetailsState extends State { } Future _loadAutoDownloadPreference() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -175,7 +178,7 @@ class _PinepodsPodcastDetailsState extends State { } Future _toggleAutoDownload() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; final prefs = await SharedPreferences.getInstance(); final newValue = !_isAutoDownloadEnabled; @@ -200,7 +203,7 @@ class _PinepodsPodcastDetailsState extends State { } Future _loadAutoPlayNextPreference() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; try { @@ -220,8 +223,54 @@ class _PinepodsPodcastDetailsState extends State { } } + // Read the feed again so a rebrand (new name, artwork, hosts) shows now. + Future _refreshDetails() async { + final podcastId = _podcast.id; + if (podcastId <= 0) return; + final settings = Provider.of(context, listen: false).currentSettings; + final userId = settings.pinepodsUserId; + if (settings.pinepodsServer == null || settings.pinepodsApiKey == null || userId == null) return; + setState(() => _isRefreshingDetails = true); + final messenger = ScaffoldMessenger.of(context); + try { + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + await _pinepodsService.refreshPodcastDetails(podcastId); + final d = await _pinepodsService.getPodcastDetailsById(podcastId, userId); + if (!mounted) return; + if (d != null) { + final p = _podcast; + setState(() { + _podcast = UnifiedPinepodsPodcast( + id: p.id, + indexId: p.indexId, + title: d['podcastname'] as String? ?? p.title, + url: p.url, + originalUrl: p.originalUrl, + link: d['websiteurl'] as String? ?? p.link, + description: d['description'] as String? ?? p.description, + author: d['author'] as String? ?? p.author, + ownerName: p.ownerName, + image: d['artworkurl'] as String? ?? p.image, + artwork: d['artworkurl'] as String? ?? p.artwork, + lastUpdateTime: p.lastUpdateTime, + categories: p.categories, + explicit: p.explicit, + episodeCount: d['episodecount'] as int? ?? p.episodeCount, + ); + }); + _resolveArtColor(); + } + await _loadPodcastFeed(); + messenger.showSnackBar(const SnackBar(content: Text('Podcast details refreshed from the feed'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text("Couldn't refresh from the feed: $e"))); + } finally { + if (mounted) setState(() => _isRefreshingDetails = false); + } + } + Future _toggleAutoPlayNext() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; final newValue = !_isAutoPlayNextEnabled; @@ -265,7 +314,7 @@ class _PinepodsPodcastDetailsState extends State { // Load AI availability + the three per-podcast AI toggles (#726/#790). Future _loadAiPreferences() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; try { final settings = @@ -308,7 +357,7 @@ class _PinepodsPodcastDetailsState extends State { // Persist one AI toggle; returns whether it succeeded. Updates widget state // so the bottom sheet and header reflect the new value after it closes. Future _setAiToggle(String which, bool value) async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return false; try { final settings = @@ -425,7 +474,7 @@ class _PinepodsPodcastDetailsState extends State { /// The server's download settings for this podcast: auto-download to the /// server, delete when finished, and delete after a number of days. Future _showServerDownloadsSheet() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; final settings = Provider.of(context, listen: false).currentSettings; final userId = settings.pinepodsUserId; @@ -560,7 +609,7 @@ class _PinepodsPodcastDetailsState extends State { /// This device's download settings for this podcast: auto-download new /// episodes, delete when finished, and delete after a number of days. Future _showDeviceDownloadsSheet() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; final settings = Provider.of(context, listen: false).currentSettings; @@ -666,7 +715,7 @@ class _PinepodsPodcastDetailsState extends State { Future _saveSortPreference() async { final prefs = await SharedPreferences.getInstance(); - final key = 'episode_sort_${widget.podcast.url.hashCode}'; + final key = 'episode_sort_${_podcast.url.hashCode}'; String value; switch (_sortDirection) { case EpisodeSortDirection.newestFirst: @@ -783,8 +832,8 @@ class _PinepodsPodcastDetailsState extends State { try { // If we have a valid podcast ID (> 0), assume it's followed since we got it from episode metadata - if (widget.podcast.id > 0 && widget.isFollowing) { - print('Using podcast ID ${widget.podcast.id} - assuming followed'); + if (_podcast.id > 0 && widget.isFollowing) { + print('Using podcast ID ${_podcast.id} - assuming followed'); setState(() { _isFollowing = true; }); @@ -792,10 +841,10 @@ class _PinepodsPodcastDetailsState extends State { return; } - print('Checking follow status for: ${widget.podcast.title}'); + print('Checking follow status for: ${_podcast.title}'); final isFollowing = await _pinepodsService.checkPodcastExists( - widget.podcast.title, - widget.podcast.url, + _podcast.title, + _podcast.url, userId, ); @@ -813,7 +862,7 @@ class _PinepodsPodcastDetailsState extends State { } Future _checkFavoriteStatus() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0) return; final settingsBloc = Provider.of(context, listen: false); @@ -842,7 +891,7 @@ class _PinepodsPodcastDetailsState extends State { } Future _toggleFavorite() async { - final podcastId = widget.podcast.id; + final podcastId = _podcast.id; if (podcastId <= 0 || _isFavoriteLoading) return; final settingsBloc = Provider.of(context, listen: false); @@ -889,11 +938,11 @@ class _PinepodsPodcastDetailsState extends State { // Convert Episode objects to PinepodsEpisode objects PinepodsEpisode _convertEpisodeToPinepodsEpisode(Episode episode) { return PinepodsEpisode( - podcastName: episode.podcast ?? widget.podcast.title, + podcastName: episode.podcast ?? _podcast.title, episodeTitle: episode.title ?? '', episodePubDate: episode.publicationDate?.toIso8601String() ?? '', episodeDescription: episode.description ?? '', - episodeArtwork: episode.imageUrl ?? widget.podcast.artwork, + episodeArtwork: episode.imageUrl ?? _podcast.artwork, episodeUrl: episode.contentUrl ?? '', episodeDuration: episode.duration, listenDuration: 0, // RSS episodes don't have listen duration @@ -921,20 +970,20 @@ class _PinepodsPodcastDetailsState extends State { if (_isFollowing && userId != null) { try { - print('Loading episodes for followed podcast: ${widget.podcast.title}'); + print('Loading episodes for followed podcast: ${_podcast.title}'); int? podcastId; // If we already have a podcast ID (from episode metadata), use it directly - if (widget.podcast.id > 0) { - podcastId = widget.podcast.id; + if (_podcast.id > 0) { + podcastId = _podcast.id; print('Using existing podcast ID: $podcastId'); } else { // Get the actual podcast ID using the dedicated endpoint podcastId = await _pinepodsService.getPodcastId( userId, - widget.podcast.url, - widget.podcast.title, + _podcast.url, + _podcast.title, ); print('Got podcast ID from lookup: $podcastId'); } @@ -949,14 +998,14 @@ class _PinepodsPodcastDetailsState extends State { // If server has no episodes, this podcast may need episode sync if (episodes.isEmpty) { print('Server has no episodes for subscribed podcast. This should not happen.'); - print('Podcast ID: $podcastId, Title: ${widget.podcast.title}'); + print('Podcast ID: $podcastId, Title: ${_podcast.title}'); // For subscribed podcasts, we should NOT fall back to RSS // The server should have episodes. This indicates a server-side sync issue. // Fall back to RSS ONLY as emergency backup, but episodes won't be clickable try { final podcastService = Provider.of(context, listen: false); - final rssPodcast = Podcast.fromUrl(url: widget.podcast.url); + final rssPodcast = Podcast.fromUrl(url: _podcast.url); final loadedPodcast = await podcastService.loadPodcast(podcast: rssPodcast); @@ -979,11 +1028,11 @@ class _PinepodsPodcastDetailsState extends State { } } else { try { - print('Loading episodes from RSS feed for non-followed podcast: ${widget.podcast.url}'); + print('Loading episodes from RSS feed for non-followed podcast: ${_podcast.url}'); // Use the existing podcast service to parse RSS feed final podcastService = Provider.of(context, listen: false); - final rssePodcast = Podcast.fromUrl(url: widget.podcast.url); + final rssePodcast = Podcast.fromUrl(url: _podcast.url); final loadedPodcast = await podcastService.loadPodcast(podcast: rssePodcast); @@ -1076,7 +1125,7 @@ class _PinepodsPodcastDetailsState extends State { builder: (context) => AlertDialog( title: const Text('Unfollow Podcast'), content: Text( - 'Are you sure you want to unfollow "${widget.podcast.title}"?\n\nThis will remove the podcast and all episode history from your library.', + 'Are you sure you want to unfollow "${_podcast.title}"?\n\nThis will remove the podcast and all episode history from your library.', ), actions: [ TextButton( @@ -1112,8 +1161,8 @@ class _PinepodsPodcastDetailsState extends State { if (oldFollowingState) { print('PinePods: Attempting to remove podcast'); success = await _pinepodsService.removePodcast( - widget.podcast.title, - widget.podcast.url, + _podcast.title, + _podcast.url, userId, ); print('PinePods: Remove podcast result: $success'); @@ -1128,7 +1177,7 @@ class _PinepodsPodcastDetailsState extends State { } } else { print('PinePods: Attempting to add podcast'); - success = await _pinepodsService.addPodcast(widget.podcast, userId); + success = await _pinepodsService.addPodcast(_podcast, userId); print('PinePods: Add podcast result: $success'); if (success) { setState(() { @@ -1575,7 +1624,7 @@ class _PinepodsPodcastDetailsState extends State { final settings = Provider.of(context, listen: false).currentSettings; if (!settings.artworkAccentColors) return; final brightness = ThemeData.estimateBrightnessForColor(Theme.of(context).scaffoldBackgroundColor); - final url = widget.podcast.artwork; + final url = _podcast.artwork; _artColor = ArtworkColors.cached(url, null, brightness); if (_artColor != null) return; ArtworkColors.resolve(url, null, brightness).then((color) { @@ -1641,7 +1690,7 @@ class _PinepodsPodcastDetailsState extends State { duration: const Duration(milliseconds: 180), child: child, ), - child: Text(widget.podcast.title, maxLines: 1, overflow: TextOverflow.ellipsis), + child: Text(_podcast.title, maxLines: 1, overflow: TextOverflow.ellipsis), ), ), SliverToBoxAdapter(child: _buildHeader(theme, accent, headerTint, bg)), @@ -1658,10 +1707,10 @@ class _PinepodsPodcastDetailsState extends State { Widget _buildHeader(ThemeData theme, Color accent, Color headerTint, Color bg) { final muted = theme.hintColor; - final count = _episodes.isNotEmpty ? _episodes.length : widget.podcast.episodeCount; + final count = _episodes.isNotEmpty ? _episodes.length : _podcast.episodeCount; final meta = [ if (count > 0) '$count episode${count == 1 ? '' : 's'}', - if (widget.podcast.explicit) 'Explicit', + if (_podcast.explicit) 'Explicit', ].join(' · '); return DecoratedBox( @@ -1688,7 +1737,7 @@ class _PinepodsPodcastDetailsState extends State { ], ), child: LazyNetworkImage( - imageUrl: widget.podcast.artwork, + imageUrl: _podcast.artwork, width: 116, height: 116, borderRadius: BorderRadius.circular(14), @@ -1700,15 +1749,15 @@ class _PinepodsPodcastDetailsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.podcast.title, + _podcast.title, maxLines: 3, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold, height: 1.2), ), - if (widget.podcast.author.isNotEmpty) ...[ + if (_podcast.author.isNotEmpty) ...[ const SizedBox(height: 4), Text( - widget.podcast.author, + _podcast.author, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall!.copyWith(color: accent, fontWeight: FontWeight.w600), @@ -1725,9 +1774,9 @@ class _PinepodsPodcastDetailsState extends State { ), const SizedBox(height: 16), _buildActionRow(theme, accent), - if (widget.podcast.description.isNotEmpty) ...[ + if (_podcast.description.isNotEmpty) ...[ const SizedBox(height: 14), - _ExpandableDescription(description: widget.podcast.description, accent: accent), + _ExpandableDescription(description: _podcast.description, accent: accent), ], if (_credits.people.isNotEmpty || _peoplePodcastId != null) ...[ const SizedBox(height: 16), @@ -1796,7 +1845,7 @@ class _PinepodsPodcastDetailsState extends State { onTap: _isFollowButtonLoading ? null : _toggleFollow, ), ], - if (widget.podcast.id > 0) ...[ + if (_podcast.id > 0) ...[ const SizedBox(width: 10), CircleAction( tooltip: _isFavorite ? 'Remove from favorites' : 'Add to favorites', @@ -1834,10 +1883,13 @@ class _PinepodsPodcastDetailsState extends State { case 'follow': _toggleFollow(); break; + case 'refresh_details': + _refreshDetails(); + break; } }, itemBuilder: (context) => [ - if (_isFollowing && widget.podcast.id > 0) ...[ + if (_isFollowing && _podcast.id > 0) ...[ const PopupMenuItem( value: 'device_downloads', child: ListTile( @@ -1859,6 +1911,15 @@ class _PinepodsPodcastDetailsState extends State { checked: _isAutoPlayNextEnabled, child: const Text('Auto-play next'), ), + PopupMenuItem( + value: 'refresh_details', + enabled: !_isRefreshingDetails, + child: const ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(PhosphorIconsRegular.arrowsClockwise), + title: Text('Refresh from feed'), + ), + ), if (_aiAvailable) const PopupMenuItem( value: 'ai', diff --git a/rust-api/openapi.json b/rust-api/openapi.json index 407105cf3..ba370f506 100644 --- a/rust-api/openapi.json +++ b/rust-api/openapi.json @@ -7969,6 +7969,49 @@ ] } }, + "/api/data/refresh_podcast_details": { + "post": { + "tags": [ + "podcasts" + ], + "summary": "Refresh a podcast's details from its feed", + "description": "Reads the podcast's feed in full now, even if it looks unchanged, and updates its name, artwork, author, description, website and episodes. Details the user edited are kept unless `reset_edits` is set.", + "operationId": "refresh_podcast_details", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshPodcastDetailsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePodcastInfoResponse" + } + } + } + }, + "401": { + "description": "Invalid or missing API key" + }, + "404": { + "description": "No such podcast" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/api/data/refresh_pods": { "get": { "tags": [ @@ -17173,6 +17216,22 @@ } } }, + "RefreshPodcastDetailsRequest": { + "type": "object", + "required": [ + "podcast_id" + ], + "properties": { + "podcast_id": { + "type": "integer", + "format": "int32" + }, + "reset_edits": { + "type": "boolean", + "description": "Also hand the details the user edited (name, artwork, ...) back to the feed." + } + } + }, "RemoteDeviceEntry": { "type": "object", "required": [ diff --git a/rust-api/src/database.rs b/rust-api/src/database.rs index e67216ad2..9ac0067a5 100644 --- a/rust-api/src/database.rs +++ b/rust-api/src/database.rs @@ -7257,6 +7257,13 @@ impl DatabasePool { // Parse the RSS feed, then apply via the shared dedup/insert path (GUID→URL→title dedup, // no-op UPDATE skipping, batched insert). This is the initial import of the back // catalogue, so no new-episode notifications. + // The podcast's details as the feed gives them (not as a search result described it). + let meta = crate::services::feed_metadata::parse(content); + if let Some(meta) = &meta { + self.store_feed_metadata(None, &[podcast_id], meta).await; + } + let artwork = meta.and_then(|m| m.artwork).unwrap_or_else(|| artwork_url.to_string()); + let artwork_url = artwork.as_str(); let episodes = self.parse_rss_feed(content, podcast_id, artwork_url).await?; let new = self.apply_parsed_episodes(podcast_id, &episodes, artwork_url, None, false).await?; // A feed someone else already follows has its episodes already: count them, and match @@ -8509,6 +8516,7 @@ impl DatabasePool { // Extract raw iTunes durations before feed_rs processes them let raw_durations = Self::extract_raw_itunes_durations(content); + let item_images = crate::services::feed_metadata::item_images(content); // Use a custom id_generator that returns empty for guid-less entries. feed-rs's default // synthesizes an id (link+title hash, or a RANDOM uuid when there's no link) which would @@ -8633,8 +8641,14 @@ impl DatabasePool { } } - // Debug what we're passing to duration parsing - + if let Some(image) = item_images.find( + episode.guid.as_deref(), + episode_data.get("enclosure_url").map(String::as_str), + entry.title.as_ref().map(|t| t.content.as_str()), + ) { + episode_data.insert("episode_artwork_href".to_string(), image.to_string()); + } + // Apply all the Python-style parsing logic with ALL fallbacks self.apply_python_style_parsing(&mut episode, &episode_data, artwork_url, estimate_missing_durations); @@ -8726,9 +8740,16 @@ impl DatabasePool { // Comprehensive artwork URL parsing - prioritizes episode-specific artwork fn parse_artwork_comprehensive(&self, data: &HashMap, default_artwork: &str) -> String { + // The episode's is an image by definition; CDN image URLs often have no + // extension, so it skips the URL guess the other candidates need. + if let Some(href) = data.get("episode_artwork_href").map(|h| h.trim()) { + let lower = href.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + return self.validate_and_clean_url(href); + } + } // Priority order: episode-specific artwork first, then fallbacks let artwork_candidates = [ - data.get("episode_artwork_href"), // or in episode data.get("media_image_url"), // data.get("media_thumbnail_url"), // data.get("itunes:image"), // Text content of @@ -15108,18 +15129,16 @@ impl DatabasePool { podcast_values.insert("feedurl".to_string(), feed_url.to_string()); podcast_values.insert("userid".to_string(), user_id.to_string()); - podcast_values.insert("podcastname".to_string(), feed.title.as_ref().map(|t| t.content.clone()).unwrap_or_default()); - podcast_values.insert("description".to_string(), feed.description.as_ref().map(|d| d.content.clone()).unwrap_or_default()); - podcast_values.insert("author".to_string(), feed.authors.first().and_then(|a| a.name.clone()).unwrap_or_default()); - podcast_values.insert("websiteurl".to_string(), feed.links.first().map(|l| l.href.clone()).unwrap_or_default()); + // The same reading of the feed's details as the refresh, so the first refresh doesn't + // "change" them. + let meta = crate::services::feed_metadata::parse(content).unwrap_or_default(); + podcast_values.insert("podcastname".to_string(), meta.name.unwrap_or_default()); + podcast_values.insert("description".to_string(), meta.description.unwrap_or_default()); + podcast_values.insert("author".to_string(), meta.author.unwrap_or_default()); + podcast_values.insert("websiteurl".to_string(), meta.website.unwrap_or_default()); podcast_values.insert("explicit".to_string(), "False".to_string()); // Default to False podcast_values.insert("episodecount".to_string(), feed.entries.len().to_string()); - - // Extract artwork URL - check feed image and iTunes image - let artwork_url = feed.logo.as_ref().map(|l| l.uri.clone()) - .or_else(|| feed.icon.as_ref().map(|i| i.uri.clone())) - .unwrap_or_default(); - podcast_values.insert("artworkurl".to_string(), artwork_url); + podcast_values.insert("artworkurl".to_string(), meta.artwork.unwrap_or_default()); // Extract categories - convert to dict format like Python. // Skip empty/whitespace category terms so feeds with blank category diff --git a/rust-api/src/handlers/podcasts.rs b/rust-api/src/handlers/podcasts.rs index f021f6397..e42d0c5c2 100644 --- a/rust-api/src/handlers/podcasts.rs +++ b/rust-api/src/handlers/podcasts.rs @@ -3728,6 +3728,42 @@ pub async fn fetch_podcast_feed( Ok(Json(serde_json::json!({ "episodes": episodes }))) } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RefreshPodcastDetailsRequest { + pub podcast_id: i32, + /// Also hand the details the user edited (name, artwork, ...) back to the feed. + #[serde(default)] + pub reset_edits: bool, +} + +#[utoipa::path( + post, + path = "/refresh_podcast_details", + tag = "podcasts", + summary = "Refresh a podcast's details from its feed", + description = "Reads the podcast's feed in full now, even if it looks unchanged, and updates its name, artwork, author, description, website and episodes. Details the user edited are kept unless `reset_edits` is set.", + request_body = RefreshPodcastDetailsRequest, + security(("api_key" = [])), + responses( + (status = 200, description = "Success", body = UpdatePodcastInfoResponse), + (status = 401, description = "Invalid or missing API key"), + (status = 404, description = "No such podcast"), + ), +)] +pub async fn refresh_podcast_details( + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + require_valid_api_key(&state, &api_key).await?; + let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + if !crate::handlers::refresh::refresh_podcast_details(&state, user_id, request.podcast_id, request.reset_edits).await? { + return Err(AppError::not_found("Podcast not found")); + } + Ok(Json(UpdatePodcastInfoResponse { success: true, message: "Podcast details refreshed".to_string() })) +} + // Handler for updating podcast basic info (URL, username, password) #[utoipa::path( put, @@ -3775,6 +3811,19 @@ pub async fn update_podcast_info( })); } + // Details changed by hand stay as the user set them when the feed changes. + let edits: Vec<(&str, &str)> = [ + ("podcastname", &request.podcast_name), + ("description", &request.description), + ("author", &request.author), + ("artworkurl", &request.artwork_url), + ("websiteurl", &request.website_url), + ] + .into_iter() + .filter_map(|(col, v)| v.as_deref().map(|v| (col, v))) + .collect(); + state.db_pool.mark_metadata_edited(request.podcast_id, request.user_id, &edits).await?; + // Update the podcast info let success = state.db_pool.update_podcast_info( request.podcast_id, diff --git a/rust-api/src/handlers/refresh.rs b/rust-api/src/handlers/refresh.rs index 5a8ffae0c..70ae0d105 100644 --- a/rust-api/src/handlers/refresh.rs +++ b/rust-api/src/handlers/refresh.rs @@ -350,7 +350,16 @@ async fn refresh_feed_group( mark_refreshed(state, &ids, next).await; } Ok(crate::database::FeedFetch::Fetched { body, etag, last_modified, content_hash, max_age }) => { - let artwork = rep.artwork_url.clone().unwrap_or_default(); + // The podcast's details first, so episodes without artwork of their own fall back + // to the feed's current artwork. + let meta = crate::services::feed_metadata::parse(&body); + if let Some(meta) = &meta { + state.db_pool.store_feed_metadata(rep.feed_id, &ids, meta).await; + } + let artwork = meta + .and_then(|m| m.artwork) + .or_else(|| rep.artwork_url.clone()) + .unwrap_or_default(); let parsed = match state.db_pool.parse_feed_body(&body, rep.podcast_id, &artwork).await { Ok(parsed) => parsed, Err(e) => { @@ -366,8 +375,7 @@ async fn refresh_feed_group( } else { group.iter().filter_map(|i| i.feed_cutoff).max() }; - let art = rep.artwork_url.as_deref().unwrap_or(""); - let all_applied = match state.db_pool.apply_parsed_episodes(rep.podcast_id, &parsed, art, cutoff, true).await { + let all_applied = match state.db_pool.apply_parsed_episodes(rep.podcast_id, &parsed, &artwork, cutoff, true).await { Ok(new_eps) => { if !new_eps.is_empty() { info!("Feed {}: {} new episodes", rep.feed_url, new_eps.len()); @@ -1161,7 +1169,61 @@ async fn refresh_rss_feed( // Same path as the scheduled refresh: conditional GET + body hash, the podcast's // FeedCutoffDays, auto-download/queue/transcribe, failure and Retry-After tracking. - let item = PodcastRefreshItem { + let item = rss_refresh_item(podcast, user_id); + let Some(_lock) = FeedLock::try_acquire(FeedLock::key(&item.feed_url, item.username.as_deref(), item.password.as_deref())) else { + debug!("Skipping manual refresh of podcast {}: it's being refreshed right now", podcast.id); + return Ok(Vec::new()); + }; + // The feed is refreshed for everyone subscribed to it: its schedule and validators are + // shared, so refreshing one subscription alone would leave the others behind. + let group = feed_group(state, podcast.feed_id, item).await?; + let mut new_episodes = refresh_feed_group(state, &group).await?; + new_episodes.retain(|e| e.podcastid == podcast.id); + + if !new_episodes.is_empty() { + info!("Refreshed podcast '{}' for user {}: {} new episodes", podcast.name, user_id, new_episodes.len()); + } + + Ok(new_episodes) +} + +/// Read one of the user's podcasts' feed in full now, even if it looks unchanged, so its details +/// and episodes match the feed. `reset_edits` first hands the details the user edited back to the +/// feed. `Ok(false)` when the user has no such podcast. +pub async fn refresh_podcast_details(state: &AppState, user_id: i32, podcast_id: i32, reset_edits: bool) -> AppResult { + let podcasts = state.db_pool.get_user_podcasts_for_refresh(user_id).await?; + let Some(podcast) = podcasts.iter().find(|p| p.id == podcast_id) else { return Ok(false) }; + if reset_edits { + state.db_pool.clear_metadata_edits(podcast_id, user_id).await?; + } + if podcast.is_youtube { + refresh_youtube_channel(state, podcast, user_id).await?; + return Ok(true); + } + if podcast.feed_url.starts_with("local://") { + return Ok(true); + } + if podcast.throttled { + return Err(AppError::external_error("The feed's host asked us to wait before reading it again")); + } + let item = rss_refresh_item(podcast, user_id); + let Some(_lock) = FeedLock::try_acquire(FeedLock::key(&item.feed_url, item.username.as_deref(), item.password.as_deref())) else { + // Being read right now; any reset edits come back from the feed on its next full read. + return Ok(true); + }; + let mut group = feed_group(state, podcast.feed_id, item).await?; + // No validators: fetch and apply the whole feed. + for i in group.iter_mut() { + i.etag = None; + i.last_modified = None; + i.content_hash = None; + } + refresh_feed_group(state, &group).await?; + Ok(true) +} + +fn rss_refresh_item(podcast: &PodcastForRefresh, user_id: i32) -> PodcastRefreshItem { + PodcastRefreshItem { podcast_id: podcast.id, feed_id: podcast.feed_id, feed_url: podcast.feed_url.clone(), @@ -1177,22 +1239,7 @@ async fn refresh_rss_feed( last_modified: podcast.last_modified.clone(), content_hash: podcast.content_hash.clone(), refresh_interval: podcast.refresh_interval, - }; - let Some(_lock) = FeedLock::try_acquire(FeedLock::key(&item.feed_url, item.username.as_deref(), item.password.as_deref())) else { - debug!("Skipping manual refresh of podcast {}: it's being refreshed right now", podcast.id); - return Ok(Vec::new()); - }; - // The feed is refreshed for everyone subscribed to it: its schedule and validators are - // shared, so refreshing one subscription alone would leave the others behind. - let group = feed_group(state, podcast.feed_id, item).await?; - let mut new_episodes = refresh_feed_group(state, &group).await?; - new_episodes.retain(|e| e.podcastid == podcast.id); - - if !new_episodes.is_empty() { - info!("Refreshed podcast '{}' for user {}: {} new episodes", podcast.name, user_id, new_episodes.len()); } - - Ok(new_episodes) } async fn refresh_youtube_channel( diff --git a/rust-api/src/lib.rs b/rust-api/src/lib.rs index bfb998672..f3f25699d 100644 --- a/rust-api/src/lib.rs +++ b/rust-api/src/lib.rs @@ -205,6 +205,7 @@ fn create_data_routes() -> OpenApiRouter { .routes(routes!(handlers::podcasts::get_default_volume)) .routes(routes!(handlers::podcasts::add_podcast)) .routes(routes!(handlers::podcasts::update_podcast_info)) + .routes(routes!(handlers::podcasts::refresh_podcast_details)) .routes(routes!(handlers::podcasts::merge_podcasts)) .routes(routes!(handlers::podcasts::unmerge_podcast)) .routes(routes!(handlers::podcasts::get_merged_podcasts)) diff --git a/rust-api/src/services/feed_metadata.rs b/rust-api/src/services/feed_metadata.rs new file mode 100644 index 000000000..6b311f19d --- /dev/null +++ b/rust-api/src/services/feed_metadata.rs @@ -0,0 +1,508 @@ +//! A podcast's own details (name, artwork, author, description, website) as its feed currently +//! gives them. Adding a podcast stores them, and every refresh that reads a changed feed brings +//! the stored ones up to date, so a rebranded show follows its feed. A field the user edited is +//! theirs: refreshes leave it alone until they reset it. + +use quick_xml::events::{BytesStart, Event}; +use sqlx::Row; +use tracing::{debug, warn}; + +use crate::database::DatabasePool; +use crate::error::AppResult; + +/// The fields a refresh keeps in step with the feed, by their `Podcasts` column (lowercase). +/// `Podcasts.EditedMetadata` lists the ones the user edited, comma-separated. +pub const FIELDS: [&str; 5] = ["podcastname", "description", "author", "artworkurl", "websiteurl"]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FeedMetadata { + pub name: Option, + pub description: Option, + pub author: Option, + pub artwork: Option, + pub website: Option, +} + +impl FeedMetadata { + /// `(column, value)` for each field the feed gives. + fn fields(&self) -> Vec<(&'static str, &str)> { + [ + ("podcastname", &self.name), + ("description", &self.description), + ("author", &self.author), + ("artworkurl", &self.artwork), + ("websiteurl", &self.website), + ] + .into_iter() + .filter_map(|(col, v)| v.as_deref().map(|v| (col, v))) + .collect() + } +} + +fn non_empty(s: Option) -> Option { + s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// Read a feed's details. None when it isn't a feed; a field the feed leaves out or empty is None. +pub fn parse(body: &str) -> Option { + let feed = feed_rs::parser::parse(body.as_bytes()).ok()?; + // The to the show's site, not the feed's own atom:link rel="self". + let website = feed + .links + .iter() + .find(|l| matches!(l.rel.as_deref(), None | Some("alternate"))) + .map(|l| l.href.clone()); + // is what podcast apps show and what shows update when they rebrand; feed-rs + // prefers the RSS , which is often older or smaller. + let artwork = channel_itunes_image(body) + .or_else(|| feed.logo.as_ref().map(|l| l.uri.clone())) + .or_else(|| feed.icon.as_ref().map(|i| i.uri.clone())); + Some(FeedMetadata { + name: non_empty(feed.title.map(|t| t.content)), + description: non_empty(feed.description.map(|d| d.content)), + author: non_empty(feed.authors.first().and_then(|a| a.name.clone())), + artwork: non_empty(artwork), + website: non_empty(website), + }) +} + +/// The channel's ``, ignoring the ones on episodes. +fn channel_itunes_image(body: &str) -> Option { + let mut reader = quick_xml::Reader::from_str(body); + loop { + match reader.read_event() { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let name = e.name(); + if matches!(name.as_ref(), "item" | "entry") { + return None; + } + if name.prefix().is_some_and(|p| p.as_ref() == "itunes") && name.local_name().as_ref() == "image" { + if let Some(href) = attr(&e, "href") { + return Some(href); + } + } + } + Ok(Event::Eof) | Err(_) => return None, + _ => {} + } + } +} + +/// Each episode's own ``. feed-rs drops an item's iTunes tags unless it also has an +/// iTunes title or summary, so these are read from the XML directly. +#[derive(Debug, Default)] +pub struct ItemImages { + by_guid: std::collections::HashMap, + by_enclosure: std::collections::HashMap, + by_title: std::collections::HashMap, +} + +impl ItemImages { + /// The image of the episode with this GUID, else this enclosure URL, else this title. + pub fn find(&self, guid: Option<&str>, enclosure: Option<&str>, title: Option<&str>) -> Option<&str> { + guid.and_then(|g| self.by_guid.get(g.trim())) + .or_else(|| enclosure.and_then(|e| self.by_enclosure.get(e.trim()))) + .or_else(|| title.and_then(|t| self.by_title.get(t.trim()))) + .map(String::as_str) + } +} + +#[derive(Default)] +struct Item { + guid: Option, + enclosure: Option, + title: Option, + image: Option, +} + +fn attr(e: &BytesStart, key: &str) -> Option { + e.attributes() + .flatten() + .find(|a| a.key.as_ref() == key) + .and_then(|a| a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok().map(|v| v.trim().to_string())) + .filter(|v| !v.is_empty()) +} + +/// An item's direct child carrying an attribute we keep. +fn read_item_tag(e: &BytesStart, depth: usize, item: &mut Item) { + if depth != 1 { + return; + } + let name = e.name(); + if name.prefix().is_none() && name.as_ref() == "enclosure" { + item.enclosure = item.enclosure.take().or_else(|| attr(e, "url")); + } else if name.prefix().is_some_and(|p| p.as_ref() == "itunes") && name.local_name().as_ref() == "image" { + item.image = item.image.take().or_else(|| attr(e, "href")); + } +} + +pub fn item_images(body: &str) -> ItemImages { + let mut images = ItemImages::default(); + let mut reader = quick_xml::Reader::from_str(body); + let mut item: Option = None; + // Depth inside the current : 1 for its direct children. + let mut depth = 0usize; + // The direct child whose text is being read (title or guid). + let mut capture: Option<&'static str> = None; + let mut text = String::new(); + loop { + let event = match reader.read_event() { + Ok(Event::Eof) | Err(_) => break, + Ok(ev) => ev, + }; + match event { + Event::Start(e) => { + let name = e.name(); + if item.is_none() { + if name.as_ref() == "item" { + item = Some(Item::default()); + depth = 0; + } + continue; + } + depth += 1; + if depth == 1 && name.prefix().is_none() { + capture = match name.as_ref() { + "title" => Some("title"), + "guid" => Some("guid"), + _ => None, + }; + text.clear(); + } + read_item_tag(&e, depth, item.as_mut().unwrap()); + } + Event::Empty(e) => { + if let Some(it) = item.as_mut() { + read_item_tag(&e, depth + 1, it); + } + } + Event::Text(t) if capture.is_some() => text.push_str(&t.xml10_content()), + Event::GeneralRef(r) if capture.is_some() => { + text.push('&'); + text.push_str(&r.xml10_content()); + text.push(';'); + } + Event::CData(c) if capture.is_some() => text.push_str(&quick_xml::escape::escape(c.into_inner().into_owned())), + Event::End(e) => { + let Some(it) = item.as_mut() else { continue }; + if depth == 0 { + if e.name().as_ref() == "item" { + let it = item.take().unwrap(); + if let Some(image) = it.image { + if let Some(g) = it.guid { + images.by_guid.entry(g).or_insert_with(|| image.clone()); + } + if let Some(u) = it.enclosure { + images.by_enclosure.entry(u).or_insert_with(|| image.clone()); + } + if let Some(t) = it.title { + images.by_title.entry(t).or_insert(image); + } + } + } + continue; + } + if depth == 1 { + if let Some(field) = capture.take() { + let raw = std::mem::take(&mut text); + let v = quick_xml::escape::unescape(&raw).map(|c| c.trim().to_string()).unwrap_or_else(|_| raw.trim().to_string()); + if !v.is_empty() { + match field { + "title" => it.title = Some(v), + _ => it.guid = Some(v), + } + } + } + } + depth -= 1; + } + _ => {} + } + } + images +} + +/// Add `fields` to an `EditedMetadata` list, keeping known fields only. +pub fn merge_edited(current: Option<&str>, fields: &[&str]) -> Option { + let mut all: Vec<&str> = current.unwrap_or("").split(',').map(str::trim).filter(|f| FIELDS.contains(f)).collect(); + for f in fields { + if FIELDS.contains(f) && !all.contains(f) { + all.push(f); + } + } + (!all.is_empty()).then(|| all.join(",")) +} + +impl DatabasePool { + /// Bring these podcast rows' details up to the feed's, skipping fields their owner edited. + /// Episodes of the feed still showing a replaced podcast artwork (they had none of their own) + /// get the new one. Failures are logged and never fail the refresh. + pub async fn store_feed_metadata(&self, feed_id: Option, podcast_ids: &[i32], meta: &FeedMetadata) { + if podcast_ids.is_empty() { + return; + } + if let Err(e) = self.apply_feed_metadata(feed_id, podcast_ids, meta).await { + warn!("Updating podcast details from the feed failed for podcasts {:?}: {}", podcast_ids, e); + } + } + + async fn apply_feed_metadata(&self, feed_id: Option, podcast_ids: &[i32], meta: &FeedMetadata) -> AppResult<()> { + let feed_id = match feed_id { + Some(id) => Some(id), + None => self.feed_of_podcast(podcast_ids[0]).await.ok(), + }; + // Every podcast artwork this feed's rows hold, so episodes that took one as their + // fallback follow the new artwork too. + let old_artwork: Vec = match &meta.artwork { + Some(new) => self + .podcast_artworks(podcast_ids) + .await? + .into_iter() + .filter(|a| a != new && !a.is_empty()) + .collect(), + None => Vec::new(), + }; + + let mut changed = 0u64; + for (column, value) in meta.fields() { + changed += match self { + DatabasePool::Postgres(pool) => { + // Column names come from FIELDS, never from input. + sqlx::query(sqlx::AssertSqlSafe(format!( + r#"UPDATE "Podcasts" SET {column} = $1 + WHERE podcastid = ANY($2) + AND {column} IS DISTINCT FROM $1 + AND NOT ($3 = ANY(string_to_array(COALESCE(editedmetadata, ''), ',')))"# + ))) + .bind(value) + .bind(podcast_ids) + .bind(column) + .execute(pool) + .await? + .rows_affected() + } + DatabasePool::MySQL(pool) => { + let marks = vec!["?"; podcast_ids.len()].join(", "); + let sql = format!( + "UPDATE Podcasts SET {column} = ? \ + WHERE PodcastID IN ({marks}) \ + AND NOT ({column} <=> ?) \ + AND FIND_IN_SET(?, COALESCE(EditedMetadata, '')) = 0" + ); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())).bind(value); + for id in podcast_ids { + q = q.bind(id); + } + q.bind(value).bind(column).execute(pool).await?.rows_affected() + } + }; + } + + if let (Some(feed_id), Some(new), false) = (feed_id, &meta.artwork, old_artwork.is_empty()) { + let n = match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Episodes" SET episodeartwork = $1 WHERE feedid = $2 AND episodeartwork = ANY($3)"#) + .bind(new) + .bind(feed_id) + .bind(&old_artwork) + .execute(pool) + .await? + .rows_affected() + } + DatabasePool::MySQL(pool) => { + let marks = vec!["?"; old_artwork.len()].join(", "); + let sql = format!("UPDATE Episodes SET EpisodeArtwork = ? WHERE FeedID = ? AND EpisodeArtwork IN ({marks})"); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())).bind(new).bind(feed_id); + for a in &old_artwork { + q = q.bind(a); + } + q.execute(pool).await?.rows_affected() + } + }; + debug!("Feed {}: {} episodes moved to the new podcast artwork", feed_id, n); + } + if changed > 0 { + debug!("Updated {} podcast details from the feed for podcasts {:?}", changed, podcast_ids); + } + Ok(()) + } + + async fn podcast_artworks(&self, podcast_ids: &[i32]) -> AppResult> { + Ok(match self { + DatabasePool::Postgres(pool) => { + sqlx::query_scalar(r#"SELECT DISTINCT artworkurl FROM "Podcasts" WHERE podcastid = ANY($1) AND artworkurl IS NOT NULL"#) + .bind(podcast_ids) + .fetch_all(pool) + .await? + } + DatabasePool::MySQL(pool) => { + let marks = vec!["?"; podcast_ids.len()].join(", "); + let sql = format!("SELECT DISTINCT ArtworkURL FROM Podcasts WHERE PodcastID IN ({marks}) AND ArtworkURL IS NOT NULL"); + let mut q = sqlx::query_scalar(sqlx::AssertSqlSafe(sql.as_str())); + for id in podcast_ids { + q = q.bind(id); + } + q.fetch_all(pool).await? + } + }) + } + + /// Mark the details a user is about to change by hand as theirs, so refreshes keep them. + /// `edits` are `(column, new value)`; only values that differ from the stored ones count. + pub async fn mark_metadata_edited(&self, podcast_id: i32, user_id: i32, edits: &[(&str, &str)]) -> AppResult<()> { + if edits.is_empty() { + return Ok(()); + } + let current = match self { + DatabasePool::Postgres(pool) => { + sqlx::query( + r#"SELECT podcastname, description, author, artworkurl, websiteurl, editedmetadata + FROM "Podcasts" WHERE podcastid = $1 AND userid = $2"#, + ) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await? + .map(|r| { + let cols: Vec> = (0..6).map(|i| r.try_get::, _>(i).ok().flatten()).collect(); + cols + }) + } + DatabasePool::MySQL(pool) => { + sqlx::query( + "SELECT PodcastName, Description, Author, ArtworkURL, WebsiteURL, EditedMetadata + FROM Podcasts WHERE PodcastID = ? AND UserID = ?", + ) + .bind(podcast_id) + .bind(user_id) + .fetch_optional(pool) + .await? + .map(|r| (0..6).map(|i| r.try_get::, _>(i).ok().flatten()).collect()) + } + }; + let Some(current) = current else { return Ok(()) }; + let edited: Vec<&str> = edits + .iter() + .filter(|(col, value)| { + FIELDS + .iter() + .position(|f| f == col) + .is_some_and(|i| current[i].as_deref() != Some(*value)) + }) + .map(|(col, _)| *col) + .collect(); + if edited.is_empty() { + return Ok(()); + } + let merged = merge_edited(current[5].as_deref(), &edited); + self.set_edited_metadata(podcast_id, user_id, merged.as_deref()).await + } + + /// Hand every detail of a podcast back to its feed (the next feed read restores them). + pub async fn clear_metadata_edits(&self, podcast_id: i32, user_id: i32) -> AppResult<()> { + self.set_edited_metadata(podcast_id, user_id, None).await + } + + async fn set_edited_metadata(&self, podcast_id: i32, user_id: i32, value: Option<&str>) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET editedmetadata = $1 WHERE podcastid = $2 AND userid = $3"#) + .bind(value) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET EditedMetadata = ? WHERE PodcastID = ? AND UserID = ?") + .bind(value) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FEED: &str = r#" + + + + New Name + https://show.example + About the show

]]>
+ The Hosts + https://cdn.example/old-small.jpgNew Namehttps://show.example + + + E1 + + + +
+
"#; + + #[test] + fn reads_channel_details() { + let m = parse(FEED).unwrap(); + assert_eq!(m.name.as_deref(), Some("New Name")); + assert_eq!(m.author.as_deref(), Some("The Hosts")); + assert_eq!(m.website.as_deref(), Some("https://show.example/")); + assert_eq!(m.artwork.as_deref(), Some("https://cdn.example/new.jpg")); + assert_eq!(m.description.as_deref(), Some("

About the show

")); + } + + #[test] + fn episode_image_is_not_the_podcast_artwork() { + let feed = FEED.replace(r#""#, ""); + assert_eq!(parse(&feed).unwrap().artwork.as_deref(), Some("https://cdn.example/old-small.jpg")); + } + + #[test] + fn empty_fields_are_left_out() { + let feed = FEED.replace("New Name", " ").replace("The Hosts", ""); + let m = parse(&feed).unwrap(); + assert_eq!(m.name, None); + assert_eq!(m.author, None); + assert!(m.fields().iter().all(|(col, _)| *col != "podcastname" && *col != "author")); + } + + #[test] + fn not_a_feed() { + assert_eq!(parse("nope"), None); + } + + #[test] + fn reads_each_episodes_own_image() { + let feed = r#" +Show +<![CDATA[Tom & Jerry]]> g-1 + + +Bits & Bobs + + +No Imageg-3 +"#; + let images = item_images(feed); + assert_eq!(images.find(Some("g-1"), None, None), Some("https://img.example/abc123")); + assert_eq!(images.find(None, Some("https://cdn.example/1.mp3"), None), Some("https://img.example/abc123")); + assert_eq!(images.find(None, None, Some("Tom & Jerry")), Some("https://img.example/abc123")); + assert_eq!(images.find(None, Some("https://cdn.example/2.mp3"), None), Some("https://img.example/2.jpg")); + assert_eq!(images.find(None, None, Some("Bits & Bobs")), Some("https://img.example/2.jpg")); + assert_eq!(images.find(Some("g-3"), None, Some("No Image")), None); + } + + #[test] + fn merges_edited_fields() { + assert_eq!(merge_edited(None, &["podcastname"]).as_deref(), Some("podcastname")); + assert_eq!(merge_edited(Some("podcastname"), &["artworkurl", "podcastname"]).as_deref(), Some("podcastname,artworkurl")); + assert_eq!(merge_edited(Some("bogus"), &["feedurl"]), None); + } +} diff --git a/rust-api/src/services/mod.rs b/rust-api/src/services/mod.rs index e9ca85544..914334caf 100644 --- a/rust-api/src/services/mod.rs +++ b/rust-api/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod download_metadata; pub mod episode_aliases; pub mod feed_health; +pub mod feed_metadata; pub mod feeds; pub mod http; pub mod people; diff --git a/rust-api/tests/integration/main.rs b/rust-api/tests/integration/main.rs index f0dadec28..e27de6ffc 100644 --- a/rust-api/tests/integration/main.rs +++ b/rust-api/tests/integration/main.rs @@ -19,6 +19,7 @@ mod moved_feeds; mod opml; mod people; mod playback; +mod podcast_details; mod podcasting20; mod podcasts; mod podping; diff --git a/rust-api/tests/integration/podcast_details.rs b/rust-api/tests/integration/podcast_details.rs new file mode 100644 index 000000000..9d3397600 --- /dev/null +++ b/rust-api/tests/integration/podcast_details.rs @@ -0,0 +1,166 @@ +//! A podcast's details (name, artwork, author, description, website) follow its feed: refreshes +//! apply a rebrand, keep what the user edited by hand, and never blank a field the feed dropped. + +use serde_json::json; + +use crate::harness::*; + +async fn column(app: &TestApp, podcast_id: i32, col: &str) -> Option { + text(app, &format!(r#"SELECT {col} FROM "Podcasts" WHERE PodcastID = {podcast_id}"#)).await +} + +async fn episode_artwork(app: &TestApp, podcast_id: i32, guid: &str) -> Option { + text( + app, + &format!( + r#"SELECT EpisodeArtwork FROM "Episodes" WHERE EpisodeGUID = '{guid}' + AND FeedID = (SELECT FeedID FROM "Podcasts" WHERE PodcastID = {podcast_id})"# + ), + ) + .await +} + +fn rebranded(mut feed: Feed) -> Feed { + feed.title = "New Name".into(); + feed.author = "New Hosts".into(); + feed.description = "The show, rebranded".into(); + feed.link = "https://example.invalid/new-site".into(); + feed.image = "https://example.invalid/new-art.jpg".into(); + feed +} + +async fn edit(app: &TestApp, user: &TestUser, podcast_id: i32, fields: serde_json::Value) { + let mut body = json!({ "user_id": user.id, "podcast_id": podcast_id }); + body.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone()); + let resp = app.put("/api/data/update_podcast_info").key(user).json(&body).send().await.ok_json(); + assert_eq!(resp["success"], true, "{resp}"); +} + +async fn refresh_details(app: &TestApp, user: &TestUser, podcast_id: i32, reset_edits: bool) { + let resp = app + .post("/api/data/refresh_podcast_details") + .key(user) + .json(&json!({ "podcast_id": podcast_id, "reset_edits": reset_edits })) + .send() + .await + .ok_json(); + assert_eq!(resp["success"], true, "{resp}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_rebranded_feed_updates_the_podcast_and_its_fallback_artwork() { + let app = TestApp::new().await; + let alice = app.user("alice").await; + let bob = app.user("bob").await; + let mut own_art = Episode::new(2); + own_art.image = Some("https://example.invalid/ep2.jpg".into()); + let feed = Feed::new("Old Name").episode(Episode::new(1)).episode(own_art); + let url = app.feed("rebrand.xml", &feed); + let a = app.subscribe(&alice, &url, 2).await; + let b = app.subscribe(&bob, &url, 2).await; + assert_eq!(episode_artwork(&app, a, "guid-1").await.as_deref(), Some("https://example.invalid/art.jpg")); + assert_eq!(episode_artwork(&app, a, "guid-2").await.as_deref(), Some("https://example.invalid/ep2.jpg")); + + // The rebranded feed also dropped episode 1; it keeps its place but gets the new artwork. + let mut new_feed = rebranded(feed); + new_feed.episodes.retain(|e| e.guid.as_deref() != Some("guid-1")); + app.feed("rebrand.xml", &new_feed.episode(Episode::new(3))); + app.refresh().await; + + for pod in [a, b] { + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("New Name")); + assert_eq!(column(&app, pod, "Author").await.as_deref(), Some("New Hosts")); + assert_eq!(column(&app, pod, "Description").await.as_deref(), Some("The show, rebranded")); + assert_eq!(column(&app, pod, "WebsiteURL").await.as_deref(), Some("https://example.invalid/new-site")); + assert_eq!(column(&app, pod, "ArtworkURL").await.as_deref(), Some("https://example.invalid/new-art.jpg")); + } + assert_eq!(episode_artwork(&app, a, "guid-1").await.as_deref(), Some("https://example.invalid/new-art.jpg")); + assert_eq!(episode_artwork(&app, a, "guid-3").await.as_deref(), Some("https://example.invalid/new-art.jpg")); + assert_eq!(episode_artwork(&app, a, "guid-2").await.as_deref(), Some("https://example.invalid/ep2.jpg"), "own artwork stays"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn hand_edited_details_survive_refreshes_until_reset() { + let app = TestApp::new().await; + let user = app.user("listener").await; + let feed = Feed::with_episodes("Old Name", 1); + let url = app.feed("edited.xml", &feed); + let pod = app.subscribe(&user, &url, 1).await; + + edit(&app, &user, pod, json!({ "podcast_name": "My Name" })).await; + app.feed("edited.xml", &rebranded(feed).episode(Episode::new(2))); + app.refresh().await; + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("My Name"), "the user's name is kept"); + assert_eq!(column(&app, pod, "ArtworkURL").await.as_deref(), Some("https://example.invalid/new-art.jpg"), "unedited fields follow the feed"); + + // Refreshing the details keeps edits; resetting hands them back to the feed, even though the + // feed hasn't changed since the last refresh. + refresh_details(&app, &user, pod, false).await; + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("My Name")); + refresh_details(&app, &user, pod, true).await; + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("New Name")); + assert_eq!(column(&app, pod, "EditedMetadata").await, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn saving_unchanged_details_marks_nothing_as_edited() { + let app = TestApp::new().await; + let user = app.user("listener").await; + let feed = Feed::with_episodes("Same Name", 1); + let url = app.feed("unchanged.xml", &feed); + let pod = app.subscribe(&user, &url, 1).await; + + edit(&app, &user, pod, json!({ "podcast_name": "Same Name", "author": "Someone Else" })).await; + assert_eq!(column(&app, pod, "EditedMetadata").await.as_deref(), Some("author")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_feed_dropping_a_field_does_not_blank_it() { + let app = TestApp::new().await; + let user = app.user("listener").await; + let feed = Feed::with_episodes("Keeps Its Name", 1).author("The Author"); + let url = app.feed("dropped.xml", &feed); + let pod = app.subscribe(&user, &url, 1).await; + + let mut broken = feed.episode(Episode::new(2)); + broken.title = String::new(); + broken.author = String::new(); + app.feed("dropped.xml", &broken); + app.refresh().await; + assert_eq!(app.episodes(&user, pod).await.len(), 2); + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("Keeps Its Name")); + assert_eq!(column(&app, pod, "Author").await.as_deref(), Some("The Author")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_upgrade_reads_every_feed_in_full_once() { + let app = TestApp::new().await; + let user = app.user("listener").await; + let feed = Feed::with_episodes("Current Name", 1); + let url = app.fixtures().serve("upgrade.xml", Fixture::new("application/rss+xml", feed.to_xml()).etag("\"v1\"")); + let pod = app.subscribe(&user, &url, 1).await; + app.refresh().await; // learns the ETag + + // Details stored by an older release that never updated them. + exec(&app, &format!(r#"UPDATE "Podcasts" SET PodcastName = 'Stale Name' WHERE PodcastID = {pod}"#)).await; + app.refresh().await; + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("Stale Name"), "a 304 doesn't reapply"); + + rerun_migrations(&app, &["085"]).await; + app.refresh().await; + assert_eq!(column(&app, pod, "PodcastName").await.as_deref(), Some("Current Name")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_episodes_own_itunes_image_is_kept() { + let app = TestApp::new().await; + let user = app.user("listener").await; + // Only and no iTunes title/summary (which feed-rs needs to keep it), on a CDN + // address without a file extension. + let mut ep = Episode::new(1); + ep.image = Some("https://img.example.invalid/a1b2c3".into()); + let url = app.feed("episode-art.xml", &Feed::new("Art Show").episode(ep).episode(Episode::new(2))); + let pod = app.subscribe(&user, &url, 2).await; + assert_eq!(episode_artwork(&app, pod, "guid-1").await.as_deref(), Some("https://img.example.invalid/a1b2c3")); + assert_eq!(episode_artwork(&app, pod, "guid-2").await.as_deref(), Some("https://example.invalid/art.jpg")); +} diff --git a/web/src/pages/episode_layout.rs b/web/src/pages/episode_layout.rs index 2f8ee1ac0..05516a38f 100644 --- a/web/src/pages/episode_layout.rs +++ b/web/src/pages/episode_layout.rs @@ -32,7 +32,8 @@ use crate::requests::pod_req::{ call_get_rss_key, call_merge_podcasts, call_remove_category, call_remove_podcasts_name, call_remove_youtube_channel, call_set_playback_speed, call_toggle_podcast_favorite, call_toggle_podcast_notifications, - call_unmerge_podcast, call_update_feed_cutoff_days, call_update_podcast_info, + call_refresh_podcast_details, call_unmerge_podcast, call_update_feed_cutoff_days, + call_update_podcast_info, call_get_podcast_auto_delete_days, call_set_podcast_auto_delete_days, call_clear_podcast_auto_delete_days, AddCategoryRequest, AutoDownloadRequest, BulkEpisodeActionRequest, ClearPlaybackSpeedRequest, @@ -906,6 +907,9 @@ pub fn episode_layout() -> Html { let auto_play_next_status = use_state(|| false); let auto_queue_status = use_state(|| false); let podcast_id = use_state(|| 0); + // Bumped after the podcast's details are read from its feed again, to reload its episodes. + let details_reload = use_state(|| 0u32); + let refreshing_details = use_state(|| false); let start_skip = use_state(|| 0); let end_skip = use_state(|| 0); // Silence-trim (#727) per-podcast settings @@ -1680,6 +1684,64 @@ pub fn episode_layout() -> Html { }) }; + // Read the podcast's feed again and show its current details; `true` also drops the user's + // own edits to them. + let refresh_from_feed = { + let api_key = api_key.clone(); + let server_name = server_name.clone(); + let user_id = user_id.clone(); + let podcast_id = podcast_id.clone(); + let page_state = page_state.clone(); + let details_reload = details_reload.clone(); + let refreshing_details = refreshing_details.clone(); + let i18n_done = i18n.t("episodes_layout.details_refreshed").to_string(); + let i18n_failed = i18n.t("episodes_layout.details_refresh_failed").to_string(); + Callback::from(move |reset_edits: bool| { + let (Some(Some(api_key)), Some(server_name), Some(user_id)) = + (api_key.clone(), server_name.clone(), user_id) + else { + return; + }; + let pid = *podcast_id; + if pid == 0 || *refreshing_details { + return; + } + refreshing_details.set(true); + let page_state = page_state.clone(); + let details_reload = details_reload.clone(); + let refreshing_details = refreshing_details.clone(); + let i18n_done = i18n_done.clone(); + let i18n_failed = i18n_failed.clone(); + spawn_local(async move { + match call_refresh_podcast_details(&server_name, &api_key, pid, reset_edits).await { + Ok(()) => { + if let Ok(d) = call_get_podcast_details(&server_name, &api_key, user_id, pid).await { + Dispatch::::global().reduce_mut(move |state| { + if let Some(info) = state.clicked_podcast_info.as_mut() { + info.podcastname = d.podcastname; + info.artworkurl = d.artworkurl; + info.author = d.author; + info.description = d.description; + info.websiteurl = d.websiteurl; + info.episodecount = d.episodecount; + } + }); + } + details_reload.set(*details_reload + 1); + page_state.set(PageState::Hidden); + Dispatch::::global() + .reduce_mut(|state| state.info_message = Some(i18n_done)); + } + Err(e) => { + Dispatch::::global() + .reduce_mut(|state| state.error_message = Some(format!("{i18n_failed}: {e}"))); + } + } + refreshing_details.set(false); + }); + }) + }; + let toggle_download = { let api_key = api_key.clone(); let server_name = server_name.clone(); @@ -3308,8 +3370,8 @@ pub fn episode_layout() -> Html { - // Edit podcast info button -
+ // Edit podcast info / read its details from the feed again +
+
@@ -3793,7 +3866,7 @@ pub fn episode_layout() -> Html { } -
+
+