diff --git a/lib/services/database/tables/preferences.dart b/lib/services/database/tables/preferences.dart index 28df2cc..5e9e3be 100755 --- a/lib/services/database/tables/preferences.dart +++ b/lib/services/database/tables/preferences.dart @@ -14,7 +14,8 @@ enum CloseBehavior { enum AudioSource { youtube, piped, - netease; + netease, + kugou; 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 60eaad7..369e86c 100755 --- a/lib/services/database/tables/source_match.dart +++ b/lib/services/database/tables/source_match.dart @@ -3,7 +3,8 @@ part of '../database.dart'; enum SourceType { youtube._('YouTube'), youtubeMusic._('YouTube Music'), - netease._('Netease Music'); + netease._('Netease Music'), + kugou._('Kugou Music'); final String label; diff --git a/lib/services/server/routes/playback.dart b/lib/services/server/routes/playback.dart index cb18459..7511053 100755 --- a/lib/services/server/routes/playback.dart +++ b/lib/services/server/routes/playback.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart' hide Response; import 'package:flutter/foundation.dart'; import 'package:get/get.dart' hide Response; @@ -6,6 +8,7 @@ import 'package:rhythm_box/providers/error_notifier.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/server/active_sourced_track.dart'; import 'package:rhythm_box/services/server/sourced_track.dart'; +import 'package:rhythm_box/services/sourced_track/sources/kugou.dart'; import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:shelf/shelf.dart'; @@ -34,6 +37,13 @@ class ServerPlaybackRoutesProvider { ); final realUrl = resp.body['data'][0]['url']; url = realUrl; + } else if (sourcedTrack is KugouSourcedTrack) { + // Special processing for kugou to get real assets url + final resp = await GetConnect(timeout: const Duration(seconds: 30)) + .get(sourcedTrack.url); + final realUrl = + KugouSourcedTrack.unescapeUrl(jsonDecode(resp.body)['url'][0]); + url = realUrl; } final res = await Dio().get( diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index cc6cec0..3d66ca2 100755 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:rhythm_box/providers/error_notifier.dart'; import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/services/sourced_track/sources/kugou.dart'; import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:rhythm_box/services/utils.dart'; import 'package:spotify/spotify.dart'; @@ -104,6 +105,8 @@ abstract class SourcedTrack extends Track { return switch (audioSource) { AudioSource.netease => await NeteaseSourcedTrack.fetchFromTrack(track: track), + AudioSource.kugou => + await KugouSourcedTrack.fetchFromTrack(track: track), AudioSource.piped => await PipedSourcedTrack.fetchFromTrack(track: track), _ => await YoutubeSourcedTrack.fetchFromTrack(track: track), @@ -117,6 +120,8 @@ abstract class SourcedTrack extends Track { AudioSource.youtube => await NeteaseSourcedTrack.fetchFromTrack(track: track), AudioSource.netease => + await KugouSourcedTrack.fetchFromTrack(track: track), + AudioSource.kugou => await YoutubeSourcedTrack.fetchFromTrack(track: track), }; } on HttpClientClosedException catch (_) { diff --git a/lib/services/sourced_track/sources/kugou.dart b/lib/services/sourced_track/sources/kugou.dart new file mode 100644 index 0000000..aa81f00 --- /dev/null +++ b/lib/services/sourced_track/sources/kugou.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:crypto/crypto.dart'; +import 'package:get/get.dart' hide Value; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:spotify/spotify.dart'; +import 'package:rhythm_box/services/sourced_track/enums.dart'; +import 'package:rhythm_box/services/sourced_track/exceptions.dart'; +import 'package:rhythm_box/services/sourced_track/models/source_info.dart'; +import 'package:rhythm_box/services/sourced_track/models/source_map.dart'; +import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; + +class KugouSourceInfo extends SourceInfo { + KugouSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class KugouSourcedTrack extends SourcedTrack { + KugouSourcedTrack({ + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static String unescapeUrl(String src) { + return src.replaceAll('\\/', '/'); + } + + static String getBaseUrl() { + return 'http://mobilecdn.kugou.com'; + } + + static GetConnect getClient() { + final client = GetConnect( + withCredentials: true, + timeout: const Duration(seconds: 30), + ); + client.baseUrl = getBaseUrl(); + return client; + } + + static Future fetchFromTrack({ + required Track track, + }) async { + 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); + + if (cachedSource == null || cachedSource.sourceType != SourceType.kugou) { + 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.kugou), + ), + ); + + return KugouSourcedTrack( + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } + + return KugouSourcedTrack( + siblings: [], + source: toSourceMap(cachedSource), + sourceInfo: KugouSourceInfo( + id: cachedSource.sourceId, + artist: 'unknown', + artistUrl: '#', + pageUrl: '#', + thumbnail: '#', + title: 'unknown', + duration: Duration.zero, + album: 'unknown', + ), + track: track, + ); + } + + static SourceMap toSourceMap(dynamic manifest) { + const baseUrl = 'http://trackercdn.kugou.com/i/v2'; + + final hash = manifest is SourceMatchTableData + ? manifest.sourceId + : manifest?['hash']; + final key = md5.convert(utf8.encode('${hash}kgcloudv2')).toString(); + final url = + '$baseUrl/song/url?key=$key&hash=$hash&appid=1005&pid=2&cmd=25&behavior=play'; + + return SourceMap( + m4a: SourceQualityMap( + high: url, + medium: url, + low: url, + ), + weba: SourceQualityMap( + high: url, + medium: url, + low: url, + ), + ); + } + + static Future> fetchSiblings({ + required Track track, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final client = getClient(); + final resp = await client.get( + '/api/v3/search/song?keyword=${Uri.encodeComponent(query)}&page=1&pagesize=10', + ); + final results = jsonDecode(resp.body)['data']['info']; + + // We can just trust kugou music for now + // If we need to check is the result correct, refer to this code + // https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129 + final matchedResults = results.map(toSiblingType).toList(); + + return matchedResults.cast(); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(track: this); + + return KugouSourcedTrack( + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final info = newSourceInfo as KugouSourceInfo; + final source = toSourceMap(newSourceInfo.id); + + final db = Get.find(); + await db.database.into(db.database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: info.id, + sourceType: const Value(SourceType.kugou), + // 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 KugouSourcedTrack( + siblings: newSiblings, + source: source, + sourceInfo: info, + track: this, + ); + } + + static KugouSourceInfo toSourceInfo(dynamic item) { + return KugouSourceInfo( + id: item['hash'], + artist: item['singername'], + artistUrl: '#', + pageUrl: '#', + thumbnail: unescapeUrl(item['trans_param']['union_cover']) + .replaceFirst('/{size}', ''), + title: item['songname'], + duration: Duration(seconds: item['duration']), + album: item['album_name'], + ); + } + + static SiblingType toSiblingType(dynamic item) { + final SiblingType sibling = ( + info: toSourceInfo(item), + source: toSourceMap(item), + ); + + return sibling; + } +} diff --git a/lib/services/sourced_track/sources/netease.dart b/lib/services/sourced_track/sources/netease.dart index c8f7934..3d69ca2 100755 --- a/lib/services/sourced_track/sources/netease.dart +++ b/lib/services/sourced_track/sources/netease.dart @@ -171,7 +171,6 @@ class NeteaseSourcedTrack extends SourcedTrack { '/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}', ); if (resp.body?['code'] == 405) throw TrackNotFoundError(track); - print(resp.body); final results = resp.body['result']['songs']; // We can just trust netease music for now diff --git a/lib/widgets/player/sibling_tracks.dart b/lib/widgets/player/sibling_tracks.dart index 5ed9ebc..8ce03d1 100644 --- a/lib/widgets/player/sibling_tracks.dart +++ b/lib/widgets/player/sibling_tracks.dart @@ -13,6 +13,7 @@ import 'package:rhythm_box/services/server/active_sourced_track.dart'; import 'package:rhythm_box/services/sourced_track/models/source_info.dart'; import 'package:rhythm_box/services/sourced_track/models/video_info.dart'; import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; +import 'package:rhythm_box/services/sourced_track/sources/kugou.dart'; import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; @@ -45,6 +46,7 @@ class _SiblingTracksState extends State { YoutubeSourceInfo: 'YouTube', PipedSourceInfo: 'Piped', NeteaseSourceInfo: 'Netease', + KugouSourceInfo: 'Kugou', }; List? _subscriptions; diff --git a/lib/widgets/player/track_source_details.dart b/lib/widgets/player/track_source_details.dart index f0d71bd..0ba5ba1 100644 --- a/lib/widgets/player/track_source_details.dart +++ b/lib/widgets/player/track_source_details.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:rhythm_box/services/duration.dart'; import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; +import 'package:rhythm_box/services/sourced_track/sources/kugou.dart'; import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; @@ -14,6 +15,7 @@ class TrackSourceDetails extends StatelessWidget { YoutubeSourceInfo: 'YouTube', PipedSourceInfo: 'Piped', NeteaseSourceInfo: 'Netease', + KugouSourceInfo: 'Kugou', }; @override