diff --git a/lib/entities/CacheTrack.dart b/lib/entities/CacheTrack.dart new file mode 100644 index 00000000..2a3a93ca --- /dev/null +++ b/lib/entities/CacheTrack.dart @@ -0,0 +1,73 @@ +import 'package:hive/hive.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +part 'CacheTrack.g.dart'; + +@HiveType(typeId: 2) +class CacheTrackEngagement { + @HiveField(0) + late int viewCount; + + @HiveField(1) + late int? likeCount; + + @HiveField(2) + late int? dislikeCount; + + CacheTrackEngagement(); + + CacheTrackEngagement.fromEngagement(Engagement engagement) + : viewCount = engagement.viewCount, + likeCount = engagement.likeCount, + dislikeCount = engagement.dislikeCount; +} + +@HiveType(typeId: 1) +class CacheTrack extends HiveObject { + @HiveField(0) + late String id; + + @HiveField(1) + late String title; + + @HiveField(2) + late String channelId; + + @HiveField(3) + late String? uploadDate; + + @HiveField(4) + late String? publishDate; + + @HiveField(5) + late String description; + + @HiveField(6) + late String? duration; + + @HiveField(7) + late List? keywords; + + @HiveField(8) + late CacheTrackEngagement engagement; + + @HiveField(9) + late String mode; + + @HiveField(10) + late String author; + + CacheTrack(); + + CacheTrack.fromVideo(Video video, this.mode) + : id = video.id.value, + title = video.title, + author = video.author, + channelId = video.channelId.value, + uploadDate = video.uploadDate.toString(), + publishDate = video.publishDate.toString(), + description = video.description, + duration = video.duration.toString(), + keywords = video.keywords, + engagement = CacheTrackEngagement.fromEngagement(video.engagement); +} diff --git a/lib/entities/CacheTrack.g.dart b/lib/entities/CacheTrack.g.dart new file mode 100644 index 00000000..e067fd79 --- /dev/null +++ b/lib/entities/CacheTrack.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'CacheTrack.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class CacheTrackEngagementAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + CacheTrackEngagement read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CacheTrackEngagement() + ..viewCount = fields[0] as int + ..likeCount = fields[1] as int? + ..dislikeCount = fields[2] as int?; + } + + @override + void write(BinaryWriter writer, CacheTrackEngagement obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.viewCount) + ..writeByte(1) + ..write(obj.likeCount) + ..writeByte(2) + ..write(obj.dislikeCount); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CacheTrackEngagementAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class CacheTrackAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + CacheTrack read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CacheTrack() + ..id = fields[0] as String + ..title = fields[1] as String + ..channelId = fields[2] as String + ..uploadDate = fields[3] as String? + ..publishDate = fields[4] as String? + ..description = fields[5] as String + ..duration = fields[6] as String? + ..keywords = (fields[7] as List?)?.cast() + ..engagement = fields[8] as CacheTrackEngagement + ..mode = fields[9] as String + ..author = fields[10] as String; + } + + @override + void write(BinaryWriter writer, CacheTrack obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.title) + ..writeByte(2) + ..write(obj.channelId) + ..writeByte(3) + ..write(obj.uploadDate) + ..writeByte(4) + ..write(obj.publishDate) + ..writeByte(5) + ..write(obj.description) + ..writeByte(6) + ..write(obj.duration) + ..writeByte(7) + ..write(obj.keywords) + ..writeByte(8) + ..write(obj.engagement) + ..writeByte(9) + ..write(obj.mode) + ..writeByte(10) + ..write(obj.author); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CacheTrackAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/extensions/yt-video-from-cache-track.dart b/lib/extensions/yt-video-from-cache-track.dart new file mode 100644 index 00000000..3aed8b5b --- /dev/null +++ b/lib/extensions/yt-video-from-cache-track.dart @@ -0,0 +1,32 @@ +import 'package:spotube/entities/CacheTrack.dart'; +import 'package:spotube/utils/duration.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +extension VideoFromCacheTrackExtension on Video { + static Video fromCacheTrack(CacheTrack cacheTrack) { + return Video( + VideoId.fromString(cacheTrack.id), + cacheTrack.title, + cacheTrack.author, + ChannelId.fromString(cacheTrack.channelId), + cacheTrack.uploadDate != null + ? DateTime.tryParse(cacheTrack.uploadDate!) + : null, + cacheTrack.publishDate != null + ? DateTime.tryParse(cacheTrack.publishDate!) + : null, + cacheTrack.description, + cacheTrack.duration != null + ? tryParseDuration(cacheTrack.duration!) + : null, + ThumbnailSet(cacheTrack.id), + cacheTrack.keywords, + Engagement( + cacheTrack.engagement.viewCount, + cacheTrack.engagement.likeCount, + cacheTrack.engagement.dislikeCount, + ), + false, + ); + } +} diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index 9e1975b9..18f680c8 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:hive/hive.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/helpers/contains-text-in-bracket.dart'; import 'package:spotube/helpers/getLyrics.dart'; import 'package:spotube/models/Logger.dart'; @@ -7,6 +9,7 @@ import 'package:spotube/models/SpotubeTrack.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:collection/collection.dart'; import 'package:spotube/extensions/list-sort-multiple.dart'; +import 'package:spotube/extensions/yt-video-from-cache-track.dart'; enum AudioQuality { high, @@ -20,6 +23,7 @@ Future toSpotubeTrack({ required String format, required SpotubeTrackMatchAlgorithm matchAlgorithm, required AudioQuality audioQuality, + LazyBox? box, }) async { final artistsName = track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ?? @@ -40,53 +44,61 @@ Future toSpotubeTrack({ .replaceAll("\$FEATURED_ARTISTS", featuredArtists); logger.v("[Youtube Search Term] $queryString"); - VideoSearchList videos = await youtube.search.search(queryString); Video ytVideo; - - if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { - List ratedRankedVideos = videos - .map((video) { - // the find should be lazy thus everything case insensitive - final ytTitle = video.title.toLowerCase(); - final bool hasTitle = ytTitle.contains(title); - final bool hasAllArtists = track.artists?.every( - (artist) => ytTitle.contains(artist.name!.toLowerCase()), - ) ?? - false; - final bool authorIsArtist = - track.artists?.first.name?.toLowerCase() == - video.author.toLowerCase(); - - final bool hasNoLiveInTitle = !containsTextInBracket(ytTitle, "live"); - - int rate = 0; - for (final el in [ - hasTitle, - hasAllArtists, - if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular) - authorIsArtist, - hasNoLiveInTitle, - !video.isLive, - ]) { - if (el) rate++; - } - // can't let pass any non title matching track - if (!hasTitle) rate = rate - 2; - return { - "video": video, - "points": rate, - "views": video.engagement.viewCount, - }; - }) - .toList() - .sortByProperties( - [false, false], - ["points", "views"], - ); - - ytVideo = ratedRankedVideos.first["video"] as Video; + final cachedTrack = await box?.get(track.id); + if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) { + logger.v( + "[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}", + ); + ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack); } else { - ytVideo = videos.where((video) => !video.isLive).first; + VideoSearchList videos = await youtube.search.search(queryString); + if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) { + List ratedRankedVideos = videos + .map((video) { + // the find should be lazy thus everything case insensitive + final ytTitle = video.title.toLowerCase(); + final bool hasTitle = ytTitle.contains(title); + final bool hasAllArtists = track.artists?.every( + (artist) => ytTitle.contains(artist.name!.toLowerCase()), + ) ?? + false; + final bool authorIsArtist = + track.artists?.first.name?.toLowerCase() == + video.author.toLowerCase(); + + final bool hasNoLiveInTitle = + !containsTextInBracket(ytTitle, "live"); + + int rate = 0; + for (final el in [ + hasTitle, + hasAllArtists, + if (matchAlgorithm == SpotubeTrackMatchAlgorithm.authenticPopular) + authorIsArtist, + hasNoLiveInTitle, + !video.isLive, + ]) { + if (el) rate++; + } + // can't let pass any non title matching track + if (!hasTitle) rate = rate - 2; + return { + "video": video, + "points": rate, + "views": video.engagement.viewCount, + }; + }) + .toList() + .sortByProperties( + [false, false], + ["points", "views"], + ); + + ytVideo = ratedRankedVideos.first["video"] as Video; + } else { + ytVideo = videos.where((video) => !video.isLive).first; + } } final trackManifest = await youtube.videos.streams.getManifest(ytVideo.id); @@ -100,16 +112,25 @@ Future toSpotubeTrack({ .where((info) => info.codec.mimeType == "audio/mp4") : trackManifest.audioOnly; + final ytUri = (audioQuality == AudioQuality.high + ? audioManifest.withHighestBitrate() + : audioManifest.sortByBitrate().last) + .url + .toString(); + + // only save when the track isn't available in the cache with same + // matchAlgorithm + if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) { + await box?.put( + track.id!, CacheTrack.fromVideo(ytVideo, matchAlgorithm.name)); + } + return SpotubeTrack.fromTrack( track: track, ytTrack: ytVideo, // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg' // codec/mimetype for those Platforms - ytUri: (audioQuality == AudioQuality.high - ? audioManifest.withHighestBitrate() - : audioManifest.sortByBitrate().last) - .url - .toString(), + ytUri: ytUri, ); } diff --git a/lib/main.dart b/lib/main.dart index 4e6291cc..97dd971e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,8 +5,10 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/AudioPlayer.dart'; @@ -19,6 +21,9 @@ import 'package:spotube/utils/AudioPlayerHandler.dart'; import 'package:spotube/utils/platform.dart'; void main() async { + await Hive.initFlutter(); + Hive.registerAdapter(CacheTrackAdapter()); + Hive.registerAdapter(CacheTrackEngagementAdapter()); AudioPlayerHandler audioPlayerHandler = await AudioService.init( builder: () => AudioPlayerHandler(), config: const AudioServiceConfig( diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index 2abcf985..2475588c 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -3,9 +3,10 @@ import 'dart:convert'; import 'package:audio_service/audio_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive/hive.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/search-youtube.dart'; @@ -33,6 +34,9 @@ class Playback extends PersistedChangeNotifier { AudioPlayerHandler player; YoutubeExplode youtube; Ref ref; + + LazyBox? cacheTrackBox; + Playback({ required this.player, required this.youtube, @@ -57,6 +61,8 @@ class Playback extends PersistedChangeNotifier { StreamSubscription? _playingStream; void _init() async { + cacheTrackBox = await Hive.openLazyBox("track-cache"); + _playingStream = player.core.playingStream.listen( (playing) { _isPlaying = playing; @@ -111,6 +117,7 @@ class Playback extends PersistedChangeNotifier { _positionStream?.cancel(); _playingStream?.cancel(); _durationStream?.cancel(); + cacheTrackBox?.close(); super.dispose(); } @@ -213,7 +220,6 @@ class Playback extends PersistedChangeNotifier { notifyListeners(); updatePersistence(); }); - // await player.play(); return; } final preferences = ref.read(userPreferencesProvider); @@ -223,6 +229,7 @@ class Playback extends PersistedChangeNotifier { format: preferences.ytSearchFormat, matchAlgorithm: preferences.trackMatchAlgorithm, audioQuality: preferences.audioQuality, + box: cacheTrackBox, ); if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); @@ -237,7 +244,6 @@ class Playback extends PersistedChangeNotifier { notifyListeners(); updatePersistence(); }); - // await player.play(); } } } catch (e, stack) { diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart new file mode 100644 index 00000000..caa92dbe --- /dev/null +++ b/lib/utils/duration.dart @@ -0,0 +1,55 @@ +/// Parses duration string formatted by Duration.toString() to [Duration]. +/// The string should be of form hours:minutes:seconds.microseconds +/// +/// Example: +/// parseTime('245:09:08.007006'); +Duration parseDuration(String input) { + final parts = input.split(':'); + + if (parts.length != 3) throw FormatException('Invalid time format'); + + int days; + int hours; + int minutes; + int seconds; + int milliseconds; + int microseconds; + + { + final p = parts[2].split('.'); + + if (p.length != 2) throw FormatException('Invalid time format'); + + final p2 = int.parse(p[1]); + microseconds = p2 % 1000; + milliseconds = p2 ~/ 1000; + + seconds = int.parse(p[0]); + } + + minutes = int.parse(parts[1]); + + { + int p = int.parse(parts[0]); + hours = p % 24; + days = p ~/ 24; + } + + // TODO verify that there are no negative parts + + return Duration( + days: days, + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + microseconds: microseconds); +} + +Duration? tryParseDuration(String input) { + try { + return parseDuration(input); + } catch (_) { + return null; + } +}