From 2e17078fea69373dad47690e959932129eb31eba Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 27 Aug 2024 16:37:31 +0800 Subject: [PATCH] :sparkles: Impl more features (clean up 20+ todo) :zap: Add query cache --- lib/main.dart | 18 +- lib/platform.dart | 2 +- lib/providers/audio_player_stream.dart | 157 +++++++ lib/providers/database.dart | 6 + lib/providers/history.dart | 62 +++ lib/providers/palette.dart | 14 + lib/providers/scrobbler.dart | 140 ++++++ lib/providers/skip_segments.dart | 117 +++++ lib/providers/user_preferences.dart | 190 ++++++++ lib/services/database/database.dart | 2 - lib/services/database/database.g.dart | 410 ------------------ lib/services/database/tables/blacklist.dart | 18 - lib/services/database/tables/preferences.dart | 3 +- .../database/tables/source_match.dart | 7 +- .../database/typeconverters/locale.dart | 6 +- .../database/typeconverters/string_list.dart | 4 +- lib/services/sourced_track/models/search.dart | 12 - .../sourced_track/models/video_info.dart | 3 +- lib/services/sourced_track/sourced_track.dart | 34 +- lib/services/sourced_track/sources/piped.dart | 98 ++++- .../sourced_track/sources/youtube.dart | 78 +++- lib/widgets/auto_cache_image.dart | 7 + pubspec.lock | 17 + pubspec.yaml | 5 + 24 files changed, 907 insertions(+), 503 deletions(-) create mode 100644 lib/providers/audio_player_stream.dart create mode 100644 lib/providers/database.dart create mode 100644 lib/providers/history.dart create mode 100644 lib/providers/palette.dart create mode 100644 lib/providers/scrobbler.dart create mode 100644 lib/providers/skip_segments.dart create mode 100644 lib/providers/user_preferences.dart delete mode 100755 lib/services/database/tables/blacklist.dart delete mode 100644 lib/services/sourced_track/models/search.dart diff --git a/lib/main.dart b/lib/main.dart index 338c84f..16173a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,14 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:media_kit/media_kit.dart'; import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/providers/audio_player_stream.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/history.dart'; +import 'package:rhythm_box/providers/palette.dart'; +import 'package:rhythm_box/providers/scrobbler.dart'; +import 'package:rhythm_box/providers/skip_segments.dart'; import 'package:rhythm_box/providers/spotify.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/router.dart'; import 'package:rhythm_box/services/server/active_sourced_track.dart'; import 'package:rhythm_box/services/server/routes/playback.dart'; @@ -51,7 +58,16 @@ class MyApp extends StatelessWidget { void _initializeProviders(BuildContext context) async { Get.lazyPut(() => SpotifyProvider()); - Get.lazyPut(() => ActiveSourcedTrackProvider()); + + Get.put(ActiveSourcedTrackProvider()); + Get.put(AudioPlayerStreamProvider()); + + Get.put(DatabaseProvider()); + Get.put(PlaybackHistoryProvider()); + Get.put(SegmentsProvider()); + Get.put(PaletteProvider()); + Get.put(ScrobblerProvider()); + Get.put(UserPreferencesProvider()); Get.put(AudioPlayerProvider()); Get.put(QueryingTrackInfoProvider()); diff --git a/lib/platform.dart b/lib/platform.dart index b5f9578..8ae2df3 100644 --- a/lib/platform.dart +++ b/lib/platform.dart @@ -9,7 +9,7 @@ abstract class PlatformInfo { static bool get isLinux => !kIsWeb && Platform.isLinux; static bool get isInFlatpak => - kIsWeb ? false : Platform.environment["FLATPAK_ID"] != null; + kIsWeb ? false : Platform.environment['FLATPAK_ID'] != null; static bool get isWindows => !kIsWeb && Platform.isWindows; diff --git a/lib/providers/audio_player_stream.dart b/lib/providers/audio_player_stream.dart new file mode 100644 index 0000000..fdf1c99 --- /dev/null +++ b/lib/providers/audio_player_stream.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:get/get.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/providers/history.dart'; +import 'package:rhythm_box/providers/scrobbler.dart'; +import 'package:rhythm_box/providers/skip_segments.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/audio_player/audio_player.dart'; +import 'package:rhythm_box/services/audio_services/audio_services.dart'; +import 'package:rhythm_box/services/audio_services/image.dart'; +import 'package:rhythm_box/services/local_track.dart'; +import 'package:rhythm_box/services/server/sourced_track.dart'; +import 'package:rhythm_box/widgets/auto_cache_image.dart'; + +class AudioPlayerStreamProvider extends GetxController { + late final AudioServices notificationService; + final Rxn palette = Rxn(); + + List? _subscriptions; + + @override + void onInit() { + super.onInit(); + AudioServices.create().then( + (value) => notificationService = value, + ); + + _subscriptions = [ + subscribeToPlaylist(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + subscribeToPosition(), + subscribeToPlayerError(), + ]; + } + + @override + void dispose() { + if (_subscriptions != null) { + for (final subscription in _subscriptions!) { + subscription.cancel(); + } + } + super.dispose(); + } + + Future updatePalette() async { + if (!Get.find().albumColorSync) { + if (palette.value != null) { + palette.value = null; + } + return; + } + + final AudioPlayerProvider playback = Get.find(); + final activeTrack = playback.state.value.activeTrack; + if (activeTrack == null) return; + + if (activeTrack.album?.images != null) { + final newPalette = await PaletteGenerator.fromImageProvider( + AutoCacheImage.provider( + (activeTrack.album?.images).asUrlString()!, + ), + ); + palette.value = newPalette; + } + } + + StreamSubscription subscribeToPlaylist() { + final AudioPlayerProvider playback = Get.find(); + return audioPlayer.playlistStream.listen((mpvPlaylist) { + final activeTrack = playback.state.value.activeTrack; + if (activeTrack != null) { + notificationService.addTrack(activeTrack); + updatePalette(); + } + }); + } + + StreamSubscription subscribeToSkipSponsor() { + return audioPlayer.positionStream.listen((position) async { + final currentSegments = + await Get.find().fetchSegments(); + + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; + + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; + + if (seconds < segment.start || seconds >= segment.end) continue; + + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + }); + } + + StreamSubscription subscribeToScrobbleChanged() { + String? lastScrobbled; + return audioPlayer.positionStream.listen((position) { + try { + final AudioPlayerProvider playback = Get.find(); + final uid = playback.state.value.activeTrack is LocalTrack + ? (playback.state.value.activeTrack as LocalTrack).path + : playback.state.value.activeTrack?.id; + + if (playback.state.value.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + Get.find() + .scrobble(playback.state.value.activeTrack!); + Get.find() + .addTrack(playback.state.value.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + log('[Scrobbler] Error: $e; Trace:\n$stack'); + } + }); + } + + StreamSubscription subscribeToPosition() { + String lastTrack = ''; // used to prevent multiple calls to the same track + final AudioPlayerProvider playback = Get.find(); + return audioPlayer.positionStream.listen((event) async { + if (event < const Duration(seconds: 3) || + audioPlayer.playlist.index == -1 || + audioPlayer.playlist.index == + playback.state.value.tracks.length - 1) { + return; + } + final nextTrack = RhythmMedia.fromMedia( + audioPlayer.playlist.medias.elementAt(audioPlayer.playlist.index + 1), + ); + + if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { + return; + } + + try { + await Get.find().fetch(nextTrack); + } finally { + lastTrack = nextTrack.track.id!; + } + }); + } + + StreamSubscription subscribeToPlayerError() { + return audioPlayer.errorStream.listen((event) { + // Handle player error events here + }); + } +} diff --git a/lib/providers/database.dart b/lib/providers/database.dart new file mode 100644 index 0000000..f230c6e --- /dev/null +++ b/lib/providers/database.dart @@ -0,0 +1,6 @@ +import 'package:get/get.dart'; +import 'package:rhythm_box/services/database/database.dart'; + +class DatabaseProvider extends GetxController { + late final AppDatabase database = AppDatabase(); +} diff --git a/lib/providers/history.dart b/lib/providers/history.dart new file mode 100644 index 0000000..0aae2fe --- /dev/null +++ b/lib/providers/history.dart @@ -0,0 +1,62 @@ +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:spotify/spotify.dart'; + +class PlaybackHistoryProvider extends GetxController { + final AppDatabase _db = Get.find().database; + + Future _batchInsertHistoryEntries( + List entries) async { + await _db.batch((batch) { + batch.insertAll(_db.historyTable, entries); + }); + } + + Future addPlaylists(List playlists) async { + await _batchInsertHistoryEntries([ + for (final playlist in playlists) + HistoryTableCompanion.insert( + type: HistoryEntryType.playlist, + itemId: playlist.id!, + data: playlist.toJson(), + ), + ]); + } + + Future addAlbums(List albums) async { + await _batchInsertHistoryEntries([ + for (final album in albums) + HistoryTableCompanion.insert( + type: HistoryEntryType.album, + itemId: album.id!, + data: album.toJson(), + ), + ]); + } + + Future addTracks(List tracks) async { + await _batchInsertHistoryEntries([ + for (final track in tracks) + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ]); + } + + Future addTrack(Track track) async { + await _db.into(_db.historyTable).insert( + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ); + } + + Future clear() async { + await _db.delete(_db.historyTable).go(); + } +} diff --git a/lib/providers/palette.dart b/lib/providers/palette.dart new file mode 100644 index 0000000..5a37b3c --- /dev/null +++ b/lib/providers/palette.dart @@ -0,0 +1,14 @@ +import 'package:get/get.dart'; +import 'package:palette_generator/palette_generator.dart'; + +class PaletteProvider extends GetxController { + final Rx palette = Rx(null); + + void updatePalette(PaletteGenerator? newPalette) { + palette.value = newPalette; + } + + void clear() { + palette.value = null; + } +} diff --git a/lib/providers/scrobbler.dart b/lib/providers/scrobbler.dart new file mode 100644 index 0000000..1cad7d7 --- /dev/null +++ b/lib/providers/scrobbler.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart' hide Value; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/services/artist.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; +import 'package:spotify/spotify.dart'; + +class ScrobblerProvider extends GetxController { + final StreamController _scrobbleController = + StreamController.broadcast(); + final Rxn scrobbler = Rxn(null); + late StreamSubscription _databaseSubscription; + late StreamSubscription _scrobbleSubscription; + + static String apiKey = 'd2a75393e1141d0c9486eb77cc7b8892'; + static String apiSecret = '3ac3a5231a2e8a0dc98577c246101b78'; + + @override + void onInit() { + super.onInit(); + _initialize(); + } + + Future _initialize() async { + final database = Get.find().database; + + final loginInfo = await (database.select(database.scrobblerTable) + ..where((t) => t.id.equals(0))) + .getSingleOrNull(); + + _databaseSubscription = + database.select(database.scrobblerTable).watch().listen((event) async { + if (event.isNotEmpty) { + try { + scrobbler.value = Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: apiKey, + apiSecret: apiSecret, + username: event.first.username, + passwordHash: event.first.passwordHash.value, + ), + ); + } catch (e, stack) { + log('[Scrobble] Error: $e; Trace:\n$stack'); + scrobbler.value = null; + } + } else { + scrobbler.value = null; + } + }); + + _scrobbleSubscription = _scrobbleController.stream.listen((track) async { + try { + await scrobbler.value?.track.scrobble( + artist: track.artists!.first.name!, + track: track.name!, + album: track.album!.name!, + chosenByUser: true, + duration: track.duration, + timestamp: DateTime.now().toUtc(), + trackNumber: track.trackNumber, + ); + } catch (e, stackTrace) { + log('[Scrobble] Error: $e; Trace:\n$stackTrace'); + } + }); + + if (loginInfo == null) { + scrobbler.value = null; + return; + } + + scrobbler.value = Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: apiKey, + apiSecret: apiSecret, + username: loginInfo.username, + passwordHash: loginInfo.passwordHash.value, + ), + ); + } + + Future login(String username, String password) async { + final database = Get.find().database; + + final lastFm = await LastFM.authenticate( + apiKey: apiKey, + apiSecret: apiSecret, + username: username, + password: password, + ); + + if (!lastFm.isAuth) throw Exception('Invalid credentials'); + + await database.into(database.scrobblerTable).insert( + ScrobblerTableCompanion.insert( + id: const Value(0), + username: username, + passwordHash: DecryptedText(lastFm.passwordHash!), + ), + ); + + scrobbler.value = Scrobblenaut(lastFM: lastFm); + } + + Future logout() async { + scrobbler.value = null; + final database = Get.find().database; + await database.delete(database.scrobblerTable).go(); + } + + void scrobble(Track track) { + _scrobbleController.add(track); + } + + Future love(Track track) async { + await scrobbler.value?.track.love( + artist: track.artists!.asString(), + track: track.name!, + ); + } + + Future unlove(Track track) async { + await scrobbler.value?.track.unLove( + artist: track.artists!.asString(), + track: track.name!, + ); + } + + @override + void onClose() { + _databaseSubscription.cancel(); + _scrobbleSubscription.cancel(); + _scrobbleController.close(); + super.onClose(); + } +} diff --git a/lib/providers/skip_segments.dart b/lib/providers/skip_segments.dart new file mode 100644 index 0000000..6d3a0b5 --- /dev/null +++ b/lib/providers/skip_segments.dart @@ -0,0 +1,117 @@ +import 'dart:developer'; + +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/services/server/active_sourced_track.dart'; + +class SourcedSegments { + final String source; + final List segments; + + SourcedSegments({required this.source, required this.segments}); +} + +Future> getAndCacheSkipSegments(String id) async { + final database = Get.find().database; + try { + final cached = await (database.select(database.skipSegmentTable) + ..where((s) => s.trackId.equals(id))) + .get(); + + if (cached.isNotEmpty) { + return cached; + } + + final res = await Dio().getUri( + Uri( + scheme: 'https', + host: 'sponsor.ajay.app', + path: '/api/skipSegments', + queryParameters: { + 'videoID': id, + 'category': [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + 'actionType': 'skip' + }, + ), + options: Options( + responseType: ResponseType.json, + validateStatus: (status) => (status ?? 0) < 500, + ), + ); + + if (res.data == 'Not Found') { + return List.castFrom([]); + } + + final data = res.data as List; + final segments = data.map((obj) { + final start = obj['segment'].first.toInt(); + final end = obj['segment'].last.toInt(); + return SkipSegmentTableCompanion.insert( + trackId: id, + start: start, + end: end, + ); + }).toList(); + + await database.batch((b) { + b.insertAll(database.skipSegmentTable, segments); + }); + + return await (database.select(database.skipSegmentTable) + ..where((s) => s.trackId.equals(id))) + .get(); + } catch (e, stack) { + log('[SkipSegment] Error: $e; Trace:\n$stack'); + return List.castFrom([]); + } +} + +class SegmentsProvider extends GetxController { + final Rx segments = Rx(null); + + Future fetchSegments() async { + final track = Get.find().state.value; + if (track == null) { + segments.value = null; + return null; + } + + final userPreferences = Get.find().state.value; + final skipNonMusic = userPreferences.skipNonMusic && + !(userPreferences.audioSource == AudioSource.piped && + userPreferences.searchMode == SearchMode.youtubeMusic); + + if (!skipNonMusic) { + segments.value = SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + return null; + } + + final fetchedSegments = await getAndCacheSkipSegments(track.sourceInfo.id); + segments.value = SourcedSegments( + source: track.sourceInfo.id, + segments: fetchedSegments, + ); + + return segments.value!; + } + + @override + void onInit() { + super.onInit(); + fetchSegments(); // Automatically load segments when controller is initialized + } +} diff --git a/lib/providers/user_preferences.dart b/lib/providers/user_preferences.dart new file mode 100644 index 0000000..6fe6a85 --- /dev/null +++ b/lib/providers/user_preferences.dart @@ -0,0 +1,190 @@ +import 'package:get/get.dart' hide Value; +import 'package:drift/drift.dart'; +import 'package:rhythm_box/platform.dart'; +import 'package:rhythm_box/providers/audio_player_stream.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/palette.dart'; +import 'package:rhythm_box/services/audio_player/audio_player.dart'; +import 'package:rhythm_box/services/color.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/services/sourced_track/enums.dart'; +import 'package:spotify/spotify.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +typedef UserPreferences = PreferencesTableData; + +class UserPreferencesProvider extends GetxController { + final Rx state = PreferencesTable.defaults().obs; + late final AppDatabase db; + + @override + void onInit() { + super.onInit(); + db = Get.find().database; + _initializePreferences(); + } + + Future _initializePreferences() async { + var result = await (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .getSingleOrNull(); + if (result == null) { + await db.into(db.preferencesTable).insert( + PreferencesTableCompanion.insert( + id: const Value(0), + downloadLocation: Value(await _getDefaultDownloadDirectory()), + ), + ); + } + state.value = await (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .getSingle(); + + // Subscribe to updates + (db.select(db.preferencesTable)..where((tbl) => tbl.id.equals(0))) + .watchSingle() + .listen((event) async { + state.value = event; + + if (PlatformInfo.isDesktop) { + await windowManager.setTitleBarStyle( + state.value.systemTitleBar + ? TitleBarStyle.normal + : TitleBarStyle.hidden, + ); + } + + await audioPlayer.setAudioNormalization(state.value.normalizeAudio); + }); + } + + Future _getDefaultDownloadDirectory() async { + if (PlatformInfo.isAndroid) return '/storage/emulated/0/Download/RhythmBox'; + + if (PlatformInfo.isMacOS) { + return join((await getLibraryDirectory()).path, 'Caches'); + } + + return getDownloadsDirectory().then((dir) { + return join(dir!.path, 'RhythmBox'); + }); + } + + Future setData(PreferencesTableCompanion data) async { + await (db.update(db.preferencesTable)..where((t) => t.id.equals(0))) + .write(data); + } + + Future reset() async { + await (db.update(db.preferencesTable)..where((t) => t.id.equals(0))) + .replace(PreferencesTableCompanion.insert()); + } + + void setStreamMusicCodec(SourceCodecs codec) { + setData(PreferencesTableCompanion(streamMusicCodec: Value(codec))); + } + + void setDownloadMusicCodec(SourceCodecs codec) { + setData(PreferencesTableCompanion(downloadMusicCodec: Value(codec))); + } + + void setThemeMode(ThemeMode mode) { + setData(PreferencesTableCompanion(themeMode: Value(mode))); + } + + void setRecommendationMarket(Market country) { + setData(PreferencesTableCompanion(market: Value(country))); + } + + void setAccentColorScheme(RhythmColor color) { + setData(PreferencesTableCompanion(accentColorScheme: Value(color))); + } + + void setAlbumColorSync(bool sync) { + setData(PreferencesTableCompanion(albumColorSync: Value(sync))); + + if (!sync) { + Get.find().clear(); + } else { + Get.find().updatePalette(); + } + } + + void setCheckUpdate(bool check) { + setData(PreferencesTableCompanion(checkUpdate: Value(check))); + } + + void setAudioQuality(SourceQualities quality) { + setData(PreferencesTableCompanion(audioQuality: Value(quality))); + } + + void setDownloadLocation(String downloadDir) { + if (downloadDir.isEmpty) return; + setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir))); + } + + void setLocalLibraryLocation(List localLibraryDirs) { + setData(PreferencesTableCompanion( + localLibraryLocation: Value(localLibraryDirs))); + } + + void setLayoutMode(LayoutMode mode) { + setData(PreferencesTableCompanion(layoutMode: Value(mode))); + } + + void setCloseBehavior(CloseBehavior behavior) { + setData(PreferencesTableCompanion(closeBehavior: Value(behavior))); + } + + void setShowSystemTrayIcon(bool show) { + setData(PreferencesTableCompanion(showSystemTrayIcon: Value(show))); + } + + void setLocale(Locale locale) { + setData(PreferencesTableCompanion(locale: Value(locale))); + } + + void setPipedInstance(String instance) { + setData(PreferencesTableCompanion(pipedInstance: Value(instance))); + } + + void setSearchMode(SearchMode mode) { + setData(PreferencesTableCompanion(searchMode: Value(mode))); + } + + void setSkipNonMusic(bool skip) { + setData(PreferencesTableCompanion(skipNonMusic: Value(skip))); + } + + void setAudioSource(AudioSource type) { + setData(PreferencesTableCompanion(audioSource: Value(type))); + } + + void setSystemTitleBar(bool isSystemTitleBar) { + setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar))); + } + + void setDiscordPresence(bool discordPresence) { + setData(PreferencesTableCompanion(discordPresence: Value(discordPresence))); + } + + void setAmoledDarkTheme(bool isAmoled) { + setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled))); + } + + void setNormalizeAudio(bool normalize) { + setData(PreferencesTableCompanion(normalizeAudio: Value(normalize))); + audioPlayer.setAudioNormalization(normalize); + } + + void setEndlessPlayback(bool endless) { + setData(PreferencesTableCompanion(endlessPlayback: Value(endless))); + } + + void setEnableConnect(bool enable) { + setData(PreferencesTableCompanion(enableConnect: Value(enable))); + } +} diff --git a/lib/services/database/database.dart b/lib/services/database/database.dart index 9e2d544..dce8791 100755 --- a/lib/services/database/database.dart +++ b/lib/services/database/database.dart @@ -21,7 +21,6 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; part 'database.g.dart'; part 'tables/authentication.dart'; -part 'tables/blacklist.dart'; part 'tables/preferences.dart'; part 'tables/scrobbler.dart'; part 'tables/skip_segment.dart'; @@ -39,7 +38,6 @@ part 'typeconverters/subtitle.dart'; @DriftDatabase( tables: [ AuthenticationTable, - BlacklistTable, PreferencesTable, ScrobblerTable, SkipSegmentTable, diff --git a/lib/services/database/database.g.dart b/lib/services/database/database.g.dart index 98de6c5..ea94029 100644 --- a/lib/services/database/database.g.dart +++ b/lib/services/database/database.g.dart @@ -281,276 +281,6 @@ class AuthenticationTableCompanion } } -class $BlacklistTableTable extends BlacklistTable - with TableInfo<$BlacklistTableTable, BlacklistTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $BlacklistTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _elementTypeMeta = - const VerificationMeta('elementType'); - @override - late final GeneratedColumnWithTypeConverter - elementType = GeneratedColumn('element_type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $BlacklistTableTable.$converterelementType); - static const VerificationMeta _elementIdMeta = - const VerificationMeta('elementId'); - @override - late final GeneratedColumn elementId = GeneratedColumn( - 'element_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - List get $columns => [id, name, elementType, elementId]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'blacklist_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('name')) { - context.handle( - _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); - } else if (isInserting) { - context.missing(_nameMeta); - } - context.handle(_elementTypeMeta, const VerificationResult.success()); - if (data.containsKey('element_id')) { - context.handle(_elementIdMeta, - elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta)); - } else if (isInserting) { - context.missing(_elementIdMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - BlacklistTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return BlacklistTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name'])!, - elementType: $BlacklistTableTable.$converterelementType.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}element_type'])!), - elementId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!, - ); - } - - @override - $BlacklistTableTable createAlias(String alias) { - return $BlacklistTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 - $converterelementType = - const EnumNameConverter(BlacklistedType.values); -} - -class BlacklistTableData extends DataClass - implements Insertable { - final int id; - final String name; - final BlacklistedType elementType; - final String elementId; - const BlacklistTableData( - {required this.id, - required this.name, - required this.elementType, - required this.elementId}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['name'] = Variable(name); - { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType)); - } - map['element_id'] = Variable(elementId); - return map; - } - - BlacklistTableCompanion toCompanion(bool nullToAbsent) { - return BlacklistTableCompanion( - id: Value(id), - name: Value(name), - elementType: Value(elementType), - elementId: Value(elementId), - ); - } - - factory BlacklistTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return BlacklistTableData( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - elementType: $BlacklistTableTable.$converterelementType - .fromJson(serializer.fromJson(json['elementType'])), - elementId: serializer.fromJson(json['elementId']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'elementType': serializer.toJson( - $BlacklistTableTable.$converterelementType.toJson(elementType)), - 'elementId': serializer.toJson(elementId), - }; - } - - BlacklistTableData copyWith( - {int? id, - String? name, - BlacklistedType? elementType, - String? elementId}) => - BlacklistTableData( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - BlacklistTableData copyWithCompanion(BlacklistTableCompanion data) { - return BlacklistTableData( - id: data.id.present ? data.id.value : this.id, - name: data.name.present ? data.name.value : this.name, - elementType: - data.elementType.present ? data.elementType.value : this.elementType, - elementId: data.elementId.present ? data.elementId.value : this.elementId, - ); - } - - @override - String toString() { - return (StringBuffer('BlacklistTableData(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, name, elementType, elementId); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BlacklistTableData && - other.id == this.id && - other.name == this.name && - other.elementType == this.elementType && - other.elementId == this.elementId); -} - -class BlacklistTableCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value elementType; - final Value elementId; - const BlacklistTableCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.elementType = const Value.absent(), - this.elementId = const Value.absent(), - }); - BlacklistTableCompanion.insert({ - this.id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) : name = Value(name), - elementType = Value(elementType), - elementId = Value(elementId); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? elementType, - Expression? elementId, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (elementType != null) 'element_type': elementType, - if (elementId != null) 'element_id': elementId, - }); - } - - BlacklistTableCompanion copyWith( - {Value? id, - Value? name, - Value? elementType, - Value? elementId}) { - return BlacklistTableCompanion( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (elementType.present) { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType.value)); - } - if (elementId.present) { - map['element_id'] = Variable(elementId.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('BlacklistTableCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } -} - class $PreferencesTableTable extends PreferencesTable with TableInfo<$PreferencesTableTable, PreferencesTableData> { @override @@ -3226,7 +2956,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $AuthenticationTableTable authenticationTable = $AuthenticationTableTable(this); - late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); late final $PreferencesTableTable preferencesTable = $PreferencesTableTable(this); late final $ScrobblerTableTable scrobblerTable = $ScrobblerTableTable(this); @@ -3236,8 +2965,6 @@ abstract class _$AppDatabase extends GeneratedDatabase { $SourceMatchTableTable(this); late final $HistoryTableTable historyTable = $HistoryTableTable(this); late final $LyricsTableTable lyricsTable = $LyricsTableTable(this); - late final Index uniqueBlacklist = Index('unique_blacklist', - 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); late final Index uniqTrackMatch = Index('uniq_track_match', 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); @override @@ -3246,14 +2973,12 @@ abstract class _$AppDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [ authenticationTable, - blacklistTable, preferencesTable, scrobblerTable, skipSegmentTable, sourceMatchTable, historyTable, lyricsTable, - uniqueBlacklist, uniqTrackMatch ]; } @@ -3395,139 +3120,6 @@ typedef $$AuthenticationTableTableProcessedTableManager = ProcessedTableManager< ), AuthenticationTableData, PrefetchHooks Function()>; -typedef $$BlacklistTableTableCreateCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - required String name, - required BlacklistedType elementType, - required String elementId, -}); -typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - Value name, - Value elementType, - Value elementId, -}); - -class $$BlacklistTableTableFilterComposer - extends FilterComposer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnWithTypeConverterFilters - get elementType => $state.composableBuilder( - column: $state.table.elementType, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); - - ColumnFilters get elementId => $state.composableBuilder( - column: $state.table.elementId, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $$BlacklistTableTableOrderingComposer - extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get elementType => $state.composableBuilder( - column: $state.table.elementType, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get elementId => $state.composableBuilder( - column: $state.table.elementId, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -class $$BlacklistTableTableTableManager extends RootTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableCreateCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder, - ( - BlacklistTableData, - BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData> - ), - BlacklistTableData, - PrefetchHooks Function()> { - $$BlacklistTableTableTableManager( - _$AppDatabase db, $BlacklistTableTable table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: - $$BlacklistTableTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $$BlacklistTableTableOrderingComposer(ComposerState(db, table)), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value elementType = const Value.absent(), - Value elementId = const Value.absent(), - }) => - BlacklistTableCompanion( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) => - BlacklistTableCompanion.insert( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - )); -} - -typedef $$BlacklistTableTableProcessedTableManager = ProcessedTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableCreateCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder, - ( - BlacklistTableData, - BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData> - ), - BlacklistTableData, - PrefetchHooks Function()>; typedef $$PreferencesTableTableCreateCompanionBuilder = PreferencesTableCompanion Function({ Value id, @@ -4727,8 +4319,6 @@ class $AppDatabaseManager { $AppDatabaseManager(this._db); $$AuthenticationTableTableTableManager get authenticationTable => $$AuthenticationTableTableTableManager(_db, _db.authenticationTable); - $$BlacklistTableTableTableManager get blacklistTable => - $$BlacklistTableTableTableManager(_db, _db.blacklistTable); $$PreferencesTableTableTableManager get preferencesTable => $$PreferencesTableTableTableManager(_db, _db.preferencesTable); $$ScrobblerTableTableTableManager get scrobblerTable => diff --git a/lib/services/database/tables/blacklist.dart b/lib/services/database/tables/blacklist.dart deleted file mode 100755 index 8a8d9de..0000000 --- a/lib/services/database/tables/blacklist.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of '../database.dart'; - -enum BlacklistedType { - artist, - track; -} - -@TableIndex( - name: "unique_blacklist", - unique: true, - columns: {#elementType, #elementId}, -) -class BlacklistTable extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get name => text()(); - TextColumn get elementType => textEnum()(); - TextColumn get elementId => text()(); -} diff --git a/lib/services/database/tables/preferences.dart b/lib/services/database/tables/preferences.dart index 2b31976..6c5a6df 100755 --- a/lib/services/database/tables/preferences.dart +++ b/lib/services/database/tables/preferences.dart @@ -13,8 +13,7 @@ enum CloseBehavior { enum AudioSource { youtube, - piped, - jiosaavn; + piped; String get label => name[0].toUpperCase() + name.substring(1); } diff --git a/lib/services/database/tables/source_match.dart b/lib/services/database/tables/source_match.dart index 78d0eb0..f31c559 100755 --- a/lib/services/database/tables/source_match.dart +++ b/lib/services/database/tables/source_match.dart @@ -1,9 +1,8 @@ part of '../database.dart'; enum SourceType { - youtube._("YouTube"), - youtubeMusic._("YouTube Music"), - jiosaavn._("JioSaavn"); + youtube._('YouTube'), + youtubeMusic._('YouTube Music'); final String label; @@ -11,7 +10,7 @@ enum SourceType { } @TableIndex( - name: "uniq_track_match", + name: 'uniq_track_match', columns: {#trackId, #sourceId, #sourceType}, unique: true, ) diff --git a/lib/services/database/typeconverters/locale.dart b/lib/services/database/typeconverters/locale.dart index c460088..cf75b2c 100755 --- a/lib/services/database/typeconverters/locale.dart +++ b/lib/services/database/typeconverters/locale.dart @@ -6,14 +6,14 @@ class LocaleConverter extends TypeConverter { @override Locale fromSql(String fromDb) { final rawMap = jsonDecode(fromDb) as Map; - return Locale(rawMap["languageCode"], rawMap["countryCode"]); + return Locale(rawMap['languageCode'], rawMap['countryCode']); } @override String toSql(Locale value) { return jsonEncode({ - "languageCode": value.languageCode, - "countryCode": value.countryCode, + 'languageCode': value.languageCode, + 'countryCode': value.countryCode, }); } } diff --git a/lib/services/database/typeconverters/string_list.dart b/lib/services/database/typeconverters/string_list.dart index 466ae4c..20c5849 100755 --- a/lib/services/database/typeconverters/string_list.dart +++ b/lib/services/database/typeconverters/string_list.dart @@ -5,11 +5,11 @@ class StringListConverter extends TypeConverter, String> { @override List fromSql(String fromDb) { - return fromDb.split(",").where((e) => e.isNotEmpty).toList(); + return fromDb.split(',').where((e) => e.isNotEmpty).toList(); } @override String toSql(List value) { - return value.join(","); + return value.join(','); } } diff --git a/lib/services/sourced_track/models/search.dart b/lib/services/sourced_track/models/search.dart deleted file mode 100644 index 976d583..0000000 --- a/lib/services/sourced_track/models/search.dart +++ /dev/null @@ -1,12 +0,0 @@ -enum SearchMode { - youtube._('YouTube'), - youtubeMusic._('YouTube Music'); - - final String label; - - const SearchMode._(this.label); - - factory SearchMode.fromString(String key) { - return SearchMode.values.firstWhere((e) => e.name == key); - } -} diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart index 09394c0..03cb81e 100755 --- a/lib/services/sourced_track/models/video_info.dart +++ b/lib/services/sourced_track/models/video_info.dart @@ -1,6 +1,5 @@ import 'package:piped_client/piped_client.dart'; -import 'package:rhythm_box/services/sourced_track/models/search.dart'; - +import 'package:rhythm_box/services/database/database.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class YoutubeVideoInfo { diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index ba980dd..004da20 100755 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -1,4 +1,7 @@ import 'package:collection/collection.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/utils.dart'; import 'package:spotify/spotify.dart'; @@ -40,8 +43,8 @@ abstract class SourcedTrack extends Track { } static SourcedTrack fromJson(Map json) { - // TODO Follow user preferences - const audioSource = 'youtube'; + final preferences = Get.find().state.value; + final audioSource = preferences.audioSource; final sourceInfo = SourceInfo.fromJson(json); final source = SourceMap.fromJson(json); @@ -52,7 +55,7 @@ abstract class SourcedTrack extends Track { .cast(); return switch (audioSource) { - 'piped' => PipedSourcedTrack( + AudioSource.piped => PipedSourcedTrack( source: source, siblings: siblings, sourceInfo: sourceInfo, @@ -86,12 +89,13 @@ abstract class SourcedTrack extends Track { static Future fetchFromTrack({ required Track track, }) async { - // TODO Follow user preferences - const audioSource = 'youtube'; + final preferences = Get.find().state.value; + final audioSource = preferences.audioSource; try { return switch (audioSource) { - 'piped' => await PipedSourcedTrack.fetchFromTrack(track: track), + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track), _ => await YoutubeSourcedTrack.fetchFromTrack(track: track), }; } on TrackNotFoundError catch (_) { @@ -110,11 +114,11 @@ abstract class SourcedTrack extends Track { static Future> fetchSiblings({ required Track track, }) { - // TODO Follow user preferences - const audioSource = 'youtube'; + final preferences = Get.find().state.value; + final audioSource = preferences.audioSource; return switch (audioSource) { - 'piped' => PipedSourcedTrack.fetchSiblings(track: track), + AudioSource.piped => PipedSourcedTrack.fetchSiblings(track: track), _ => YoutubeSourcedTrack.fetchSiblings(track: track), }; } @@ -128,15 +132,15 @@ abstract class SourcedTrack extends Track { } String get url { - // TODO Follow user preferences - const streamMusicCodec = SourceCodecs.weba; + final preferences = Get.find().state.value; + final streamMusicCodec = preferences.streamMusicCodec; return getUrlOfCodec(streamMusicCodec); } String getUrlOfCodec(SourceCodecs codec) { - // TODO Follow user preferences - const audioQuality = SourceQualities.high; + final preferences = Get.find().state.value; + final audioQuality = preferences.audioQuality; return source[codec]?[audioQuality] ?? // this will ensure playback doesn't break @@ -145,8 +149,8 @@ abstract class SourcedTrack extends Track { } SourceCodecs get codec { - // TODO Follow user preferences - const streamMusicCodec = SourceCodecs.weba; + final preferences = Get.find().state.value; + final streamMusicCodec = preferences.streamMusicCodec; return streamMusicCodec; } diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 1814568..58bc2aa 100755 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -1,6 +1,10 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart' hide Value; import 'package:piped_client/piped_client.dart'; -import 'package:rhythm_box/services/sourced_track/models/search.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/user_preferences.dart'; +import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/utils.dart'; import 'package:spotify/spotify.dart'; @@ -41,21 +45,62 @@ class PipedSourcedTrack extends SourcedTrack { static Future fetchFromTrack({ required Track track, }) async { - // TODO Add cache query here + final DatabaseProvider db = Get.find(); + final cachedSource = await (db.database.select(db.database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .getSingleOrNull(); - final siblings = await fetchSiblings(track: track); - if (siblings.isEmpty) { - throw TrackNotFoundError(track); + final preferences = Get.find().state.value; + + if (cachedSource == null) { + final siblings = await fetchSiblings(track: track); + if (siblings.isEmpty) { + throw TrackNotFoundError(track); + } + + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: Value( + preferences.searchMode == SearchMode.youtube + ? SourceType.youtube + : SourceType.youtubeMusic, + ), + ), + ); + + return PipedSourcedTrack( + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } else { + final client = _getClient(); + final manifest = await client.streams(cachedSource.sourceId); + + return PipedSourcedTrack( + siblings: [], + source: toSourceMap(manifest), + sourceInfo: PipedSourceInfo( + id: manifest.id, + artist: manifest.uploader, + artistUrl: manifest.uploaderUrl, + pageUrl: 'https://www.youtube.com/watch?v=${manifest.id}', + thumbnail: manifest.thumbnailUrl, + title: manifest.title, + duration: manifest.duration, + album: null, + ), + track: track, + ); } - - // TODO Insert to cache here - - return PipedSourcedTrack( - siblings: siblings.map((s) => s.info).skip(1).toList(), - source: siblings.first.source as SourceMap, - sourceInfo: siblings.first.info, - track: track, - ); } static SourceMap toSourceMap(PipedStreamResponse manifest) { @@ -114,11 +159,10 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, }) async { final pipedClient = _getClient(); + final preferences = Get.find().state.value; - // TODO Allow user search with normal youtube video (`youtube`) - const searchMode = SearchMode.youtubeMusic; - // TODO Follow user preferences - const audioSource = 'youtube'; + final searchMode = preferences.searchMode; + final audioSource = preferences.audioSource; final query = SourcedTrack.getSearchTerm(track); @@ -130,8 +174,9 @@ class PipedSourcedTrack extends SourcedTrack { ); // when falling back to piped API make sure to use the YouTube mode - const isYouTubeMusic = - audioSource != 'piped' ? false : searchMode == SearchMode.youtubeMusic; + final isYouTubeMusic = audioSource != AudioSource.piped + ? false + : searchMode == SearchMode.youtubeMusic; if (isYouTubeMusic) { final artists = (track.artists ?? []) @@ -227,7 +272,18 @@ class PipedSourcedTrack extends SourcedTrack { final manifest = await pipedClient.streams(newSourceInfo.id); - // TODO Save to cache here + final DatabaseProvider db = Get.find(); + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); return PipedSourcedTrack( siblings: newSiblings, diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 3d42b35..1310092 100755 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,7 +1,11 @@ import 'dart:developer'; import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart' hide Value; import 'package:http/http.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/utils.dart'; import 'package:spotify/spotify.dart'; import 'package:rhythm_box/services/song_link/song_link.dart'; @@ -43,19 +47,61 @@ class YoutubeSourcedTrack extends SourcedTrack { static Future fetchFromTrack({ required Track track, }) async { - // TODO Add cache query here + final DatabaseProvider db = Get.find(); + final cachedSource = await (db.database.select(db.database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .get() + .then((s) => s.firstOrNull); - final siblings = await fetchSiblings(track: track); - if (siblings.isEmpty) { - throw TrackNotFoundError(track); + if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { + final siblings = await fetchSiblings(track: track); + if (siblings.isEmpty) { + throw TrackNotFoundError(track); + } + + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.youtube), + ), + ); + + return YoutubeSourcedTrack( + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); } - // TODO Save to cache here - + final item = await youtubeClient.videos.get(cachedSource.sourceId); + final manifest = await youtubeClient.videos.streamsClient + .getManifest( + cachedSource.sourceId, + ) + .timeout( + const Duration(seconds: 5), + onTimeout: () => throw ClientException('Timeout'), + ); return YoutubeSourcedTrack( - siblings: siblings.map((s) => s.info).skip(1).toList(), - source: siblings.first.source as SourceMap, - sourceInfo: siblings.first.info, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: YoutubeSourceInfo( + id: item.id.value, + artist: item.author, + artistUrl: 'https://www.youtube.com/channel/${item.channelId}', + pageUrl: item.url, + thumbnail: item.thumbnails.highResUrl, + title: item.title, + duration: item.duration ?? Duration.zero, + album: null, + ), track: track, ); } @@ -243,7 +289,19 @@ class YoutubeSourcedTrack extends SourcedTrack { onTimeout: () => throw ClientException('Timeout'), ); - // TODO Save to cache here + final DatabaseProvider db = Get.find(); + + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); return YoutubeSourcedTrack( siblings: newSiblings, diff --git a/lib/widgets/auto_cache_image.dart b/lib/widgets/auto_cache_image.dart index fee892d..e637fd2 100644 --- a/lib/widgets/auto_cache_image.dart +++ b/lib/widgets/auto_cache_image.dart @@ -23,4 +23,11 @@ class AutoCacheImage extends StatelessWidget { height: height, ); } + + static ImageProvider provider(String url) { + if (PlatformInfo.canCacheImage) { + return CachedNetworkImageProvider(url); + } + return NetworkImage(url); + } } diff --git a/pubspec.lock b/pubspec.lock index cb8970a..ec9b2d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -837,6 +837,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: d50fbcd69abb80c5baec66d700033b1a320108b1aa17a5961866a12c0abb7c0c + url: "https://pub.dev" + source: hosted + version: "0.3.3+4" path: dependency: "direct main" description: @@ -997,6 +1005,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.9" + scrobblenaut: + dependency: "direct main" + description: + path: "." + ref: dart-3-support + resolved-ref: d90cb75d71737f3cfa2de4469d48080c0f3eedc2 + url: "https://github.com/KRTirtho/scrobblenaut.git" + source: git + version: "3.0.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 95faa59..726c718 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,11 @@ dependencies: path_provider: ^2.1.4 sqlite3: ^2.4.6 sqlite3_flutter_libs: ^0.5.24 + palette_generator: ^0.3.3+4 + scrobblenaut: + git: + url: https://github.com/KRTirtho/scrobblenaut.git + ref: dart-3-support dev_dependencies: flutter_test: