diff --git a/database_functions/migration_definitions.py b/database_functions/migration_definitions.py index 961bbb1c1..dc1dbde4b 100644 --- a/database_functions/migration_definitions.py +++ b/database_functions/migration_definitions.py @@ -921,7 +921,7 @@ def migration_007_queue_download_tables(conn, db_type: str): UserID INT, EpisodeID INT, DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - DownloadedSize INT, + DownloadedSize BIGINT, DownloadedLocation VARCHAR(255), FOREIGN KEY (UserID) REFERENCES "Users"(UserID), FOREIGN KEY (EpisodeID) REFERENCES "Episodes"(EpisodeID) @@ -934,7 +934,7 @@ def migration_007_queue_download_tables(conn, db_type: str): UserID INT, VideoID INT, DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - DownloadedSize INT, + DownloadedSize BIGINT, DownloadedLocation VARCHAR(255), FOREIGN KEY (UserID) REFERENCES "Users"(UserID), FOREIGN KEY (VideoID) REFERENCES "YouTubeVideos"(VideoID) @@ -985,7 +985,7 @@ def migration_007_queue_download_tables(conn, db_type: str): UserID INT, EpisodeID INT, DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - DownloadedSize INT, + DownloadedSize BIGINT, DownloadedLocation VARCHAR(255), FOREIGN KEY (UserID) REFERENCES Users(UserID), FOREIGN KEY (EpisodeID) REFERENCES Episodes(EpisodeID) @@ -998,7 +998,7 @@ def migration_007_queue_download_tables(conn, db_type: str): UserID INT, VideoID INT, DownloadedDate TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - DownloadedSize INT, + DownloadedSize BIGINT, DownloadedLocation VARCHAR(255), FOREIGN KEY (UserID) REFERENCES Users(UserID), FOREIGN KEY (VideoID) REFERENCES YouTubeVideos(VideoID) @@ -7221,6 +7221,24 @@ def _refresh_interval_minutes(published, now) -> int: return int(min(max(median / 8 / 60, 15), 6 * 60)) +@register_migration("079", "downloaded_size_bigint", "DownloadedEpisodes/DownloadedVideos.DownloadedSize: BIGINT, so downloads over 2 GiB can be recorded", requires=["007"]) +def migration_079_downloaded_size_bigint(conn, db_type: str) -> None: + cursor = conn.cursor() + pg = db_type == "postgresql" + try: + for table in ("DownloadedEpisodes", "DownloadedVideos"): + if pg: + cursor.execute(f'ALTER TABLE "{table}" ALTER COLUMN DownloadedSize TYPE BIGINT') + else: + cursor.execute(f"ALTER TABLE {table} MODIFY DownloadedSize BIGINT") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cursor.close() + + @register_migration("080", "create_feeds", "Feeds: one row per feed address and credentials, holding the refresh state its subscriptions share", requires=["044", "063", "071", "075"]) def migration_080_create_feeds(conn, db_type: str) -> None: """Feeds: one row per feed, shared by every user subscribed to it, with the refresh state @@ -7836,6 +7854,21 @@ def migration_085_podcast_edited_metadata(conn, db_type: str) -> None: cursor.close() +@register_migration("086", "user_use_gravatar", "Users.UseGravatar: show the user's Gravatar, off unless they turn it on", requires=["001"]) +def migration_086_user_use_gravatar(conn, db_type: str) -> None: + cursor = conn.cursor() + users = '"Users"' if db_type == "postgresql" else "Users" + try: + if not _column_exists(cursor, db_type, "Users", "UseGravatar"): + cursor.execute(f"ALTER TABLE {users} ADD COLUMN UseGravatar BOOLEAN NOT NULL DEFAULT FALSE") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cursor.close() + + @register_migration("118", "gpodder_actions_follow_merged_episodes", "Episode actions point at the shared episode their merged copy became; the copies are deleted", requires=["082", "111"]) def migration_118_gpodder_actions_follow_merged_episodes(conn, db_type: str) -> None: """GpodderSyncEpisodeActions.EpisodeID of actions on an episode copy merged by 082 now points diff --git a/mobile/lib/bloc/settings/settings_bloc.dart b/mobile/lib/bloc/settings/settings_bloc.dart index 07ea6e737..ef19cb46a 100644 --- a/mobile/lib/bloc/settings/settings_bloc.dart +++ b/mobile/lib/bloc/settings/settings_bloc.dart @@ -39,6 +39,7 @@ class SettingsBloc extends Bloc { final BehaviorSubject> _bottomBarOrder = BehaviorSubject>(); final BehaviorSubject _autoDownloadWifiOnly = BehaviorSubject(); final BehaviorSubject _preferServerDownloadSource = BehaviorSubject(); + final BehaviorSubject _pinepodsUseGravatar = BehaviorSubject(); final BehaviorSubject _autoDownloadQueueCount = BehaviorSubject(); final BehaviorSubject _mirrorServerDownloads = BehaviorSubject(); final BehaviorSubject _deleteDownloadsAfterDays = BehaviorSubject(); @@ -52,35 +53,29 @@ class SettingsBloc extends Bloc { _fetchUserDetailsIfNeeded(); } + /// Refreshes the account details mirrored from the server: username and email when + /// missing, and the Gravatar setting every start so a change made elsewhere applies. Future _fetchUserDetailsIfNeeded() async { - // Only fetch if we have server/api key but no username - if (_currentSettings.pinepodsServer != null && - _currentSettings.pinepodsApiKey != null && - (_currentSettings.pinepodsUsername == null || _currentSettings.pinepodsUsername!.isEmpty)) { - - try { - final pinepodsService = PinepodsService(); - pinepodsService.setCredentials(_currentSettings.pinepodsServer!, _currentSettings.pinepodsApiKey!); - - // Use stored user ID if available, otherwise we need to get it somehow - final userId = _currentSettings.pinepodsUserId; - print('DEBUG: User ID from settings: $userId'); - if (userId != null) { - final userDetails = await pinepodsService.getUserDetails(userId); - print('DEBUG: User details response: $userDetails'); - if (userDetails != null) { - // Update settings with user details - final username = userDetails['Username'] ?? userDetails['username'] ?? ''; - final email = userDetails['Email'] ?? userDetails['email'] ?? ''; - print('DEBUG: Parsed username: "$username", email: "$email"'); - setPinepodsUsername(username); - setPinepodsEmail(email); - } - } - } catch (e) { - // Silently fail - don't break the app if this fails - print('Failed to fetch user details on startup: $e'); + final userId = _currentSettings.pinepodsUserId; + if (_currentSettings.pinepodsServer == null || _currentSettings.pinepodsApiKey == null || userId == null) { + return; + } + + try { + final pinepodsService = PinepodsService(); + pinepodsService.setCredentials(_currentSettings.pinepodsServer!, _currentSettings.pinepodsApiKey!); + + final userDetails = await pinepodsService.getUserDetails(userId); + if (userDetails == null) return; + + if (_currentSettings.pinepodsUsername == null || _currentSettings.pinepodsUsername!.isEmpty) { + setPinepodsUsername(userDetails['Username'] ?? userDetails['username'] ?? ''); + setPinepodsEmail(userDetails['Email'] ?? userDetails['email'] ?? ''); } + setPinepodsUseGravatar(userDetails['UseGravatar'] == true); + } catch (e) { + // Silently fail - don't break the app if this fails + print('Failed to fetch user details on startup: $e'); } } @@ -118,6 +113,7 @@ class SettingsBloc extends Bloc { bottomBarOrder: _settingsService.bottomBarOrder, autoDownloadWifiOnly: _settingsService.autoDownloadWifiOnly, preferServerDownloadSource: _settingsService.preferServerDownloadSource, + pinepodsUseGravatar: _settingsService.pinepodsUseGravatar, autoDownloadQueueCount: _settingsService.autoDownloadQueueCount, mirrorServerDownloads: _settingsService.mirrorServerDownloads, deleteDownloadsAfterDays: _settingsService.deleteDownloadsAfterDays, @@ -282,6 +278,12 @@ class SettingsBloc extends Bloc { _settingsService.preferServerDownloadSource = value; }); + _pinepodsUseGravatar.listen((value) { + _currentSettings = _currentSettings.copyWith(pinepodsUseGravatar: value); + _settings.add(_currentSettings); + _settingsService.pinepodsUseGravatar = value; + }); + _autoDownloadQueueCount.listen((value) { _currentSettings = _currentSettings.copyWith(autoDownloadQueueCount: value); _settings.add(_currentSettings); @@ -361,6 +363,8 @@ class SettingsBloc extends Bloc { void Function(bool) get setPreferServerDownloadSource => _preferServerDownloadSource.add; + void Function(bool) get setPinepodsUseGravatar => _pinepodsUseGravatar.add; + void Function(int) get setAutoDownloadQueueCount => _autoDownloadQueueCount.add; void Function(bool) get setMirrorServerDownloads => _mirrorServerDownloads.add; @@ -451,6 +455,7 @@ class SettingsBloc extends Bloc { _bottomBarOrder.close(); _autoDownloadWifiOnly.close(); _preferServerDownloadSource.close(); + _pinepodsUseGravatar.close(); _autoDownloadQueueCount.close(); _mirrorServerDownloads.close(); _deleteDownloadsAfterDays.close(); diff --git a/mobile/lib/core/utils.dart b/mobile/lib/core/utils.dart index bf1c26915..67b7f55f3 100644 --- a/mobile/lib/core/utils.dart +++ b/mobile/lib/core/utils.dart @@ -9,7 +9,6 @@ import 'package:pinepods_mobile/services/settings/mobile_settings_service.dart'; import 'package:pinepods_mobile/services/settings/settings_service.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; /// Returns the storage directory for the current platform. /// @@ -40,17 +39,10 @@ Future createDownloadDirectory(Episode episode) async { Directory(path).createSync(recursive: true); } -Future hasStoragePermission() async { - SettingsService? settings = await MobileSettingsService.instance(); - - if (Platform.isIOS || !settings!.storeDownloadsSDCard) { - return Future.value(true); - } else { - final permissionStatus = await Permission.storage.request(); - - return Future.value(permissionStatus.isGranted); - } -} +/// Downloads only go to app-specific directories (including the SD card's +/// `Android/data/` folder), which need no storage permission. Requesting +/// Permission.storage would always be denied on Android 13+ (#983). +Future hasStoragePermission() async => true; Future getStorageDirectory() async { SettingsService? settings = await MobileSettingsService.instance(); @@ -111,11 +103,64 @@ String? safeFile(String? s) { return s?.replaceAll(RegExp(r'[^\w\s\.]+'), '').trim(); } -Future resolveUrl(String url, {bool forceHttps = false}) async { +/// The URL to hand the native downloader for [url]. +/// +/// When every hop of the redirect chain is https the starting URL is kept, so +/// the downloader follows the redirects itself: signed CDN URLs can be tied to +/// the network that requested them (#1035). An http URL is tried as https +/// first. A chain that still goes through plain http is resolved and upgraded +/// to https instead, since the native downloaders refuse an https -> http +/// redirect. +Future resolveDownloadUrl(String url) async { + final original = Uri.parse(url); + if (original.scheme == 'http') { + try { + final secure = await redirectChain(httpsVariant(original).toString()); + if (secure.status < 400 && secure.chain.every((uri) => uri.scheme == 'https')) { + return secure.chain.first.toString(); + } + } catch (_) { + // No https version; fall back to the http chain. + } + } + + return pickDownloadUrl((await redirectChain(url)).chain); +} + +/// [uri] over https. BBC media selector URLs also name the protocol of the +/// URL they redirect to, which is switched to https too. +Uri httpsVariant(Uri uri) { + var path = uri.path; + if (uri.host.endsWith('bbc.co.uk') && path.contains('/mediaselector/')) { + path = path.replaceFirst('/proto/http/', '/proto/https/'); + } + + return uri.replace(scheme: 'https', path: path); +} + +/// See [resolveDownloadUrl]; [chain] is the original URL followed by each +/// redirect target. +String pickDownloadUrl(List chain) { + if (chain.every((uri) => uri.scheme == 'https')) { + return chain.first.toString(); + } + + var last = chain.last; + if (last.scheme == 'http') { + last = last.replace(scheme: 'https'); + } + + return last.toString(); +} + +/// [url] followed by each redirect target (up to 10), and the status code of +/// the last response. +Future<({List chain, int status})> redirectChain(String url) async { final client = HttpClient(); try { var uri = Uri.parse(url); + final chain = [uri]; var request = await client.getUrl(uri); request.followRedirects = false; @@ -136,18 +181,15 @@ Future resolveUrl(String url, {bool forceHttps = false}) async { } uri = uri.resolve(location); + chain.add(uri); request = await client.getUrl(uri); request.followRedirects = false; response = await request.close(); redirectCount++; } - if (forceHttps && uri.scheme == 'http') { - uri = uri.replace(scheme: 'https'); - } - - return uri.toString(); + return (chain: chain, status: response.statusCode); } finally { - client.close(); + client.close(force: true); } } diff --git a/mobile/lib/entities/app_settings.dart b/mobile/lib/entities/app_settings.dart index a8756f761..ba75b58a0 100644 --- a/mobile/lib/entities/app_settings.dart +++ b/mobile/lib/entities/app_settings.dart @@ -85,6 +85,9 @@ class AppSettings { /// exists, falling back to the original feed URL otherwise. final bool preferServerDownloadSource; + /// Whether to show the user's Gravatar. Mirrors the server setting; off by default. + final bool pinepodsUseGravatar; + /// Number of leading queue episodes to keep downloaded locally (0 = disabled). final int autoDownloadQueueCount; @@ -125,6 +128,7 @@ class AppSettings { required this.bottomBarOrder, this.autoDownloadWifiOnly = true, this.preferServerDownloadSource = true, + this.pinepodsUseGravatar = false, this.autoDownloadQueueCount = 0, this.mirrorServerDownloads = false, this.deleteDownloadsAfterDays = 0, @@ -157,6 +161,7 @@ class AppSettings { bottomBarOrder = defaultBottomBarOrder, autoDownloadWifiOnly = true, preferServerDownloadSource = true, + pinepodsUseGravatar = false, autoDownloadQueueCount = 0, mirrorServerDownloads = false, deleteDownloadsAfterDays = 0, @@ -188,6 +193,7 @@ class AppSettings { List? bottomBarOrder, bool? autoDownloadWifiOnly, bool? preferServerDownloadSource, + bool? pinepodsUseGravatar, int? autoDownloadQueueCount, bool? mirrorServerDownloads, int? deleteDownloadsAfterDays, @@ -219,6 +225,7 @@ class AppSettings { bottomBarOrder: bottomBarOrder ?? this.bottomBarOrder, autoDownloadWifiOnly: autoDownloadWifiOnly ?? this.autoDownloadWifiOnly, preferServerDownloadSource: preferServerDownloadSource ?? this.preferServerDownloadSource, + pinepodsUseGravatar: pinepodsUseGravatar ?? this.pinepodsUseGravatar, autoDownloadQueueCount: autoDownloadQueueCount ?? this.autoDownloadQueueCount, mirrorServerDownloads: mirrorServerDownloads ?? this.mirrorServerDownloads, deleteDownloadsAfterDays: deleteDownloadsAfterDays ?? this.deleteDownloadsAfterDays, diff --git a/mobile/lib/services/download/mobile_download_service.dart b/mobile/lib/services/download/mobile_download_service.dart index 9ccea47ad..19c7f3cf1 100644 --- a/mobile/lib/services/download/mobile_download_service.dart +++ b/mobile/lib/services/download/mobile_download_service.dart @@ -141,9 +141,7 @@ class MobileDownloadService extends DownloadService { // it as-is rather than resolving/forcing https. url = episode.downloadUrl!; } else { - /// If we get a redirect to an http endpoint the download will fail. Let's fully resolve - /// the URL before calling download and ensure it is https. - url = await resolveUrl(episode.contentUrl!, forceHttps: true); + url = await resolveDownloadUrl(episode.contentUrl!); } final taskId = await downloadManager.enqueueTask(url, downloadPath, filename); diff --git a/mobile/lib/services/pinepods/login_service.dart b/mobile/lib/services/pinepods/login_service.dart index 8ed68b9a4..e69df6a08 100644 --- a/mobile/lib/services/pinepods/login_service.dart +++ b/mobile/lib/services/pinepods/login_service.dart @@ -550,12 +550,14 @@ class UserDetails { final String? fullname; final String? username; final String? email; + final bool useGravatar; UserDetails({ required this.userId, this.fullname, this.username, this.email, + this.useGravatar = false, }); factory UserDetails.fromJson(Map json) { @@ -564,6 +566,7 @@ class UserDetails { fullname: json['Fullname'], username: json['Username'], email: json['Email'], + useGravatar: json['UseGravatar'] == true, ); } } diff --git a/mobile/lib/services/pinepods/pinepods_service.dart b/mobile/lib/services/pinepods/pinepods_service.dart index 2fd1efab2..ea2047372 100644 --- a/mobile/lib/services/pinepods/pinepods_service.dart +++ b/mobile/lib/services/pinepods/pinepods_service.dart @@ -1019,6 +1019,9 @@ class PinepodsService { ); } + Future setUseGravatar(int userId, bool enabled) async => + await _postJson('user/set_use_gravatar', {'user_id': userId, 'enabled': enabled}) != null; + Future setAutoDeleteOnCompleteDefault(int userId, bool enabled) async => await _postJson('user/set_auto_delete_on_complete', {'user_id': userId, 'enabled': enabled}) != diff --git a/mobile/lib/services/settings/mobile_settings_service.dart b/mobile/lib/services/settings/mobile_settings_service.dart index 5a3895151..8be08a0cb 100644 --- a/mobile/lib/services/settings/mobile_settings_service.dart +++ b/mobile/lib/services/settings/mobile_settings_service.dart @@ -309,6 +309,15 @@ class MobileSettingsService extends SettingsService { settingsNotifier.sink.add('preferServerDownloadSource'); } + @override + bool get pinepodsUseGravatar => _sharedPreferences.getBool('pinepods_use_gravatar') ?? false; + + @override + set pinepodsUseGravatar(bool value) { + _sharedPreferences.setBool('pinepods_use_gravatar', value); + settingsNotifier.sink.add('pinepods_use_gravatar'); + } + @override int get autoDownloadQueueCount => _sharedPreferences.getInt('autoDownloadQueueCount') ?? 0; diff --git a/mobile/lib/services/settings/settings_service.dart b/mobile/lib/services/settings/settings_service.dart index c1e28aba5..ae5de92da 100644 --- a/mobile/lib/services/settings/settings_service.dart +++ b/mobile/lib/services/settings/settings_service.dart @@ -84,6 +84,9 @@ abstract class SettingsService { bool get preferServerDownloadSource; set preferServerDownloadSource(bool value); + bool get pinepodsUseGravatar; + set pinepodsUseGravatar(bool value); + /// Number of leading queue episodes to keep downloaded locally. 0 disables the /// feature. int get autoDownloadQueueCount; diff --git a/mobile/lib/ui/auth/pinepods_startup_login.dart b/mobile/lib/ui/auth/pinepods_startup_login.dart index da2d7ec8b..4d9128c45 100644 --- a/mobile/lib/ui/auth/pinepods_startup_login.dart +++ b/mobile/lib/ui/auth/pinepods_startup_login.dart @@ -267,6 +267,7 @@ class _PinepodsStartupLoginState extends State { if (userDetails.email != null) { settingsBloc.setPinepodsEmail(userDetails.email!); } + settingsBloc.setPinepodsUseGravatar(userDetails.useGravatar); // Fetch theme from server try { diff --git a/mobile/lib/ui/pinepods_podcast_app.dart b/mobile/lib/ui/pinepods_podcast_app.dart index 24383bc6e..65747d4e7 100644 --- a/mobile/lib/ui/pinepods_podcast_app.dart +++ b/mobile/lib/ui/pinepods_podcast_app.dart @@ -534,6 +534,7 @@ class _PinepodsHomePageState extends State with WidgetsBinding if (userDetails.email != null) { settingsBloc.setPinepodsEmail(userDetails.email!); } + settingsBloc.setPinepodsUseGravatar(userDetails.useGravatar); // Fetch theme from server await settingsBloc.fetchThemeFromServer(); @@ -1043,7 +1044,7 @@ class TitleWidget extends StatelessWidget { builder: (context, settingsBloc, child) { final settings = settingsBloc.currentSettings; final username = settings.pinepodsUsername; - final email = settings.pinepodsEmail; + final email = settings.pinepodsUseGravatar ? settings.pinepodsEmail : null; if (username == null || username.isEmpty) { // Fallback to PinePods logo if no user is logged in - make it clickable diff --git a/mobile/lib/ui/settings/settings.dart b/mobile/lib/ui/settings/settings.dart index f634ae448..54e99633c 100644 --- a/mobile/lib/ui/settings/settings.dart +++ b/mobile/lib/ui/settings/settings.dart @@ -8,6 +8,7 @@ import 'package:pinepods_mobile/core/download_cleanup.dart'; import 'package:pinepods_mobile/core/utils.dart'; import 'package:pinepods_mobile/entities/app_settings.dart'; import 'package:pinepods_mobile/l10n/L.dart'; +import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart'; import 'package:pinepods_mobile/ui/settings/episode_refresh.dart'; import 'package:pinepods_mobile/ui/settings/search_provider.dart'; import 'package:pinepods_mobile/ui/settings/server_download_defaults.dart'; @@ -61,6 +62,18 @@ class _SettingsState extends State { children: [ SettingsDividerLabel(label: L.of(context)!.settings_personalisation_divider_label), const Divider(), + if (snapshot.data!.pinepodsUserId != null) + MergeSemantics( + child: ListTile( + leading: const Icon(PhosphorIconsRegular.userCircle), + title: const Text('Show my Gravatar'), + subtitle: const Text('Loads an image from gravatar.com using a hash of your email'), + trailing: Switch.adaptive( + value: snapshot.data!.pinepodsUseGravatar, + onChanged: (value) => _setUseGravatar(settingsBloc, snapshot.data!, value), + ), + ), + ), MergeSemantics( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), @@ -354,6 +367,18 @@ class _SettingsState extends State { /// Dropdown for selecting a skip interval (in seconds). Ensures the /// currently-stored value is always selectable even if it isn't one of the /// preset options. + Future _setUseGravatar(SettingsBloc settingsBloc, AppSettings settings, bool value) async { + final previous = settings.pinepodsUseGravatar; + settingsBloc.setPinepodsUseGravatar(value); + final service = PinepodsService()..setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!); + if (!await service.setUseGravatar(settings.pinepodsUserId!, value)) { + settingsBloc.setPinepodsUseGravatar(previous); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not update the Gravatar setting'))); + } + } + } + Widget _buildIntervalDropdown({ required BuildContext context, required int value, diff --git a/mobile/test/core/utils_test.dart b/mobile/test/core/utils_test.dart index 21c60c374..3727b6712 100644 --- a/mobile/test/core/utils_test.dart +++ b/mobile/test/core/utils_test.dart @@ -1,7 +1,9 @@ -// Only covers the pure string-sanitizing helpers in core/utils.dart. The -// rest of that file (resolvePath, getStorageDirectory, hasStoragePermission, -// resolveUrl, etc.) talks to platform channels, the filesystem, or the -// network, so it's out of scope for plain unit tests. +// Covers the pure helpers in core/utils.dart, plus redirectChain against a +// local server. The rest of that file (resolvePath, getStorageDirectory, +// hasStoragePermission, etc.) talks to platform channels or the filesystem, +// so it's out of scope for plain unit tests. + +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:pinepods_mobile/core/utils.dart'; @@ -34,4 +36,72 @@ void main() { expect(safeFile(null), isNull); }); }); + + group('pickDownloadUrl', () { + List chain(List urls) => urls.map(Uri.parse).toList(); + + test('keeps the original URL when every hop is https', () { + expect( + pickDownloadUrl(chain(['https://feed.example/ep.mp3', 'https://cdn.example/signed/ep.mp3?token=1'])), + 'https://feed.example/ep.mp3', + ); + }); + + test('resolves and upgrades a chain through http', () { + expect( + pickDownloadUrl(chain(['https://feed.example/ep.mp3', 'http://cdn.example/ep.mp3'])), + 'https://cdn.example/ep.mp3', + ); + expect( + pickDownloadUrl(chain(['http://feed.example/ep.mp3', 'https://cdn.example/ep.mp3'])), + 'https://cdn.example/ep.mp3', + ); + }); + + test('upgrades an http URL without redirects', () { + expect(pickDownloadUrl(chain(['http://feed.example/ep.mp3'])), 'https://feed.example/ep.mp3'); + }); + }); + + group('redirectChain', () { + test('lists the original URL and each redirect target', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) { + final next = {'/a': '/b', '/b': '/c'}[request.uri.path]; + if (next != null) { + request.response.redirect(Uri.parse(next)); + } else { + request.response.write('audio'); + request.response.close(); + } + }); + addTearDown(() => server.close(force: true)); + + final base = 'http://127.0.0.1:${server.port}'; + final result = await redirectChain('$base/a'); + expect(result.chain.map((u) => u.toString()), ['$base/a', '$base/b', '$base/c']); + expect(result.status, 200); + }); + }); + + group('httpsVariant', () { + test('switches the scheme to https', () { + expect(httpsVariant(Uri.parse('http://feed.example/ep.mp3?x=1')).toString(), 'https://feed.example/ep.mp3?x=1'); + }); + + test('asks BBC media selector URLs for an https redirect', () { + const path = '/mediaselector/6/redir/version/2.0/mediaset/audio-nondrm-download-rss/proto'; + expect( + httpsVariant(Uri.parse('http://open.live.bbc.co.uk$path/http/vpid/p0pj8fyh.mp3')).toString(), + 'https://open.live.bbc.co.uk$path/https/vpid/p0pj8fyh.mp3', + ); + }); + + test('leaves proto segments on other hosts alone', () { + expect( + httpsVariant(Uri.parse('http://cdn.example/proto/http/ep.mp3')).toString(), + 'https://cdn.example/proto/http/ep.mp3', + ); + }); + }); } diff --git a/rust-api/openapi.json b/rust-api/openapi.json index 60e3b85bc..15c6275ef 100644 --- a/rust-api/openapi.json +++ b/rust-api/openapi.json @@ -10791,6 +10791,43 @@ ] } }, + "/api/data/user/set_use_gravatar": { + "post": { + "tags": [ + "settings" + ], + "summary": "Set whether the user's Gravatar is shown", + "operationId": "set_use_gravatar", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUseGravatar" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "description": "Invalid or missing API key" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/api/data/user/set_username": { "put": { "tags": [ @@ -16801,7 +16838,8 @@ "feedurl", "explicit", "is_favorite", - "is_video" + "is_video", + "play_count" ], "properties": { "podcastid": { @@ -16872,6 +16910,22 @@ }, "is_video": { "type": "boolean" + }, + "oldest_episode_date": { + "type": [ + "string", + "null" + ] + }, + "newest_episode_date": { + "type": [ + "string", + "null" + ] + }, + "play_count": { + "type": "integer", + "format": "int64" } } }, @@ -18334,6 +18388,22 @@ } } }, + "SetUseGravatar": { + "type": "object", + "required": [ + "user_id", + "enabled" + ], + "properties": { + "user_id": { + "type": "integer", + "format": "int32" + }, + "enabled": { + "type": "boolean" + } + } + }, "SetUsernameRequest": { "type": "object", "required": [ @@ -19497,7 +19567,8 @@ "UserDetails": { "type": "object", "required": [ - "UserID" + "UserID", + "UseGravatar" ], "properties": { "UserID": { @@ -19535,6 +19606,10 @@ "null" ], "description": "Always null; kept for response compatibility." + }, + "UseGravatar": { + "type": "boolean", + "description": "Whether the clients show the user's Gravatar. Off by default." } } }, diff --git a/rust-api/src/database.rs b/rust-api/src/database.rs index 67ffa9b5b..a967127aa 100644 --- a/rust-api/src/database.rs +++ b/rust-api/src/database.rs @@ -498,7 +498,7 @@ impl DatabasePool { pub async fn get_user_details_by_id(&self, user_id: i32) -> AppResult { match self { DatabasePool::Postgres(pool) => { - let row = sqlx::query(r#"SELECT userid, fullname, username, email FROM "Users" WHERE userid = $1"#) + let row = sqlx::query(r#"SELECT userid, fullname, username, email, usegravatar FROM "Users" WHERE userid = $1"#) .bind(user_id) .fetch_one(pool) .await?; @@ -511,10 +511,11 @@ impl DatabasePool { Email: row.try_get("email").ok(), Hashed_PW: None, Salt: None, + UseGravatar: row.try_get("usegravatar")?, }) } DatabasePool::MySQL(pool) => { - let row = sqlx::query("SELECT UserID, Fullname, Username, Email FROM Users WHERE UserID = ?") + let row = sqlx::query("SELECT UserID, Fullname, Username, Email, UseGravatar FROM Users WHERE UserID = ?") .bind(user_id) .fetch_one(pool) .await?; @@ -526,6 +527,7 @@ impl DatabasePool { Email: row.try_get("Email").ok(), Hashed_PW: None, Salt: None, + UseGravatar: row.try_get::("UseGravatar")?, }) } } @@ -1315,19 +1317,30 @@ impl DatabasePool { /// with a concurrent write to the same rows (e.g. a queued import of that feed). async fn remove_podcast_cascade(&self, podcast_id: i32, user_id: i32, origin: crate::services::sync::SyncOrigin) -> AppResult { let mut attempt = 0; - loop { + let files = loop { match self.remove_podcast_cascade_once(podcast_id, user_id, origin).await { Err(e) if attempt < 3 && is_write_conflict(&e) => { attempt += 1; debug!("Removing podcast {} hit a write conflict, retrying ({}/3)", podcast_id, attempt); tokio::time::sleep(Duration::from_millis(20 * attempt as u64)).await; } - other => return other, + other => break other?, + } + }; + let Some(files) = files else { return Ok(false) }; + // The download rows are gone with the podcast, so their files go too. + for path in files { + if tokio::fs::metadata(&path).await.is_ok() { + if let Err(e) = tokio::fs::remove_file(&path).await { + warn!("Could not remove downloaded file {} of removed podcast {}: {}", path, podcast_id, e); + } } } + Ok(true) } - async fn remove_podcast_cascade_once(&self, podcast_id: i32, user_id: i32, origin: crate::services::sync::SyncOrigin) -> AppResult { + // Returns the user's downloaded files of the podcast, or None if it doesn't exist. + async fn remove_podcast_cascade_once(&self, podcast_id: i32, user_id: i32, origin: crate::services::sync::SyncOrigin) -> AppResult>> { use crate::services::sync::{events, shareable_feed}; let log = events::should_log(self, user_id, origin).await; match self { @@ -1341,7 +1354,7 @@ impl DatabasePool { .bind(podcast_id) .fetch_optional(&mut *tx) .await?; - let Some(podcast) = podcast else { return Ok(false) }; + let Some(podcast) = podcast else { return Ok(None) }; let feed_url: String = podcast.try_get::, _>("feedurl")?.unwrap_or_default(); let shareable = shareable_feed( &feed_url, @@ -1354,6 +1367,19 @@ impl DatabasePool { // The feed's episodes are shared: only this user's rows for them go, and the // episodes themselves only with the last subscription. let episodes = feed_episodes_sql(feed_id, podcast_id, true); + // Local podcasts point at the user's own media, which stays. + let files: Vec> = if feed_url.starts_with("local://") { + Vec::new() + } else { + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + r#"SELECT downloadedlocation FROM "DownloadedEpisodes" WHERE userid = $1 AND episodeid IN ({episodes}) + UNION ALL + SELECT downloadedlocation FROM "DownloadedVideos" WHERE userid = $1 AND videoid IN (SELECT videoid FROM "YouTubeVideos" WHERE podcastid = {podcast_id})"# + ))) + .bind(user_id) + .fetch_all(&mut *tx) + .await? + }; for sql in user_episode_rows_pg(&episodes) { sqlx::query(sqlx::AssertSqlSafe(sql)).bind(user_id).execute(&mut *tx).await?; } @@ -1396,7 +1422,7 @@ impl DatabasePool { events::subscription_pg(self, &mut tx, user_id, &feed_url, "remove").await; } tx.commit().await?; - Ok(true) + Ok(Some(files.into_iter().flatten().collect())) } DatabasePool::MySQL(pool) => { let mut tx = crate::services::sync::events::begin_mysql(pool, user_id, log).await?; @@ -1406,7 +1432,7 @@ impl DatabasePool { .bind(podcast_id) .fetch_optional(&mut *tx) .await?; - let Some(podcast) = podcast else { return Ok(false) }; + let Some(podcast) = podcast else { return Ok(None) }; let feed_url: String = podcast.try_get::, _>("FeedURL")?.unwrap_or_default(); let shareable = shareable_feed( &feed_url, @@ -1417,6 +1443,19 @@ impl DatabasePool { let feed_id: Option = podcast.try_get("FeedID")?; let episodes = feed_episodes_sql(feed_id, podcast_id, false); + let files: Vec> = if feed_url.starts_with("local://") { + Vec::new() + } else { + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT DownloadedLocation FROM DownloadedEpisodes WHERE UserID = ? AND EpisodeID IN ({episodes}) + UNION ALL + SELECT DownloadedLocation FROM DownloadedVideos WHERE UserID = ? AND VideoID IN (SELECT VideoID FROM YouTubeVideos WHERE PodcastID = {podcast_id})" + ))) + .bind(user_id) + .bind(user_id) + .fetch_all(&mut *tx) + .await? + }; for sql in user_episode_rows_mysql(&episodes) { sqlx::query(sqlx::AssertSqlSafe(sql)).bind(user_id).execute(&mut *tx).await?; } @@ -1459,7 +1498,7 @@ impl DatabasePool { events::subscription_mysql(self, &mut tx, user_id, &feed_url, "remove").await; } tx.commit().await?; - Ok(true) + Ok(Some(files.into_iter().flatten().collect())) } } } @@ -1620,7 +1659,15 @@ impl DatabasePool { COALESCE(explicit, false) as explicit, COALESCE(podcastindexid, 0) as podcastindexid, COALESCE(isfavorite, false) as isfavorite, - EXISTS(SELECT 1 FROM "Episodes" e WHERE e.feedid = "Podcasts".feedid AND e.is_video = true) as is_video + EXISTS(SELECT 1 FROM "Episodes" e WHERE e.feedid = "Podcasts".feedid AND e.is_video = true) as is_video, + TO_CHAR(COALESCE((SELECT MIN(e.episodepubdate) FROM "Episodes" e WHERE e.feedid = "Podcasts".feedid), + (SELECT MIN(v.publishedat) FROM "YouTubeVideos" v WHERE v.podcastid = "Podcasts".podcastid)), + 'YYYY-MM-DD"T"HH24:MI:SS') as oldest_episode_date, + TO_CHAR(COALESCE((SELECT MAX(e.episodepubdate) FROM "Episodes" e WHERE e.feedid = "Podcasts".feedid), + (SELECT MAX(v.publishedat) FROM "YouTubeVideos" v WHERE v.podcastid = "Podcasts".podcastid)), + 'YYYY-MM-DD"T"HH24:MI:SS') as newest_episode_date, + (SELECT COUNT(*) FROM "UserEpisodeHistory" h JOIN "Episodes" e ON e.episodeid = h.episodeid + WHERE h.userid = $1 AND e.feedid = "Podcasts".feedid) as play_count FROM "Podcasts" WHERE userid = $1 AND COALESCE(displaypodcast, TRUE) = TRUE ORDER BY podcastname"# @@ -1648,6 +1695,9 @@ impl DatabasePool { podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), is_favorite: row.try_get("isfavorite").unwrap_or(false), is_video: row.try_get("is_video").unwrap_or(false), + oldest_episode_date: row.try_get("oldest_episode_date")?, + newest_episode_date: row.try_get("newest_episode_date")?, + play_count: row.try_get("play_count")?, }); } Ok(podcasts) @@ -1671,12 +1721,21 @@ impl DatabasePool { COALESCE(Explicit, false) as explicit, COALESCE(PodcastIndexID, 0) as podcastindexid, COALESCE(IsFavorite, false) as isfavorite, - EXISTS(SELECT 1 FROM Episodes e WHERE e.FeedID = Podcasts.FeedID AND e.IsVideo = 1) as is_video + EXISTS(SELECT 1 FROM Episodes e WHERE e.FeedID = Podcasts.FeedID AND e.IsVideo = 1) as is_video, + DATE_FORMAT(COALESCE((SELECT MIN(e.EpisodePubDate) FROM Episodes e WHERE e.FeedID = Podcasts.FeedID), + (SELECT MIN(v.PublishedAt) FROM YouTubeVideos v WHERE v.PodcastID = Podcasts.PodcastID)), + '%Y-%m-%dT%H:%i:%s') as oldest_episode_date, + DATE_FORMAT(COALESCE((SELECT MAX(e.EpisodePubDate) FROM Episodes e WHERE e.FeedID = Podcasts.FeedID), + (SELECT MAX(v.PublishedAt) FROM YouTubeVideos v WHERE v.PodcastID = Podcasts.PodcastID)), + '%Y-%m-%dT%H:%i:%s') as newest_episode_date, + (SELECT COUNT(*) FROM UserEpisodeHistory h JOIN Episodes e ON e.EpisodeID = h.EpisodeID + WHERE h.UserID = ? AND e.FeedID = Podcasts.FeedID) as play_count FROM Podcasts WHERE UserID = ? AND COALESCE(DisplayPodcast, 1) = 1 ORDER BY PodcastName" ) .bind(user_id) + .bind(user_id) .fetch_all(pool) .await?; @@ -1699,6 +1758,9 @@ impl DatabasePool { podcastindexid: row.try_get::("podcastindexid").ok().map(|i| i as i64), is_favorite: row.try_get("isfavorite").unwrap_or(false), is_video: row.try_get("is_video").unwrap_or(false), + oldest_episode_date: row.try_get("oldest_episode_date")?, + newest_episode_date: row.try_get("newest_episode_date")?, + play_count: row.try_get("play_count")?, }); } Ok(podcasts) @@ -1728,9 +1790,9 @@ impl DatabasePool { COALESCE(p.explicit, false) as explicit, COALESCE(p.podcastindexid, 0) as podcastindexid, COUNT(ueh.userepisodehistoryid) as play_count, - COUNT(DISTINCT ueh.episodeid) as episodes_played, - MIN(e.episodepubdate) as oldest_episode_date, - COALESCE(p.isyoutube, false) as is_youtube, + COUNT(DISTINCT ueh.episodeid)::INT as episodes_played, + TO_CHAR(MIN(e.episodepubdate), 'YYYY-MM-DD"T"HH24:MI:SS') as oldest_episode_date, + COALESCE(p.isyoutubechannel, false) as is_youtube, COALESCE(BOOL_OR(e.is_video), false) as is_video, COALESCE(p.isfavorite, false) as isfavorite FROM "Podcasts" p @@ -1739,7 +1801,7 @@ impl DatabasePool { WHERE p.userid = $1 AND COALESCE(p.displaypodcast, TRUE) = TRUE GROUP BY p.podcastid, p.podcastname, p.artworkurl, p.description, p.episodecount, p.websiteurl, p.feedurl, p.author, - p.categories, p.explicit, p.podcastindexid, p.isyoutube, p.isfavorite + p.categories, p.explicit, p.podcastindexid, p.isyoutubechannel, p.isfavorite ORDER BY p.podcastname"# ) .bind(user_id) @@ -1796,7 +1858,7 @@ impl DatabasePool { COALESCE(p.PodcastIndexID, 0) as podcastindexid, COUNT(ueh.UserEpisodeHistoryID) as play_count, COUNT(DISTINCT ueh.EpisodeID) as episodes_played, - MIN(e.EpisodePubDate) as oldest_episode_date, + DATE_FORMAT(MIN(e.EpisodePubDate), '%Y-%m-%dT%H:%i:%s') as oldest_episode_date, COALESCE(p.IsYouTubeChannel, false) as is_youtube, COALESCE(MAX(e.IsVideo), 0) as is_video, COALESCE(p.IsFavorite, false) as isfavorite @@ -2633,7 +2695,7 @@ impl DatabasePool { "Episodes".is_video as is_video FROM "EpisodeQueue" INNER JOIN "Episodes" ON "EpisodeQueue".episodeid = "Episodes".episodeid - INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = "EpisodeQueue".userid LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "UserEpisodeHistory" ON "EpisodeQueue".episodeid = "UserEpisodeHistory".episodeid @@ -2749,7 +2811,7 @@ impl DatabasePool { Episodes.IsVideo as is_video FROM EpisodeQueue INNER JOIN Episodes ON EpisodeQueue.EpisodeID = Episodes.EpisodeID - INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID + INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID AND Podcasts.UserID = EpisodeQueue.UserID LEFT JOIN Users ON Podcasts.UserID = Users.UserID LEFT JOIN UserEpisodeHistory ON EpisodeQueue.EpisodeID = UserEpisodeHistory.EpisodeID @@ -3685,7 +3747,7 @@ impl DatabasePool { // Total data downloaded let dl_row = sqlx::query(r#" - SELECT COALESCE(SUM(downloadedsize), 0) AS total_bytes + SELECT COALESCE(SUM(downloadedsize), 0)::BIGINT AS total_bytes FROM "DownloadedEpisodes" WHERE userid = $1 "#).bind(user_id).fetch_one(pool).await?; @@ -3838,7 +3900,7 @@ impl DatabasePool { // Total downloaded let dl_row = sqlx::query( - "SELECT COALESCE(SUM(DownloadedSize), 0) AS total_bytes + "SELECT CAST(COALESCE(SUM(DownloadedSize), 0) AS SIGNED) AS total_bytes FROM DownloadedEpisodes WHERE UserID = ?" ).bind(user_id).fetch_one(pool).await?; @@ -4259,7 +4321,7 @@ impl DatabasePool { CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded FROM "UserEpisodeHistory" JOIN "Episodes" ON "UserEpisodeHistory".episodeid = "Episodes".episodeid - JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = "UserEpisodeHistory".userid LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "SavedEpisodes" ON "Episodes".episodeid = "SavedEpisodes".episodeid @@ -4316,7 +4378,7 @@ impl DatabasePool { CASE WHEN "DownloadedEpisodes".episodeid IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded FROM "EpisodeQueue" INNER JOIN "Episodes" ON "EpisodeQueue".episodeid = "Episodes".episodeid - INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = "EpisodeQueue".userid LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "UserEpisodeHistory" ON "EpisodeQueue".episodeid = "UserEpisodeHistory".episodeid @@ -4600,7 +4662,7 @@ impl DatabasePool { CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded FROM UserEpisodeHistory JOIN Episodes ON UserEpisodeHistory.EpisodeID = Episodes.EpisodeID - JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID + JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID AND Podcasts.UserID = UserEpisodeHistory.UserID LEFT JOIN Users ON Podcasts.UserID = Users.UserID LEFT JOIN SavedEpisodes ON Episodes.EpisodeID = SavedEpisodes.EpisodeID @@ -4657,7 +4719,7 @@ impl DatabasePool { CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS downloaded FROM EpisodeQueue INNER JOIN Episodes ON EpisodeQueue.EpisodeID = Episodes.EpisodeID - INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID + INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID AND Podcasts.UserID = EpisodeQueue.UserID LEFT JOIN Users ON Podcasts.UserID = Users.UserID LEFT JOIN UserEpisodeHistory ON EpisodeQueue.EpisodeID = UserEpisodeHistory.EpisodeID @@ -4903,8 +4965,8 @@ impl DatabasePool { let mut preview_episodes = Vec::new(); for preview_row in preview_rows { preview_episodes.push(serde_json::json!({ - "title": preview_row.try_get::("episodetitle")?, - "artwork": preview_row.try_get::("episodeartwork")? + "title": preview_row.try_get::, _>("episodetitle")?.unwrap_or_default(), + "artwork": preview_row.try_get::, _>("episodeartwork")?.unwrap_or_default() })); } @@ -4994,8 +5056,8 @@ impl DatabasePool { let mut preview_episodes = Vec::new(); for preview_row in preview_rows { preview_episodes.push(serde_json::json!({ - "title": preview_row.try_get::("EpisodeTitle")?, - "artwork": preview_row.try_get::("EpisodeArtwork")? + "title": preview_row.try_get::, _>("EpisodeTitle")?.unwrap_or_default(), + "artwork": preview_row.try_get::, _>("EpisodeArtwork")?.unwrap_or_default() })); } @@ -9355,7 +9417,7 @@ impl DatabasePool { "Episodes".is_video as is_video FROM "DownloadedEpisodes" INNER JOIN "Episodes" ON "DownloadedEpisodes".episodeid = "Episodes".episodeid - INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = "DownloadedEpisodes".userid LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "UserEpisodeHistory" ON "DownloadedEpisodes".episodeid = "UserEpisodeHistory".episodeid @@ -9480,7 +9542,7 @@ impl DatabasePool { Episodes.IsVideo as is_video FROM DownloadedEpisodes INNER JOIN Episodes ON DownloadedEpisodes.EpisodeID = Episodes.EpisodeID - INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID + INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID AND Podcasts.UserID = DownloadedEpisodes.UserID LEFT JOIN Users ON Podcasts.UserID = Users.UserID LEFT JOIN UserEpisodeHistory ON DownloadedEpisodes.EpisodeID = UserEpisodeHistory.EpisodeID @@ -9794,7 +9856,7 @@ impl DatabasePool { "Episodes".is_video as is_video FROM "DownloadedEpisodes" INNER JOIN "Episodes" ON "DownloadedEpisodes".episodeid = "Episodes".episodeid - INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + INNER JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = "DownloadedEpisodes".userid LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "UserEpisodeHistory" ON "DownloadedEpisodes".episodeid = "UserEpisodeHistory".episodeid @@ -9955,7 +10017,7 @@ impl DatabasePool { Episodes.IsVideo as is_video FROM DownloadedEpisodes INNER JOIN Episodes ON DownloadedEpisodes.EpisodeID = Episodes.EpisodeID - INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID + INNER JOIN Podcasts ON Episodes.FeedID = Podcasts.FeedID AND Podcasts.UserID = DownloadedEpisodes.UserID LEFT JOIN Users ON Podcasts.UserID = Users.UserID LEFT JOIN UserEpisodeHistory ON DownloadedEpisodes.EpisodeID = UserEpisodeHistory.EpisodeID @@ -10139,10 +10201,11 @@ impl DatabasePool { } // Delete downloaded episode - pub async fn delete_episode(&self, user_id: i32, episode_id: i32, is_youtube: bool) -> AppResult<()> { + // Returns whether a download row was removed. + pub async fn delete_episode(&self, user_id: i32, episode_id: i32, is_youtube: bool) -> AppResult { match self { DatabasePool::Postgres(pool) => { - if is_youtube { + let deleted = if is_youtube { let result = sqlx::query(r#"DELETE FROM "DownloadedVideos" WHERE userid = $1 AND videoid = $2"#) .bind(user_id) .bind(episode_id) @@ -10156,6 +10219,7 @@ impl DatabasePool { .execute(pool) .await?; } + result.rows_affected() > 0 } else { let result = sqlx::query(r#"DELETE FROM "DownloadedEpisodes" WHERE userid = $1 AND episodeid = $2"#) .bind(user_id) @@ -10170,11 +10234,12 @@ impl DatabasePool { .execute(pool) .await?; } - } - Ok(()) + result.rows_affected() > 0 + }; + Ok(deleted) } DatabasePool::MySQL(pool) => { - if is_youtube { + let deleted = if is_youtube { let result = sqlx::query("DELETE FROM DownloadedVideos WHERE UserID = ? AND VideoID = ?") .bind(user_id) .bind(episode_id) @@ -10188,6 +10253,7 @@ impl DatabasePool { .execute(pool) .await?; } + result.rows_affected() > 0 } else { let result = sqlx::query("DELETE FROM DownloadedEpisodes WHERE UserID = ? AND EpisodeID = ?") .bind(user_id) @@ -10202,8 +10268,9 @@ impl DatabasePool { .execute(pool) .await?; } - } - Ok(()) + result.rows_affected() > 0 + }; + Ok(deleted) } } } @@ -11642,6 +11709,7 @@ impl DatabasePool { } // Get the next episode in a podcast (chronologically after the given episode) + // The episode after `episode_id` in its podcast, skipping ones the user already finished. pub async fn get_next_podcast_episode(&self, episode_id: i32, user_id: i32) -> AppResult> { match self { DatabasePool::Postgres(pool) => { @@ -11677,6 +11745,7 @@ impl DatabasePool { WHERE e.feedid = (SELECT feedid FROM "Episodes" WHERE episodeid = $1) AND e.episodepubdate > (SELECT episodepubdate FROM "Episodes" WHERE episodeid = $1) AND p.userid = $2 + AND COALESCE((SELECT ues.completed FROM "UserEpisodeState" ues WHERE ues.episodeid = e.episodeid AND ues.userid = p.userid), FALSE) = FALSE ORDER BY e.episodepubdate ASC LIMIT 1"# ) @@ -11746,6 +11815,7 @@ impl DatabasePool { WHERE e.FeedID = (SELECT FeedID FROM Episodes WHERE EpisodeID = ?) AND e.EpisodePubDate > (SELECT EpisodePubDate FROM Episodes WHERE EpisodeID = ?) AND p.UserID = ? + AND COALESCE((SELECT ues.Completed FROM UserEpisodeState ues WHERE ues.EpisodeID = e.EpisodeID AND ues.UserID = p.UserID), 0) = 0 ORDER BY e.EpisodePubDate ASC LIMIT 1" ) @@ -12020,6 +12090,10 @@ impl DatabasePool { }; let order_dir = if sort_order == "asc" { "ASC" } else { "DESC" }; let has_search = !search.is_empty(); + // Each branch keeps only the rows that can reach the requested page, sorted the same way + // as the outer query. The completed/in-progress filter runs on the combined rows, so a + // filtered page needs every row. + let filter_is_all = !matches!(filter, "completed" | "in_progress" | "incomplete"); match self { DatabasePool::Postgres(pool) => { let lim: i64 = limit.unwrap_or(i64::MAX); @@ -12027,7 +12101,7 @@ impl DatabasePool { // Per-branch cap: each branch must keep at least (offset + limit) rows so the // outer ORDER BY can correctly interleave them. saturating_add avoids overflow // when limit is i64::MAX (the "give me everything" caller path). - let per_branch_limit: i64 = lim.saturating_add(off); + let per_branch_limit: i64 = if filter_is_all { lim.saturating_add(off) } else { i64::MAX }; // Resolve the full set of podcast IDs (primary + merged) up front so the main // query is just `podcastid = ANY($2)` instead of the per-row JSON unpacking the @@ -12114,7 +12188,7 @@ impl DatabasePool { "Episodes".episodeid = "DownloadedEpisodes".episodeid AND "DownloadedEpisodes".userid = $1 WHERE "Podcasts".userid = $1 AND "Podcasts".podcastid = ANY($2){ep_search_clause} - ORDER BY "Episodes".episodepubdate DESC + ORDER BY {pg_order_col} {order_dir} NULLS LAST LIMIT {pb_lim_pos}) UNION ALL @@ -12153,7 +12227,7 @@ impl DatabasePool { "YouTubeVideos".videoid = "DownloadedVideos".videoid AND "DownloadedVideos".userid = $1 WHERE "Podcasts".userid = $1 AND "Podcasts".podcastid = ANY($2){yt_search_clause} - ORDER BY "YouTubeVideos".publishedat DESC + ORDER BY {pg_order_col} {order_dir} NULLS LAST LIMIT {pb_lim_pos}) ) combined{filter_clause} ORDER BY {pg_order_col} {order_dir} NULLS LAST @@ -12252,7 +12326,7 @@ impl DatabasePool { DatabasePool::MySQL(pool) => { let lim: i64 = limit.unwrap_or(i64::MAX); let off: i64 = offset.unwrap_or(0); - let per_branch_limit: i64 = lim.saturating_add(off); + let per_branch_limit: i64 = if filter_is_all { lim.saturating_add(off) } else { i64::MAX }; // Resolve podcast IDs (primary + merged) up front so the main query is just // `PodcastID IN (?, ?, ...)` instead of MySQL's JSON-extract-with-numbers join. @@ -12339,7 +12413,7 @@ impl DatabasePool { Episodes.EpisodeID = DownloadedEpisodes.EpisodeID AND DownloadedEpisodes.UserID = ? WHERE Podcasts.UserID = ? AND Podcasts.PodcastID IN ({ph}){ep_search_clause} - ORDER BY Episodes.EpisodePubDate DESC + ORDER BY {my_order_col} {order_dir} LIMIT ?) UNION ALL @@ -12378,7 +12452,7 @@ impl DatabasePool { YouTubeVideos.VideoID = DownloadedVideos.VideoID AND DownloadedVideos.UserID = ? WHERE Podcasts.UserID = ? AND Podcasts.PodcastID IN ({ph}){yt_search_clause} - ORDER BY YouTubeVideos.PublishedAt DESC + ORDER BY {my_order_col} {order_dir} LIMIT ?) ) combined{mysql_filter} ORDER BY {my_order_col} {order_dir} @@ -17730,6 +17804,26 @@ impl DatabasePool { Ok(()) } + pub async fn set_use_gravatar(&self, user_id: i32, enabled: bool) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::query(r#"UPDATE "Users" SET usegravatar = $1 WHERE userid = $2"#) + .bind(enabled) + .bind(user_id) + .execute(pool) + .await?; + } + DatabasePool::MySQL(pool) => { + sqlx::query("UPDATE Users SET UseGravatar = ? WHERE UserID = ?") + .bind(enabled) + .bind(user_id) + .execute(pool) + .await?; + } + } + Ok(()) + } + // Per-podcast override; None goes back to the user's default. pub async fn set_podcast_auto_delete_on_complete(&self, user_id: i32, podcast_id: i32, enabled: Option) -> AppResult<()> { match self { @@ -19898,7 +19992,7 @@ impl DatabasePool { "PlaylistContents".dateadded FROM "PlaylistContents" JOIN "Episodes" ON "PlaylistContents".episodeid = "Episodes".episodeid - JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid + JOIN "Podcasts" ON "Episodes".feedid = "Podcasts".feedid AND "Podcasts".userid = $1 LEFT JOIN "Users" ON "Podcasts".userid = "Users".userid LEFT JOIN "UserEpisodeHistory" ON "Episodes".episodeid = "UserEpisodeHistory".episodeid AND "UserEpisodeHistory".userid = $1 @@ -20083,7 +20177,7 @@ impl DatabasePool { pc.DateAdded as addeddate FROM PlaylistContents pc JOIN Episodes e ON pc.EpisodeID = e.EpisodeID - JOIN Podcasts p ON e.FeedID = p.FeedID + JOIN Podcasts p ON e.FeedID = p.FeedID AND p.UserID = ? LEFT JOIN Users u ON p.UserID = u.UserID LEFT JOIN UserEpisodeHistory ueh ON e.EpisodeID = ueh.EpisodeID AND ueh.UserID = ? LEFT JOIN SavedEpisodes se ON e.EpisodeID = se.EpisodeID AND se.UserID = ? @@ -20096,6 +20190,7 @@ impl DatabasePool { .bind(user_id) .bind(user_id) .bind(user_id) + .bind(user_id) .bind(playlist_id) .fetch_all(pool) .await? @@ -22771,107 +22866,6 @@ impl DatabasePool { Ok((processed, failed)) } - // Bulk delete downloaded episodes - efficient batch processing for mass deletion - pub async fn bulk_delete_downloaded_episodes(&self, episode_ids: Vec, user_id: i32, is_youtube: bool) -> AppResult<(i32, i32)> { - if episode_ids.is_empty() { - return Ok((0, 0)); - } - - let mut processed = 0; - let mut failed = 0; - - match self { - DatabasePool::Postgres(pool) => { - let mut tx = pool.begin().await?; - - if is_youtube { - // Delete YouTube videos from DownloadedEpisodes (they use the same table but different logic) - for episode_id in episode_ids { - match sqlx::query( - r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid = $1 AND userid = $2"# - ) - .bind(episode_id) - .bind(user_id) - .execute(&mut *tx) - .await { - Ok(result) => { - if result.rows_affected() > 0 { - processed += 1; - } else { - failed += 1; // Episode wasn't downloaded by this user - } - }, - Err(_) => failed += 1, - } - } - } else { - // Batch delete regular episodes using IN clause for efficiency - let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); - let ids_clause = episode_ids_str.join(","); - - let query = format!( - r#"DELETE FROM "DownloadedEpisodes" WHERE episodeid IN ({}) AND userid = $1"#, - ids_clause - ); - - let result = sqlx::query(sqlx::AssertSqlSafe(query.as_str())) - .bind(user_id) - .execute(&mut *tx) - .await?; - processed = result.rows_affected() as i32; - failed = episode_ids.len() as i32 - processed; // Assume failures are episodes not found - } - - tx.commit().await?; - } - DatabasePool::MySQL(pool) => { - let mut tx = pool.begin().await?; - - if is_youtube { - // Delete YouTube videos from DownloadedEpisodes - for episode_id in episode_ids { - match sqlx::query( - "DELETE FROM DownloadedEpisodes WHERE EpisodeID = ? AND UserID = ?" - ) - .bind(episode_id) - .bind(user_id) - .execute(&mut *tx) - .await { - Ok(result) => { - if result.rows_affected() > 0 { - processed += 1; - } else { - failed += 1; // Episode wasn't downloaded by this user - } - }, - Err(_) => failed += 1, - } - } - } else { - // Batch delete regular episodes using IN clause for efficiency - let episode_ids_str: Vec = episode_ids.iter().map(|id| id.to_string()).collect(); - let ids_clause = episode_ids_str.join(","); - - let query = format!( - "DELETE FROM DownloadedEpisodes WHERE EpisodeID IN ({}) AND UserID = ?", - ids_clause - ); - - let result = sqlx::query(sqlx::AssertSqlSafe(query.as_str())) - .bind(user_id) - .execute(&mut *tx) - .await?; - processed = result.rows_affected() as i32; - failed = episode_ids.len() as i32 - processed; // Assume failures are episodes not found - } - - tx.commit().await?; - } - } - - Ok((processed, failed)) - } - // Turn on the PinePods gpodder server for a user. Clients log in with the user's password or // an app password, so no token is generated. An external server the user already syncs with // keeps its settings ("both"). The user's current listening state is published to the sync diff --git a/rust-api/src/handlers/auth.rs b/rust-api/src/handlers/auth.rs index f5c65bcbf..78f80f455 100644 --- a/rust-api/src/handlers/auth.rs +++ b/rust-api/src/handlers/auth.rs @@ -80,6 +80,8 @@ pub struct UserDetails { pub Hashed_PW: Option, /// Always null; kept for response compatibility. pub Salt: Option, + /// Whether the clients show the user's Gravatar. Off by default. + pub UseGravatar: bool, } #[derive(Serialize, utoipa::ToSchema)] diff --git a/rust-api/src/handlers/episodes.rs b/rust-api/src/handlers/episodes.rs index bc7ee8e01..772874ce9 100644 --- a/rust-api/src/handlers/episodes.rs +++ b/rust-api/src/handlers/episodes.rs @@ -251,9 +251,13 @@ pub async fn bulk_delete_downloaded_episodes( let is_youtube = request.is_youtube.unwrap_or(false); require_episodes_owner(&state, request.user_id, &request.episode_ids, is_youtube).await?; - let (processed_count, failed_count) = state.db_pool - .bulk_delete_downloaded_episodes(request.episode_ids, request.user_id, is_youtube) - .await?; + let (mut processed_count, mut failed_count) = (0, 0); + for episode_id in request.episode_ids { + match crate::handlers::podcasts::delete_download(&state, request.user_id, episode_id, is_youtube).await { + Ok(true) => processed_count += 1, + _ => failed_count += 1, + } + } let message = if failed_count > 0 { format!("Deleted {} downloaded episodes, {} failed or were not found", processed_count, failed_count) diff --git a/rust-api/src/handlers/feed.rs b/rust-api/src/handlers/feed.rs index 587168bad..a200a3663 100644 --- a/rust-api/src/handlers/feed.rs +++ b/rust-api/src/handlers/feed.rs @@ -136,7 +136,7 @@ pub async fn get_user_feed( }; Ok(Response::builder() - .header("content-type", "application/rss+xml") + .header("content-type", "application/rss+xml; charset=utf-8") .body(feed_content) .map_err(|e| AppError::internal(&format!("Failed to create response: {}", e)))?) } diff --git a/rust-api/src/handlers/podcasts.rs b/rust-api/src/handlers/podcasts.rs index e42d0c5c2..13604ef73 100644 --- a/rust-api/src/handlers/podcasts.rs +++ b/rust-api/src/handlers/podcasts.rs @@ -1351,27 +1351,37 @@ pub async fn delete_episode( require_episode_owner(&state, request.user_id, request.episode_id, request.is_youtube.unwrap_or(false)).await?; let is_youtube = request.is_youtube.unwrap_or(false); + delete_download(&state, request.user_id, request.episode_id, is_youtube).await?; - // Prevent deleting episodes from local podcasts — manage files on disk directly - if let Ok(Some(feed_url)) = state.db_pool.get_feed_url_for_episode(request.episode_id).await { - if feed_url.starts_with("local://") { - return Err(AppError::bad_request( - "Cannot delete local podcast episodes. Manage audio files directly on the server and use Refresh to sync.", - )); + let content_type = if is_youtube { "Video" } else { "Episode" }; + + Ok(Json(serde_json::json!({ + "detail": format!("{} deleted successfully.", content_type) + }))) +} + +// Remove a user's download of an episode or video: the row, then the file. Returns whether a +// download was removed. Local podcast files are managed on disk, so those are refused. +pub(crate) async fn delete_download(state: &AppState, user_id: i32, episode_id: i32, is_youtube: bool) -> Result { + if !is_youtube { + if let Ok(Some(feed_url)) = state.db_pool.get_feed_url_for_episode(episode_id).await { + if feed_url.starts_with("local://") { + return Err(AppError::bad_request( + "Cannot delete local podcast episodes. Manage audio files directly on the server and use Refresh to sync.", + )); + } } } // Capture the file path before removing the DB record let file_path = if is_youtube { - state.db_pool.get_video_download_location(request.user_id, request.episode_id).await.ok().flatten() + state.db_pool.get_video_download_location(user_id, episode_id).await.ok().flatten() } else { - state.db_pool.get_download_location(request.episode_id, request.user_id).await.ok().flatten() + state.db_pool.get_download_location(episode_id, user_id).await.ok().flatten() }; - // Delete the DB record - state.db_pool.delete_episode(request.user_id, request.episode_id, is_youtube).await?; + let deleted = state.db_pool.delete_episode(user_id, episode_id, is_youtube).await?; - // Remove the file from disk if one was recorded if let Some(path) = file_path { if tokio::fs::metadata(&path).await.is_ok() { if let Err(e) = tokio::fs::remove_file(&path).await { @@ -1380,11 +1390,7 @@ pub async fn delete_episode( } } - let content_type = if is_youtube { "Video" } else { "Episode" }; - - Ok(Json(serde_json::json!({ - "detail": format!("{} deleted successfully.", content_type) - }))) + Ok(deleted) } // Download all episodes of a podcast - matches Python download_all_podcast endpoint diff --git a/rust-api/src/handlers/settings.rs b/rust-api/src/handlers/settings.rs index 792fac543..b77c0559f 100644 --- a/rust-api/src/handlers/settings.rs +++ b/rust-api/src/handlers/settings.rs @@ -3496,6 +3496,42 @@ pub async fn set_auto_delete_on_complete_user( Ok(Json(serde_json::json!({ "detail": "Delete-when-finished setting updated." }))) } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SetUseGravatar { + pub user_id: i32, + pub enabled: bool, +} + +#[utoipa::path( + post, + path = "/user/set_use_gravatar", + tag = "settings", + summary = "Set whether the user's Gravatar is shown", + request_body = SetUseGravatar, + security(("api_key" = [])), + responses( + (status = 200, description = "Success", body = serde_json::Value), + (status = 401, description = "Invalid or missing API key"), + ), +)] +pub async fn set_use_gravatar( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + let api_key = extract_api_key(&headers)?; + require_valid_api_key(&state, &api_key).await?; + + 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 settings.")); + } + + state.db_pool.set_use_gravatar(request.user_id, request.enabled).await?; + Ok(Json(serde_json::json!({ "detail": "Gravatar setting updated." }))) +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct SetAutoDeleteOnCompletePodcast { pub user_id: i32, diff --git a/rust-api/src/lib.rs b/rust-api/src/lib.rs index 72105cd9f..8e905fe6d 100644 --- a/rust-api/src/lib.rs +++ b/rust-api/src/lib.rs @@ -423,6 +423,7 @@ fn create_data_routes() -> OpenApiRouter { .routes(routes!(handlers::settings::clear_podcast_auto_download_delete_days)) .routes(routes!(handlers::settings::get_auto_delete_on_complete)) .routes(routes!(handlers::settings::set_auto_delete_on_complete_user)) + .routes(routes!(handlers::settings::set_use_gravatar)) .routes(routes!(handlers::settings::set_podcast_auto_delete_on_complete)) .routes(routes!(handlers::settings::set_podcast_cover_preference)) .routes(routes!(handlers::settings::clear_podcast_cover_preference)) diff --git a/rust-api/src/main.rs b/rust-api/src/main.rs index e10534b96..8ca0394be 100644 --- a/rust-api/src/main.rs +++ b/rust-api/src/main.rs @@ -57,7 +57,12 @@ async fn main() -> AppResult<()> { info!("Database pool initialized"); // Initialize Redis client - let redis_client = RedisClient::new(&config).await?; + let redis_client = RedisClient::new(&config).await.inspect_err(|e| { + error!( + "Could not connect to Valkey/Redis at {}:{} ({}). PinePods requires Valkey: point VALKEY_URL or VALKEY_HOST/VALKEY_PORT at a running instance.", + config.redis.host, config.redis.port, e + ); + })?; info!("Redis/Valkey client initialized"); // Unique id for this API process — created before the task manager so it can diff --git a/rust-api/src/models.rs b/rust-api/src/models.rs index d236ab67d..d1b84475a 100644 --- a/rust-api/src/models.rs +++ b/rust-api/src/models.rs @@ -129,11 +129,14 @@ pub struct PodcastResponse { pub podcastindexid: Option, pub is_favorite: bool, pub is_video: bool, + pub oldest_episode_date: Option, + pub newest_episode_date: Option, + pub play_count: i64, } // Lean per-subscription inputs for the recommendation taste profile (#103). Deliberately // selects only columns that are guaranteed to exist on the Podcasts table, avoiding the -// heavier return_pods_extra query (which references a non-existent p.isyoutube column). +// heavier return_pods_extra query. #[derive(Debug, Clone)] pub struct RecommendationTasteInput { pub podcastname: String, diff --git a/rust-api/tests/integration/feeds.rs b/rust-api/tests/integration/feeds.rs index b59f4527b..b0f337198 100644 --- a/rust-api/tests/integration/feeds.rs +++ b/rust-api/tests/integration/feeds.rs @@ -51,7 +51,7 @@ async fn rss_feeds_are_off_until_the_user_enables_them() { assert_eq!(status().await, true); let resp = feed(&app, s.user.id, &key).send().await; resp.assert_ok(); - assert_eq!(resp.headers["content-type"], "application/rss+xml"); + assert_eq!(resp.headers["content-type"], "application/rss+xml; charset=utf-8"); let xml = resp.text(); assert!(xml.starts_with(" = eps.iter().map(|e| e["Episodeid"].as_i64().unwrap()).collect(); + + app.post("/api/data/mark_episode_completed") + .key(&user) + .json(&json!({ "episode_id": ids[1], "user_id": user.id, "is_youtube": false })) + .send() + .await + .assert_ok(); + let next = app + .post("/api/data/get_next_podcast_episode") + .key(&user) + .json(&json!({ "episode_id": ids[0], "user_id": user.id })) + .send() + .await + .ok_json(); + assert_eq!(next["episodeid"], ids[2], "{next}"); +} diff --git a/rust-api/tests/integration/podcasts.rs b/rust-api/tests/integration/podcasts.rs index 0044a7322..ede6fff59 100644 --- a/rust-api/tests/integration/podcasts.rs +++ b/rust-api/tests/integration/podcasts.rs @@ -332,3 +332,72 @@ async fn empty_searches_are_refused_without_asking_the_search_service() { app.get(&format!("/api/data/proxy_search?query={query}&index=podcast_index")).key(&user).send().await.assert_status(400); } } + +#[tokio::test(flavor = "multi_thread")] +async fn paged_episodes_sort_and_filter_the_whole_podcast() { + let app = TestApp::new().await; + let user = app.user("pager").await; + let url = app.feed("long.xml", &Feed::with_episodes("Long Show", 10)); + let pod = app.subscribe(&user, &url, 10).await; + + let page = |sort_by: &'static str, sort_order: &'static str, filter: &'static str, limit: i32| { + let (app, user) = (&app, &user); + async move { + let body = app + .get("/api/data/podcast_episodes") + .key(user) + .query("user_id", user.id) + .query("podcast_id", pod) + .query("limit", limit) + .query("sort_by", sort_by) + .query("sort_order", sort_order) + .query("filter", filter) + .send() + .await + .ok_json(); + body["episodes"].as_array().unwrap().iter().map(|e| e["Episodeid"].as_i64().unwrap()).collect::>() + } + }; + + for (sort_by, order) in [("date", "asc"), ("title", "asc"), ("title", "desc")] { + let all = page(sort_by, order, "all", 1000).await; + assert_eq!(all.len(), 10); + assert_eq!(page(sort_by, order, "all", 3).await, all[..3], "{sort_by} {order}"); + } + + let oldest = page("date", "asc", "all", 1).await[0]; + app.post("/api/data/mark_episode_completed") + .key(&user) + .json(&json!({ "episode_id": oldest, "user_id": user.id, "is_youtube": false })) + .send() + .await + .assert_ok(); + assert_eq!(page("date", "desc", "completed", 1).await, [oldest]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn podcast_list_reports_each_podcasts_oldest_and_newest_episode_dates() { + let app = TestApp::new().await; + let user = app.user("dates").await; + let day = |d: i64| chrono::Utc::now() - chrono::Duration::days(d); + // The old show started recently but its last episode is old; the active show is the reverse. + let stale = Feed::new("Stale").episode(Episode::new(1).pub_date(day(30))).episode(Episode::new(2).pub_date(day(20))); + let active = Feed::new("Active").episode(Episode::new(1).pub_date(day(400))).episode(Episode::new(2).pub_date(day(1))); + app.subscribe(&user, &app.feed("stale.xml", &stale), 2).await; + app.subscribe(&user, &app.feed("active.xml", &active), 2).await; + + let pods = app.podcasts(&user).await; + let dates = |name: &str| { + let p = pods.iter().find(|p| p["podcastname"] == name).unwrap(); + (p["oldest_episode_date"].as_str().unwrap().to_string(), p["newest_episode_date"].as_str().unwrap().to_string()) + }; + let (stale_old, stale_new) = dates("Stale"); + let (active_old, active_new) = dates("Active"); + assert!(stale_old < stale_new && active_old < active_new); + assert!(active_new > stale_new, "active's latest episode is the newest"); + assert!(active_old < stale_old); + + let extra = app.get(&format!("/api/data/return_pods_extra/{}", user.id)).key(&user).send().await.ok_json(); + let active = extra["pods"].as_array().unwrap().iter().find(|p| p["podcastname"] == "Active").unwrap(); + assert_eq!(active["oldest_episode_date"].as_str(), Some(active_old.as_str())); +} diff --git a/rust-api/tests/integration/settings.rs b/rust-api/tests/integration/settings.rs index 3686847a8..2246ec481 100644 --- a/rust-api/tests/integration/settings.rs +++ b/rust-api/tests/integration/settings.rs @@ -233,3 +233,19 @@ async fn custom_themes_can_be_created_listed_and_deleted_by_their_owner() { .assert_ok(); assert!(list(&user).await.is_empty()); } + +#[tokio::test(flavor = "multi_thread")] +async fn gravatar_is_off_until_the_user_turns_it_on() { + let app = TestApp::new().await; + let user = app.user("avatar").await; + let details = || async { app.get(&format!("/api/data/user_details_id/{}", user.id)).key(&user).send().await.ok_json() }; + assert_eq!(details().await["UseGravatar"], false); + + app.post("/api/data/user/set_use_gravatar") + .key(&user) + .json(&json!({ "user_id": user.id, "enabled": true })) + .send() + .await + .assert_ok(); + assert_eq!(details().await["UseGravatar"], true); +} diff --git a/rust-api/tests/integration/shared_feeds.rs b/rust-api/tests/integration/shared_feeds.rs index f6a76cb4d..89150a33e 100644 --- a/rust-api/tests/integration/shared_feeds.rs +++ b/rust-api/tests/integration/shared_feeds.rs @@ -176,3 +176,44 @@ async fn upgrading_creates_feeds_with_the_merged_refresh_state() { assert_eq!(app.db().link_unlinked_feeds().await.unwrap(), 0); assert_eq!(feeds(&app).await, 2); } + +#[tokio::test(flavor = "multi_thread")] +async fn other_subscribers_do_not_duplicate_a_users_lists() { + let app = TestApp::new().await; + let (alice, bob, carol) = (app.user("alice").await, app.user("bob").await, app.user("carol").await); + let url = app.feed("crowded.xml", &Feed::with_episodes("Crowded", 2)); + let pod = app.subscribe(&alice, &url, 2).await; + app.subscribe(&bob, &url, 2).await; + app.subscribe(&carol, &url, 2).await; + let ep = app.episode_id(&alice, pod, "Episode 1").await; + + app.post("/api/data/queue_pod") + .key(&alice) + .json(&json!({ "episode_id": ep, "user_id": alice.id, "is_youtube": false })) + .send() + .await + .assert_ok(); + app.post("/api/data/record_listen_duration") + .key(&alice) + .json(&json!({ "episode_id": ep, "user_id": alice.id, "listen_duration": 120.0 })) + .send() + .await + .assert_ok(); + exec( + &app, + &format!( + r#"INSERT INTO "DownloadedEpisodes" (UserID, EpisodeID, DownloadedSize, DownloadedLocation) VALUES ({}, {ep}, 1, '/tmp/x.mp3')"#, + alice.id + ), + ) + .await; + + let count = |v: &Value| v.as_array().unwrap().iter().filter(|e| e["episodeid"] == ep).count(); + let queue = app.get("/api/data/get_queued_episodes").key(&alice).query("user_id", alice.id).send().await.ok_json(); + assert_eq!(count(&queue["data"]), 1, "queue: {queue}"); + let home = app.get("/api/data/home_overview").key(&alice).query("user_id", alice.id).send().await.ok_json(); + assert_eq!(count(&home["in_progress_episodes"]), 1, "home in-progress: {home}"); + assert_eq!(count(&home["queue_preview"]), 1, "home queue preview: {home}"); + let downloads = app.get("/api/data/download_episode_list").key(&alice).query("user_id", alice.id).send().await.ok_json(); + assert_eq!(count(&downloads["downloaded_episodes"]), 1, "downloads: {downloads}"); +} diff --git a/web/src/components/app_drawer.rs b/web/src/components/app_drawer.rs index d0ed6d072..9a7ad13c3 100644 --- a/web/src/components/app_drawer.rs +++ b/web/src/components/app_drawer.rs @@ -74,6 +74,7 @@ pub fn app_drawer() -> Html { .as_ref() .map(|ud| ud.api_key.clone()); let user_id = post_state.user_details.as_ref().map(|ud| ud.UserID.clone()); + let use_gravatar = post_state.user_details.as_ref().map_or(false, |ud| ud.UseGravatar); let server_name = post_state .auth_details .as_ref() @@ -242,7 +243,7 @@ pub fn app_drawer() -> Html {
to={Route::UserStats} classes="sb-item"> User avatar diff --git a/web/src/components/context.rs b/web/src/components/context.rs index 71c655252..08c8fe44a 100644 --- a/web/src/components/context.rs +++ b/web/src/components/context.rs @@ -714,6 +714,37 @@ impl AppState { false } } + + /// Copy the desktop app's saved login into localStorage, where the startup auto-login + /// reads it. Runs before the app renders; does nothing outside Tauri. + pub async fn load_tauri_credentials() { + if !Self::is_tauri() { + return; + } + let Some(storage) = window().and_then(|w| w.local_storage().ok().flatten()) else { return }; + for key in ["userState", "userAuthState", "serverState"] { + let args = serde_wasm_bindgen::to_value(&json!({ "key": key })).unwrap(); + if let Ok(value) = tauri_invoke("get_credentials", args).await { + if let Some(value) = value.as_string() { + let _ = storage.set_item(key, &value); + } + } + } + } + + /// Delete the desktop app's saved login so the next launch starts signed out. + pub async fn clear_tauri_credentials() { + if Self::is_tauri() { + let _ = tauri_invoke("clear_all_credentials", JsValue::NULL).await; + } + } +} + +#[wasm_bindgen::prelude::wasm_bindgen] +extern "C" { + // Rejects when the command fails (e.g. no saved credential yet). + #[wasm_bindgen(catch, js_namespace = ["window", "__TAURI__", "core"], js_name = invoke)] + async fn tauri_invoke(cmd: &str, args: JsValue) -> Result; } #[derive(Default, Clone, PartialEq, Store, Debug)] diff --git a/web/src/components/gen_funcs.rs b/web/src/components/gen_funcs.rs index cedbd1b2d..d7eca7abc 100644 --- a/web/src/components/gen_funcs.rs +++ b/web/src/components/gen_funcs.rs @@ -15,7 +15,7 @@ pub fn calculate_gravatar_hash(email: &String) -> String { } pub fn generate_gravatar_url(email: &Option, size: usize) -> String { - let hash = calculate_gravatar_hash(&email.clone().unwrap()); + let hash = calculate_gravatar_hash(&email.clone().unwrap_or_default()); format!("https://gravatar.com/avatar/{}?s={}", hash, size) } diff --git a/web/src/components/navigation.rs b/web/src/components/navigation.rs index 9afa3ab5c..352405470 100644 --- a/web/src/components/navigation.rs +++ b/web/src/components/navigation.rs @@ -194,9 +194,9 @@ pub fn navigation_handler(props: &NavigationHandlerProps) -> Html { } } - // Add this check to skip authentication for the OAuth callback route + // The OAuth callback and public shared-episode links work without a login. let pathname = window.location().pathname().unwrap_or_default(); - if pathname != "/oauth/callback" { + if pathname != "/oauth/callback" && !pathname.starts_with("/shared_episode/") { use_check_authentication(dispatch.clone(), ¤t_route); } else { web_sys::console::log_1(&"Skipping auth check for OAuth callback".into()); diff --git a/web/src/components/oauth_callback.rs b/web/src/components/oauth_callback.rs index 257612c96..6e5ddeb1d 100644 --- a/web/src/components/oauth_callback.rs +++ b/web/src/components/oauth_callback.rs @@ -31,7 +31,7 @@ fn calculate_gravatar_hash(email: &String) -> String { #[allow(dead_code)] pub fn generate_gravatar_url(email: &Option, size: usize) -> String { - let hash = calculate_gravatar_hash(&email.clone().unwrap()); + let hash = calculate_gravatar_hash(&email.clone().unwrap_or_default()); format!("https://gravatar.com/avatar/{}?s={}", hash, size) } diff --git a/web/src/components/setting_components/start_page_options.rs b/web/src/components/setting_components/start_page_options.rs index 631b304ab..3fd1fb9a9 100644 --- a/web/src/components/setting_components/start_page_options.rs +++ b/web/src/components/setting_components/start_page_options.rs @@ -1,7 +1,7 @@ use crate::components::context::{AppState, NotificationState}; use crate::components::gen_funcs::format_error_message; use crate::requests::setting_reqs::{call_get_startpage, call_set_startpage, call_set_global_podcast_cover_preference, call_get_podcast_cover_preference}; -use crate::requests::pod_req::{call_get_collection_add_ui, call_set_collection_add_ui}; +use crate::requests::pod_req::{call_get_collection_add_ui, call_set_collection_add_ui, call_set_use_gravatar}; use wasm_bindgen_futures::spawn_local; use web_sys::{HtmlSelectElement, HtmlInputElement}; use yew::prelude::*; @@ -253,6 +253,40 @@ pub fn startpage() -> Html { }) }; + let use_gravatar = state.user_details.as_ref().map_or(false, |d| d.UseGravatar); + let on_gravatar_change = { + let state = state.clone(); + let dispatch = _dispatch.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + let enabled = input.checked(); + let dispatch = dispatch.clone(); + if let (Some(api_key), Some(user_id), Some(server_name)) = ( + state.auth_details.as_ref().and_then(|d| d.api_key.clone()), + state.user_details.as_ref().map(|d| d.UserID), + state.auth_details.as_ref().map(|d| d.server_name.clone()), + ) { + spawn_local(async move { + match call_set_use_gravatar(&server_name, &Some(api_key), user_id, enabled).await { + Ok(_) => dispatch.reduce_mut(|state| { + if let Some(details) = state.user_details.as_mut() { + details.UseGravatar = enabled; + } + state.store_app_state(); + }), + Err(e) => { + let formatted_error = format_error_message(&e.to_string()); + Dispatch::::global().reduce_mut(|state| { + state.error_message = Some(format!("Failed to update Gravatar setting: {}", formatted_error)); + }); + } + } + }); + } + } + }) + }; + let startpage_options = vec![ ("Home", "home"), ("Feed", "feed"), @@ -315,6 +349,22 @@ pub fn startpage() -> Html {
+
{i18n.t("start_page_options.avatar")}
+ +
+
{i18n.t("start_page_options.use_gravatar")}
+
+ +
+
+
{i18n.t("collections.collections")}
diff --git a/web/src/main.rs b/web/src/main.rs index 697cb6f26..e66f0f588 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -265,5 +265,8 @@ fn main_component() -> Html { } fn main() { - yew::Renderer::
::new().render(); + wasm_bindgen_futures::spawn_local(async { + crate::components::context::AppState::load_tauri_credentials().await; + yew::Renderer::
::new().render(); + }); } diff --git a/web/src/pages/episode_layout.rs b/web/src/pages/episode_layout.rs index 05516a38f..67f113b15 100644 --- a/web/src/pages/episode_layout.rs +++ b/web/src/pages/episode_layout.rs @@ -5266,7 +5266,14 @@ pub fn episode_layout() -> Html { { if *is_selecting && !selected_episodes.is_empty() { let selected_count = selected_episodes.len(); - let selected_ids: Vec = selected_episodes.iter().cloned().collect(); + // In list order, so bulk-queued episodes keep the order they're shown in. + let mut selected_ids: Vec = filtered_episodes + .iter() + .map(|ep| ep.episodeid) + .filter(|id| selected_episodes.contains(id)) + .collect(); + let unlisted: Vec = selected_episodes.iter().filter(|id| !selected_ids.contains(id)).cloned().collect(); + selected_ids.extend(unlisted); let user_id_value = user_id.unwrap_or(0); html! { diff --git a/web/src/pages/login.rs b/web/src/pages/login.rs index 501995aa4..7eddff5c4 100644 --- a/web/src/pages/login.rs +++ b/web/src/pages/login.rs @@ -34,7 +34,7 @@ fn calculate_gravatar_hash(email: &String) -> String { #[allow(dead_code)] pub fn generate_gravatar_url(email: &Option, size: usize) -> String { - let hash = calculate_gravatar_hash(&email.clone().unwrap()); + let hash = calculate_gravatar_hash(&email.clone().unwrap_or_default()); format!("https://gravatar.com/avatar/{}?s={}", hash, size) } @@ -299,7 +299,7 @@ pub fn login() -> Html { let final_dispatch = effect_displatch .clone(); - let gravatar_url = generate_gravatar_url(&Some(wasm_email.clone().unwrap()), 80); + let gravatar_url = generate_gravatar_url(&wasm_email, 80); // Auto login logic here final_dispatch.reduce_mut(move |state| { state.user_details = wasm_app_state.user_details; @@ -2041,6 +2041,48 @@ pub fn login() -> Html { } } +// Store a login that passed its second factor, load the user's theme and time settings, then +// go to their start page. +async fn finish_verified_login( + dispatch: &Dispatch, + user_details: login_requests::GetUserDetails, + login_request: login_requests::LoginServerRequest, + server_details: login_requests::GetApiDetails, +) { + let server_name = login_request.server_name.clone(); + let api_key = login_request.api_key.clone().unwrap_or_default(); + let user_id = user_details.UserID; + let gravatar_url = generate_gravatar_url(&user_details.Email, 80); + dispatch.reduce_mut(move |state| { + state.user_details = Some(user_details); + state.auth_details = Some(login_request); + state.server_details = Some(server_details); + state.store_app_state(); + }); + Dispatch::::global().reduce_mut(move |state| { + state.gravatar_url = Some(gravatar_url); + }); + let window = web_sys::window(); + if let Some(storage) = window.as_ref().and_then(|w| w.session_storage().ok().flatten()) { + let _ = storage.set_item("isAuthenticated", "true"); + } + if let Ok(theme) = call_get_theme(server_name.clone(), api_key.clone(), &user_id).await { + crate::components::setting_components::theme_options::changeTheme(&theme); + if let Some(storage) = window.as_ref().and_then(|w| w.local_storage().ok().flatten()) { + let _ = storage.set_item("selected_theme", &theme); + } + } + if let Ok(tz) = call_get_time_info(server_name.clone(), api_key.clone(), &user_id).await { + Dispatch::::global().reduce_mut(move |state| { + state.user_tz = Some(tz.timezone); + state.hour_preference = Some(tz.hour_pref); + state.date_format = Some(tz.date_format); + }); + } + let start_page = call_get_startpage(&server_name, &api_key, &user_id).await.unwrap_or_default(); + BrowserHistory::new().push(if start_page.is_empty() { "/home" } else { start_page.as_str() }); +} + #[function_component(ChangeServer)] pub fn login() -> Html { let (i18n, _) = use_translation(); @@ -2083,6 +2125,8 @@ pub fn login() -> Html { let temp_api_key = use_state(|| "".to_string()); let temp_user_id = use_state(|| 0); let temp_server_name = use_state(|| "".to_string()); + // Set when the server asks for a second factor before handing out an API key. + let mfa_session_token = use_state(|| "".to_string()); let page_state = use_state(|| PageState::Default); let user_language = use_state(|| "en".to_string()); let available_languages: UseStateHandle> = use_state(Vec::new); @@ -2168,6 +2212,7 @@ pub fn login() -> Html { let call_api_key = temp_api_key.clone(); let call_user_id = temp_user_id.clone(); let submit_post_state = _app_dispatch.clone(); + let submit_mfa_session_token = mfa_session_token.clone(); let on_submit = { let submit_dispatch = dispatch.clone(); let i18n_error_checking_mfa = (&i18n_error_checking_mfa).clone(); @@ -2187,18 +2232,30 @@ pub fn login() -> Html { let temp_server_name = call_server_name.clone(); let temp_api_key = call_api_key.clone(); let temp_user_id = call_user_id.clone(); + let mfa_session_token = submit_mfa_session_token.clone(); wasm_bindgen_futures::spawn_local(async move { // let server_name = location.href().expect("should have a href"); let server_name = server_name.clone(); let page_state = page_state.clone(); - match login_requests::login_new_server( + match login_requests::login_new_server_secure( server_name.to_string(), username.to_string(), password.to_string(), ) .await { - Ok((user_details, login_request, server_details)) => { + Ok(login_requests::LoginResult::MfaRequired { + server_name: mfa_server, + user_id: mfa_user_id, + mfa_session_token: token, + .. + }) => { + temp_server_name.set(mfa_server); + temp_user_id.set(mfa_user_id); + mfa_session_token.set(token); + page_state.set(PageState::MFAPrompt); + } + Ok(login_requests::LoginResult::Success(user_details, login_request, server_details)) => { // After user login, update the image URL with user's email from user_details let gravatar_url = generate_gravatar_url(&user_details.Email, 80); // 80 is the image size let key_copy = login_request.clone(); @@ -2737,6 +2794,8 @@ pub fn login() -> Html { // let error_message_create = error_message.clone(); let dispatch_wasm = dispatch.clone(); let i18n_error_validating_mfa = i18n_error_validating_mfa.clone(); + let mfa_session_token = mfa_session_token.clone(); + let mfa_server_name = temp_server_name.clone(); Callback::from(move |e: MouseEvent| { let i18n_error_validating_mfa = i18n_error_validating_mfa.clone(); let _dispatch = dispatch_wasm.clone(); @@ -2750,6 +2809,29 @@ pub fn login() -> Html { // let error_message_clone = error_message_create.clone(); e.prevent_default(); + if !mfa_session_token.is_empty() { + let dispatch = _dispatch.clone(); + let server = (*mfa_server_name).clone(); + let token = (*mfa_session_token).clone(); + let code = (*mfa_code).clone(); + let i18n_error_validating_mfa = i18n_error_validating_mfa.clone(); + wasm_bindgen_futures::spawn_local(async move { + match login_requests::complete_mfa_login(server, "".to_string(), token, code).await { + Ok((user_details, login_request, server_details)) => { + page_state.set(PageState::Default); + finish_verified_login(&dispatch, user_details, login_request, server_details).await; + } + Err(_) => { + page_state.set(PageState::Default); + Dispatch::::global().reduce_mut(|state| { + state.error_message = Option::from(i18n_error_validating_mfa) + }); + } + } + }); + return; + } + wasm_bindgen_futures::spawn_local(async move { match call_verify_mfa( &server_name.clone().unwrap(), @@ -2994,7 +3076,11 @@ pub fn logout() -> Html { // in-memory yewdux stores (AppState, PodcastFeedState, etc.) are wiped. // A client-side push leaves the previous user's data in WASM memory, which // leaks across user switches (e.g. a stale podcast list on /podcasts). - let _ = window.location().set_href("/"); + // The desktop app's saved login goes first, or the next launch signs back in. + wasm_bindgen_futures::spawn_local(async move { + crate::components::context::AppState::clear_tauri_credentials().await; + let _ = window.location().set_href("/"); + }); html! {} } diff --git a/web/src/pages/login_tauri.rs b/web/src/pages/login_tauri.rs index 9f70c78bb..f3ee4ead5 100644 --- a/web/src/pages/login_tauri.rs +++ b/web/src/pages/login_tauri.rs @@ -25,7 +25,7 @@ fn calculate_gravatar_hash(email: &String) -> String { } fn generate_gravatar_url(email: &Option, size: usize) -> String { - let hash = calculate_gravatar_hash(&email.clone().unwrap()); + let hash = calculate_gravatar_hash(&email.clone().unwrap_or_default()); format!("https://gravatar.com/avatar/{}?s={}", hash, size) } @@ -278,7 +278,7 @@ pub fn login() -> Html { let final_dispatch = effect_displatch .clone(); - let gravatar_url = generate_gravatar_url(&Some(wasm_email.clone().unwrap()), 80); + let gravatar_url = generate_gravatar_url(&wasm_email, 80); // Auto login logic here final_dispatch.reduce_mut(move |state| { state.user_details = wasm_app_state.user_details; diff --git a/web/src/pages/podcasts.rs b/web/src/pages/podcasts.rs index 096124c00..9a6d2bb2c 100644 --- a/web/src/pages/podcasts.rs +++ b/web/src/pages/podcasts.rs @@ -341,7 +341,33 @@ pub fn podcasts() -> Html { MostPlayed, LeastPlayed, } - let sort_direction = use_state(|| Some(SortDirection::AlphaAsc)); + impl SortDirection { + const ALL: [(SortDirection, &'static str); 8] = [ + (SortDirection::AlphaAsc, "alpha_asc"), + (SortDirection::AlphaDesc, "alpha_desc"), + (SortDirection::EpisodeCountHigh, "episodes_high"), + (SortDirection::EpisodeCountLow, "episodes_low"), + (SortDirection::OldestFirst, "oldest"), + (SortDirection::NewestFirst, "newest"), + (SortDirection::MostPlayed, "most_played"), + (SortDirection::LeastPlayed, "least_played"), + ]; + fn from_value(value: &str) -> Option { + Self::ALL.iter().find(|(_, v)| *v == value).map(|(d, _)| d.clone()) + } + fn value(&self) -> &'static str { + Self::ALL.iter().find(|(d, _)| d == self).map(|(_, v)| *v).unwrap_or("alpha_asc") + } + } + const SORT_STORAGE_KEY: &str = "podcasts_sort"; + // The chosen sort survives leaving the page. + let sort_direction = use_state(|| { + let stored = web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .and_then(|s| s.get_item(SORT_STORAGE_KEY).ok().flatten()); + Some(stored.as_deref().and_then(SortDirection::from_value).unwrap_or(SortDirection::AlphaAsc)) + }); + let current_sort = (*sort_direction).as_ref().map_or("alpha_asc", |d| d.value()); // Capture all i18n strings at function start to avoid borrow checker issues let i18n_delete_podcast = i18n.t("podcasts.delete_podcast").to_string(); @@ -998,12 +1024,15 @@ pub fn podcasts() -> Html { .cmp(&a.podcastname.to_lowercase()), SortDirection::EpisodeCountHigh => b.episodecount.cmp(&a.episodecount), SortDirection::EpisodeCountLow => a.episodecount.cmp(&b.episodecount), - SortDirection::OldestFirst => { - a.oldest_episode_date.cmp(&b.oldest_episode_date) - } - SortDirection::NewestFirst => { - b.oldest_episode_date.cmp(&a.oldest_episode_date) - } + // By each podcast's latest episode; podcasts with no episodes go last. + SortDirection::OldestFirst => match (&a.newest_episode_date, &b.newest_episode_date) { + (Some(x), Some(y)) => x.cmp(y), + (x, y) => x.is_none().cmp(&y.is_none()), + }, + SortDirection::NewestFirst => match (&a.newest_episode_date, &b.newest_episode_date) { + (Some(x), Some(y)) => y.cmp(x), + (x, y) => x.is_none().cmp(&y.is_none()), + }, SortDirection::MostPlayed => b.play_count.cmp(&a.play_count), SortDirection::LeastPlayed => a.play_count.cmp(&b.play_count), }); @@ -1084,28 +1113,21 @@ pub fn podcasts() -> Html { Callback::from(move |e: Event| { let target = e.target_dyn_into::().unwrap(); let value = target.value(); - match value.as_str() { - "alpha_asc" => sort_direction.set(Some(SortDirection::AlphaAsc)), - "alpha_desc" => sort_direction.set(Some(SortDirection::AlphaDesc)), - "episodes_high" => sort_direction.set(Some(SortDirection::EpisodeCountHigh)), - "episodes_low" => sort_direction.set(Some(SortDirection::EpisodeCountLow)), - "oldest" => sort_direction.set(Some(SortDirection::OldestFirst)), - "newest" => sort_direction.set(Some(SortDirection::NewestFirst)), - "most_played" => sort_direction.set(Some(SortDirection::MostPlayed)), - "least_played" => sort_direction.set(Some(SortDirection::LeastPlayed)), - _ => sort_direction.set(None), + if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) { + let _ = storage.set_item(SORT_STORAGE_KEY, &value); } + sort_direction.set(SortDirection::from_value(&value)); }) } > - - - - - - - - + + + + + + + +
diff --git a/web/src/pages/search.rs b/web/src/pages/search.rs index 6215bb8c5..a347de4f6 100644 --- a/web/src/pages/search.rs +++ b/web/src/pages/search.rs @@ -750,7 +750,9 @@ pub fn search(_props: &SearchProps) -> Html { && sel_cur.len() == sel_all_ids.len() && sel_all_ids.iter().all(|id| sel_cur.contains(id)); let sel_count = sel_cur.len(); - let sel_ids: Vec = sel_cur.iter().cloned().collect(); + // In list order, so bulk-queued episodes keep the order they're shown in. + let mut sel_ids: Vec = visible_ep_ids.iter().filter(|id| sel_cur.contains(id)).cloned().collect(); + sel_ids.extend(sel_cur.iter().filter(|id| !visible_ep_ids.contains(id)).cloned().collect::>()); let sel_user_id = user_id.unwrap_or(0); // ── HTML ───────────────────────────────────────────────────────────────── diff --git a/web/src/requests/login_requests.rs b/web/src/requests/login_requests.rs index a8d096010..dbabea528 100644 --- a/web/src/requests/login_requests.rs +++ b/web/src/requests/login_requests.rs @@ -120,6 +120,8 @@ pub struct GetUserDetails { pub Email: Option, pub Hashed_PW: Option, pub Salt: Option, + #[serde(default)] + pub UseGravatar: bool, } pub async fn call_get_user_details( diff --git a/web/src/requests/pod_req.rs b/web/src/requests/pod_req.rs index 378346a88..79f359250 100644 --- a/web/src/requests/pod_req.rs +++ b/web/src/requests/pod_req.rs @@ -533,6 +533,8 @@ pub struct PodcastExtra { #[serde(default)] pub oldest_episode_date: Option, #[serde(default)] + pub newest_episode_date: Option, + #[serde(default)] pub is_youtube: bool, #[serde(default)] pub is_video: bool, @@ -559,6 +561,7 @@ impl From for PodcastExtra { play_count: 0, episodes_played: 0, oldest_episode_date: None, + newest_episode_date: None, is_youtube, is_video: podcast.is_video, is_favorite: podcast.is_favorite, @@ -6263,6 +6266,17 @@ pub async fn call_set_auto_delete_on_complete_user( post_json_ok(&url, api_key, &body).await.map(|_| ()) } +pub async fn call_set_use_gravatar( + server_name: &str, + api_key: &Option, + user_id: i32, + enabled: bool, +) -> Result<(), Error> { + let url = format!("{}/api/data/user/set_use_gravatar", server_name); + let body = serde_json::json!({ "user_id": user_id, "enabled": enabled }); + post_json_ok(&url, api_key, &body).await.map(|_| ()) +} + /// `enabled` None goes back to the user's default. pub async fn call_set_podcast_auto_delete_on_complete( server_name: &str, diff --git a/web/src/translations/en.json b/web/src/translations/en.json index 7b749faac..1f2b9921d 100644 --- a/web/src/translations/en.json +++ b/web/src/translations/en.json @@ -1583,7 +1583,9 @@ "start_page_settings": "Start Page Settings", "start_page_updated_successfully": "Start page updated successfully!", "podcast_cover_display": "Podcast Cover Display", - "always_use_podcast_covers": "Always use podcast covers instead of episode covers" + "always_use_podcast_covers": "Always use podcast covers instead of episode covers", + "avatar": "Avatar", + "use_gravatar": "Show my Gravatar (loads an image from gravatar.com using a hash of your email)" }, "time_formats": { "12_hour": "12 Hour",