✨ Netease backend support
This commit is contained in:
@ -2,6 +2,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/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/services/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@ -55,6 +56,12 @@ abstract class SourcedTrack extends Track {
|
||||
.cast<SourceInfo>();
|
||||
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease => NeteaseSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
AudioSource.piped => PipedSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
@ -94,14 +101,20 @@ abstract class SourcedTrack extends Track {
|
||||
|
||||
try {
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease =>
|
||||
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.piped =>
|
||||
await PipedSourcedTrack.fetchFromTrack(track: track),
|
||||
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||
};
|
||||
} on TrackNotFoundError catch (_) {
|
||||
// TODO Try to look it up in other source
|
||||
// But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary
|
||||
rethrow;
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.piped ||
|
||||
AudioSource.youtube =>
|
||||
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.netease =>
|
||||
await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||
};
|
||||
} on HttpClientClosedException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||
} on VideoUnplayableException catch (_) {
|
||||
|
235
lib/services/sourced_track/sources/netease.dart
Executable file
235
lib/services/sourced_track/sources/netease.dart
Executable file
@ -0,0 +1,235 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
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: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 NeteaseSourceInfo extends SourceInfo {
|
||||
NeteaseSourceInfo({
|
||||
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 NeteaseSourcedTrack extends SourcedTrack {
|
||||
NeteaseSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static String _getBaseUrl() {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
return preferences.neteaseApiInstance;
|
||||
}
|
||||
|
||||
static GetConnect _getClient() {
|
||||
final client = GetConnect();
|
||||
client.baseUrl = _getBaseUrl();
|
||||
client.timeout = const Duration(seconds: 30);
|
||||
return client;
|
||||
}
|
||||
|
||||
static String? _lookedUpRealIp;
|
||||
|
||||
static Future<String> lookupRealIp() async {
|
||||
if (_lookedUpRealIp != null) return _lookedUpRealIp!;
|
||||
const ipCheckUrl = 'https://api.ipify.org';
|
||||
final client = GetConnect(timeout: const Duration(seconds: 30));
|
||||
final resp = await client.get(ipCheckUrl);
|
||||
_lookedUpRealIp = resp.body;
|
||||
return _lookedUpRealIp!;
|
||||
}
|
||||
|
||||
static Future<NeteaseSourcedTrack> 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) {
|
||||
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.netease),
|
||||
),
|
||||
);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
final client = _getClient();
|
||||
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
|
||||
final item = resp.body['songs'][0];
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: [],
|
||||
source: toSourceMap(item),
|
||||
sourceInfo: NeteaseSourceInfo(
|
||||
id: item['id'].toString(),
|
||||
artist: item['ar'].map((x) => x['name']).join(','),
|
||||
artistUrl: 'https://music.163.com/#/artist?id=${item['ar'][0]['id']}',
|
||||
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||
thumbnail: item['al']['picUrl'],
|
||||
title: item['name'],
|
||||
duration: Duration(milliseconds: item['dt']),
|
||||
album: item['al']['name'],
|
||||
),
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(dynamic manifest) {
|
||||
final baseUrl = _getBaseUrl();
|
||||
|
||||
// Due to netease may provide m4a, mp3 and others, we cannot decide this so mock this data.
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) async {
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final client = _getClient();
|
||||
final resp =
|
||||
await client.get('/search?keywords=${Uri.encodeComponent(query)}');
|
||||
final results = resp.body['result']['songs'];
|
||||
|
||||
// We can just trust netease 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<NeteaseSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(track: this);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NeteaseSourcedTrack?> 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 client = _getClient();
|
||||
final resp = await client.get('/song/detail?ids=${newSourceInfo.id}');
|
||||
final item = resp.body['songs'][0];
|
||||
|
||||
final (:info, :source) = toSiblingType(item);
|
||||
|
||||
final db = Get.find<DatabaseProvider>();
|
||||
await db.database.into(db.database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: id!,
|
||||
sourceId: info.id,
|
||||
sourceType: const Value(SourceType.netease),
|
||||
// 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 NeteaseSourcedTrack(
|
||||
siblings: newSiblings,
|
||||
source: source!,
|
||||
sourceInfo: info,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
static SiblingType toSiblingType(dynamic item) {
|
||||
final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0];
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: NeteaseSourceInfo(
|
||||
id: item['id'].toString(),
|
||||
artist: item['ar'] != null
|
||||
? item['ar'].map((x) => x['name']).join(',')
|
||||
: item['artists'].map((x) => x['name']).toString(),
|
||||
artistUrl: 'https://music.163.com/#/artist?id=${firstArtist['id']}',
|
||||
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||
thumbnail: item['al']?['picUrl'] ??
|
||||
'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg',
|
||||
title: item['name'],
|
||||
duration: item['dt'] != null
|
||||
? Duration(milliseconds: item['dt'])
|
||||
: Duration.zero,
|
||||
album: item['al']?['name'],
|
||||
),
|
||||
source: toSourceMap(item),
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
}
|
@ -85,7 +85,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
cachedSource.sourceId,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
return YoutubeSourcedTrack(
|
||||
@ -140,7 +140,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
if (index == 0) {
|
||||
final manifest =
|
||||
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
@ -285,7 +285,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
final manifest = await youtubeClient.videos.streamsClient
|
||||
.getManifest(newSourceInfo.id)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
|
||||
|
Reference in New Issue
Block a user