✨ Kugou music source
This commit is contained in:
		| @@ -14,7 +14,8 @@ enum CloseBehavior { | |||||||
| enum AudioSource { | enum AudioSource { | ||||||
|   youtube, |   youtube, | ||||||
|   piped, |   piped, | ||||||
|   netease; |   netease, | ||||||
|  |   kugou; | ||||||
|  |  | ||||||
|   String get label => name[0].toUpperCase() + name.substring(1); |   String get label => name[0].toUpperCase() + name.substring(1); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,7 +3,8 @@ part of '../database.dart'; | |||||||
| enum SourceType { | enum SourceType { | ||||||
|   youtube._('YouTube'), |   youtube._('YouTube'), | ||||||
|   youtubeMusic._('YouTube Music'), |   youtubeMusic._('YouTube Music'), | ||||||
|   netease._('Netease Music'); |   netease._('Netease Music'), | ||||||
|  |   kugou._('Kugou Music'); | ||||||
|  |  | ||||||
|   final String label; |   final String label; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:dio/dio.dart' hide Response; | import 'package:dio/dio.dart' hide Response; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:get/get.dart' hide Response; | 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/audio_player/audio_player.dart'; | ||||||
| import 'package:rhythm_box/services/server/active_sourced_track.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/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:rhythm_box/services/sourced_track/sources/netease.dart'; | ||||||
| import 'package:shelf/shelf.dart'; | import 'package:shelf/shelf.dart'; | ||||||
|  |  | ||||||
| @@ -34,6 +37,13 @@ class ServerPlaybackRoutesProvider { | |||||||
|         ); |         ); | ||||||
|         final realUrl = resp.body['data'][0]['url']; |         final realUrl = resp.body['data'][0]['url']; | ||||||
|         url = realUrl; |         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( |       final res = await Dio().get( | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import 'package:get/get.dart'; | |||||||
| import 'package:rhythm_box/providers/error_notifier.dart'; | import 'package:rhythm_box/providers/error_notifier.dart'; | ||||||
| import 'package:rhythm_box/providers/user_preferences.dart'; | import 'package:rhythm_box/providers/user_preferences.dart'; | ||||||
| import 'package:rhythm_box/services/database/database.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/sourced_track/sources/netease.dart'; | ||||||
| import 'package:rhythm_box/services/utils.dart'; | import 'package:rhythm_box/services/utils.dart'; | ||||||
| import 'package:spotify/spotify.dart'; | import 'package:spotify/spotify.dart'; | ||||||
| @@ -104,6 +105,8 @@ abstract class SourcedTrack extends Track { | |||||||
|       return switch (audioSource) { |       return switch (audioSource) { | ||||||
|         AudioSource.netease => |         AudioSource.netease => | ||||||
|           await NeteaseSourcedTrack.fetchFromTrack(track: track), |           await NeteaseSourcedTrack.fetchFromTrack(track: track), | ||||||
|  |         AudioSource.kugou => | ||||||
|  |           await KugouSourcedTrack.fetchFromTrack(track: track), | ||||||
|         AudioSource.piped => |         AudioSource.piped => | ||||||
|           await PipedSourcedTrack.fetchFromTrack(track: track), |           await PipedSourcedTrack.fetchFromTrack(track: track), | ||||||
|         _ => await YoutubeSourcedTrack.fetchFromTrack(track: track), |         _ => await YoutubeSourcedTrack.fetchFromTrack(track: track), | ||||||
| @@ -117,6 +120,8 @@ abstract class SourcedTrack extends Track { | |||||||
|         AudioSource.youtube => |         AudioSource.youtube => | ||||||
|           await NeteaseSourcedTrack.fetchFromTrack(track: track), |           await NeteaseSourcedTrack.fetchFromTrack(track: track), | ||||||
|         AudioSource.netease => |         AudioSource.netease => | ||||||
|  |           await KugouSourcedTrack.fetchFromTrack(track: track), | ||||||
|  |         AudioSource.kugou => | ||||||
|           await YoutubeSourcedTrack.fetchFromTrack(track: track), |           await YoutubeSourcedTrack.fetchFromTrack(track: track), | ||||||
|       }; |       }; | ||||||
|     } on HttpClientClosedException catch (_) { |     } on HttpClientClosedException catch (_) { | ||||||
|   | |||||||
							
								
								
									
										229
									
								
								lib/services/sourced_track/sources/kugou.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								lib/services/sourced_track/sources/kugou.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<KugouSourcedTrack> 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<List<SiblingType>> 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<SiblingType>(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<KugouSourcedTrack> 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<KugouSourcedTrack?> 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<DatabaseProvider>(); | ||||||
|  |     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; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -171,7 +171,6 @@ class NeteaseSourcedTrack extends SourcedTrack { | |||||||
|       '/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}', |       '/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}', | ||||||
|     ); |     ); | ||||||
|     if (resp.body?['code'] == 405) throw TrackNotFoundError(track); |     if (resp.body?['code'] == 405) throw TrackNotFoundError(track); | ||||||
|     print(resp.body); |  | ||||||
|     final results = resp.body['result']['songs']; |     final results = resp.body['result']['songs']; | ||||||
|  |  | ||||||
|     // We can just trust netease music for now |     // We can just trust netease music for now | ||||||
|   | |||||||
| @@ -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/source_info.dart'; | ||||||
| import 'package:rhythm_box/services/sourced_track/models/video_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/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/netease.dart'; | ||||||
| import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; | import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; | ||||||
| import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; | import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; | ||||||
| @@ -45,6 +46,7 @@ class _SiblingTracksState extends State<SiblingTracks> { | |||||||
|     YoutubeSourceInfo: 'YouTube', |     YoutubeSourceInfo: 'YouTube', | ||||||
|     PipedSourceInfo: 'Piped', |     PipedSourceInfo: 'Piped', | ||||||
|     NeteaseSourceInfo: 'Netease', |     NeteaseSourceInfo: 'Netease', | ||||||
|  |     KugouSourceInfo: 'Kugou', | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   List<StreamSubscription>? _subscriptions; |   List<StreamSubscription>? _subscriptions; | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:rhythm_box/services/duration.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/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/netease.dart'; | ||||||
| import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; | import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; | ||||||
| import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; | import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; | ||||||
| @@ -14,6 +15,7 @@ class TrackSourceDetails extends StatelessWidget { | |||||||
|     YoutubeSourceInfo: 'YouTube', |     YoutubeSourceInfo: 'YouTube', | ||||||
|     PipedSourceInfo: 'Piped', |     PipedSourceInfo: 'Piped', | ||||||
|     NeteaseSourceInfo: 'Netease', |     NeteaseSourceInfo: 'Netease', | ||||||
|  |     KugouSourceInfo: 'Kugou', | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user