Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions database_functions/migration_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 32 additions & 27 deletions mobile/lib/bloc/settings/settings_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SettingsBloc extends Bloc {
final BehaviorSubject<List<String>> _bottomBarOrder = BehaviorSubject<List<String>>();
final BehaviorSubject<bool> _autoDownloadWifiOnly = BehaviorSubject<bool>();
final BehaviorSubject<bool> _preferServerDownloadSource = BehaviorSubject<bool>();
final BehaviorSubject<bool> _pinepodsUseGravatar = BehaviorSubject<bool>();
final BehaviorSubject<int> _autoDownloadQueueCount = BehaviorSubject<int>();
final BehaviorSubject<bool> _mirrorServerDownloads = BehaviorSubject<bool>();
final BehaviorSubject<int> _deleteDownloadsAfterDays = BehaviorSubject<int>();
Expand All @@ -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<void> _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');
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -451,6 +455,7 @@ class SettingsBloc extends Bloc {
_bottomBarOrder.close();
_autoDownloadWifiOnly.close();
_preferServerDownloadSource.close();
_pinepodsUseGravatar.close();
_autoDownloadQueueCount.close();
_mirrorServerDownloads.close();
_deleteDownloadsAfterDays.close();
Expand Down
80 changes: 61 additions & 19 deletions mobile/lib/core/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -40,17 +39,10 @@ Future<void> createDownloadDirectory(Episode episode) async {
Directory(path).createSync(recursive: true);
}

Future<bool> 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/<package>` folder), which need no storage permission. Requesting
/// Permission.storage would always be denied on Android 13+ (#983).
Future<bool> hasStoragePermission() async => true;

Future<String> getStorageDirectory() async {
SettingsService? settings = await MobileSettingsService.instance();
Expand Down Expand Up @@ -111,11 +103,64 @@ String? safeFile(String? s) {
return s?.replaceAll(RegExp(r'[^\w\s\.]+'), '').trim();
}

Future<String> 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<String> 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<Uri> 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<Uri> 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;
Expand All @@ -136,18 +181,15 @@ Future<String> 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);
}
}
7 changes: 7 additions & 0 deletions mobile/lib/entities/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -157,6 +161,7 @@ class AppSettings {
bottomBarOrder = defaultBottomBarOrder,
autoDownloadWifiOnly = true,
preferServerDownloadSource = true,
pinepodsUseGravatar = false,
autoDownloadQueueCount = 0,
mirrorServerDownloads = false,
deleteDownloadsAfterDays = 0,
Expand Down Expand Up @@ -188,6 +193,7 @@ class AppSettings {
List<String>? bottomBarOrder,
bool? autoDownloadWifiOnly,
bool? preferServerDownloadSource,
bool? pinepodsUseGravatar,
int? autoDownloadQueueCount,
bool? mirrorServerDownloads,
int? deleteDownloadsAfterDays,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 1 addition & 3 deletions mobile/lib/services/download/mobile_download_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/services/pinepods/login_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> json) {
Expand All @@ -564,6 +566,7 @@ class UserDetails {
fullname: json['Fullname'],
username: json['Username'],
email: json['Email'],
useGravatar: json['UseGravatar'] == true,
);
}
}
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/services/pinepods/pinepods_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,9 @@ class PinepodsService {
);
}

Future<bool> setUseGravatar(int userId, bool enabled) async =>
await _postJson('user/set_use_gravatar', {'user_id': userId, 'enabled': enabled}) != null;

Future<bool> setAutoDeleteOnCompleteDefault(int userId, bool enabled) async =>
await _postJson('user/set_auto_delete_on_complete',
{'user_id': userId, 'enabled': enabled}) !=
Expand Down
Loading
Loading