// Create this file at lib/services/pinepods/pinepods_service.dart import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/entities/pinepods_episode.dart'; import 'package:pinepods_mobile/entities/pinepods_search.dart'; import 'package:pinepods_mobile/entities/user_stats.dart'; import 'package:pinepods_mobile/entities/home_data.dart'; import 'package:pinepods_mobile/entities/podcast.dart'; class PinepodsService { String? _server; String? _apiKey; // Method to initialize with existing credentials void initializeWithCredentials(String server, String apiKey) { _server = server; _apiKey = apiKey; } String get apiKey => _apiKey ?? ''; Future verifyPinepodsInstance(String serverUrl) async { // Normalize the URL by removing trailing slashes final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); final url = Uri.parse('$normalizedUrl/api/pinepods_check'); try { final response = await http.get(url); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['pinepods_instance'] == true; } return false; } catch (e) { print('Error verifying PinePods instance: $e'); return false; } } Future login(String serverUrl, String username, String password) async { // Normalize the URL by removing trailing slashes final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), ''); _server = normalizedUrl; // Create Basic Auth header final credentials = base64Encode(utf8.encode('$username:$password')); final authHeader = 'Basic $credentials'; final url = Uri.parse('$normalizedUrl/api/data/get_key'); try { final response = await http.get( url, headers: {'Authorization': authHeader}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); _apiKey = data['retrieved_key']; // Verify the API key return await verifyApiKey(); } return false; } catch (e) { print('Login error: $e'); return false; } } Future verifyApiKey() async { if (_server == null || _apiKey == null) { return false; } final url = Uri.parse('$_server/api/data/verify_key'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['status'] == 'success'; } return false; } catch (e) { print('Error verifying API key: $e'); return false; } } // Add method to fetch podcasts from PinePods Future>> fetchPodcasts() async { if (_server == null || _apiKey == null) { return []; } // This endpoint would need to be implemented in your PinePods backend final url = Uri.parse('$_server/api/data/podcasts'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body) as List; return data.cast>(); } return []; } catch (e) { print('Error fetching podcasts: $e'); return []; } } // Get user's subscribed podcasts using return_pods endpoint Future> getUserPodcasts(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/return_pods/$userId'); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); // User podcasts API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); final List podsData = data['pods'] ?? []; List podcasts = []; for (var podData in podsData) { // Use episode count from server response final episodeCount = podData['episodecount'] ?? 0; // Create placeholder episodes to represent the count final placeholderEpisodes = List.generate( episodeCount, (index) => Episode( guid: 'placeholder_$index', podcast: podData['podcastname'] ?? '', title: 'Episode ${index + 1}', ), ); podcasts.add( Podcast( id: podData['podcastid'], title: podData['podcastname'] ?? '', description: podData['description'] ?? '', imageUrl: podData['artworkurl'] ?? '', thumbImageUrl: podData['artworkurl'] ?? '', url: podData['feedurl'] ?? '', link: podData['websiteurl'] ?? '', copyright: podData['author'] ?? '', guid: podData['feedurl'] ?? '', episodes: placeholderEpisodes, ), ); } return podcasts; } else { throw Exception('Failed to get user podcasts: ${response.statusCode}'); } } catch (e) { print('Error getting user podcasts: $e'); rethrow; } } // Get recent episodes (last 30 days) Future> getRecentEpisodes(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated - server or API key missing'); } final url = Uri.parse('$_server/api/data/return_episodes/$userId'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final responseText = response.body; final data = jsonDecode(responseText); // Handle the response structure from the web implementation if (data is Map && data['episodes'] != null) { final episodesList = data['episodes'] as List; return episodesList .map((episode) => PinepodsEpisode.fromJson(episode)) .toList(); } else if (data is List) { // Handle direct list response return data .map((episode) => PinepodsEpisode.fromJson(episode)) .toList(); } else { return []; } } else { throw Exception( 'Failed to fetch recent episodes: ${response.statusCode} ${response.reasonPhrase}', ); } } catch (e) { print('Error fetching recent episodes: $e'); throw Exception('Error fetching recent episodes: $e'); } } // Set credentials (used when user logs in) void setCredentials(String server, String apiKey) { _server = server.trim().replaceAll(RegExp(r'/$'), ''); _apiKey = apiKey; } // Check if user is authenticated bool get isAuthenticated => _server != null && _apiKey != null; // Get server URL String? get server => _server; // Check if episode exists in database Future checkEpisodeInDb( int userId, String episodeTitle, String episodeUrl, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/check_episode_in_db'); try { final requestBody = jsonEncode({ 'user_id': userId, 'episode_title': episodeTitle, 'episode_url': episodeUrl, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['exists'] == true; } return false; } catch (e) { print('Error checking episode in DB: $e'); return false; } } // Get episode ID from title and URL Future getEpisodeId( int userId, String episodeTitle, String episodeUrl, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/get_episode_id_ep_name?user_id=$userId&episode_url=${Uri.encodeComponent(episodeUrl)}&episode_title=${Uri.encodeComponent(episodeTitle)}&is_youtube=$isYoutube', ); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { // Parse the response as a plain integer final episodeId = int.tryParse(response.body.trim()) ?? 0; return episodeId; } return 0; } catch (e) { print('Error getting episode ID: $e'); return 0; } } // Add episode to history Future addHistory( int episodeId, double episodePos, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/record_podcast_history'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'episode_pos': episodePos, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); // History API response received return response.statusCode == 200; } catch (e) { print('Error adding history: $e'); return false; } } // Queue episode Future queueEpisode(int episodeId, int userId, bool isYoutube) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/queue_pod'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); // Queue API response received return response.statusCode == 200; } catch (e) { print('Error queueing episode: $e'); return false; } } // Increment played count Future incrementPlayed(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/increment_played/$userId'); print('Making API call to: $url'); try { final response = await http.put(url, headers: {'Api-Key': _apiKey!}); print( 'Increment played response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error incrementing played: $e'); return false; } } // Get podcast ID from episode Future getPodcastIdFromEpisode( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/get_podcast_id_from_ep/$episodeId', ); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['podcast_id'] ?? 0; } return 0; } catch (e) { print('Error getting podcast ID: $e'); return 0; } } // Get play episode details (playback speed, skip times) Future getPlayEpisodeDetails( int userId, int podcastId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_play_episode_details'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'user_id': userId, 'podcast_id': podcastId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Play episode details response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return PlayEpisodeDetails( playbackSpeed: (data['playback_speed'] as num?)?.toDouble() ?? 1.0, startSkip: data['start_skip'] ?? 0, endSkip: data['end_skip'] ?? 0, ); } return PlayEpisodeDetails(playbackSpeed: 1.0, startSkip: 0, endSkip: 0); } catch (e) { print('Error getting play episode details: $e'); return PlayEpisodeDetails(playbackSpeed: 1.0, startSkip: 0, endSkip: 0); } } // Record listen duration for episode Future recordListenDuration( int episodeId, int userId, double listenDuration, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/record_listen_duration'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'listen_duration': listenDuration, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Record listen duration response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error recording listen duration: $e'); return false; } } // Increment listen time for user stats Future incrementListenTime(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/increment_listen_time/$userId'); print('Making API call to: $url'); try { final response = await http.put(url, headers: {'Api-Key': _apiKey!}); print( 'Increment listen time response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error incrementing listen time: $e'); return false; } } // Save episode Future saveEpisode(int episodeId, int userId, bool isYoutube) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/save_episode'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); // Save episode API response received return response.statusCode == 200; } catch (e) { print('Error saving episode: $e'); return false; } } // Remove saved episode Future removeSavedEpisode( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/remove_saved_episode'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Remove saved episode response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error removing saved episode: $e'); return false; } } // Download episode to server Future downloadEpisode( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/download_podcast'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Download episode response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error downloading episode: $e'); return false; } } // Delete downloaded episode from server Future deleteEpisode(int episodeId, int userId, bool isYoutube) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/delete_episode'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Delete episode response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error deleting episode: $e'); return false; } } // Mark episode as completed Future markEpisodeCompleted( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/mark_episode_completed'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Mark completed response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error marking episode completed: $e'); return false; } } // Mark episode as uncompleted Future markEpisodeUncompleted( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/mark_episode_uncompleted'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Mark uncompleted response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error marking episode uncompleted: $e'); return false; } } // Remove episode from queue Future removeQueuedEpisode( int episodeId, int userId, bool isYoutube, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/remove_queued_pod'); print('Making API call to: $url'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); print( 'Remove queued response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error removing queued episode: $e'); return false; } } // Get user history Future> getUserHistory(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/user_history/$userId'); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); // User history API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodesList = data['data'] as List? ?? []; return episodesList.map((episodeData) { return PinepodsEpisode( podcastName: episodeData['podcastname'] ?? '', episodeTitle: episodeData['episodetitle'] ?? '', episodePubDate: episodeData['episodepubdate'] ?? '', episodeDescription: episodeData['episodedescription'] ?? '', episodeArtwork: episodeData['episodeartwork'] ?? '', episodeUrl: episodeData['episodeurl'] ?? '', episodeDuration: episodeData['episodeduration'] ?? 0, listenDuration: episodeData['listenduration'] ?? 0, episodeId: episodeData['episodeid'] ?? 0, completed: episodeData['completed'] ?? false, saved: episodeData['saved'] ?? false, queued: episodeData['queued'] ?? false, downloaded: episodeData['downloaded'] ?? false, isYoutube: episodeData['is_youtube'] ?? false, ); }).toList(); } else { throw Exception('Failed to load user history: ${response.statusCode}'); } } catch (e) { print('Error getting user history: $e'); rethrow; } } // Get queued episodes Future> getQueuedEpisodes(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/get_queued_episodes?user_id=$userId', ); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); print( 'Queued episodes response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodesList = data['data'] as List? ?? []; return episodesList.map((episodeData) { return PinepodsEpisode( podcastName: episodeData['podcastname'] ?? '', episodeTitle: episodeData['episodetitle'] ?? '', episodePubDate: episodeData['episodepubdate'] ?? '', episodeDescription: episodeData['episodedescription'] ?? '', episodeArtwork: episodeData['episodeartwork'] ?? '', episodeUrl: episodeData['episodeurl'] ?? '', episodeDuration: episodeData['episodeduration'] ?? 0, listenDuration: episodeData['listenduration'] ?? 0, episodeId: episodeData['episodeid'] ?? 0, completed: episodeData['completed'] ?? false, saved: episodeData['saved'] ?? false, queued: episodeData['queued'] ?? true, // Should always be true for queued episodes downloaded: episodeData['downloaded'] ?? false, isYoutube: episodeData['is_youtube'] ?? false, ); }).toList(); } else { throw Exception( 'Failed to load queued episodes: ${response.statusCode}', ); } } catch (e) { print('Error getting queued episodes: $e'); rethrow; } } // Get saved episodes Future> getSavedEpisodes(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/saved_episode_list/$userId'); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); // Saved episodes API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodesList = data['saved_episodes'] as List? ?? []; return episodesList.map((episodeData) { return PinepodsEpisode( podcastName: episodeData['podcastname'] ?? '', episodeTitle: episodeData['episodetitle'] ?? '', episodePubDate: episodeData['episodepubdate'] ?? '', episodeDescription: episodeData['episodedescription'] ?? '', episodeArtwork: episodeData['episodeartwork'] ?? '', episodeUrl: episodeData['episodeurl'] ?? '', episodeDuration: episodeData['episodeduration'] ?? 0, listenDuration: episodeData['listenduration'] ?? 0, episodeId: episodeData['episodeid'] ?? 0, completed: episodeData['completed'] ?? false, saved: episodeData['saved'] ?? true, // Should always be true for saved episodes queued: episodeData['queued'] ?? false, downloaded: episodeData['downloaded'] ?? false, isYoutube: episodeData['is_youtube'] ?? false, ); }).toList(); } else { throw Exception( 'Failed to load saved episodes: ${response.statusCode}', ); } } catch (e) { print('Error getting saved episodes: $e'); rethrow; } } // Get episode metadata Future getEpisodeMetadata( int episodeId, int userId, { bool isYoutube = false, bool personEpisode = false, }) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_episode_metadata'); try { final requestBody = jsonEncode({ 'episode_id': episodeId, 'user_id': userId, 'person_episode': personEpisode, 'is_youtube': isYoutube, }); final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodeData = data['episode']; return PinepodsEpisode( podcastName: episodeData['podcastname'] ?? '', episodeTitle: episodeData['episodetitle'] ?? '', episodePubDate: episodeData['episodepubdate'] ?? '', episodeDescription: episodeData['episodedescription'] ?? '', episodeArtwork: episodeData['episodeartwork'] ?? '', episodeUrl: episodeData['episodeurl'] ?? '', episodeDuration: episodeData['episodeduration'] ?? 0, listenDuration: episodeData['listenduration'] ?? 0, episodeId: episodeData['episodeid'] ?? episodeId, completed: episodeData['completed'] ?? false, saved: episodeData['is_saved'] ?? false, queued: episodeData['is_queued'] ?? false, downloaded: episodeData['is_downloaded'] ?? false, isYoutube: episodeData['is_youtube'] ?? isYoutube, podcastId: episodeData['podcastid'], ); } return null; } catch (e) { print('Error getting episode metadata: $e'); return null; } } // Get downloaded episodes from server Future> getServerDownloads(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/download_episode_list?user_id=$userId', ); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodesList = data['downloaded_episodes'] as List? ?? []; return episodesList.map((episodeData) { return PinepodsEpisode( podcastName: episodeData['podcastname'] ?? '', episodeTitle: episodeData['episodetitle'] ?? '', episodePubDate: episodeData['episodepubdate'] ?? '', episodeDescription: episodeData['episodedescription'] ?? '', episodeArtwork: episodeData['episodeartwork'] ?? '', episodeUrl: episodeData['episodeurl'] ?? '', episodeDuration: episodeData['episodeduration'] ?? 0, listenDuration: episodeData['listenduration'] ?? 0, episodeId: episodeData['episodeid'] ?? 0, completed: episodeData['completed'] ?? false, saved: episodeData['saved'] ?? false, queued: episodeData['queued'] ?? false, downloaded: episodeData['downloaded'] ?? true, // Should always be true for downloaded episodes isYoutube: episodeData['is_youtube'] ?? false, ); }).toList(); } else { throw Exception( 'Failed to load server downloads: ${response.statusCode}', ); } } catch (e) { print('Error getting server downloads: $e'); rethrow; } } // Get stream URL for episode String getStreamUrl( int episodeId, int userId, { bool isYoutube = false, bool isLocal = false, }) { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } if (isYoutube) { return '$_server/api/data/stream/$episodeId?api_key=$_apiKey&user_id=$userId&type=youtube'; } else if (isLocal) { return '$_server/api/data/stream/$episodeId?api_key=$_apiKey&user_id=$userId'; } else { // For external episodes, return the original URL return ''; } } // Search for podcasts using PinePods search API Future searchPodcasts( String query, SearchProvider provider, ) async { const searchApiUrl = 'https://search.pinepods.online'; final url = Uri.parse( '$searchApiUrl/api/search?query=${Uri.encodeComponent(query)}&index=${provider.value}', ); try { print('Making search request to: $url'); final response = await http.get(url); if (response.statusCode == 200) { final data = jsonDecode(response.body); // Search API response received return PinepodsSearchResult.fromJson(data); } else { throw Exception('Failed to search podcasts: ${response.statusCode}'); } } catch (e) { print('Error searching podcasts: $e'); rethrow; } } // Check if a podcast is already added to the server Future checkPodcastExists( String podcastTitle, String podcastUrl, int userId, ) async { if (_server == null || _apiKey == null) { return false; } final url = Uri.parse('$_server/api/data/check_podcast').replace( queryParameters: { 'user_id': userId.toString(), 'podcast_name': podcastTitle, 'podcast_url': podcastUrl, }, ); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['exists'] == true; } return false; } catch (e) { print('Error checking podcast exists: $e'); return false; } } // Add a podcast to the server Future addPodcast(UnifiedPinepodsPodcast podcast, int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/add_podcast'); final body = { 'podcast_values': { 'pod_title': podcast.title, 'pod_artwork': podcast.artwork, 'pod_author': podcast.author, 'categories': podcast.categories ?? {}, 'pod_description': podcast.description, 'pod_episode_count': podcast.episodeCount, 'pod_feed_url': podcast.url, 'pod_website': podcast.link, 'pod_explicit': podcast.explicit, 'user_id': userId, }, 'podcast_index_id': podcast.indexId, }; try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode(body), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['success'] == true; } return false; } catch (e) { print('Error adding podcast: $e'); rethrow; } } // Remove a podcast from the server Future removePodcast( String podcastTitle, String podcastUrl, int userId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/remove_podcast'); final body = { 'podcast_name': podcastTitle, 'podcast_url': podcastUrl, 'user_id': userId, }; try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode(body), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['success'] == true; } return false; } catch (e) { print('Error removing podcast: $e'); rethrow; } } // Get podcast details dynamically (whether added or not) Future getPodcastDetailsDynamic({ required int userId, required String podcastTitle, required String podcastUrl, required int podcastIndexId, required bool added, bool displayOnly = false, }) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_podcast_details_dynamic') .replace( queryParameters: { 'user_id': userId.toString(), 'podcast_title': podcastTitle, 'podcast_url': podcastUrl, 'podcast_index_id': podcastIndexId.toString(), 'added': added.toString(), 'display_only': displayOnly.toString(), }, ); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); // Podcast details API response received return PodcastDetailsData.fromJson(data); } else { throw Exception( 'Failed to get podcast details: ${response.statusCode}', ); } } catch (e) { print('Error getting podcast details: $e'); rethrow; } } // Get podcast details by podcast ID (for subscribed podcasts) Future?> getPodcastDetailsById( int podcastId, int userId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_podcast_details').replace( queryParameters: { 'podcast_id': podcastId.toString(), 'user_id': userId.toString(), }, ); try { print('Getting podcast details by ID from: $url'); final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); // Podcast details by ID API response received return data['details']; } else { throw Exception( 'Failed to get podcast details: ${response.statusCode}', ); } } catch (e) { print('Error getting podcast details by ID: $e'); rethrow; } } // Get podcast ID by feed URL and title Future getPodcastId( int userId, String podcastFeed, String podcastTitle, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_podcast_id').replace( queryParameters: { 'user_id': userId.toString(), 'podcast_feed': podcastFeed, 'podcast_title': podcastTitle, }, ); try { print('Getting podcast ID from: $url'); final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); // Podcast ID API response received final podcastId = data['podcast_id']; if (podcastId is int) { return podcastId; } return null; } else { throw Exception('Failed to get podcast ID: ${response.statusCode}'); } } catch (e) { print('Error getting podcast ID: $e'); return null; } } // Get episodes for an added podcast Future> getPodcastEpisodes( int userId, int podcastId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/podcast_episodes').replace( queryParameters: { 'user_id': userId.toString(), 'podcast_id': podcastId.toString(), }, ); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); if (response.statusCode == 200) { final data = jsonDecode(response.body); final episodes = data['episodes'] as List; return episodes.map((episodeData) { // Add default values only for fields not provided by this endpoint final episodeWithDefaults = Map.from(episodeData); // Only add defaults if these fields are not present in the API response episodeWithDefaults['saved'] ??= false; episodeWithDefaults['queued'] ??= false; episodeWithDefaults['downloaded'] ??= false; episodeWithDefaults['is_youtube'] ??= false; return PinepodsEpisode.fromJson(episodeWithDefaults); }).toList(); } else { throw Exception( 'Failed to get podcast episodes: ${response.statusCode}', ); } } catch (e) { print('Error getting podcast episodes: $e'); rethrow; } } // Get user statistics Future getUserStats(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/get_stats', ).replace(queryParameters: {'user_id': userId.toString()}); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return UserStats.fromJson(data); } else { throw Exception('Failed to get user stats: ${response.statusCode}'); } } catch (e) { print('Error getting user stats: $e'); rethrow; } } // Get PinePods version Future getPinepodsVersion() async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_pinepods_version'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['data'] ?? 'Unknown'; } else { throw Exception( 'Failed to get PinePods version: ${response.statusCode}', ); } } catch (e) { print('Error getting PinePods version: $e'); return 'Unknown'; } } // Get user details by user ID Future?> getUserDetails(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/user_details_id/$userId'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data; } else { throw Exception('Failed to get user details: ${response.statusCode}'); } } catch (e) { print('Error getting user details: $e'); return null; } } // Get user ID from API key Future getUserIdFromApiKey() async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/id_from_api_key'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final userId = int.tryParse(response.body.trim()); return userId; } else { throw Exception('Failed to get user ID: ${response.statusCode}'); } } catch (e) { print('Error getting user ID: $e'); return null; } } // Get home overview data Future getHomeOverview(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/home_overview?user_id=$userId'); print('Making API call to: $url'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return HomeOverview.fromJson(data); } else { throw Exception('Failed to load home overview: ${response.statusCode}'); } } catch (e) { print('Error getting home overview: $e'); rethrow; } } // Get playlists Future getPlaylists(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_playlists?user_id=$userId'); print('Making API call to: $url'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); // Playlists API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); return PlaylistResponse.fromJson(data); } else { throw Exception('Failed to load playlists: ${response.statusCode}'); } } catch (e) { print('Error getting playlists: $e'); rethrow; } } // Get user theme Future getUserTheme(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_theme/$userId'); print('Making API call to: $url'); try { final response = await http.get( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, ); // Theme API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['theme'] as String?; } else { throw Exception('Failed to get user theme: ${response.statusCode}'); } } catch (e) { print('Error getting user theme: $e'); return null; } } // Set user theme Future setUserTheme(int userId, String theme) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/user/set_theme'); print('Making API call to: $url'); try { final requestBody = jsonEncode({'user_id': userId, 'new_theme': theme}); final response = await http.put( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: requestBody, ); // Set theme API response received if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['message'] != null; } else { throw Exception('Failed to set user theme: ${response.statusCode}'); } } catch (e) { print('Error setting user theme: $e'); return false; } } // Get user playlists Future> getUserPlaylists(int userId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/get_playlists?user_id=$userId'); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); print( 'Get playlists response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final List playlistsData = data['playlists'] ?? []; List playlists = []; for (var playlistData in playlistsData) { playlists.add(PlaylistData.fromJson(playlistData)); } return playlists; } else { throw Exception('Failed to get playlists: ${response.statusCode}'); } } catch (e) { print('Error getting playlists: $e'); rethrow; } } // Create playlist Future createPlaylist(CreatePlaylistRequest request) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/create_playlist'); print('Making API call to: $url'); try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode(request.toJson()), ); print( 'Create playlist response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error creating playlist: $e'); return false; } } // Delete playlist Future deletePlaylist(int userId, int playlistId) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/delete_playlist'); print('Making API call to: $url'); try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode({'user_id': userId, 'playlist_id': playlistId}), ); print( 'Delete playlist response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error deleting playlist: $e'); return false; } } // Get playlist episodes Future getPlaylistEpisodes( int userId, int playlistId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse( '$_server/api/data/get_playlist_episodes?user_id=$userId&playlist_id=$playlistId', ); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); print( 'Get playlist episodes response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return PlaylistEpisodesResponse.fromJson(data); } else { throw Exception( 'Failed to get playlist episodes: ${response.statusCode}', ); } } catch (e) { print('Error getting playlist episodes: $e'); rethrow; } } // Reorder queue episodes Future reorderQueue(int userId, List episodeIds) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/reorder_queue?user_id=$userId'); print('Making API call to: $url'); try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode({'episode_ids': episodeIds}), ); print( 'Reorder queue response: ${response.statusCode} - ${response.body}', ); return response.statusCode == 200; } catch (e) { print('Error reordering queue: $e'); return false; } } // Search episodes in user's subscriptions Future> searchEpisodes( int userId, String searchTerm, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/search_data'); print('Making API call to: $url'); try { final response = await http.post( url, headers: {'Api-Key': _apiKey!, 'Content-Type': 'application/json'}, body: jsonEncode({'search_term': searchTerm, 'user_id': userId}), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final List episodesData = data['data'] ?? []; List episodes = []; for (var episodeData in episodesData) { episodes.add(SearchEpisodeResult.fromJson(episodeData)); } return episodes; } else { throw Exception('Failed to search episodes: ${response.statusCode}'); } } catch (e) { print('Error searching episodes: $e'); rethrow; } } // Fetch podcast 2.0 data for a specific episode Future?> fetchPodcasting2Data( int episodeId, int userId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/fetch_podcasting_2_data').replace( queryParameters: { 'episode_id': episodeId.toString(), 'user_id': userId.toString(), }, ); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); print( 'Podcast 2.0 data response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data; } else { print('Failed to fetch podcast 2.0 data: ${response.statusCode}'); return null; } } catch (e) { print('Error fetching podcast 2.0 data: $e'); return null; } } // Fetch podcast 2.0 data for a specific podcast Future?> fetchPodcasting2PodData( int podcastId, int userId, ) async { if (_server == null || _apiKey == null) { throw Exception('Not authenticated'); } final url = Uri.parse('$_server/api/data/fetch_podcasting_2_pod_data') .replace( queryParameters: { 'podcast_id': podcastId.toString(), 'user_id': userId.toString(), }, ); print('Making API call to: $url'); try { final response = await http.get(url, headers: {'Api-Key': _apiKey!}); print( 'Podcast 2.0 pod data response: ${response.statusCode} - ${response.body}', ); if (response.statusCode == 200) { final data = jsonDecode(response.body); return data; } else { print('Failed to fetch podcast 2.0 pod data: ${response.statusCode}'); return null; } } catch (e) { print('Error fetching podcast 2.0 pod data: $e'); return null; } } } class PodcastDetailsData { final int podcastId; final String podcastName; final String feedUrl; final String description; final String author; final String artworkUrl; final bool explicit; final int episodeCount; final Map? categories; final String websiteUrl; final int podcastIndexId; final bool isYoutube; PodcastDetailsData({ required this.podcastId, required this.podcastName, required this.feedUrl, required this.description, required this.author, required this.artworkUrl, required this.explicit, required this.episodeCount, this.categories, required this.websiteUrl, required this.podcastIndexId, required this.isYoutube, }); factory PodcastDetailsData.fromJson(Map json) { return PodcastDetailsData( podcastId: json['podcastid'] ?? 0, podcastName: json['podcastname'] ?? '', feedUrl: json['feedurl'] ?? '', description: json['description'] ?? '', author: json['author'] ?? '', artworkUrl: json['artworkurl'] ?? '', explicit: json['explicit'] ?? false, episodeCount: json['episodecount'] ?? 0, categories: json['categories'] != null ? Map.from(json['categories'] as Map) : null, websiteUrl: json['websiteurl'] ?? '', podcastIndexId: json['podcastindexid'] ?? 0, isYoutube: json['is_youtube'] ?? false, ); } } class PlayEpisodeDetails { final double playbackSpeed; final int startSkip; final int endSkip; PlayEpisodeDetails({ required this.playbackSpeed, required this.startSkip, required this.endSkip, }); } // Playlist Data Classes class PlaylistData { final int playlistId; final int userId; final String name; final String? description; final bool isSystemPlaylist; final List? podcastIds; final bool includeUnplayed; final bool includePartiallyPlayed; final bool includePlayed; final int? minDuration; final int? maxDuration; final String sortOrder; final bool groupByPodcast; final int? maxEpisodes; final String lastUpdated; final String created; final int? episodeCount; final String? iconName; PlaylistData({ required this.playlistId, required this.userId, required this.name, this.description, required this.isSystemPlaylist, this.podcastIds, required this.includeUnplayed, required this.includePartiallyPlayed, required this.includePlayed, this.minDuration, this.maxDuration, required this.sortOrder, required this.groupByPodcast, this.maxEpisodes, required this.lastUpdated, required this.created, this.episodeCount, this.iconName, }); factory PlaylistData.fromJson(Map json) { return PlaylistData( playlistId: json['playlist_id'] ?? 0, userId: json['user_id'] ?? 0, name: json['name'] ?? '', description: json['description'], isSystemPlaylist: json['is_system_playlist'] ?? false, podcastIds: json['podcast_ids'] != null ? List.from(json['podcast_ids']) : null, includeUnplayed: json['include_unplayed'] ?? true, includePartiallyPlayed: json['include_partially_played'] ?? true, includePlayed: json['include_played'] ?? false, minDuration: json['min_duration'], maxDuration: json['max_duration'], sortOrder: json['sort_order'] ?? 'date_desc', groupByPodcast: json['group_by_podcast'] ?? false, maxEpisodes: json['max_episodes'], lastUpdated: json['last_updated'] ?? '', created: json['created'] ?? '', episodeCount: json['episode_count'], iconName: json['icon_name'], ); } } class CreatePlaylistRequest { final int userId; final String name; final String? description; final List? podcastIds; final bool includeUnplayed; final bool includePartiallyPlayed; final bool includePlayed; final int? minDuration; final int? maxDuration; final String sortOrder; final bool groupByPodcast; final int? maxEpisodes; final String iconName; final double? playProgressMin; final double? playProgressMax; final int? timeFilterHours; CreatePlaylistRequest({ required this.userId, required this.name, this.description, this.podcastIds, required this.includeUnplayed, required this.includePartiallyPlayed, required this.includePlayed, this.minDuration, this.maxDuration, required this.sortOrder, required this.groupByPodcast, this.maxEpisodes, required this.iconName, this.playProgressMin, this.playProgressMax, this.timeFilterHours, }); Map toJson() { return { 'user_id': userId, 'name': name, 'description': description, 'podcast_ids': podcastIds, 'include_unplayed': includeUnplayed, 'include_partially_played': includePartiallyPlayed, 'include_played': includePlayed, 'min_duration': minDuration, 'max_duration': maxDuration, 'sort_order': sortOrder, 'group_by_podcast': groupByPodcast, 'max_episodes': maxEpisodes, 'icon_name': iconName, 'play_progress_min': playProgressMin, 'play_progress_max': playProgressMax, 'time_filter_hours': timeFilterHours, }; } } class PlaylistEpisodesResponse { final List episodes; final PlaylistInfo playlistInfo; PlaylistEpisodesResponse({ required this.episodes, required this.playlistInfo, }); factory PlaylistEpisodesResponse.fromJson(Map json) { return PlaylistEpisodesResponse( episodes: (json['episodes'] as List? ?? []) .map((e) => PinepodsEpisode.fromJson(e)) .toList(), playlistInfo: PlaylistInfo.fromJson(json['playlist_info'] ?? {}), ); } } class PlaylistInfo { final String name; final String? description; final int? episodeCount; final String? iconName; PlaylistInfo({ required this.name, this.description, this.episodeCount, this.iconName, }); factory PlaylistInfo.fromJson(Map json) { return PlaylistInfo( name: json['name'] ?? '', description: json['description'], episodeCount: json['episode_count'], iconName: json['icon_name'], ); } } class SearchEpisodeResult { final int podcastId; final String podcastName; final String artworkUrl; final String author; final String categories; final String description; final int? episodeCount; final String feedUrl; final String websiteUrl; final bool explicit; final int userId; final int episodeId; final String episodeTitle; final String episodeDescription; final String episodePubDate; final String episodeArtwork; final String episodeUrl; final int episodeDuration; final bool completed; final bool saved; final bool queued; final bool downloaded; final bool isYoutube; final int? listenDuration; SearchEpisodeResult({ required this.podcastId, required this.podcastName, required this.artworkUrl, required this.author, required this.categories, required this.description, this.episodeCount, required this.feedUrl, required this.websiteUrl, required this.explicit, required this.userId, required this.episodeId, required this.episodeTitle, required this.episodeDescription, required this.episodePubDate, required this.episodeArtwork, required this.episodeUrl, required this.episodeDuration, required this.completed, required this.saved, required this.queued, required this.downloaded, required this.isYoutube, this.listenDuration, }); factory SearchEpisodeResult.fromJson(Map json) { return SearchEpisodeResult( podcastId: json['podcastid'] ?? 0, podcastName: json['podcastname'] ?? '', artworkUrl: json['artworkurl'] ?? '', author: json['author'] ?? '', categories: _parseCategories(json['categories']), description: json['description'] ?? '', episodeCount: json['episodecount'], feedUrl: json['feedurl'] ?? '', websiteUrl: json['websiteurl'] ?? '', explicit: (json['explicit'] ?? 0) == 1, userId: json['userid'] ?? 0, episodeId: json['episodeid'] ?? 0, episodeTitle: json['episodetitle'] ?? '', episodeDescription: json['episodedescription'] ?? '', episodePubDate: json['episodepubdate'] ?? '', episodeArtwork: json['episodeartwork'] ?? '', episodeUrl: json['episodeurl'] ?? '', episodeDuration: json['episodeduration'] ?? 0, completed: json['completed'] ?? false, saved: json['saved'] ?? false, queued: json['queued'] ?? false, downloaded: json['downloaded'] ?? false, isYoutube: json['is_youtube'] ?? false, listenDuration: json['listenduration'], ); } // Convert to PinepodsEpisode for compatibility with existing widgets PinepodsEpisode toPinepodsEpisode() { return PinepodsEpisode( podcastName: podcastName, episodeTitle: episodeTitle, episodePubDate: episodePubDate, episodeDescription: episodeDescription, episodeArtwork: episodeArtwork.isNotEmpty ? episodeArtwork : artworkUrl, episodeUrl: episodeUrl, episodeDuration: episodeDuration, listenDuration: listenDuration, episodeId: episodeId, completed: completed, saved: saved, queued: queued, downloaded: downloaded, isYoutube: isYoutube, ); } /// Parse categories from either string or Map format static String _parseCategories(dynamic categories) { if (categories == null) return ''; if (categories is String) { // Old format - return as is return categories; } else if (categories is Map) { // New format - convert map values to comma-separated string if (categories.isEmpty) return ''; return categories.values.join(', '); } return ''; } }