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