diff --git a/database_functions/migration_definitions.py b/database_functions/migration_definitions.py index 5750435f..ee567dd8 100644 --- a/database_functions/migration_definitions.py +++ b/database_functions/migration_definitions.py @@ -3446,6 +3446,60 @@ def migration_106_optimize_subscription_sync_performance(conn, db_type: str): cursor.close() +@register_migration("108", "gpodder_subscription_snapshot", "Add subscription snapshot table for delta-based sync upload", requires=["008"]) +def migration_108_gpodder_subscription_snapshot(conn, db_type: str): + """Create GpodderSubscriptionSnapshot table. + + Stores, per (user, sync target), the set of local feed URLs at the end of the last sync. + The sync code diffs the current local feeds against this snapshot to compute genuine local + add/remove deltas to push up - instead of re-uploading the full list every sync (which bloats + the server change log) and without a per-change queue. + """ + cursor = conn.cursor() + + try: + logger.info("Starting gpodder migration 108: Add subscription snapshot table") + + if db_type == 'postgresql': + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS "GpodderSubscriptionSnapshot" ( + SnapshotID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + SyncTarget TEXT NOT NULL, + FeedURL TEXT NOT NULL, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, SyncTarget, FeedURL) + ) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX IF NOT EXISTS idx_gpodder_subsnapshot_user_target ON "GpodderSubscriptionSnapshot"(UserID, SyncTarget) + ''', conn=conn) + else: # mysql + safe_execute_sql(cursor, ''' + CREATE TABLE IF NOT EXISTS GpodderSubscriptionSnapshot ( + SnapshotID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + SyncTarget VARCHAR(512) NOT NULL, + FeedURL VARCHAR(2048) NOT NULL, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, SyncTarget, FeedURL(512)) + ) + ''', conn=conn) + + safe_execute_sql(cursor, ''' + CREATE INDEX idx_gpodder_subsnapshot_user_target ON GpodderSubscriptionSnapshot(UserID, SyncTarget) + ''', conn=conn) + + logger.info("Created GpodderSubscriptionSnapshot table successfully") + + except Exception as e: + logger.error(f"Error in gpodder migration 108: {e}") + raise + finally: + cursor.close() + + @register_migration("033", "add_http_notification_columns", "Add generic HTTP notification columns to UserNotificationSettings table", requires=["011"]) def migration_033_add_http_notification_columns(conn, db_type: str): """Add generic HTTP notification columns for platforms like Telegram""" diff --git a/gpodder-api/internal/api/episode.go b/gpodder-api/internal/api/episode.go index 9f0c45f0..075b93b1 100644 --- a/gpodder-api/internal/api/episode.go +++ b/gpodder-api/internal/api/episode.go @@ -280,9 +280,10 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { } } - // ORDER BY DESC (newest first) to prioritize recent actions - // This ensures recent play state is synced first, even if total actions > limit - queryParts = append(queryParts, "ORDER BY e.Timestamp DESC") + // ORDER BY ASC (oldest first) so clients can paginate forward through ALL actions + // using the returned timestamp as the next 'since'. With DESC + a row limit, anything + // beyond the limit (>25k actions) would be silently dropped on the next page. + queryParts = append(queryParts, "ORDER BY e.Timestamp ASC") // Add LIMIT for performance - prevents returning massive datasets // Clients should use the 'since' parameter to paginate through results @@ -311,6 +312,7 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { // Build response actions := make([]models.EpisodeAction, 0) + var maxBatchTimestamp int64 for rows.Next() { var action models.EpisodeAction var deviceIDInt sql.NullInt64 @@ -318,6 +320,7 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { var started sql.NullInt64 var position sql.NullInt64 var total sql.NullInt64 + var actionTimestamp int64 if err := rows.Scan( &action.ActionID, @@ -326,7 +329,7 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { &action.Podcast, &action.Episode, &action.Action, - &action.Timestamp, + &actionTimestamp, &started, &position, &total, @@ -336,6 +339,11 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { continue } + action.Timestamp = actionTimestamp + if actionTimestamp > maxBatchTimestamp { + maxBatchTimestamp = actionTimestamp + } + // Set optional fields if present if deviceName.Valid { action.Device = deviceName.String @@ -368,10 +376,19 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc { totalDuration := time.Since(startTime) log.Printf("[DEBUG] getEpisodeActions: Returning %d actions, total time: %v", len(actions), totalDuration) + // Determine the timestamp the client should use as 'since' on its next request. + // If we hit the row limit there are more actions to fetch, so return the max timestamp + // in THIS batch (results are ordered ascending) - that lets the client page forward + // through everything. Otherwise return the global latest so the next sync is incremental. + responseTimestamp := latestTimestamp + if len(actions) >= MAX_EPISODE_ACTIONS && maxBatchTimestamp > 0 { + responseTimestamp = maxBatchTimestamp + } + // Return response in gpodder format c.JSON(http.StatusOK, models.EpisodeActionsResponse{ Actions: actions, - Timestamp: latestTimestamp, + Timestamp: responseTimestamp, }) } } diff --git a/gpodder-api/internal/db/migrations.go b/gpodder-api/internal/db/migrations.go index 0bdec922..eeb2962c 100644 --- a/gpodder-api/internal/db/migrations.go +++ b/gpodder-api/internal/db/migrations.go @@ -534,5 +534,33 @@ func GetMigrations() []Migration { CREATE INDEX idx_gpodder_syncstate_userid_deviceid ON GpodderSyncState(UserID, DeviceID); `, }, + { + Version: 5, + Description: "Add subscription snapshot table for delta-based sync upload", + PostgreSQLSQL: ` + CREATE TABLE IF NOT EXISTS "GpodderSubscriptionSnapshot" ( + SnapshotID SERIAL PRIMARY KEY, + UserID INT NOT NULL, + SyncTarget TEXT NOT NULL, + FeedURL TEXT NOT NULL, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE, + UNIQUE(UserID, SyncTarget, FeedURL) + ); + + CREATE INDEX IF NOT EXISTS idx_gpodder_subsnapshot_user_target ON "GpodderSubscriptionSnapshot"(UserID, SyncTarget); + `, + MySQLSQL: ` + CREATE TABLE IF NOT EXISTS GpodderSubscriptionSnapshot ( + SnapshotID INT AUTO_INCREMENT PRIMARY KEY, + UserID INT NOT NULL, + SyncTarget VARCHAR(512) NOT NULL, + FeedURL VARCHAR(2048) NOT NULL, + FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE, + UNIQUE(UserID, SyncTarget, FeedURL(512)) + ); + + CREATE INDEX idx_gpodder_subsnapshot_user_target ON GpodderSubscriptionSnapshot(UserID, SyncTarget); + `, + }, } } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 76421af7..4f65e67c 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -68,16 +68,21 @@ - - + + + + + - - diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 4b68236b..7f951aae 100644 --- a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -60,6 +60,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.linusu.flutter_web_auth_2.FlutterWebAuth2Plugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_web_auth_2, com.linusu.flutter_web_auth_2.FlutterWebAuth2Plugin", e); + } try { flutterEngine.getPlugins().add(new com.ryanheise.just_audio.JustAudioPlugin()); } catch (Exception e) { @@ -100,10 +105,5 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e); - } } } diff --git a/mobile/android/app/src/main/kotlin/com/gooseberrydevelopment/pinepods/audio/PinepodsMediaService.kt b/mobile/android/app/src/main/kotlin/com/gooseberrydevelopment/pinepods/audio/PinepodsMediaService.kt index ee072bb9..b18186e1 100644 --- a/mobile/android/app/src/main/kotlin/com/gooseberrydevelopment/pinepods/audio/PinepodsMediaService.kt +++ b/mobile/android/app/src/main/kotlin/com/gooseberrydevelopment/pinepods/audio/PinepodsMediaService.kt @@ -5,6 +5,7 @@ import android.app.PendingIntent import android.content.Intent import android.media.audiofx.LoudnessEnhancer import android.net.Uri +import java.io.File import android.os.Binder import android.os.Handler import android.os.IBinder @@ -353,7 +354,12 @@ class PinepodsMediaService : MediaLibraryService() { player?.let { p -> try { - val uri = Uri.parse(url) + // For local downloads `url` is a raw filesystem path (which may + // contain spaces and has no scheme). Uri.parse() leaves it + // scheme-less and unencoded, so ExoPlayer fails to load it and + // never reports a duration (player shows 00:00 and won't scrub). + // Uri.fromFile() builds a properly-encoded file:// URI. + val uri = if (isLocal) Uri.fromFile(File(url)) else Uri.parse(url) // Build media metadata val mediaMetadataBuilder = MediaMetadata.Builder() diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m index c96efaa2..7f55c958 100644 --- a/mobile/ios/Runner/GeneratedPluginRegistrant.m +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -54,6 +54,12 @@ @import flutter_downloader; #endif +#if __has_include() +#import +#else +@import flutter_web_auth_2; +#endif + #if __has_include() #import #else @@ -102,12 +108,6 @@ @import url_launcher_ios; #endif -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -119,6 +119,7 @@ + (void)registerWithRegistry:(NSObject*)registry { [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FlutterCarplayPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCarplayPlugin"]]; [FlutterDownloaderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterDownloaderPlugin"]]; + [FlutterWebAuth2Plugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterWebAuth2Plugin"]]; [JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; @@ -127,7 +128,6 @@ + (void)registerWithRegistry:(NSObject*)registry { [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; } @end diff --git a/mobile/lib/entities/pending_action.dart b/mobile/lib/entities/pending_action.dart new file mode 100644 index 00000000..d7646ec5 --- /dev/null +++ b/mobile/lib/entities/pending_action.dart @@ -0,0 +1,106 @@ +// lib/entities/pending_action.dart + +/// The kind of server interaction a [PendingAction] represents. +/// +/// These map 1:1 onto the relevant PinepodsService calls so the offline queue +/// can dispatch each action when connectivity is restored. +enum PendingActionType { + recordPosition, + markCompleted, + markUncompleted, + saveEpisode, + removeSaved, + queue, + addHistory, +} + +/// A user interaction (progress, completion, save, queue, history) that could +/// not be — or has not yet been — sent to the server, persisted locally so it +/// can be synced later. This is what lets episodes downloaded for offline +/// listening still record interactions and reconcile once back online. +class PendingAction { + /// Database key (Sembast record id). Null until persisted. + int? id; + + final PendingActionType type; + final int episodeId; + final int userId; + final bool isYoutube; + + /// Action-specific data, e.g. {'position': 123.0} for [recordPosition]. + final Map payload; + + final DateTime createdAt; + + /// Number of failed sync attempts. Used for backoff / surfacing stuck items. + int retryCount; + + PendingAction({ + this.id, + required this.type, + required this.episodeId, + required this.userId, + this.isYoutube = false, + this.payload = const {}, + DateTime? createdAt, + this.retryCount = 0, + }) : createdAt = createdAt ?? DateTime.now(); + + /// Convenience for the common position payload. + double? get position { + final p = payload['position']; + if (p is num) return p.toDouble(); + return null; + } + + Map toMap() { + return { + 'type': type.name, + 'episodeId': episodeId, + 'userId': userId, + 'isYoutube': isYoutube, + 'payload': payload, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'retryCount': retryCount, + }; + } + + static PendingAction fromMap(int? key, Map map) { + return PendingAction( + id: key, + type: PendingActionType.values.firstWhere( + (t) => t.name == map['type'], + orElse: () => PendingActionType.recordPosition, + ), + episodeId: map['episodeId'] as int? ?? 0, + userId: map['userId'] as int? ?? 0, + isYoutube: map['isYoutube'] as bool? ?? false, + payload: (map['payload'] as Map?)?.cast() ?? const {}, + createdAt: map['createdAt'] == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), + retryCount: map['retryCount'] as int? ?? 0, + ); + } + + /// Human-friendly label for the action queue viewer. + String get description { + switch (type) { + case PendingActionType.recordPosition: + final p = position; + return p != null ? 'Save progress (${p.toInt()}s)' : 'Save progress'; + case PendingActionType.markCompleted: + return 'Mark completed'; + case PendingActionType.markUncompleted: + return 'Mark not completed'; + case PendingActionType.saveEpisode: + return 'Save episode'; + case PendingActionType.removeSaved: + return 'Remove saved episode'; + case PendingActionType.queue: + return 'Add to queue'; + case PendingActionType.addHistory: + return 'Add to history'; + } + } +} diff --git a/mobile/lib/repository/repository.dart b/mobile/lib/repository/repository.dart index b5ec5ebf..ff075380 100644 --- a/mobile/lib/repository/repository.dart +++ b/mobile/lib/repository/repository.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/pending_action.dart'; import 'package:pinepods_mobile/entities/podcast.dart'; import 'package:pinepods_mobile/entities/transcript.dart'; import 'package:pinepods_mobile/state/episode_state.dart'; @@ -64,7 +65,17 @@ abstract class Repository { Future> loadQueue(); + /// Offline action queue (pending server interactions) + Future savePendingAction(PendingAction action); + + Future> getPendingActions(); + + Future deletePendingAction(int id); + /// Event listeners Stream? podcastListener; Stream? episodeListener; + + /// Emits whenever the pending action queue changes (add/update/delete). + Stream? pendingActionListener; } diff --git a/mobile/lib/repository/sembast/sembast_repository.dart b/mobile/lib/repository/sembast/sembast_repository.dart index 0defaba1..ec65ae24 100644 --- a/mobile/lib/repository/sembast/sembast_repository.dart +++ b/mobile/lib/repository/sembast/sembast_repository.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'package:pinepods_mobile/core/extensions.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; import 'package:pinepods_mobile/entities/episode.dart'; +import 'package:pinepods_mobile/entities/pending_action.dart'; import 'package:pinepods_mobile/entities/podcast.dart'; import 'package:pinepods_mobile/entities/queue.dart'; import 'package:pinepods_mobile/entities/transcript.dart'; @@ -22,11 +24,13 @@ class SembastRepository extends Repository { final _podcastSubject = BehaviorSubject(); final _episodeSubject = BehaviorSubject(); + final _pendingActionSubject = BehaviorSubject(); final _podcastStore = intMapStoreFactory.store('podcast'); final _episodeStore = intMapStoreFactory.store('episode'); final _queueStore = intMapStoreFactory.store('queue'); final _transcriptStore = intMapStoreFactory.store('transcript'); + final _pendingActionStore = intMapStoreFactory.store('pending_action'); final _queueGuids = []; @@ -44,6 +48,34 @@ class SembastRepository extends Repository { _cleanupEpisodes().then((value) { log.fine('Orphan episodes cleanup complete'); }); + _cleanupPlaybackDownloadArtifacts(); + } + } + + /// Remove "download" records created as a side effect of playing a local + /// download. Local downloads are always stored under a 'pinepods_' guid; + /// playback used to persist a second record keyed by the episode's content + /// URL and mark it downloaded, which then appeared as a duplicate entry in the + /// downloads list (often with a bogus duration). These artifacts are only ever + /// created by playback, so any downloaded record without a 'pinepods_' guid is + /// safe to delete on startup (nothing is playing yet). + Future _cleanupPlaybackDownloadArtifacts() async { + try { + final all = await findAllEpisodes(); + final artifacts = all + .where((e) => + e.downloadState == DownloadState.downloaded && !e.guid.startsWith('pinepods_')) + .toList(); + + if (artifacts.isNotEmpty) { + log.info('Removing ${artifacts.length} duplicate playback download artifact(s)'); + await deleteEpisodes(artifacts); + for (final e in artifacts) { + _episodeSubject.add(EpisodeDeleteState(e)); + } + } + } catch (e) { + log.warning('Playback download artifact cleanup failed: $e'); } } @@ -389,6 +421,40 @@ class SembastRepository extends Repository { } } + @override + Future savePendingAction(PendingAction action) async { + if (action.id == null) { + action.id = await _pendingActionStore.add(await _db, action.toMap()); + } else { + final finder = Finder(filter: Filter.byKey(action.id)); + await _pendingActionStore.update(await _db, action.toMap(), finder: finder); + } + + _pendingActionSubject.add(null); + + return action; + } + + @override + Future> getPendingActions() async { + // Oldest first so the queue flushes in the order interactions happened. + final finder = Finder(sortOrders: [SortOrder('createdAt', true)]); + + final List>> recordSnapshots = + await _pendingActionStore.find(await _db, finder: finder); + + return recordSnapshots + .map((snapshot) => PendingAction.fromMap(snapshot.key, snapshot.value)) + .toList(); + } + + @override + Future deletePendingAction(int id) async { + final finder = Finder(filter: Filter.byKey(id)); + await _pendingActionStore.delete(await _db, finder: finder); + _pendingActionSubject.add(null); + } + @override Future findTranscriptById(int? id) async { final finder = Finder(filter: Filter.byKey(id)); @@ -678,4 +744,7 @@ class SembastRepository extends Repository { @override Stream get podcastListener => _podcastSubject.stream; + + @override + Stream get pendingActionListener => _pendingActionSubject.stream; } diff --git a/mobile/lib/services/audio/audio_player_service.dart b/mobile/lib/services/audio/audio_player_service.dart index 2461b812..0fc52d89 100644 --- a/mobile/lib/services/audio/audio_player_service.dart +++ b/mobile/lib/services/audio/audio_player_service.dart @@ -47,6 +47,12 @@ abstract class AudioPlayerService { /// Play a new episode, optionally resume at last save point. Future playEpisode({required Episode episode, bool resume = true}); + /// Look up a locally-downloaded copy of a PinePods episode by its server + /// episode id. Returns the stored [Episode] (carrying filepath/filename) if a + /// completed download exists, otherwise null. Used to prefer the on-disk file + /// over streaming when playing. + Future findDownloadedEpisode(int episodeId); + /// Resume playing of current episode Future play(); diff --git a/mobile/lib/services/audio/default_audio_player_service.dart b/mobile/lib/services/audio/default_audio_player_service.dart index 367a4693..4bb50693 100644 --- a/mobile/lib/services/audio/default_audio_player_service.dart +++ b/mobile/lib/services/audio/default_audio_player_service.dart @@ -356,6 +356,24 @@ class DefaultAudioPlayerService extends AudioPlayerService { } } + @override + Future findDownloadedEpisode(int episodeId) async { + final guid = 'pinepods_$episodeId'; + + // Common case: download stored under the canonical guid. + final direct = await repository.findEpisodeByGuid(guid); + if (direct != null && direct.downloadState == DownloadState.downloaded) { + return direct; + } + + // Legacy downloads used a 'pinepods__' guid; fall back to a + // scan so older downloads still resolve to their local file. + final all = await repository.findAllEpisodes(); + return all.firstWhereOrNull((e) => + (e.guid == guid || e.guid.startsWith('${guid}_')) && + e.downloadState == DownloadState.downloaded); + } + @override Future rewind() => _audioHandler.rewind(); @@ -1340,11 +1358,13 @@ class _DefaultAudioPlayerHandler extends BaseAudioHandler with SeekHandler { log.fine('loading new track ${mediaItem.id} - from position ${start.inSeconds} (${start.inMilliseconds})'); + // For downloaded episodes mediaItem.id is a filesystem path which may + // contain spaces or other characters that are invalid in a raw URI string + // (podcast folder names keep spaces). Uri.file() percent-encodes them + // correctly; string-concatenating "file://" does not and breaks playback + // (the player never loads, so position/duration stay at 00:00). var source = downloaded - ? AudioSource.uri( - Uri.parse("file://${mediaItem.id}"), - tag: mediaItem.id, - ) + ? AudioSource.uri(Uri.file(mediaItem.id), tag: mediaItem.id) : AudioSource.uri(Uri.parse(mediaItem.id), tag: mediaItem.id); try { diff --git a/mobile/lib/services/audio/native_audio_player_service.dart b/mobile/lib/services/audio/native_audio_player_service.dart index 6dfc4bd6..7611d732 100644 --- a/mobile/lib/services/audio/native_audio_player_service.dart +++ b/mobile/lib/services/audio/native_audio_player_service.dart @@ -290,6 +290,26 @@ class NativeAudioPlayerService extends AudioPlayerService { // This is just for logging/tracking } + @override + Future findDownloadedEpisode(int episodeId) async { + final guid = 'pinepods_$episodeId'; + + final direct = await repository.findEpisodeByGuid(guid); + if (direct != null && direct.downloadState == DownloadState.downloaded) { + return direct; + } + + // Legacy 'pinepods__' guids: fall back to a scan. + final all = await repository.findAllEpisodes(); + for (final e in all) { + if ((e.guid == guid || e.guid.startsWith('${guid}_')) && + e.downloadState == DownloadState.downloaded) { + return e; + } + } + return null; + } + @override Future playEpisode({required Episode episode, bool resume = true}) async { log.info('playEpisode: ${episode.title}, resume: $resume'); diff --git a/mobile/lib/services/global_services.dart b/mobile/lib/services/global_services.dart index 7e542388..ce19dbec 100644 --- a/mobile/lib/services/global_services.dart +++ b/mobile/lib/services/global_services.dart @@ -1,4 +1,5 @@ // lib/services/global_services.dart +import 'package:pinepods_mobile/services/offline/offline_action_queue.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; @@ -6,15 +7,21 @@ import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; class GlobalServices { static PinepodsAudioService? _pinepodsAudioService; static PinepodsService? _pinepodsService; - + static OfflineActionQueue? _offlineActionQueue; + /// Set the global services (called from PinepodsPodcastApp) static void initialize({ required PinepodsAudioService pinepodsAudioService, required PinepodsService pinepodsService, + OfflineActionQueue? offlineActionQueue, }) { _pinepodsAudioService = pinepodsAudioService; _pinepodsService = pinepodsService; + _offlineActionQueue = offlineActionQueue; } + + /// Get the global offline action queue instance + static OfflineActionQueue? get offlineActionQueue => _offlineActionQueue; /// Update global service credentials (called when user logs in or settings change) static void setCredentials(String server, String apiKey) { @@ -31,5 +38,6 @@ class GlobalServices { static void clear() { _pinepodsAudioService = null; _pinepodsService = null; + _offlineActionQueue = null; } } \ No newline at end of file diff --git a/mobile/lib/services/offline/offline_action_queue.dart b/mobile/lib/services/offline/offline_action_queue.dart new file mode 100644 index 00000000..9548ae51 --- /dev/null +++ b/mobile/lib/services/offline/offline_action_queue.dart @@ -0,0 +1,177 @@ +// lib/services/offline/offline_action_queue.dart + +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:logging/logging.dart'; +import 'package:pinepods_mobile/entities/pending_action.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/services/settings/settings_service.dart'; + +/// Durable "outbox" for episode interactions (progress, completion, save, +/// queue, history). Every interaction for a locally-playable episode is written +/// here and flushed to the PinePods server when the device is online — on +/// app start, on reconnect, and opportunistically after each enqueue. +/// +/// This is what lets users listen to downloaded episodes offline and have their +/// progress / completion / saved state reconcile with the server once back +/// online, rather than being silently dropped. +class OfflineActionQueue { + final log = Logger('OfflineActionQueue'); + + final Repository repository; + final PinepodsService pinepodsService; + final SettingsService settingsService; + + /// Stop draining after this many consecutive failures for a single item so we + /// don't spin; the item is retried on the next flush trigger. + static const int _maxRetries = 8; + + bool _flushing = false; + StreamSubscription>? _connectivitySub; + + OfflineActionQueue({ + required this.repository, + required this.pinepodsService, + required this.settingsService, + }); + + /// Begin listening for connectivity changes and attempt an initial flush. + void start() { + _connectivitySub ??= Connectivity().onConnectivityChanged.listen((results) { + final online = results.any((r) => r != ConnectivityResult.none); + if (online) { + log.fine('Connectivity restored - flushing offline action queue'); + flush(); + } + }); + + // Drain anything left over from a previous session. + flush(); + } + + void dispose() { + _connectivitySub?.cancel(); + _connectivitySub = null; + } + + /// Persist an interaction and immediately attempt to flush it. + /// + /// For [PendingActionType.recordPosition] only the latest position per episode + /// matters, so any earlier pending position update for the same episode is + /// collapsed to avoid the queue growing without bound during playback. + Future enqueue(PendingAction action) async { + try { + if (action.type == PendingActionType.recordPosition) { + final existing = await repository.getPendingActions(); + for (final a in existing) { + if (a.type == PendingActionType.recordPosition && + a.episodeId == action.episodeId && + a.id != null) { + await repository.deletePendingAction(a.id!); + } + } + } + + await repository.savePendingAction(action); + } catch (e) { + log.warning('Failed to enqueue offline action: $e'); + return; + } + + // Opportunistic flush — succeeds immediately when online, harmless offline. + flush(); + } + + /// Convenience helpers for the common interactions. + Future enqueuePosition(int episodeId, int userId, double positionSeconds, bool isYoutube) => + enqueue(PendingAction( + type: PendingActionType.recordPosition, + episodeId: episodeId, + userId: userId, + isYoutube: isYoutube, + payload: {'position': positionSeconds}, + )); + + Future enqueueHistory(int episodeId, int userId, double positionSeconds, bool isYoutube) => + enqueue(PendingAction( + type: PendingActionType.addHistory, + episodeId: episodeId, + userId: userId, + isYoutube: isYoutube, + payload: {'position': positionSeconds}, + )); + + Future enqueueSimple(PendingActionType type, int episodeId, int userId, bool isYoutube) => + enqueue(PendingAction(type: type, episodeId: episodeId, userId: userId, isYoutube: isYoutube)); + + /// Attempt to send all pending actions to the server in order. Stops at the + /// first failure (likely offline) so the remaining items are retried later. + Future flush() async { + if (_flushing) return; + + final server = settingsService.pinepodsServer; + final apiKey = settingsService.pinepodsApiKey; + if (server == null || apiKey == null) { + // Not logged in / no credentials — nothing we can send yet. + return; + } + + _flushing = true; + try { + pinepodsService.setCredentials(server, apiKey); + + final actions = await repository.getPendingActions(); + for (final action in actions) { + try { + await _dispatch(action); + if (action.id != null) { + await repository.deletePendingAction(action.id!); + } + } catch (e) { + log.warning('Failed to sync pending action (${action.type.name}): $e'); + action.retryCount++; + if (action.id != null && action.retryCount < _maxRetries) { + await repository.savePendingAction(action); + } else if (action.id != null) { + // Give up on a persistently-failing item rather than blocking the + // rest of the queue forever. + log.warning('Dropping pending action after $_maxRetries attempts: ${action.type.name}'); + await repository.deletePendingAction(action.id!); + } + // Stop draining; likely offline. Remaining items retry next trigger. + break; + } + } + } finally { + _flushing = false; + } + } + + Future _dispatch(PendingAction a) async { + switch (a.type) { + case PendingActionType.recordPosition: + await pinepodsService.recordListenDuration(a.episodeId, a.userId, a.position ?? 0, a.isYoutube); + break; + case PendingActionType.addHistory: + await pinepodsService.addHistory(a.episodeId, a.position ?? 0, a.userId, a.isYoutube); + break; + case PendingActionType.markCompleted: + await pinepodsService.markEpisodeCompleted(a.episodeId, a.userId, a.isYoutube); + break; + case PendingActionType.markUncompleted: + await pinepodsService.markEpisodeUncompleted(a.episodeId, a.userId, a.isYoutube); + break; + case PendingActionType.saveEpisode: + await pinepodsService.saveEpisode(a.episodeId, a.userId, a.isYoutube); + break; + case PendingActionType.removeSaved: + await pinepodsService.removeSavedEpisode(a.episodeId, a.userId, a.isYoutube); + break; + case PendingActionType.queue: + await pinepodsService.queueEpisode(a.episodeId, a.userId, a.isYoutube); + break; + } + } +} diff --git a/mobile/lib/services/pinepods/oidc_service.dart b/mobile/lib/services/pinepods/oidc_service.dart index b2bd5fce..afd7e56e 100644 --- a/mobile/lib/services/pinepods/oidc_service.dart +++ b/mobile/lib/services/pinepods/oidc_service.dart @@ -94,12 +94,14 @@ class OidcService { OidcPkce? pkce, }) async { try { - // Store state on server first - use web origin for in-app browser + // Store state on server first - use the app deep link as the origin so the + // backend redirects the result back to the native auth session via the + // custom scheme (captured by flutter_web_auth_2). final stateStored = await storeOidcState( serverUrl: serverUrl, state: state, clientId: provider.clientId, - originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser + originUrl: '$callbackUrlScheme:/$callbackPath', // pinepods://auth/callback codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier ); @@ -132,22 +134,6 @@ class OidcService { } } - /// Extract API key from callback URL (for in-app browser) - static String? extractApiKeyFromUrl(String url) { - try { - final uri = Uri.parse(url); - - // Check if this is our callback URL with API key - if (uri.path.contains('/oauth/callback')) { - return uri.queryParameters['api_key']; - } - - return null; - } catch (e) { - return null; - } - } - /// Handle OIDC callback and extract authentication result static OidcCallbackResult parseCallback(String callbackUrl) { try { diff --git a/mobile/lib/services/pinepods/pinepods_audio_service.dart b/mobile/lib/services/pinepods/pinepods_audio_service.dart index 2e0cc15e..e946ecae 100644 --- a/mobile/lib/services/pinepods/pinepods_audio_service.dart +++ b/mobile/lib/services/pinepods/pinepods_audio_service.dart @@ -1,12 +1,14 @@ // lib/services/pinepods/pinepods_audio_service.dart import 'dart:async'; +import 'package:pinepods_mobile/entities/downloadable.dart'; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/entities/chapter.dart'; import 'package:pinepods_mobile/entities/person.dart'; import 'package:pinepods_mobile/entities/transcript.dart'; import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/offline/offline_action_queue.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; import 'package:logging/logging.dart'; @@ -17,6 +19,10 @@ class PinepodsAudioService { final PinepodsService _pinepodsService; final SettingsBloc _settingsBloc; + /// Offline outbox. When set, interaction recording (progress/history) is + /// routed through it so it syncs even when the device was offline. + OfflineActionQueue? _actionQueue; + Timer? _episodeUpdateTimer; Timer? _userStatsTimer; int? _currentEpisodeId; @@ -39,10 +45,31 @@ class PinepodsAudioService { }) : _onPauseCallback = onPauseCallback, _onStopCallback = onStopCallback; + /// Wire up the offline outbox (called once during app start-up). + void setActionQueue(OfflineActionQueue queue) { + _actionQueue = queue; + } + void setPlaylistContext(int? playlistId) { _audioPlayerService.setPlaylistContext(playlistId); } + /// Record a playback position, routing through the offline outbox when one is + /// configured so the update survives being offline. Falls back to a direct + /// (error-isolated) call otherwise. + Future _recordPosition(int episodeId, int userId, double positionSeconds) async { + final queue = _actionQueue; + if (queue != null) { + await queue.enqueuePosition(episodeId, userId, positionSeconds, _isYoutube); + return; + } + try { + await _pinepodsService.recordListenDuration(episodeId, userId, positionSeconds, _isYoutube); + } catch (e) { + log.fine('Could not record position (continuing): $e'); + } + } + /// Play a PinePods episode with full server integration Future playPinepodsEpisode({ required PinepodsEpisode pinepodsEpisode, @@ -73,26 +100,59 @@ class PinepodsAudioService { _currentEpisodeId = episodeId; - // Get podcast ID for settings - final podcastId = await _pinepodsService.getPodcastIdFromEpisode( - episodeId, - userId, - pinepodsEpisode.isYoutube, - ); + // Is there a local download for this episode? If so we play the on-disk + // file and tolerate the server being unreachable for everything else. + final localDownload = await _audioPlayerService.findDownloadedEpisode(episodeId); + final hasLocalDownload = localDownload != null; - // Get playback settings (speed, skip times) + // Get podcast ID for settings (tolerate offline / failures) + int podcastId = 0; + try { + podcastId = await _pinepodsService.getPodcastIdFromEpisode( + episodeId, + userId, + pinepodsEpisode.isYoutube, + ); + } catch (e) { + log.fine('Could not fetch podcast id (continuing): $e'); + } + + // Get playback settings (speed, skip times). This already returns sane + // defaults on failure. final playDetails = await _pinepodsService.getPlayEpisodeDetails( userId, podcastId, pinepodsEpisode.isYoutube, ); - // Fetch podcast 2.0 data including chapters - final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId); - + // Fetch podcast 2.0 data including chapters (tolerate offline / failures) + Map? podcast2Data; + try { + podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId); + } catch (e) { + log.fine('Could not fetch podcast 2.0 data (continuing): $e'); + } + // Convert PinepodsEpisode to Episode for the audio player final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data); + // Prefer the locally-downloaded file when one exists so playback works + // offline and avoids needless streaming. + // + // We set only downloadState (which is what selects the on-disk file) plus + // the file location. We deliberately do NOT set downloadPercentage = 100: + // this is a transient playback record whose guid is the content URL, not + // 'pinepods_'. Marking it as a complete download would make it show up + // as a SECOND entry in the downloads list (with a bogus duration, since + // this record's duration is in milliseconds). The real 'pinepods_' + // record stays the single source of truth for the downloads list. + if (hasLocalDownload) { + episode.downloadState = DownloadState.downloaded; + episode.filepath = localDownload.filepath; + episode.filename = localDownload.filename; + log.info('Playing local download for episode $episodeId'); + } + // Start playing with the existing audio service await _audioPlayerService.playEpisode(episode: episode, resume: resume); @@ -105,14 +165,10 @@ class PinepodsAudioService { await _audioPlayerService.seek(position: playDetails.startSkip); } - // Add to history + // Add to history. Routes through the offline outbox so it is not lost when + // the device is offline (e.g. playing a local download on a plane). final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0; - await _pinepodsService.recordListenDuration( - episodeId, - userId, - initialPosition, // Send seconds like web app does - pinepodsEpisode.isYoutube, - ); + await _recordPosition(episodeId, userId, initialPosition); // Queue episode for tracking (skip if auto-play-next is enabled or explicitly skipped) bool shouldQueue = !skipQueue; @@ -127,15 +183,23 @@ class PinepodsAudioService { } } if (shouldQueue) { - await _pinepodsService.queueEpisode( - episodeId, - userId, - pinepodsEpisode.isYoutube, - ); + try { + await _pinepodsService.queueEpisode( + episodeId, + userId, + pinepodsEpisode.isYoutube, + ); + } catch (e) { + log.fine('Could not queue episode (continuing): $e'); + } } - // Increment played count - await _pinepodsService.incrementPlayed(userId); + // Increment played count (tolerate offline / failures) + try { + await _pinepodsService.incrementPlayed(userId); + } catch (e) { + log.fine('Could not increment played count (continuing): $e'); + } // Start periodic updates _startPeriodicUpdates(); @@ -206,19 +270,8 @@ class PinepodsAudioService { // Only update if position has changed by more than 2 seconds (more responsive) if ((currentPosition - _lastRecordedPosition).abs() > 2) { - // Convert seconds to minutes for the API - final currentPositionMinutes = currentPosition / 60.0; - // Position changed, syncing to server - - await _pinepodsService.recordListenDuration( - _currentEpisodeId!, - _currentUserId!, - currentPosition, // Send seconds like web app does - _isYoutube, - ); - + await _recordPosition(_currentEpisodeId!, _currentUserId!, currentPosition); _lastRecordedPosition = currentPosition; - // Sync completed successfully } } catch (e) { log.warning('Failed to update episode position: $e'); @@ -264,18 +317,13 @@ class PinepodsAudioService { } final currentPosition = positionState.position.inSeconds.toDouble(); - + log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId'); - - await _pinepodsService.recordListenDuration( - _currentEpisodeId!, - _currentUserId!, - currentPosition, // Send seconds like web app does - _isYoutube, - ); - + + await _recordPosition(_currentEpisodeId!, _currentUserId!, currentPosition); + _lastRecordedPosition = currentPosition; - log.info('Successfully synced position to server: ${currentPosition}s'); + log.info('Successfully synced position (queued if offline): ${currentPosition}s'); } catch (e) { log.warning('Failed to sync position to server: $e'); log.warning('Stack trace: ${StackTrace.current}'); diff --git a/mobile/lib/services/pinepods/pinepods_service.dart b/mobile/lib/services/pinepods/pinepods_service.dart index 2858c028..93e06692 100644 --- a/mobile/lib/services/pinepods/pinepods_service.dart +++ b/mobile/lib/services/pinepods/pinepods_service.dart @@ -1333,14 +1333,19 @@ class PinepodsService { String query, SearchProvider provider, ) async { - const searchApiUrl = 'https://search.pinepods.online'; + if (_server == null || _apiKey == null) { + throw Exception('Server and API key must be set'); + } + + // Route through the backend search proxy so the configured (possibly + // internal-only) SEARCH_API_URL is honored instead of the public default. final url = Uri.parse( - '$searchApiUrl/api/search?query=${Uri.encodeComponent(query)}&index=${provider.value}', + '$_server/api/data/proxy_search?query=${Uri.encodeComponent(query)}&index=${provider.value}', ); try { print('Making search request to: $url'); - final response = await http.get(url); + final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); diff --git a/mobile/lib/ui/auth/oidc_browser.dart b/mobile/lib/ui/auth/oidc_browser.dart deleted file mode 100644 index a00d7dde..00000000 --- a/mobile/lib/ui/auth/oidc_browser.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; - -class OidcBrowser extends StatefulWidget { - final String authUrl; - final String serverUrl; - final Function(String apiKey) onSuccess; - final Function(String error) onError; - - const OidcBrowser({ - super.key, - required this.authUrl, - required this.serverUrl, - required this.onSuccess, - required this.onError, - }); - - @override - State createState() => _OidcBrowserState(); -} - -class _OidcBrowserState extends State { - late final WebViewController _controller; - bool _isLoading = true; - String _currentUrl = ''; - bool _callbackTriggered = false; // Prevent duplicate callbacks - - @override - void initState() { - super.initState(); - _initializeWebView(); - } - - void _initializeWebView() { - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate( - NavigationDelegate( - onPageStarted: (String url) { - setState(() { - _currentUrl = url; - _isLoading = true; - }); - - _checkForCallback(url); - }, - onPageFinished: (String url) { - setState(() { - _isLoading = false; - }); - - _checkForCallback(url); - }, - onNavigationRequest: (NavigationRequest request) { - _checkForCallback(request.url); - return NavigationDecision.navigate; - }, - ), - ) - ..loadRequest(Uri.parse(widget.authUrl)); - } - - void _checkForCallback(String url) { - if (_callbackTriggered) return; // Prevent duplicate callbacks - - // Check if we've reached the callback URL with an API key - final apiKey = OidcService.extractApiKeyFromUrl(url); - if (apiKey != null) { - _callbackTriggered = true; // Mark callback as triggered - widget.onSuccess(apiKey); - return; - } - - // Check for error in callback URL - final uri = Uri.tryParse(url); - if (uri != null && uri.path.contains('/oauth/callback')) { - final error = uri.queryParameters['error']; - if (error != null) { - _callbackTriggered = true; // Mark callback as triggered - final errorDescription = uri.queryParameters['description'] ?? uri.queryParameters['details'] ?? 'Authentication failed'; - widget.onError('$error: $errorDescription'); - return; - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Sign In'), - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - widget.onError('User cancelled authentication'); - }, - ), - actions: [ - if (_isLoading) - const Padding( - padding: EdgeInsets.all(16.0), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ), - ], - ), - body: Column( - children: [ - // URL bar for debugging - if (MediaQuery.of(context).size.height > 600) - Container( - padding: const EdgeInsets.all(8.0), - color: Colors.grey[200], - child: Row( - children: [ - const Icon(Icons.link, size: 16), - const SizedBox(width: 8), - Expanded( - child: Text( - _currentUrl, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - // WebView - Expanded( - child: WebViewWidget( - controller: _controller, - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/mobile/lib/ui/auth/pinepods_startup_login.dart b/mobile/lib/ui/auth/pinepods_startup_login.dart index eb6083ce..6d111341 100644 --- a/mobile/lib/ui/auth/pinepods_startup_login.dart +++ b/mobile/lib/ui/auth/pinepods_startup_login.dart @@ -1,10 +1,11 @@ // lib/ui/auth/pinepods_startup_login.dart import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; import 'package:pinepods_mobile/services/pinepods/login_service.dart'; import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; import 'package:pinepods_mobile/services/auth_notifier.dart'; -import 'package:pinepods_mobile/ui/auth/oidc_browser.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'dart:math'; @@ -185,32 +186,37 @@ class _PinepodsStartupLoginState extends State { return; } - setState(() { - _isLoading = false; - }); + // Launch a native auth session (Chrome Custom Tab on Android, + // ASWebAuthenticationSession on iOS). The backend redirects the result + // back to the custom scheme, which flutter_web_auth_2 captures and returns. + final result = await FlutterWebAuth2.authenticate( + url: authUrl, + callbackUrlScheme: OidcService.callbackUrlScheme, + ); - // Launch in-app browser - if (mounted) { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => OidcBrowser( - authUrl: authUrl, - serverUrl: serverUrl, - onSuccess: (apiKey) async { - Navigator.of(context).pop(); // Close the browser - await _completeOidcLogin(apiKey, serverUrl); - }, - onError: (error) { - Navigator.of(context).pop(); // Close the browser - setState(() { - _errorMessage = 'Authentication failed: $error'; - }); - }, - ), - ), - ); + final callback = OidcService.parseCallback(result); + if (callback.isSuccess && callback.hasApiKey) { + await _completeOidcLogin(callback.apiKey!, serverUrl); + } else { + setState(() { + _errorMessage = + 'Authentication failed: ${callback.error ?? 'no API key returned'}'; + _isLoading = false; + }); + } + + } on PlatformException catch (e) { + // User dismissed the auth session - not an error worth surfacing. + if (e.code == 'CANCELED') { + setState(() { + _isLoading = false; + }); + } else { + setState(() { + _errorMessage = 'OIDC login error: ${e.message ?? e.code}'; + _isLoading = false; + }); } - } catch (e) { setState(() { _errorMessage = 'OIDC login error: ${e.toString()}'; diff --git a/mobile/lib/ui/pinepods/action_queue.dart b/mobile/lib/ui/pinepods/action_queue.dart new file mode 100644 index 00000000..d51c7b38 --- /dev/null +++ b/mobile/lib/ui/pinepods/action_queue.dart @@ -0,0 +1,162 @@ +// lib/ui/pinepods/action_queue.dart +import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; +import 'package:pinepods_mobile/entities/pending_action.dart'; +import 'package:pinepods_mobile/repository/repository.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +/// Shows the offline "outbox" — episode interactions (progress, completion, +/// saved, queue) that were recorded while offline and are waiting to sync to +/// the PinePods server. Lets the user trigger a manual sync. +class ActionQueue extends StatefulWidget { + const ActionQueue({super.key}); + + @override + State createState() => _ActionQueueState(); +} + +class _ActionQueueState extends State { + late final Repository _repository; + List _actions = []; + final Map _titleCache = {}; + bool _loading = true; + bool _syncing = false; + + @override + void initState() { + super.initState(); + _repository = Provider.of(context, listen: false).podcastService.repository; + _load(); + } + + Future _load() async { + setState(() => _loading = true); + final actions = await _repository.getPendingActions(); + + // Resolve episode titles from local downloads where possible. + for (final a in actions) { + if (!_titleCache.containsKey(a.episodeId)) { + final ep = await _repository.findEpisodeByGuid('pinepods_${a.episodeId}'); + if (ep?.title != null) { + _titleCache[a.episodeId] = ep!.title!; + } + } + } + + if (mounted) { + setState(() { + _actions = actions; + _loading = false; + }); + } + } + + Future _syncNow() async { + final queue = GlobalServices.offlineActionQueue; + if (queue == null) return; + + setState(() => _syncing = true); + await queue.flush(); + await _load(); + if (mounted) { + setState(() => _syncing = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_actions.isEmpty ? 'All actions synced' : '${_actions.length} action(s) still pending'), + duration: const Duration(seconds: 2), + ), + ); + } + } + + IconData _iconFor(PendingActionType type) { + switch (type) { + case PendingActionType.recordPosition: + case PendingActionType.addHistory: + return Icons.timelapse; + case PendingActionType.markCompleted: + return Icons.check_circle; + case PendingActionType.markUncompleted: + return Icons.unpublished; + case PendingActionType.saveEpisode: + return Icons.bookmark_add; + case PendingActionType.removeSaved: + return Icons.bookmark_remove; + case PendingActionType.queue: + return Icons.queue_music; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Action Queue'), + actions: [ + IconButton( + icon: const Icon(Icons.sync), + tooltip: 'Sync now', + onPressed: _syncing ? null : _syncNow, + ), + ], + ), + body: _loading + ? const Center(child: PlatformProgressIndicator()) + : RefreshIndicator( + onRefresh: _load, + child: _actions.isEmpty + ? ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.3), + Icon(Icons.cloud_done, size: 64, color: Colors.green[400]), + const SizedBox(height: 16), + Center( + child: Text('Everything is synced', + style: Theme.of(context).textTheme.titleMedium), + ), + const SizedBox(height: 8), + Center( + child: Text( + 'Interactions made offline appear here until they reach the server.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ) + : Column( + children: [ + if (_syncing) const LinearProgressIndicator(), + Expanded( + child: ListView.separated( + itemCount: _actions.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final a = _actions[index]; + final title = _titleCache[a.episodeId] ?? 'Episode #${a.episodeId}'; + return ListTile( + leading: Icon(_iconFor(a.type), color: Theme.of(context).primaryColor), + title: Text(a.description), + subtitle: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: a.retryCount > 0 + ? Tooltip( + message: 'Failed ${a.retryCount} time(s)', + child: Icon(Icons.error_outline, size: 18, color: Colors.orange[700]), + ) + : null, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/pinepods/download_activity.dart b/mobile/lib/ui/pinepods/download_activity.dart index ac845325..9fc6ae48 100644 --- a/mobile/lib/ui/pinepods/download_activity.dart +++ b/mobile/lib/ui/pinepods/download_activity.dart @@ -1,7 +1,11 @@ // lib/ui/pinepods/download_activity.dart import 'package:flutter/material.dart'; +import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; +import 'package:pinepods_mobile/entities/downloadable.dart'; +import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; import 'package:provider/provider.dart'; class DownloadActivity extends StatefulWidget { @@ -29,37 +33,126 @@ class _DownloadActivityState extends State { _errorMessage = null; }); + final settingsBloc = Provider.of(context, listen: false); + final settings = settingsBloc.currentSettings; + + // Local download activity from the on-device repository. Always available, + // even offline, and includes auto-downloads, in-progress, and failures. + List localTasks = []; try { - final settingsBloc = Provider.of(context, listen: false); - final settings = settingsBloc.currentSettings; - - if (settings.pinepodsServer == null || - settings.pinepodsApiKey == null || - settings.pinepodsUserId == null) { - setState(() { - _errorMessage = 'Not connected to PinePods server.'; - _isLoading = false; - }); - return; + final podcastBloc = Provider.of(context, listen: false); + final episodes = await podcastBloc.podcastService.repository.findAllEpisodes(); + localTasks = _localTasksFromEpisodes(episodes); + } catch (e) { + // Non-fatal; just show whatever else we have. + } + + // Server-side download activity (best effort — may be offline / logged out). + List serverTasks = []; + String? serverError; + if (settings.pinepodsServer != null && + settings.pinepodsApiKey != null && + settings.pinepodsUserId != null) { + try { + _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + serverTasks = await _pinepodsService.getDownloadActivity(settings.pinepodsUserId!); + } catch (e) { + serverError = 'Failed to load server download activity.'; } + } - _pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); - final userId = settings.pinepodsUserId!; - final tasks = await _pinepodsService.getDownloadActivity(userId); + final all = [...serverTasks, ...localTasks] + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - setState(() { - _tasks = tasks; - _isLoading = false; - }); - } catch (e) { - setState(() { - _errorMessage = 'Failed to load download activity.'; - _isLoading = false; - }); + if (!mounted) return; + setState(() { + _tasks = all; + // Only surface an error if we genuinely have nothing to show. + _errorMessage = all.isEmpty ? serverError : null; + _isLoading = false; + }); + } + + /// Build activity entries for local downloads from the on-device episode + /// store. Local downloads persist their state on the [Episode] record + /// (downloadState / downloadPercentage / lastUpdated), so recent activity — + /// including failures and auto-downloads — can be derived directly. + List _localTasksFromEpisodes(List episodes) { + final cutoff = DateTime.now().subtract(const Duration(days: 7)); + final tasks = []; + + for (final e in episodes) { + // Local downloads are always keyed by a 'pinepods_' guid; anything + // else (streaming/playback records) is not a local download. + if (!e.guid.startsWith('pinepods_')) continue; + if (e.downloadState == DownloadState.none) continue; + + final updated = e.lastUpdated ?? DateTime.now(); + if (updated.isBefore(cutoff)) continue; + + tasks.add(DownloadTask( + id: e.guid, + taskType: 'local', + status: _statusForState(e.downloadState), + progress: (e.downloadPercentage ?? 0).toDouble(), + message: e.downloadState == DownloadState.failed + ? 'Local download failed' + : e.downloadState == DownloadState.cancelled + ? 'Download cancelled' + : null, + createdAt: updated, + updatedAt: updated, + result: {'episode_id': LocalDownloadUtils.episodeIdFromGuid(e.guid)}, + episodeTitle: e.title, + podcastName: e.podcast, + )); + } + + return tasks; + } + + String _statusForState(DownloadState state) { + switch (state) { + case DownloadState.downloaded: + return 'SUCCESS'; + case DownloadState.failed: + case DownloadState.cancelled: + return 'FAILED'; + case DownloadState.downloading: + case DownloadState.paused: + return 'DOWNLOADING'; + case DownloadState.queued: + case DownloadState.none: + return 'PENDING'; } } Future _retryDownload(DownloadTask task) async { + // Local downloads retry through the on-device download service rather than + // the server. + if (task.taskType == 'local') { + final podcastBloc = Provider.of(context, listen: false); + final episode = await podcastBloc.podcastService.repository.findEpisodeByGuid(task.id); + if (episode == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Episode no longer available'), duration: Duration(seconds: 2)), + ); + return; + } + final success = await podcastBloc.downloadService.downloadEpisode(episode); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success ? 'Local download restarted' : 'Failed to restart download'), + backgroundColor: success ? Colors.green : Colors.red, + duration: const Duration(seconds: 2), + ), + ); + if (success) _loadActivity(); + return; + } + final episodeId = task.episodeId; if (episodeId == null) return; @@ -210,6 +303,8 @@ class _DownloadActivityState extends State { ), ), const SizedBox(width: 8), + _sourceChip(task, theme), + const SizedBox(width: 8), Text( timeLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), @@ -263,6 +358,30 @@ class _DownloadActivityState extends State { ); } + /// Small chip distinguishing on-device (local) downloads from server-side ones. + Widget _sourceChip(DownloadTask task, ThemeData theme) { + final isLocal = task.taskType == 'local'; + final color = isLocal ? Colors.green[600]! : Colors.blue[600]!; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isLocal ? Icons.smartphone : Icons.cloud, size: 11, color: color), + const SizedBox(width: 3), + Text( + isLocal ? 'Local' : 'Server', + style: TextStyle(fontSize: 10, color: color, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } + // Extract a title from a status message like "Downloaded Episode Name" or "Preparing Episode Name" String? _titleFromMessage(String? message) { if (message == null) return null; diff --git a/mobile/lib/ui/pinepods/downloads.dart b/mobile/lib/ui/pinepods/downloads.dart index 1a7add35..f738ad69 100644 --- a/mobile/lib/ui/pinepods/downloads.dart +++ b/mobile/lib/ui/pinepods/downloads.dart @@ -5,17 +5,17 @@ import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart'; import 'package:pinepods_mobile/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; -import 'package:pinepods_mobile/services/download/download_service.dart'; import 'package:pinepods_mobile/ui/pinepods/download_activity.dart'; +import 'package:pinepods_mobile/ui/pinepods/action_queue.dart'; import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart'; import 'package:pinepods_mobile/state/bloc_state.dart'; import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; -import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart'; -import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart'; import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; import 'package:pinepods_mobile/services/error_handling_service.dart'; -import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; +import 'package:pinepods_mobile/services/global_services.dart'; +import 'package:pinepods_mobile/ui/utils/local_download_utils.dart'; +import 'package:pinepods_mobile/ui/utils/player_utils.dart'; import 'package:pinepods_mobile/ui/pinepods/episode_details.dart'; import 'package:provider/provider.dart'; import 'package:logging/logging.dart'; @@ -462,7 +462,8 @@ class _PinepodsDownloadsState extends State { author: episode.podcastName, season: 0, episode: 0, - position: episode.listenDuration ?? 0, + // Episode.position is stored in milliseconds; listenDuration is seconds. + position: (episode.listenDuration ?? 0) * 1000, played: episode.completed, chapters: [], transcriptUrls: [], @@ -623,28 +624,60 @@ class _PinepodsDownloadsState extends State { ); } - Widget _buildLocalPodcastDropdown(String podcastKey, List episodes, {String? displayName}) { - final isExpanded = _expandedPodcasts.contains(podcastKey); - final title = displayName ?? podcastKey; + /// Flatten the per-podcast local download map into a single list sorted by the + /// active sort control. Local downloads render as a flat list of standard + /// episode cards (no per-podcast dropdown) so they match the rest of the app. + List _flattenLocalDownloads(Map> byPodcast) { + final all = []; + for (final list in byPodcast.values) { + all.addAll(list); + } + all.sort((a, b) { + switch (_sortDirection) { + case DownloadSortDirection.newestFirst: + return _compareLocalDates(b.publicationDate, a.publicationDate); + case DownloadSortDirection.oldestFirst: + return _compareLocalDates(a.publicationDate, b.publicationDate); + case DownloadSortDirection.titleAZ: + return (a.title ?? '').toLowerCase().compareTo((b.title ?? '').toLowerCase()); + case DownloadSortDirection.titleZA: + return (b.title ?? '').toLowerCase().compareTo((a.title ?? '').toLowerCase()); + } + }); + return all; + } - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: Column( - children: [ - ListTile( - leading: Icon(Icons.file_download, color: Colors.green[600]), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text('${episodes.length} episode${episodes.length != 1 ? 's' : ''}'), - trailing: Icon(isExpanded ? Icons.expand_less : Icons.expand_more), - onTap: () => _togglePodcastExpansion(podcastKey), - ), - if (isExpanded) - PaginatedEpisodeList( - episodes: episodes, - isServerEpisodes: false, - onPlayPressed: (episode) => _playLocalEpisode(episode), - ), - ], + /// Render a single local download using the standard episode card so it looks + /// and behaves identically to server episodes (tap opens the episode page, + /// long-press shows the context menu, play uses the unified path). + Widget _buildLocalEpisodeCard(Episode episode) { + final pe = LocalDownloadUtils.toPinepodsEpisode(episode); + return PinepodsEpisodeCard( + episode: pe, + isLocalDownload: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PinepodsEpisodeDetails(initialEpisode: pe), + ), + ), + onPlayPressed: () => _playLocalEpisode(episode), + onLongPress: () => _showLocalContextMenu(pe, episode), + ); + } + + void _showLocalContextMenu(PinepodsEpisode pe, Episode episode) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.3), + builder: (context) => EpisodeContextMenu( + episode: pe, + isDownloadedLocally: true, + onDeleteLocalDownload: () { + Navigator.of(context).pop(); + _handleLocalEpisodeDelete(episode); + }, + onDismiss: () => Navigator.of(context).pop(), ), ); } @@ -767,6 +800,36 @@ class _PinepodsDownloadsState extends State { ); } + /// Button linking to the offline action queue, with a live count of pending + /// interactions waiting to sync. + Widget _buildActionQueueButton() { + final repository = Provider.of(context, listen: false).podcastService.repository; + + return StreamBuilder( + stream: repository.pendingActionListener, + builder: (context, _) { + return FutureBuilder( + future: repository.getPendingActions().then((a) => a.length), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ActionQueue()), + ), + icon: Icon(count > 0 ? Icons.sync_problem : Icons.sync, size: 16), + label: Text(count > 0 ? 'Action Queue ($count)' : 'Action Queue'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + visualDensity: VisualDensity.compact, + foregroundColor: count > 0 ? Colors.orange[700] : null, + ), + ); + }, + ); + }, + ); + } + Widget _buildSearchAndFilterBar() { return SliverToBoxAdapter( child: Padding( @@ -777,6 +840,8 @@ class _PinepodsDownloadsState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + _buildActionQueueButton(), + const SizedBox(width: 4), TextButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadActivity()), @@ -908,9 +973,7 @@ class _PinepodsDownloadsState extends State { ], ), ), - ..._filteredLocalDownloadsByPodcast.entries.map((entry) { - return _buildLocalPodcastDropdown('local_${entry.key}', entry.value, displayName: entry.key); - }).toList(), + ..._flattenLocalDownloads(_filteredLocalDownloadsByPodcast).map(_buildLocalEpisodeCard), ], if (serverSummaries.isNotEmpty) ...[ @@ -952,8 +1015,20 @@ class _PinepodsDownloadsState extends State { Future _playLocalEpisode(Episode episode) async { try { log.info('Playing local episode: ${episode.title}'); - final audioPlayerService = Provider.of(context, listen: false); - await audioPlayerService.playEpisode(episode: episode, resume: true); + final audioService = GlobalServices.pinepodsAudioService; + if (audioService == null) { + _showErrorSnackBar('Audio service not available'); + return; + } + // Play through the unified PinePods path so position/duration tracking and + // interaction recording work the same as for server episodes. + final pe = LocalDownloadUtils.toPinepodsEpisode(episode); + await playPinepodsEpisodeWithOptionalFullScreen( + context, + audioService, + pe, + resume: true, + ); log.info('Successfully started local episode playback'); } catch (e) { log.severe('Error playing local episode: $e'); @@ -1038,9 +1113,7 @@ class _PinepodsDownloadsState extends State { ], ), ), - ...localDownloadsByPodcast.entries.map((entry) { - return _buildLocalPodcastDropdown('offline_local_${entry.key}', entry.value, displayName: entry.key); - }).toList(), + ..._flattenLocalDownloads(localDownloadsByPodcast).map(_buildLocalEpisodeCard), const SizedBox(height: 100), ]), ), diff --git a/mobile/lib/ui/pinepods/episode_details.dart b/mobile/lib/ui/pinepods/episode_details.dart index 0ed481f0..ab506f50 100644 --- a/mobile/lib/ui/pinepods/episode_details.dart +++ b/mobile/lib/ui/pinepods/episode_details.dart @@ -6,6 +6,7 @@ import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; import 'package:pinepods_mobile/services/audio/audio_player_service.dart'; import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart'; import 'package:pinepods_mobile/entities/pinepods_episode.dart'; +import 'package:pinepods_mobile/entities/pending_action.dart'; import 'package:pinepods_mobile/entities/pinepods_search.dart'; import 'package:pinepods_mobile/entities/person.dart'; import 'package:pinepods_mobile/ui/widgets/podcast_html.dart'; @@ -50,6 +51,16 @@ class _PinepodsEpisodeDetailsState extends State { PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService; + /// Enqueue an interaction in the offline outbox so it syncs when back online, + /// used when a direct server call fails (e.g. the device is offline). Returns + /// true if it was queued. + Future _enqueueOffline(PendingActionType type, int userId) async { + final queue = GlobalServices.offlineActionQueue; + if (queue == null) return false; + await queue.enqueueSimple(type, _episode!.episodeId, userId, _episode!.isYoutube); + return true; + } + Future _checkLocalDownloadStatus() async { if (_episode == null) return; @@ -102,11 +113,12 @@ class _PinepodsEpisodeDetailsState extends State { final settingsBloc = Provider.of(context, listen: false); final settings = settingsBloc.currentSettings; - if (settings.pinepodsServer == null || - settings.pinepodsApiKey == null || + if (settings.pinepodsServer == null || + settings.pinepodsApiKey == null || settings.pinepodsUserId == null) { + // No server connection: still show the episode we were given (e.g. a + // local download opened while offline) rather than an error page. setState(() { - _errorMessage = 'Not connected to PinePods server. Please login first.'; _isLoading = false; }); return; @@ -157,14 +169,19 @@ class _PinepodsEpisodeDetailsState extends State { _isLoading = false; }); } else { + // Metadata unavailable: fall back to the episode passed in so the page + // (and playback of a local download) still works. setState(() { - _errorMessage = 'Failed to load episode details'; _isLoading = false; }); } } catch (e) { + // Likely offline. Keep the initial episode so local downloads remain + // playable; only surface an error if we have nothing to show. setState(() { - _errorMessage = 'Error loading episode details: ${e.toString()}'; + if (_episode == null) { + _errorMessage = 'Error loading episode details: ${e.toString()}'; + } _isLoading = false; }); } @@ -303,7 +320,12 @@ class _PinepodsEpisodeDetailsState extends State { _showSnackBar('Failed to save episode', Colors.red); } } catch (e) { - _showSnackBar('Error saving episode: $e', Colors.red); + if (await _enqueueOffline(PendingActionType.saveEpisode, userId)) { + setState(() => _episode = _updateEpisodeProperty(_episode!, saved: true)); + _showSnackBar('Saved — will sync when online', Colors.blueGrey); + } else { + _showSnackBar('Error saving episode: $e', Colors.red); + } } } @@ -333,7 +355,12 @@ class _PinepodsEpisodeDetailsState extends State { _showSnackBar('Failed to remove saved episode', Colors.red); } } catch (e) { - _showSnackBar('Error removing saved episode: $e', Colors.red); + if (await _enqueueOffline(PendingActionType.removeSaved, userId)) { + setState(() => _episode = _updateEpisodeProperty(_episode!, saved: false)); + _showSnackBar('Removed — will sync when online', Colors.blueGrey); + } else { + _showSnackBar('Error removing saved episode: $e', Colors.red); + } } } @@ -471,7 +498,17 @@ class _PinepodsEpisodeDetailsState extends State { _showSnackBar('Failed to update completion status', Colors.red); } } catch (e) { - _showSnackBar('Error updating completion: $e', Colors.red); + final wasCompleted = _episode!.completed; + final type = wasCompleted ? PendingActionType.markUncompleted : PendingActionType.markCompleted; + if (await _enqueueOffline(type, userId)) { + setState(() => _episode = _updateEpisodeProperty(_episode!, completed: !wasCompleted)); + _showSnackBar( + wasCompleted ? 'Marked incomplete — will sync when online' : 'Marked complete — will sync when online', + Colors.blueGrey, + ); + } else { + _showSnackBar('Error updating completion: $e', Colors.red); + } } } diff --git a/mobile/lib/ui/pinepods_podcast_app.dart b/mobile/lib/ui/pinepods_podcast_app.dart index 66b831c0..8e99bdce 100644 --- a/mobile/lib/ui/pinepods_podcast_app.dart +++ b/mobile/lib/ui/pinepods_podcast_app.dart @@ -30,6 +30,7 @@ import 'package:pinepods_mobile/services/download/mobile_download_manager.dart'; import 'package:pinepods_mobile/services/download/mobile_download_service.dart'; import 'package:pinepods_mobile/services/podcast/mobile_podcast_service.dart'; import 'package:pinepods_mobile/services/podcast/podcast_service.dart'; +import 'package:pinepods_mobile/services/offline/offline_action_queue.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart'; import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; @@ -92,6 +93,7 @@ class PinepodsPodcastApp extends StatefulWidget { List certificateAuthorityBytes; late PinepodsAudioService pinepodsAudioService; late PinepodsService pinepodsService; + late OfflineActionQueue offlineActionQueue; CarPlayService? carPlayService; PinepodsPodcastApp({ @@ -143,10 +145,21 @@ class PinepodsPodcastApp extends StatefulWidget { ); } + // Offline outbox: durably queues episode interactions and syncs them when + // online. Interaction recording in the audio service routes through it. + offlineActionQueue = OfflineActionQueue( + repository: repository, + pinepodsService: pinepodsService, + settingsService: mobileSettingsService, + ); + pinepodsAudioService.setActionQueue(offlineActionQueue); + offlineActionQueue.start(); + // Initialize global services for app-wide access GlobalServices.initialize( pinepodsAudioService: pinepodsAudioService, pinepodsService: pinepodsService, + offlineActionQueue: offlineActionQueue, ); // Initialize CarPlay service for iOS diff --git a/mobile/lib/ui/utils/local_download_utils.dart b/mobile/lib/ui/utils/local_download_utils.dart index ef20e325..538c12da 100644 --- a/mobile/lib/ui/utils/local_download_utils.dart +++ b/mobile/lib/ui/utils/local_download_utils.dart @@ -16,6 +16,40 @@ class LocalDownloadUtils { return 'pinepods_${episode.episodeId}'; } + /// Parse the server episode id out of a local-download guid. Handles both the + /// canonical `pinepods_` format and the legacy `pinepods__` one. + static int episodeIdFromGuid(String guid) { + if (!guid.startsWith('pinepods_')) return 0; + final rest = guid.substring('pinepods_'.length); + return int.tryParse(rest.split('_').first) ?? 0; + } + + /// Convert a stored local-download [Episode] back into a [PinepodsEpisode] so + /// it can be rendered and played through the same widgets/path as every other + /// (server) episode. The reverse of [localDownloadEpisode]'s conversion. + static PinepodsEpisode toPinepodsEpisode(Episode episode) { + return PinepodsEpisode( + podcastName: episode.podcast ?? 'Unknown Podcast', + episodeTitle: episode.title ?? '', + episodePubDate: episode.publicationDate?.toIso8601String() ?? '', + episodeDescription: episode.description ?? '', + episodeArtwork: episode.imageUrl ?? '', + // episodeUrl doubles as the now-playing match key; keep it as the original + // content URL so highlighting lines up with the unified play path. + episodeUrl: episode.contentUrl ?? '', + episodeDuration: episode.duration, // stored in seconds + // Episode.position is milliseconds; listenDuration is seconds. + listenDuration: episode.position > 0 ? episode.position ~/ 1000 : null, + episodeId: episodeIdFromGuid(episode.guid), + completed: episode.played, + saved: false, + queued: false, + downloaded: true, + isYoutube: episode.pguid?.contains('youtube') ?? false, + podcastId: null, + ); + } + /// Clear the local download status cache (call on refresh) static void clearCache() { _localDownloadStatusCache.clear(); @@ -142,7 +176,8 @@ class LocalDownloadUtils { author: episode.podcastName, season: 0, episode: 0, - position: episode.listenDuration ?? 0, + // Episode.position is stored in milliseconds; listenDuration is seconds. + position: (episode.listenDuration ?? 0) * 1000, played: episode.completed, chapters: [], transcriptUrls: [], diff --git a/mobile/lib/ui/widgets/offline_episode_tile.dart b/mobile/lib/ui/widgets/offline_episode_tile.dart deleted file mode 100644 index d0d43a7a..00000000 --- a/mobile/lib/ui/widgets/offline_episode_tile.dart +++ /dev/null @@ -1,162 +0,0 @@ -// lib/ui/widgets/offline_episode_tile.dart -import 'package:flutter/material.dart'; -import 'package:pinepods_mobile/entities/episode.dart'; -import 'package:pinepods_mobile/ui/widgets/tile_image.dart'; -import 'package:pinepods_mobile/l10n/L.dart'; -import 'package:intl/intl.dart' show DateFormat; - -/// A custom episode tile specifically for offline downloaded episodes. -/// This bypasses the legacy PlayControl system and uses a custom play callback. -class OfflineEpisodeTile extends StatelessWidget { - final Episode episode; - final VoidCallback? onPlayPressed; - final VoidCallback? onTap; - - const OfflineEpisodeTile({ - super.key, - required this.episode, - this.onPlayPressed, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: ListTile( - onTap: onTap, - leading: Stack( - alignment: Alignment.bottomLeft, - children: [ - Opacity( - opacity: episode.played ? 0.5 : 1.0, - child: TileImage( - url: episode.thumbImageUrl ?? episode.imageUrl!, - size: 56.0, - highlight: episode.highlight, - ), - ), - // Progress indicator - SizedBox( - height: 5.0, - width: 56.0 * (episode.percentagePlayed / 100), - child: Container( - color: Theme.of(context).primaryColor, - ), - ), - ], - ), - title: Opacity( - opacity: episode.played ? 0.5 : 1.0, - child: Text( - episode.title!, - overflow: TextOverflow.ellipsis, - maxLines: 2, - style: textTheme.bodyMedium, - ), - ), - subtitle: Opacity( - opacity: episode.played ? 0.5 : 1.0, - child: _EpisodeSubtitle(episode), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Offline indicator - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green[100], - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.offline_pin, - size: 12, - color: Colors.green[700], - ), - const SizedBox(width: 4), - Text( - 'Offline', - style: TextStyle( - fontSize: 10, - color: Colors.green[700], - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Custom play button that bypasses legacy audio system - SizedBox( - width: 48, - height: 48, - child: IconButton( - onPressed: onPlayPressed, - icon: Icon( - Icons.play_arrow, - color: Theme.of(context).primaryColor, - ), - tooltip: L.of(context)?.play_button_label ?? 'Play', - ), - ), - ], - ), - ), - ); - } -} - -class _EpisodeSubtitle extends StatelessWidget { - final Episode episode; - final String date; - final Duration length; - - _EpisodeSubtitle(this.episode) - : date = episode.publicationDate == null - ? '' - : DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy') - .format(episode.publicationDate!), - length = Duration(seconds: episode.duration); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - var timeRemaining = episode.timeRemaining; - - String title; - - if (length.inSeconds > 0) { - if (length.inSeconds < 60) { - title = '$date • ${length.inSeconds} sec'; - } else { - title = '$date • ${length.inMinutes} min'; - } - } else { - title = date; - } - - if (timeRemaining.inSeconds > 0) { - if (timeRemaining.inSeconds < 60) { - title = '$title / ${timeRemaining.inSeconds} sec left'; - } else { - title = '$title / ${timeRemaining.inMinutes} min left'; - } - } - - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - title, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: textTheme.bodySmall, - ), - ); - } -} \ No newline at end of file diff --git a/mobile/lib/ui/widgets/paginated_episode_list.dart b/mobile/lib/ui/widgets/paginated_episode_list.dart index 6affd305..44e78fe1 100644 --- a/mobile/lib/ui/widgets/paginated_episode_list.dart +++ b/mobile/lib/ui/widgets/paginated_episode_list.dart @@ -4,13 +4,11 @@ import 'package:pinepods_mobile/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart'; import 'package:pinepods_mobile/ui/widgets/episode_tile.dart'; -import 'package:pinepods_mobile/ui/widgets/offline_episode_tile.dart'; import 'package:pinepods_mobile/ui/widgets/shimmer_episode_tile.dart'; class PaginatedEpisodeList extends StatefulWidget { final List episodes; // Can be PinepodsEpisode or Episode final bool isServerEpisodes; - final bool isOfflineMode; // New flag for offline mode final Function(dynamic episode)? onEpisodeTap; final Function(dynamic episode, int globalIndex)? onEpisodeLongPress; final Function(dynamic episode)? onPlayPressed; @@ -20,7 +18,6 @@ class PaginatedEpisodeList extends StatefulWidget { super.key, required this.episodes, required this.isServerEpisodes, - this.isOfflineMode = false, this.onEpisodeTap, this.onEpisodeLongPress, this.onPlayPressed, @@ -73,24 +70,11 @@ class _PaginatedEpisodeListState extends State { : null, ); } else if (!widget.isServerEpisodes && episode is Episode) { - // Use offline episode tile when in offline mode to bypass legacy audio system - if (widget.isOfflineMode) { - return OfflineEpisodeTile( - episode: episode, - onTap: widget.onEpisodeTap != null - ? () => widget.onEpisodeTap!(episode) - : null, - onPlayPressed: widget.onPlayPressed != null - ? () => widget.onPlayPressed!(episode) - : null, - ); - } else { - return EpisodeTile( - episode: episode, - download: false, - play: true, - ); - } + return EpisodeTile( + episode: episode, + download: false, + play: true, + ); } return const SizedBox.shrink(); // Fallback diff --git a/mobile/lib/ui/widgets/pinepods_episode_card.dart b/mobile/lib/ui/widgets/pinepods_episode_card.dart index 7af5fd78..ba3eae8f 100644 --- a/mobile/lib/ui/widgets/pinepods_episode_card.dart +++ b/mobile/lib/ui/widgets/pinepods_episode_card.dart @@ -15,12 +15,17 @@ class PinepodsEpisodeCard extends StatefulWidget { final VoidCallback? onLongPress; final VoidCallback? onPlayPressed; + /// When true the card shows an "offline" badge, indicating the episode is + /// available as a local download and can be played without a connection. + final bool isLocalDownload; + const PinepodsEpisodeCard({ Key? key, required this.episode, this.onTap, this.onLongPress, this.onPlayPressed, + this.isLocalDownload = false, }) : super(key: key); @override @@ -245,6 +250,15 @@ class _PinepodsEpisodeCardState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ + if (widget.isLocalDownload) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon( + Icons.offline_pin, + size: 16, + color: Colors.green[600], + ), + ), if (widget.episode.saved) Icon( Icons.bookmark, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 543001f5..3bdc18f0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 + url: "https://pub.dev" + source: hosted + version: "0.3.0" device_info_plus: dependency: "direct main" description: @@ -480,6 +488,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -1274,38 +1298,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.dev" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3" - url: "https://pub.dev" - source: hosted - version: "4.10.0" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" - url: "https://pub.dev" - source: hosted - version: "2.14.0" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f - url: "https://pub.dev" - source: hosted - version: "3.23.0" win32: dependency: "direct overridden" description: @@ -1322,6 +1314,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" xdg_directories: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index df4fd353..654819f1 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,7 +51,7 @@ dependencies: shared_preferences: ^2.5.5 sliver_tools: ^0.2.12 url_launcher: ^6.3.1 - webview_flutter: ^4.13.1 + flutter_web_auth_2: ^5.0.0 xml: ^6.6.0 flutter: diff --git a/rust-api/src/database.rs b/rust-api/src/database.rs index aae545d2..dd112d7e 100644 --- a/rust-api/src/database.rs +++ b/rust-api/src/database.rs @@ -9391,6 +9391,27 @@ impl DatabasePool { Ok(()) } + // Clear podcast playback speed - resets the podcast back to the global default + pub async fn clear_podcast_playback_speed(&self, user_id: i32, podcast_id: i32) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Podcasts" SET playbackspeed = 1.0, playbackspeedcustomized = FALSE WHERE podcastid = $1 AND userid = $2"#) + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Podcasts SET PlaybackSpeed = 1.0, PlaybackSpeedCustomized = FALSE WHERE PodcastID = ? AND UserID = ?") + .bind(podcast_id) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + // Enable/disable auto download for podcast - matches Python enable_auto_download function pub async fn enable_auto_download(&self, podcast_id: i32, auto_download: bool, user_id: i32) -> AppResult<()> { match self { @@ -10041,7 +10062,7 @@ impl DatabasePool { } // Get play episode details - matches Python get_play_episode_details function exactly - pub async fn get_play_episode_details(&self, user_id: i32, podcast_id: i32, _is_youtube: bool) -> AppResult<(f64, i32, i32)> { + pub async fn get_play_episode_details(&self, user_id: i32, podcast_id: i32, _is_youtube: bool) -> AppResult<(f64, i32, i32, bool)> { match self { DatabasePool::Postgres(pool) => { // First get user's default playback speed @@ -10081,15 +10102,16 @@ impl DatabasePool { let start_skip: Option = row.try_get("startskip")?; let end_skip: Option = row.try_get("endskip")?; - let final_playback_speed = if playback_speed_customized.unwrap_or(false) { + let is_customized = playback_speed_customized.unwrap_or(false); + let final_playback_speed = if is_customized { podcast_playback_speed.unwrap_or(user_playback_speed) } else { user_playback_speed }; - - Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0))) + + Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0), is_customized)) } else { - Ok((user_playback_speed, 0, 0)) + Ok((user_playback_speed, 0, 0, false)) } } DatabasePool::MySQL(pool) => { @@ -10130,15 +10152,16 @@ impl DatabasePool { let start_skip: Option = row.try_get("StartSkip")?; let end_skip: Option = row.try_get("EndSkip")?; - let final_playback_speed = if playback_speed_customized.unwrap_or(false) { + let is_customized = playback_speed_customized.unwrap_or(false); + let final_playback_speed = if is_customized { podcast_playback_speed.unwrap_or(user_playback_speed) } else { user_playback_speed }; - - Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0))) + + Ok((final_playback_speed, start_skip.unwrap_or(0), end_skip.unwrap_or(0), is_customized)) } else { - Ok((user_playback_speed, 0, 0)) + Ok((user_playback_speed, 0, 0, false)) } } } @@ -13812,38 +13835,7 @@ impl DatabasePool { } } - tracing::info!("Total: {} episode actions fetched", total_actions_fetched); - - println!("\n========== FORCE SYNC EPISODE ACTIONS DOWNLOAD COMPLETE =========="); - println!("📊 Downloaded {} total episode actions from ALL {} devices", all_episode_actions.len(), devices.len()); - - // Check if changelog-news-168 is in the downloaded actions - let has_changelog_168 = all_episode_actions.iter().any(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }); - - if has_changelog_168 { - println!("🎯 FOUND changelog-news-168 in downloaded episode actions!"); - // Find which device it came from - if let Some(action) = all_episode_actions.iter().find(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }) { - println!(" Device: {}", action.get("device").and_then(|d| d.as_str()).unwrap_or("unknown")); - println!(" Episode URL: {}", action.get("episode").and_then(|e| e.as_str()).unwrap_or("unknown")); - println!(" Position: {}", action.get("position").and_then(|p| p.as_i64()).unwrap_or(0)); - println!(" Timestamp: {}", action.get("timestamp").and_then(|t| t.as_i64()).unwrap_or(0)); - } - } else { - println!("❌ changelog-news-168 NOT FOUND in downloaded episode actions!"); - println!(" This means the GPodder API never returned it across any device."); - } - println!("==================================================================\n"); + tracing::info!("Total: {} episode actions fetched from {} devices", total_actions_fetched, devices.len()); // Step 4: Process all subscriptions (additions) let subscriptions_vec: Vec = all_subscriptions.into_iter().collect(); @@ -13862,22 +13854,47 @@ impl DatabasePool { } } - // Step 7: Upload local subscriptions to gPodder service (to default device) - if since_timestamp.is_none() || !subscriptions_vec.is_empty() || !removals_vec.is_empty() { - let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; - self.upload_subscriptions_to_gpodder(gpodder_url, username, password, device_name, &local_subscriptions).await?; - } else { - tracing::info!("Skipping subscription upload - no changes detected in incremental sync"); + // Step 7: Push genuine LOCAL subscription changes up to the gPodder service. + // We diff the current local feed set against the snapshot saved at the end of the last + // sync, so we only upload real local add/removes - never the full list (which would bloat + // the server change log) and never re-upload what the server just sent us (echo). This is + // also what propagates a local unsubscribe up to the server. + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + let (mut to_add, mut to_remove) = self + .compute_subscription_delta(user_id, gpodder_url, &local_subscriptions) + .await?; + // Suppress echoes: don't push back changes that originated from the server this sync. + let server_added: std::collections::HashSet<&String> = subscriptions_vec.iter().collect(); + let server_removed: std::collections::HashSet<&String> = removals_vec.iter().collect(); + to_add.retain(|f| !server_added.contains(f)); + to_remove.retain(|f| !server_removed.contains(f)); + if let Err(e) = self + .upload_subscription_delta_to_gpodder(gpodder_url, username, password, device_name, &to_add, &to_remove) + .await + { + tracing::warn!("Subscription delta upload failed but continuing: {}", e); } - - // Step 8: Upload local episode actions to gPodder service (to default device) - if let Err(e) = self.sync_episode_actions_with_gpodder(gpodder_url, username, password, device_name, user_id).await { - tracing::warn!("Episode actions sync failed but continuing: {}", e); + // Record the reconciled local state as the new baseline for next time. + self.save_subscription_snapshot(user_id, gpodder_url, &local_subscriptions).await?; + + // Step 8: Upload local episode actions since the last sync. Remote actions were already + // downloaded and applied in steps 3 and 6, so this is upload-only (no redundant re-fetch). + let local_actions = match since_timestamp { + Some(since) => self.get_user_episode_actions_since(user_id, since).await?, + None => self.get_user_episode_actions(user_id).await?, + }; + if !local_actions.is_empty() { + if let Err(e) = self + .upload_episode_actions_to_gpodder(gpodder_url, username, password, &local_actions) + .await + { + tracing::warn!("Episode actions upload failed but continuing: {}", e); + } } - + // Step 9: Update last sync timestamp for next incremental sync self.update_last_sync_timestamp(user_id).await?; - + Ok(true) } @@ -14057,38 +14074,7 @@ impl DatabasePool { } } - tracing::info!("Total: {} episode actions fetched", total_actions_fetched); - - println!("\n========== INITIAL SYNC EPISODE ACTIONS DOWNLOAD COMPLETE =========="); - println!("📊 Downloaded {} total episode actions from ALL {} devices", all_episode_actions.len(), devices.len()); - - // Check if changelog-news-168 is in the downloaded actions - let has_changelog_168 = all_episode_actions.iter().any(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }); - - if has_changelog_168 { - println!("🎯 FOUND changelog-news-168 in downloaded episode actions!"); - // Find which device it came from - if let Some(action) = all_episode_actions.iter().find(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }) { - println!(" Device: {}", action.get("device").and_then(|d| d.as_str()).unwrap_or("unknown")); - println!(" Episode URL: {}", action.get("episode").and_then(|e| e.as_str()).unwrap_or("unknown")); - println!(" Position: {}", action.get("position").and_then(|p| p.as_i64()).unwrap_or(0)); - println!(" Timestamp: {}", action.get("timestamp").and_then(|t| t.as_i64()).unwrap_or(0)); - } - } else { - println!("❌ changelog-news-168 NOT FOUND in downloaded episode actions!"); - println!(" This means the GPodder API never returned it across any device."); - } - println!("====================================================================\n"); + tracing::info!("Total: {} episode actions fetched from {} devices", total_actions_fetched, devices.len()); // Step 3: Process all subscriptions and add missing podcasts FIRST // This ensures podcasts and their episodes are in the database before applying episode actions @@ -14101,10 +14087,12 @@ impl DatabasePool { } } - // Step 4: Upload local subscriptions to GPodder service + // Step 4: Upload local subscriptions to GPodder service (full list for the initial sync) let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; self.upload_subscriptions_to_gpodder(gpodder_url, username, password, device_name, &local_subscriptions).await?; - + // Save the snapshot baseline so later incremental syncs upload only genuine local deltas. + self.save_subscription_snapshot(user_id, gpodder_url, &local_subscriptions).await?; + // Step 5: Upload local episode actions to GPodder service let local_episode_actions = self.get_user_episode_actions(user_id).await?; if !local_episode_actions.is_empty() { @@ -14145,18 +14133,17 @@ impl DatabasePool { let subscriptions: serde_json::Value = response.json().await .map_err(|e| AppError::internal(&format!("Failed to parse Nextcloud subscriptions: {}", e)))?; - // Process subscriptions - Nextcloud returns array of feed URLs - let feed_urls = if let Some(feeds) = subscriptions.as_array() { - let urls: Vec = feeds.iter() - .filter_map(|f| f.as_str().map(|s| s.to_string())) - .collect(); - - tracing::info!("Downloaded {} subscriptions from Nextcloud", urls.len()); - urls + // The gPodder Sync app returns an object {"add": [...], "remove": [...], "timestamp": N}. + // (Older/other servers may return a bare array of feed URLs, so handle both.) + let feed_urls: Vec = if let Some(add_list) = subscriptions.get("add").and_then(|v| v.as_array()) { + add_list.iter().filter_map(|f| f.as_str().map(|s| s.to_string())).collect() + } else if let Some(feeds) = subscriptions.as_array() { + feeds.iter().filter_map(|f| f.as_str().map(|s| s.to_string())).collect() } else { - tracing::warn!("No subscriptions found in Nextcloud response"); + tracing::warn!("No subscriptions found in Nextcloud response: {:?}", subscriptions); vec![] }; + tracing::info!("Downloaded {} subscriptions from Nextcloud", feed_urls.len()); // Get ALL episode actions from Nextcloud let episode_actions_url = format!("{}/index.php/apps/gpoddersync/episode_action", nextcloud_url.trim_end_matches('/')); @@ -14203,10 +14190,12 @@ impl DatabasePool { // Process all subscriptions and add missing podcasts self.process_gpodder_subscriptions(user_id, &feed_urls).await?; - // Upload local subscriptions to Nextcloud + // Upload local subscriptions to Nextcloud (full list for the initial sync) let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; self.upload_subscriptions_to_nextcloud(nextcloud_url, username, password, &local_subscriptions).await?; - + // Save the snapshot baseline (same target key as incremental sync: URL without trailing slash). + self.save_subscription_snapshot(user_id, nextcloud_url.trim_end_matches('/'), &local_subscriptions).await?; + // Upload local episode actions to Nextcloud let local_episode_actions = self.get_user_episode_actions(user_id).await?; if !local_episode_actions.is_empty() { @@ -14274,23 +14263,40 @@ impl DatabasePool { // Upload subscriptions to Nextcloud using the gPodder Sync app endpoint async fn upload_subscriptions_to_nextcloud(&self, nextcloud_url: &str, username: &str, password: &str, subscriptions: &[String]) -> AppResult<()> { + self.upload_subscription_changes_to_nextcloud(nextcloud_url, username, password, subscriptions, &[]).await + } + + // Upload subscription add/remove changes using the gPodder Sync app's subscription_change/create + // endpoint. The app expects an object {"add": [...], "remove": [...]} (see AntennaPod's + // NextcloudSyncService.uploadSubscriptionChanges), not a bare array. + async fn upload_subscription_changes_to_nextcloud(&self, nextcloud_url: &str, username: &str, password: &str, add: &[String], remove: &[String]) -> AppResult<()> { + if add.is_empty() && remove.is_empty() { + return Ok(()); + } + let client = reqwest::Client::new(); - // Nextcloud gPodder Sync app uses the subscription_change endpoint - let upload_url = format!("{}/index.php/apps/gpoddersync/subscription_change/upload", nextcloud_url.trim_end_matches('/')); - + let upload_url = format!("{}/index.php/apps/gpoddersync/subscription_change/create", nextcloud_url.trim_end_matches('/')); + + let body = serde_json::json!({ + "add": add, + "remove": remove, + }); + let response = client .post(&upload_url) .basic_auth(username, Some(password)) - .json(subscriptions) + .json(&body) .send() .await .map_err(|e| AppError::internal(&format!("Failed to upload subscriptions to Nextcloud: {}", e)))?; - + if response.status().is_success() { - tracing::info!("Successfully uploaded {} subscriptions to Nextcloud", subscriptions.len()); + tracing::info!("Successfully uploaded {} added / {} removed subscriptions to Nextcloud", add.len(), remove.len()); Ok(()) } else { - tracing::warn!("Failed to upload subscriptions to Nextcloud: {}", response.status()); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!("Failed to upload subscriptions to Nextcloud: {} - {}", status, body); Ok(()) // Don't fail the whole sync if upload fails } } @@ -14458,14 +14464,24 @@ impl DatabasePool { // Upload local subscriptions to gPodder service - matches GPodder API spec POST /api/2/subscriptions/{username}/{device}.json async fn upload_subscriptions_to_gpodder(&self, gpodder_url: &str, username: &str, password: &str, device_name: &str, subscriptions: &[String]) -> AppResult<()> { + self.upload_subscription_delta_to_gpodder(gpodder_url, username, password, device_name, subscriptions, &[]).await + } + + // Upload subscription add/remove changes to a gPodder service (internal or external) following + // the GPodder API spec: POST /api/2/subscriptions/{user}/{device}.json with {"add":[...],"remove":[...]}. + async fn upload_subscription_delta_to_gpodder(&self, gpodder_url: &str, username: &str, password: &str, device_name: &str, add: &[String], remove: &[String]) -> AppResult<()> { + if add.is_empty() && remove.is_empty() { + return Ok(()); + } + let upload_url = format!("{}/api/2/subscriptions/{}/{}.json", gpodder_url.trim_end_matches('/'), username, device_name); - + // Format subscription changes according to GPodder API spec let subscription_changes = serde_json::json!({ - "add": subscriptions, - "remove": [] + "add": add, + "remove": remove }); - + // Use correct authentication based on internal vs external let response = if gpodder_url == "http://localhost:8042" { // Internal GPodder API - use X-GPodder-Token header @@ -14496,7 +14512,7 @@ impl DatabasePool { match response { Ok(resp) if resp.status().is_success() => { - tracing::info!("Successfully uploaded {} subscriptions to gPodder service", subscriptions.len()); + tracing::info!("Uploaded subscription delta to gPodder service: {} added, {} removed", add.len(), remove.len()); Ok(()) } Ok(resp) => { @@ -14510,227 +14526,125 @@ impl DatabasePool { } } - // Sync episode actions with gPodder service - matches Python episode actions sync with timestamp support - async fn sync_episode_actions_with_gpodder(&self, gpodder_url: &str, username: &str, password: &str, device_name: &str, user_id: i32) -> AppResult<()> { - println!("\n========== STARTING EPISODE ACTIONS SYNC =========="); - - // Get last sync timestamp for incremental sync (BETTER than Python - follows GPodder spec) - let since_timestamp = self.get_last_sync_timestamp(user_id).await?; + // Get user podcast feeds for sync + // Returns the user's syncable feed URLs. Excludes: + // - private feeds (with credentials) - we don't push credentials to a sync server + // - non-http(s) feeds (e.g. local:///opt/... local media, internal identifiers) - gpodder and + // Nextcloud servers only accept http/https URLs and reject anything else, so including them + // just produces doomed uploads and snapshot noise. + async fn get_user_podcast_feeds(&self, user_id: i32) -> AppResult> { + match self { + DatabasePool::Postgres(pool) => { + let rows = sqlx::query(r#"SELECT feedurl FROM "Podcasts" WHERE userid = $1 AND (username IS NULL OR username = '') AND (password IS NULL OR password = '') AND (feedurl LIKE 'http://%' OR feedurl LIKE 'https://%')"#) + .bind(user_id) + .fetch_all(pool) + .await?; - println!("📅 Last sync timestamp from DB: {:?}", since_timestamp); - - // Get local episode actions since last sync for efficient incremental sync - let local_actions = if let Some(since) = since_timestamp { - self.get_user_episode_actions_since(user_id, since).await? - } else { - self.get_user_episode_actions(user_id).await? - }; - - // Upload local actions to gPodder service - matches Python POST /api/2/episodes/{username}.json - if !local_actions.is_empty() { - let upload_url = format!("{}/api/2/episodes/{}.json", gpodder_url.trim_end_matches('/'), username); - - // Use correct authentication based on internal vs external - let response = if gpodder_url == "http://localhost:8042" { - // Internal GPodder API - use X-GPodder-Token header - let client = reqwest::Client::new(); - client.post(&upload_url) - .header("X-GPodder-Token", password) - .json(&local_actions) - .send() - .await - } else { - // External GPodder API - use session auth with basic fallback - let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; - if session.authenticated { - // Use session-based authentication - session.client - .post(&upload_url) - .json(&local_actions) - .send() - .await - } else { - // Fallback to basic auth - session.client - .post(&upload_url) - .basic_auth(username, Some(password)) - .json(&local_actions) - .send() - .await - } - }; - - match response { - Ok(resp) if resp.status().is_success() => { - tracing::info!("Successfully uploaded {} episode actions", local_actions.len()); - } - Ok(resp) => { - tracing::warn!("Failed to upload episode actions: {}", resp.status()); - } - Err(e) => { - return Err(AppError::internal(&format!("Failed to upload episode actions: {}", e))); + let mut feeds = Vec::new(); + for row in rows { + feeds.push(row.try_get::("feedurl")?); } + Ok(feeds) } - } - - // Download remote actions from gPodder service with pagination support - // The server limits responses to 25k actions, so we need to loop until we get all of them - let mut all_remote_actions = Vec::new(); - const MAX_ACTIONS_PER_BATCH: usize = 25000; - - let initial_since = if let Some(since) = since_timestamp { - since.timestamp() - } else { - 0 - }; - - println!("🔍 GPodder episode actions sync starting with since={} ({})", - initial_since, - if initial_since == 0 { "FULL SYNC" } else { "INCREMENTAL SYNC" }); - println!(" Fetching from ALL devices (no device filter to include NULL device actions)"); - - let mut current_since = initial_since; - - loop { - // DON'T filter by device - get actions from ALL devices including NULL device actions - // The 'since' parameter ensures we only get NEW actions (efficient incremental sync) - let download_url = format!("{}/api/2/episodes/{}.json?since={}", - gpodder_url.trim_end_matches('/'), username, current_since); - - println!("📥 Fetching episode actions from: {}", download_url); - - // Use correct authentication based on internal vs external for download - let response = if gpodder_url == "http://localhost:8042" { - // Internal GPodder API - use X-GPodder-Token header - let client = reqwest::Client::new(); - client.get(&download_url) - .header("X-GPodder-Token", password) - .send() - .await - } else { - // External GPodder API - use session auth with basic fallback - let session = self.create_gpodder_session_with_password(gpodder_url, username, password).await?; - if session.authenticated { - session.client - .get(&download_url) - .send() - .await - } else { - session.client - .get(&download_url) - .basic_auth(username, Some(password)) - .send() - .await - } - }; - - match response { - Ok(resp) if resp.status().is_success() => { - let episode_data: serde_json::Value = resp.json().await - .map_err(|e| AppError::internal(&format!("Failed to parse episode actions response: {}", e)))?; - - let actions = episode_data.get("actions").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let batch_size = actions.len(); - let new_timestamp = episode_data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(current_since); - - // Check if changelog-news-168 is in this batch - let has_changelog_168 = actions.iter().any(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }); - - println!("📦 Fetched {} episode actions (since={}, response_timestamp={}) {}", - batch_size, current_since, new_timestamp, - if has_changelog_168 { "🎯 CONTAINS changelog-news-168" } else { "" }); - - // Add actions from this batch - for action in actions { - all_remote_actions.push(action); - } - - // If we got less than MAX_ACTIONS_PER_BATCH, we've reached the end - if batch_size < MAX_ACTIONS_PER_BATCH { - tracing::info!("Reached end of episode actions (got {} < {} limit)", batch_size, MAX_ACTIONS_PER_BATCH); - break; - } + DatabasePool::MySQL(pool) => { + let rows = sqlx::query("SELECT FeedURL FROM Podcasts WHERE UserID = ? AND (Username IS NULL OR Username = '') AND (Password IS NULL OR Password = '') AND (FeedURL LIKE 'http://%' OR FeedURL LIKE 'https://%')") + .bind(user_id) + .fetch_all(pool) + .await?; - // Update since to the timestamp from the response for next iteration - if new_timestamp > current_since { - current_since = new_timestamp; - tracing::info!("Updated since timestamp to {} for next batch", current_since); - } else { - tracing::warn!("Timestamp didn't advance, stopping pagination to avoid infinite loop"); - break; - } - } - Ok(resp) => { - tracing::warn!("Failed to download episode actions: {}", resp.status()); - break; - } - Err(e) => { - return Err(AppError::internal(&format!("Failed to download episode actions: {}", e))); + let mut feeds = Vec::new(); + for row in rows { + feeds.push(row.try_get::("FeedURL")?); } + Ok(feeds) } } - - println!("✅ Downloaded {} total remote episode actions across all batches", all_remote_actions.len()); - - // Check if changelog-news-168 is in the aggregated actions - let has_changelog_168 = all_remote_actions.iter().any(|a| { - a.get("episode") - .and_then(|e| e.as_str()) - .map(|s| s.contains("changelog-news-168")) - .unwrap_or(false) - }); - - if has_changelog_168 { - println!("🎯 FOUND changelog-news-168 in aggregated remote actions before processing!"); - } else { - println!("❌ changelog-news-168 NOT FOUND in aggregated remote actions!"); - println!(" This means it was never returned by the GPodder API across all batches."); - } - - // Apply all remote actions locally - if !all_remote_actions.is_empty() { - self.apply_remote_episode_actions(user_id, &all_remote_actions).await?; - } - - // Update last sync timestamp for incremental sync (BETTER than Python) - self.update_last_sync_timestamp(user_id).await?; - - Ok(()) } - // Get user podcast feeds for sync - async fn get_user_podcast_feeds(&self, user_id: i32) -> AppResult> { + // Get the set of feed URLs that were present locally at the end of the last sync to `target`. + // Used to compute genuine local add/remove deltas to push up, without a per-change queue. + async fn get_subscription_snapshot(&self, user_id: i32, target: &str) -> AppResult> { + let mut feeds = std::collections::HashSet::new(); match self { DatabasePool::Postgres(pool) => { - let rows = sqlx::query(r#"SELECT feedurl FROM "Podcasts" WHERE userid = $1 AND (username IS NULL OR username = '') AND (password IS NULL OR password = '')"#) + let rows = sqlx::query(r#"SELECT feedurl FROM "GpodderSubscriptionSnapshot" WHERE userid = $1 AND synctarget = $2"#) .bind(user_id) + .bind(target) .fetch_all(pool) .await?; - - let mut feeds = Vec::new(); for row in rows { - feeds.push(row.try_get::("feedurl")?); + feeds.insert(row.try_get::("feedurl")?); } - Ok(feeds) } DatabasePool::MySQL(pool) => { - let rows = sqlx::query("SELECT FeedURL FROM Podcasts WHERE UserID = ? AND (Username IS NULL OR Username = '') AND (Password IS NULL OR Password = '')") + let rows = sqlx::query("SELECT FeedURL FROM GpodderSubscriptionSnapshot WHERE UserID = ? AND SyncTarget = ?") .bind(user_id) + .bind(target) .fetch_all(pool) .await?; - - let mut feeds = Vec::new(); for row in rows { - feeds.push(row.try_get::("FeedURL")?); + feeds.insert(row.try_get::("FeedURL")?); } - Ok(feeds) } } + Ok(feeds) + } + + // Replace the stored snapshot for (user, target) with the given feed set. Called after a + // successful sync so the next sync only uploads genuine local changes. + async fn save_subscription_snapshot(&self, user_id: i32, target: &str, feeds: &[String]) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + let mut tx = pool.begin().await?; + sqlx::query(r#"DELETE FROM "GpodderSubscriptionSnapshot" WHERE userid = $1 AND synctarget = $2"#) + .bind(user_id) + .bind(target) + .execute(&mut *tx) + .await?; + for feed in feeds { + sqlx::query(r#"INSERT INTO "GpodderSubscriptionSnapshot" (userid, synctarget, feedurl) VALUES ($1, $2, $3) ON CONFLICT (userid, synctarget, feedurl) DO NOTHING"#) + .bind(user_id) + .bind(target) + .bind(feed) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + } + DatabasePool::MySQL(pool) => { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM GpodderSubscriptionSnapshot WHERE UserID = ? AND SyncTarget = ?") + .bind(user_id) + .bind(target) + .execute(&mut *tx) + .await?; + for feed in feeds { + sqlx::query("INSERT IGNORE INTO GpodderSubscriptionSnapshot (UserID, SyncTarget, FeedURL) VALUES (?, ?, ?)") + .bind(user_id) + .bind(target) + .bind(feed) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + } + } + Ok(()) + } + + // Compute genuine local subscription changes since the last sync to `target` by diffing the + // current local feed set against the saved snapshot. Returns (to_add, to_remove): + // to_add = local now but not in snapshot (subscribed locally since last sync) + // to_remove = in snapshot but not local now (unsubscribed locally since last sync) + // Private feeds (with credentials) are excluded by get_user_podcast_feeds, so they are never + // in the snapshot and never appear in either list - they are never pushed to a sync server. + async fn compute_subscription_delta(&self, user_id: i32, target: &str, local_feeds: &[String]) -> AppResult<(Vec, Vec)> { + let snapshot = self.get_subscription_snapshot(user_id, target).await?; + let local_set: std::collections::HashSet = local_feeds.iter().cloned().collect(); + + let to_add: Vec = local_feeds.iter().filter(|f| !snapshot.contains(*f)).cloned().collect(); + let to_remove: Vec = snapshot.iter().filter(|f| !local_set.contains(*f)).cloned().collect(); + Ok((to_add, to_remove)) } // Apply remote episode actions locally - matches Python apply_episode_actions function exactly @@ -14741,18 +14655,10 @@ impl DatabasePool { let mut applied_count = 0; let mut not_found_count = 0; let mut not_found_urls: Vec = Vec::new(); - let mut changelog_168_found = false; - let mut changelog_168_result = String::new(); - // Process in batches with progress logging - const BATCH_SIZE: usize = 1000; + // Process with periodic progress logging const LOG_INTERVAL: usize = 500; // Log progress every 500 actions - // DEBUG: Log the first action to see its structure - if !actions.is_empty() { - tracing::info!("DEBUG: First episode action structure: {}", serde_json::to_string_pretty(&actions[0]).unwrap_or_else(|_| "failed to serialize".to_string())); - } - for (index, action) in actions.iter().enumerate() { // Log progress periodically instead of for every action if index > 0 && index % LOG_INTERVAL == 0 { @@ -14802,18 +14708,8 @@ impl DatabasePool { match self.mark_episode_completed(episode_id, user_id, false).await { Ok(_) => { applied_count += 1; - // Only log changelog-news-168 for debugging - if episode_url.contains("changelog-news-168") { - changelog_168_found = true; - changelog_168_result = format!("✅ Marked as completed: {}s/{}s", position_sec, episode_duration); - tracing::info!("✓ Marked changelog-news-168 as completed via GPodder sync"); - } } Err(e) => { - if episode_url.contains("changelog-news-168") { - changelog_168_found = true; - changelog_168_result = format!("❌ FAILED to mark complete: {}", e); - } tracing::debug!("Failed to mark episode as completed: {}", e); } } @@ -14822,17 +14718,8 @@ impl DatabasePool { match self.update_episode_progress(user_id, episode_id, position_sec, timestamp).await { Ok(_) => { applied_count += 1; - if episode_url.contains("changelog-news-168") { - changelog_168_found = true; - changelog_168_result = format!("✅ Updated progress: {}s/{}s", position_sec, episode_duration); - tracing::info!("✓ Updated changelog-news-168 progress to {}s/{}s", position_sec, episode_duration); - } } Err(e) => { - if episode_url.contains("changelog-news-168") { - changelog_168_found = true; - changelog_168_result = format!("❌ FAILED: {}", e); - } tracing::debug!("Failed to update episode progress: {}", e); } } @@ -14851,10 +14738,6 @@ impl DatabasePool { } else { not_found_count += 1; not_found_urls.push(episode_url.to_string()); - if episode_url.contains("changelog-news-168") { - changelog_168_found = true; - changelog_168_result = "❌ NOT FOUND in database".to_string(); - } } } } @@ -14873,25 +14756,13 @@ impl DatabasePool { } } - tracing::info!("✅ Episode actions processing complete: {}/{} applied, {} not found in local database", + tracing::info!("Episode actions processing complete: {}/{} applied, {} not found in local database", applied_count, total_actions, not_found_count); - // Print changelog-news-168 result - println!("\n========== CHANGELOG-NEWS-168 DEBUG =========="); - if changelog_168_found { - println!("🎯 changelog-news-168 WAS PROCESSED: {}", changelog_168_result); - } else { - println!("⚠️ changelog-news-168 was NOT found in episode actions"); - } - println!("==============================================\n"); - - // Print sample of not found URLs for debugging + // Log a sample of not-found URLs to help diagnose feed/episode URL mismatches if !not_found_urls.is_empty() { - println!("\n========== EPISODE ACTIONS NOT FOUND (first 20) =========="); - for url in not_found_urls.iter().take(20) { - println!("❌ NOT FOUND: {}", url); - } - println!("========== END NOT FOUND EPISODES (total: {}) ==========\n", not_found_urls.len()); + tracing::debug!("{} episode actions referenced episodes not in the local database (showing up to 20): {:?}", + not_found_urls.len(), not_found_urls.iter().take(20).collect::>()); } Ok(()) @@ -18018,8 +17889,10 @@ impl DatabasePool { for video in videos { let video_id = video.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let title = video.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let description = video.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let raw_title = video.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let raw_description = video.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let title = self.clean_and_normalize_title(raw_title); + let description = self.decode_html_entities(raw_description); let url = video.get("url").and_then(|v| v.as_str()).unwrap_or(""); let thumbnail = video.get("thumbnail").and_then(|v| v.as_str()).unwrap_or(""); @@ -18471,7 +18344,14 @@ impl DatabasePool { .fetch_one(pool) .await?; - Ok(row.try_get::("PlaybackSpeed").unwrap_or(1.0)) + // PlaybackSpeed is a NUMERIC column in Postgres, which sqlx + // decodes to BigDecimal (not f64). The column comes back + // lowercased because it was selected unquoted. + if let Ok(speed) = row.try_get::("playbackspeed") { + Ok(speed.to_f64().unwrap_or(1.0)) + } else { + Ok(1.0) + } } DatabasePool::MySQL(pool) => { let query = if let Some(_pod_id) = podcast_id { @@ -22373,10 +22253,11 @@ impl DatabasePool { let podcast_artwork: Option = row.try_get("artworkurl").ok(); let artwork_url = episode_artwork.filter(|url| !url.is_empty()).or(podcast_artwork); - let pub_date = if let Ok(dt) = row.try_get::, _>("episodepubdate") { - dt.format("%a, %d %b %Y %H:%M:%S %z").to_string() - } else { - Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string() + let pub_date = match row.try_get::("episodepubdate") { + Ok(naive) => DateTime::::from_naive_utc_and_offset(naive, Utc) + .format("%a, %d %b %Y %H:%M:%S %z") + .to_string(), + Err(_) => Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string(), }; episodes.push(RssEpisode { @@ -22490,10 +22371,11 @@ impl DatabasePool { let podcast_artwork: Option = row.try_get("ArtworkURL").ok(); let artwork_url = episode_artwork.filter(|url| !url.is_empty()).or(podcast_artwork); - let pub_date = if let Ok(dt) = row.try_get::, _>("EpisodePubDate") { - dt.format("%a, %d %b %Y %H:%M:%S %z").to_string() - } else { - Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string() + let pub_date = match row.try_get::("EpisodePubDate") { + Ok(naive) => DateTime::::from_naive_utc_and_offset(naive, Utc) + .format("%a, %d %b %Y %H:%M:%S %z") + .to_string(), + Err(_) => Utc::now().format("%a, %d %b %Y %H:%M:%S %z").to_string(), }; episodes.push(RssEpisode { @@ -24134,8 +24016,14 @@ impl DatabasePool { match resp.json::().await { Ok(subs_data) => { tracing::info!("Nextcloud subscriptions response: {:?}", subs_data); - if let Some(subs_array) = subs_data.as_array() { - tracing::info!("Found {} subscriptions in Nextcloud array", subs_array.len()); + // The gPodder Sync app returns {"add": [...], "remove": [...], "timestamp": N}; + // the full subscription list lives in "add". Fall back to a bare array. + let subs_array = subs_data + .get("add") + .and_then(|v| v.as_array()) + .or_else(|| subs_data.as_array()); + if let Some(subs_array) = subs_array { + tracing::info!("Found {} subscriptions in Nextcloud response", subs_array.len()); for sub in subs_array { if let Some(url) = sub.as_str() { server_subscriptions.push(ServerSubscription { @@ -24146,7 +24034,7 @@ impl DatabasePool { } } } else { - tracing::warn!("Nextcloud subscriptions response is not an array: {:?}", subs_data); + tracing::warn!("Unexpected Nextcloud subscriptions response shape: {:?}", subs_data); } } Err(e) => { @@ -24842,12 +24730,10 @@ impl DatabasePool { // Decrypt token using existing decrypt_password method let password = self.decrypt_password(&encrypted_token).await?; - // Get last sync timestamp for incremental sync - let since_timestamp = if let Some(last_sync) = self.get_last_sync_timestamp(user_id).await? { - last_sync.timestamp() - } else { - 0 - }; + // Get last sync timestamp for incremental sync (keep both the DateTime, for selecting + // local episode actions to upload, and the unix form, for the Nextcloud `since` query). + let last_sync_dt = self.get_last_sync_timestamp(user_id).await?; + let since_timestamp = last_sync_dt.map(|t| t.timestamp()).unwrap_or(0); // Build Nextcloud API endpoint URLs let base_url = if gpodder_url.ends_with('/') { @@ -24871,15 +24757,20 @@ impl DatabasePool { .await .map_err(|e| AppError::internal(&format!("Failed to fetch Nextcloud subscriptions: {}", e)))?; + // Track changes the server sent us so we don't echo them back on upload. + let mut server_added: std::collections::HashSet = std::collections::HashSet::new(); + let mut server_removed: std::collections::HashSet = std::collections::HashSet::new(); + if subscriptions_response.status().is_success() { let subscription_data: serde_json::Value = subscriptions_response.json().await .map_err(|e| AppError::internal(&format!("Failed to parse subscription response: {}", e)))?; - + // Process subscription changes if let Some(add_list) = subscription_data.get("add").and_then(|v| v.as_array()) { for url in add_list { if let Some(podcast_url) = url.as_str() { tracing::info!("Adding Nextcloud subscription: {}", podcast_url); + server_added.insert(podcast_url.to_string()); if let Err(e) = self.add_podcast_from_url(user_id, podcast_url, None).await { tracing::error!("Failed to add podcast {}: {}", podcast_url, e); } else { @@ -24888,11 +24779,12 @@ impl DatabasePool { } } } - + if let Some(remove_list) = subscription_data.get("remove").and_then(|v| v.as_array()) { for url in remove_list { if let Some(podcast_url) = url.as_str() { tracing::info!("Removing Nextcloud subscription: {}", podcast_url); + server_removed.insert(podcast_url.to_string()); if let Err(e) = self.remove_podcast_by_url(user_id, podcast_url).await { tracing::error!("Failed to remove podcast {}: {}", podcast_url, e); } else { @@ -24927,15 +24819,46 @@ impl DatabasePool { } } - // Update last sync timestamp + // Push genuine LOCAL subscription changes up to Nextcloud. Without an upload the sync is + // download-only, so podcasts added locally never reach the server. We diff the current + // local feed set against the snapshot from the last sync (rather than re-sending the full + // list) so we only push real local add/removes, propagate local unsubscribes, and avoid + // echoing back what the server just sent us. + let local_subscriptions = self.get_user_podcast_feeds(user_id).await?; + let (mut to_add, mut to_remove) = self + .compute_subscription_delta(user_id, &base_url, &local_subscriptions) + .await?; + to_add.retain(|f| !server_added.contains(f)); + to_remove.retain(|f| !server_removed.contains(f)); + if let Err(e) = self + .upload_subscription_changes_to_nextcloud(&base_url, &username, &password, &to_add, &to_remove) + .await + { + tracing::warn!("Failed to upload subscription delta to Nextcloud: {}", e); + } + self.save_subscription_snapshot(user_id, &base_url, &local_subscriptions).await?; + + // Upload only episode actions created since the last sync (full history on the first sync), + // matching the gpodder path - avoids re-uploading the entire play history every sync. + let local_episode_actions = match last_sync_dt { + Some(since) => self.get_user_episode_actions_since(user_id, since).await?, + None => self.get_user_episode_actions(user_id).await?, + }; + if !local_episode_actions.is_empty() { + if let Err(e) = self.upload_episode_actions_to_nextcloud(&base_url, &username, &password, &local_episode_actions).await { + tracing::warn!("Failed to upload local episode actions to Nextcloud: {}", e); + } + } + + // Update last sync timestamp if let Err(e) = self.update_last_sync_timestamp(user_id).await { tracing::error!("Failed to update sync timestamp for user {}: {}", user_id, e); } - + tracing::info!("Nextcloud sync completed for user {} - changes: {}", user_id, has_changes); Ok(has_changes) } - + // Process individual episode action from Nextcloud async fn process_nextcloud_episode_action(&self, user_id: i32, action: &serde_json::Value) -> AppResult<()> { let episode_url = action.get("episode") diff --git a/rust-api/src/handlers/auth.rs b/rust-api/src/handlers/auth.rs index f76c5e2c..9312ad67 100644 --- a/rust-api/src/handlers/auth.rs +++ b/rust-api/src/handlers/auth.rs @@ -902,13 +902,15 @@ async fn process_opml_import( tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } - // Mark task as completed - let _ = task_manager.update_task_progress( + // Mark task as completed (sets status to SUCCESS so the notification clears; + // update_task_progress only ever sets the status to DOWNLOADING, which would + // leave the notification stuck as an active task forever) + let _ = task_manager.complete_task( &task_id, - 100.0, + None, Some("OPML import completed".to_string()), ).await; - + // Clear progress from Redis let _ = redis_client.delete(&progress_key).await; } diff --git a/rust-api/src/handlers/local_podcast.rs b/rust-api/src/handlers/local_podcast.rs index cc9bde0d..4ba072c9 100644 --- a/rust-api/src/handlers/local_podcast.rs +++ b/rust-api/src/handlers/local_podcast.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Multipart, State}, + extract::{Multipart, Query, State}, http::HeaderMap, response::Json, }; @@ -35,6 +35,19 @@ pub struct RefreshLocalPodcastRequest { pub podcast_id: i32, } +#[derive(Deserialize)] +pub struct ListLocalDirectoriesQuery { + #[serde(default)] + pub path: String, +} + +#[derive(Serialize)] +pub struct LocalDirectoryEntry { + pub name: String, + pub path: String, + pub audio_count: usize, +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct LocalEpisodeCandidate { pub file_path: String, @@ -485,3 +498,135 @@ pub async fn add_local_podcast_artwork( "artwork_url": artwork_url }))) } + +// Count immediate audio files in a directory (non-recursive) +fn count_audio_files(dir: &Path) -> usize { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return 0, + }; + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let path = entry.path(); + if !path.is_file() { + return false; + } + path.extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTENSIONS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) + }) + .count() +} + +// Detect the cover art a directory would use (cover.jpg/embedded ID3 art), so the +// frontend can preview it before adding. Mirrors the artwork the add flow auto-selects. +pub async fn detect_local_cover( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + if query.path.trim().is_empty() { + return Ok(Json(serde_json::json!({ "artwork_url": serde_json::Value::Null }))); + } + + let canonical_path = validate_local_media_path(&query.path)?; + if !canonical_path.is_dir() { + return Ok(Json(serde_json::json!({ "artwork_url": serde_json::Value::Null }))); + } + + // Reuse the same scan the add flow uses, then pick the first available artwork — + // exactly how add_local_podcast chooses the podcast artwork. + let artwork_url = scan_local_directory(&canonical_path) + .ok() + .and_then(|candidates| candidates.iter().find_map(|c| c.artwork_url.clone())); + + Ok(Json(serde_json::json!({ "artwork_url": artwork_url }))) +} + +// List immediate subdirectories under the local-media root (optionally within a +// relative subpath), with an audio-file count for each, so the frontend can browse. +pub async fn list_local_directories( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Resolve the directory to list. Empty path = the local-media root (create it if it + // does not exist yet so first-time users get an empty list rather than an error). + let target = if query.path.trim().is_empty() { + let root = PathBuf::from(LOCAL_MEDIA_ROOT); + std::fs::create_dir_all(&root) + .map_err(|e| AppError::internal(format!("Failed to access local-media root: {}", e)))?; + root.canonicalize() + .map_err(|_| AppError::internal("local-media root is not accessible"))? + } else { + validate_local_media_path(&query.path)? + }; + + if !target.is_dir() { + return Err(AppError::bad_request("Path is not a directory")); + } + + // Canonical root, used to compute paths relative to the local-media mount. + let root_canonical = PathBuf::from(LOCAL_MEDIA_ROOT) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(LOCAL_MEDIA_ROOT)); + + let mut directories: Vec = Vec::new(); + + let entries = std::fs::read_dir(&target) + .map_err(|e| AppError::bad_request(format!("Cannot read directory: {}", e)))?; + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_dir() { + continue; + } + // Skip the internal artwork directory + if path.file_name().and_then(|n| n.to_str()) == Some(ARTWORK_DIR) { + continue; + } + + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + continue; + } + + // Relative path the frontend can pass straight back as directory_path + let relative = path + .strip_prefix(&root_canonical) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| name.clone()); + + directories.push(LocalDirectoryEntry { + name, + path: relative, + audio_count: count_audio_files(&path), + }); + } + + // Alphabetical, case-insensitive + directories.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + // Current path relative to root (empty string at the root) + let current_path = target + .strip_prefix(&root_canonical) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + Ok(Json(serde_json::json!({ + "current_path": current_path, + "directories": directories, + }))) +} diff --git a/rust-api/src/handlers/podcasts.rs b/rust-api/src/handlers/podcasts.rs index c4def3fa..127a436f 100644 --- a/rust-api/src/handlers/podcasts.rs +++ b/rust-api/src/handlers/podcasts.rs @@ -1624,6 +1624,7 @@ pub struct PlayEpisodeDetailsResponse { pub playback_speed: f64, pub start_skip: i32, pub end_skip: i32, + pub playback_speed_customized: bool, } // Get play episode details - matches Python get_play_episode_details endpoint exactly @@ -1645,7 +1646,7 @@ pub async fn get_play_episode_details( if key_id == request.user_id || is_web_key { // Get all details in one function call - let (playback_speed, start_skip, end_skip) = state.db_pool.get_play_episode_details( + let (playback_speed, start_skip, end_skip, playback_speed_customized) = state.db_pool.get_play_episode_details( request.user_id, request.podcast_id, request.is_youtube.unwrap_or(false) @@ -1654,7 +1655,8 @@ pub async fn get_play_episode_details( Ok(Json(PlayEpisodeDetailsResponse { playback_speed, start_skip, - end_skip + end_skip, + playback_speed_customized })) } else { Err(AppError::forbidden("You can only get metadata for yourself!")) @@ -2929,4 +2931,61 @@ pub async fn get_merged_podcasts( Ok(Json(MergedPodcastsResponse { merged_podcast_ids: merged_ids, })) +} + +#[derive(Deserialize, Debug)] +pub struct ProxySearchParams { + pub query: String, + pub index: String, + #[serde(default)] + pub search_type: Option, +} + +// Proxy podcast/iTunes/YouTube/person search through the backend so the +// browser (and mobile clients) never need to reach SEARCH_API_URL directly. +// SEARCH_API_URL can therefore be an internal-only Docker hostname. +pub async fn proxy_search( + Query(params): Query, + headers: HeaderMap, + State(state): State, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + + let is_valid = state.db_pool.verify_api_key(&api_key).await?; + if !is_valid { + return Err(AppError::unauthorized("Invalid API key")); + } + + let search_api_url = std::env::var("SEARCH_API_URL") + .unwrap_or_else(|_| "https://search.pinepods.online/api/search".to_string()); + + // Forward params via reqwest's query builder so encoding is handled for us. + let mut query_params: Vec<(&str, String)> = vec![ + ("query", params.query.clone()), + ("index", params.index.clone()), + ]; + if let Some(search_type) = params.search_type.clone() { + query_params.push(("search_type", search_type)); + } + + let response = reqwest::Client::new() + .get(&search_api_url) + .query(&query_params) + .send() + .await + .map_err(|e| AppError::external_error(&format!("Failed to call search service: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError::external_error(&format!( + "Search service error: {}", + response.status() + ))); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| AppError::external_error(&format!("Failed to parse search response: {}", e)))?; + + Ok(Json(body)) } \ No newline at end of file diff --git a/rust-api/src/handlers/settings.rs b/rust-api/src/handlers/settings.rs index df1a41ce..9e04aefe 100644 --- a/rust-api/src/handlers/settings.rs +++ b/rust-api/src/handlers/settings.rs @@ -3028,6 +3028,35 @@ pub async fn set_podcast_playback_speed( Ok(Json(serde_json::json!({ "detail": "Default podcast playback speed updated." }))) } +// Request struct for clear_podcast_playback_speed +#[derive(Deserialize)] +pub struct ClearPlaybackSpeedPodcast { + pub user_id: i32, + pub podcast_id: i32, +} + +// Clear podcast playback speed - resets the podcast back to the global default +pub async fn clear_podcast_playback_speed( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + validate_api_key(&state, &api_key).await?; + + // Check authorization - web key or user can only modify their own podcasts + let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; + let is_web_key = state.db_pool.is_web_key(&api_key).await?; + + if key_id != request.user_id && !is_web_key { + return Err(AppError::forbidden("You can only modify your own podcasts.")); + } + + state.db_pool.clear_podcast_playback_speed(request.user_id, request.podcast_id).await?; + + Ok(Json(serde_json::json!({ "message": "Podcast playback speed reset to global default." }))) +} + // Request struct for enable_auto_download - matches Python AutoDownloadRequest model #[derive(Deserialize)] pub struct AutoDownloadRequest { diff --git a/rust-api/src/handlers/sync.rs b/rust-api/src/handlers/sync.rs index ea44e4e2..156f5d18 100644 --- a/rust-api/src/handlers/sync.rs +++ b/rust-api/src/handlers/sync.rs @@ -175,22 +175,22 @@ pub async fn gpodder_sync( let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; - // Use the same sync process as the scheduler (tasks.rs) which uses proper API calls with timestamps - let sync_result = state.db_pool.refresh_gpodder_subscription_background(user_id).await?; - - if sync_result { - Ok(Json(serde_json::json!({ - "success": true, - "message": "Sync completed successfully", - "data": null - }))) + // Use the same sync process as the scheduler (tasks.rs) which uses proper API calls with timestamps. + // The boolean indicates whether any changes were applied - "no changes" is a successful sync, + // not a failure, so report success either way and surface a clear message. + let had_changes = state.db_pool.refresh_gpodder_subscription_background(user_id).await?; + + let message = if had_changes { + "Sync completed successfully" } else { - Ok(Json(serde_json::json!({ - "success": false, - "message": "Sync failed or no changes detected - check your sync configuration", - "data": null - }))) - } + "Sync completed - already up to date" + }; + + Ok(Json(serde_json::json!({ + "success": true, + "message": message, + "data": null + }))) } // Get gPodder status - matches Python get_gpodder_status function exactly diff --git a/rust-api/src/main.rs b/rust-api/src/main.rs index cb89dab4..61b9e754 100644 --- a/rust-api/src/main.rs +++ b/rust-api/src/main.rs @@ -257,6 +257,7 @@ fn create_data_routes() -> Router { .route("/get_extended_stats", get(handlers::podcasts::get_extended_stats)) .route("/get_pinepods_version", get(handlers::podcasts::get_pinepods_version)) .route("/search_data", post(handlers::podcasts::search_data)) + .route("/proxy_search", get(handlers::podcasts::proxy_search)) .route("/fetch_transcript", post(handlers::podcasts::fetch_transcript)) .route("/home_overview", get(handlers::podcasts::home_overview)) .route("/get_playlists", get(handlers::podcasts::get_playlists)) @@ -334,6 +335,8 @@ fn create_data_routes() -> Router { .route("/add_local_podcast", post(handlers::local_podcast::add_local_podcast)) .route("/add_local_podcast_artwork", post(handlers::local_podcast::add_local_podcast_artwork)) .route("/refresh_local_podcast", post(handlers::local_podcast::refresh_local_podcast)) + .route("/list_local_directories", get(handlers::local_podcast::list_local_directories)) + .route("/detect_local_cover", get(handlers::local_podcast::detect_local_cover)) .route("/user/notification_settings", get(handlers::settings::get_notification_settings)) .route("/user/notification_settings", put(handlers::settings::update_notification_settings)) .route("/user/set_playback_speed", post(handlers::settings::set_playback_speed_user)) @@ -359,6 +362,7 @@ fn create_data_routes() -> Router { .route("/remove_category", post(handlers::settings::remove_category)) .route("/add_category", post(handlers::settings::add_category)) .route("/podcast/set_playback_speed", post(handlers::settings::set_podcast_playback_speed)) + .route("/clear_podcast_playback_speed", post(handlers::settings::clear_podcast_playback_speed)) .route("/podcast/set_cover_preference", post(handlers::settings::set_podcast_cover_preference)) .route("/podcast/clear_cover_preference", post(handlers::settings::clear_podcast_cover_preference)) .route("/podcast/toggle_notifications", put(handlers::settings::toggle_podcast_notifications)) diff --git a/web/Cargo.toml b/web/Cargo.toml index 61116c74..419c6783 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -57,6 +57,7 @@ web-sys = { version = "0.3.99", features = [ "IntersectionObserver", "IntersectionObserverInit", "IntersectionObserverEntry", + "ResizeObserver", ] } log = "0.4.30" wasm-bindgen = "0.2.122" diff --git a/web/src/components/app_drawer.rs b/web/src/components/app_drawer.rs index feaac984..aa7ab0d8 100644 --- a/web/src/components/app_drawer.rs +++ b/web/src/components/app_drawer.rs @@ -315,7 +315,7 @@ pub fn app_drawer() -> Html { // Sign out + version at bottom -
+

to={Route::LogOut} classes="sb-item"> diff --git a/web/src/components/audio.rs b/web/src/components/audio.rs index 44a441a6..52ecabd1 100644 --- a/web/src/components/audio.rs +++ b/web/src/components/audio.rs @@ -2291,7 +2291,7 @@ pub fn on_play_click( ) .await { - Ok((playback_speed, start_skip, end_skip)) => { + Ok((playback_speed, start_skip, end_skip, _playback_speed_customized)) => { let start_pos_sec = episode.listenduration.max(start_skip) as f64; let end_pos_sec = end_skip as f64; diff --git a/web/src/components/context_menu_button.rs b/web/src/components/context_menu_button.rs index 8f6743b7..6ab3cfa2 100644 --- a/web/src/components/context_menu_button.rs +++ b/web/src/components/context_menu_button.rs @@ -196,9 +196,10 @@ pub fn context_button(props: &ContextButtonProps) -> Html { click_handler(event); }); - // Add touchend listener for mobile (more reliable than touchstart for outside clicks) + // Use touchstart for mobile: avoids the lift-after-long-press immediately + // triggering dismissal (touchend from the long press would close the menu instantly). let touch_handler = handle_outside_interaction.clone(); - let touch_listener = EventListener::new(&document, "touchend", move |event| { + let touch_listener = EventListener::new(&document, "touchstart", move |event| { touch_handler(event); }); diff --git a/web/src/components/gen_components.rs b/web/src/components/gen_components.rs index e31b5570..5d829c90 100644 --- a/web/src/components/gen_components.rs +++ b/web/src/components/gen_components.rs @@ -58,27 +58,43 @@ pub fn fallback_image(props: &FallbackImageProps) -> Html { }); let server_name = (*server_name_sel).clone(); - // Just use the original src without timestamps - let image_src = use_state(|| props.src.clone()); + const FALLBACK_IMAGE: &str = "/static/assets/favicon.png"; + + // Just use the original src without timestamps; treat empty src as immediate fallback + let image_src = use_state(|| { + if props.src.is_empty() { + FALLBACK_IMAGE.to_string() + } else { + props.src.clone() + } + }); // Update src when props.src changes { let image_src = image_src.clone(); let props_src = props.src.clone(); use_effect_with(props_src, move |src| { - image_src.set(src.clone()); + if src.is_empty() { + image_src.set(FALLBACK_IMAGE.to_string()); + } else { + image_src.set(src.clone()); + } || () }); } - // Create a proxied URL from the original source + // Create a proxied URL from the original source; skip proxy for empty URLs let proxied_url = { let original_url = props.src.clone(); - format!( - "{}/api/proxy/image?url={}", - server_name, - urlencoding::encode(&original_url) - ) + if original_url.is_empty() { + FALLBACK_IMAGE.to_string() + } else { + format!( + "{}/api/proxy/image?url={}", + server_name, + urlencoding::encode(&original_url) + ) + } }; // Handle image load error @@ -192,10 +208,10 @@ pub fn search_bar() -> Html { let (i18n, _) = use_translation(); let i18n_podcast_index = i18n.t("gen_components.podcast_index").to_string(); let history = BrowserHistory::new(); - // Selective subscription — only re-render when server_details changes (login/logout), + // Selective subscription — only re-render when auth_details changes (login/logout), // not on every episode save/download/queue action. - let server_details_sel = use_selector(|state: &AppState| state.server_details.clone()); - let server_details = (*server_details_sel).clone(); + let auth_details_sel = use_selector(|state: &AppState| state.auth_details.clone()); + let auth_details = (*auth_details_sel).clone(); let podcast_value = use_state(|| "".to_string()); let search_index = use_state(|| "podcast_index".to_string()); let is_submitting = use_state(|| false); @@ -212,7 +228,7 @@ pub fn search_bar() -> Html { let handle_submit = { let is_submitting = is_submitting.clone(); - let server_details = server_details.clone(); + let auth_details = auth_details.clone(); let history = history_clone.clone(); let podcast_value = podcast_value_clone.clone(); let search_index = search_index_clone.clone(); @@ -222,7 +238,14 @@ pub fn search_bar() -> Html { return; } is_submitting.set(true); - let api_url = server_details.as_ref().map(|ud| ud.api_url.clone()); + let server_name = auth_details + .as_ref() + .map(|ad| ad.server_name.clone()) + .unwrap_or_default(); + let api_key = auth_details + .as_ref() + .and_then(|ad| ad.api_key.clone()) + .unwrap_or_default(); let history = history.clone(); let search_value = podcast_value.clone(); let search_index = search_index.clone(); @@ -231,7 +254,7 @@ pub fn search_bar() -> Html { wasm_bindgen_futures::spawn_local(async move { Dispatch::::global().reduce_mut(|state| state.is_loading = Some(true)); if *search_index == "youtube" { - match call_youtube_search(&search_value, &api_url.unwrap()).await { + match call_youtube_search(&search_value, &server_name, &api_key).await { Ok(yt_results) => { let search_results = YouTubeSearchResults { channels: yt_results.results, @@ -257,7 +280,7 @@ pub fn search_bar() -> Html { } } } else { - match call_get_podcast_info(&search_value, &api_url.unwrap(), &search_index) + match call_get_podcast_info(&search_value, &server_name, &api_key, &search_index) .await { Ok(search_results) => { @@ -1194,3 +1217,156 @@ pub fn refresh_progress(props: &RefreshProgressProps) -> Html {
} } + +// Reusable themed image picker with live preview. Hides the native file input behind a +// styled label button, shows the selected image, and lists supported file types. +#[derive(Properties, PartialEq, Clone)] +pub struct ImagePickerProps { + /// Unique DOM id so the styled
-
+
{i18n.t("backup_server.download_backup_file")}
{i18n.t("backup_server.download_backup_description")}
-
+
Html { })} class="input" placeholder="mYDBp@ss!" - style="width:180px;" + style="flex: 1; min-width: 0;" />
-
+
{i18n.t("backup_server.save_to_backup_directory")}
{i18n.t("backup_server.save_to_backup_description")}
-
+