🔀 Merge some services from spotube
This commit is contained in:
18
lib/services/sourced_track/enums.dart
Executable file
18
lib/services/sourced_track/enums.dart
Executable file
@ -0,0 +1,18 @@
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
|
||||
|
||||
enum SourceCodecs {
|
||||
m4a._("M4a (Best for downloaded music)"),
|
||||
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
|
||||
|
||||
final String label;
|
||||
const SourceCodecs._(this.label);
|
||||
}
|
||||
|
||||
enum SourceQualities {
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
}
|
||||
|
||||
typedef SiblingType<T extends SourceInfo> = ({T info, SourceMap? source});
|
12
lib/services/sourced_track/exceptions.dart
Executable file
12
lib/services/sourced_track/exceptions.dart
Executable file
@ -0,0 +1,12 @@
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
class TrackNotFoundError extends Error {
|
||||
final Track track;
|
||||
|
||||
TrackNotFoundError(this.track);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '[TrackNotFoundError] ${track.name} - ${track.artists?.map((e) => e.name).join(", ")}';
|
||||
}
|
||||
}
|
12
lib/services/sourced_track/models/search.dart
Normal file
12
lib/services/sourced_track/models/search.dart
Normal file
@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
}
|
33
lib/services/sourced_track/models/source_info.dart
Executable file
33
lib/services/sourced_track/models/source_info.dart
Executable file
@ -0,0 +1,33 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'source_info.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class SourceInfo {
|
||||
final String id;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String artistUrl;
|
||||
final String? album;
|
||||
|
||||
final String thumbnail;
|
||||
final String pageUrl;
|
||||
|
||||
final Duration duration;
|
||||
|
||||
SourceInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
required this.thumbnail,
|
||||
required this.pageUrl,
|
||||
required this.duration,
|
||||
required this.artistUrl,
|
||||
this.album,
|
||||
});
|
||||
|
||||
factory SourceInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$SourceInfoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SourceInfoToJson(this);
|
||||
}
|
30
lib/services/sourced_track/models/source_info.g.dart
Executable file
30
lib/services/sourced_track/models/source_info.g.dart
Executable file
@ -0,0 +1,30 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'source_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
artist: json['artist'] as String,
|
||||
thumbnail: json['thumbnail'] as String,
|
||||
pageUrl: json['pageUrl'] as String,
|
||||
duration: Duration(microseconds: json['duration'] as int),
|
||||
artistUrl: json['artistUrl'] as String,
|
||||
album: json['album'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SourceInfoToJson(SourceInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'artist': instance.artist,
|
||||
'artistUrl': instance.artistUrl,
|
||||
'album': instance.album,
|
||||
'thumbnail': instance.thumbnail,
|
||||
'pageUrl': instance.pageUrl,
|
||||
'duration': instance.duration.inMicroseconds,
|
||||
};
|
58
lib/services/sourced_track/models/source_map.dart
Executable file
58
lib/services/sourced_track/models/source_map.dart
Executable file
@ -0,0 +1,58 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||
|
||||
part 'source_map.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class SourceQualityMap {
|
||||
final String high;
|
||||
final String medium;
|
||||
final String low;
|
||||
|
||||
const SourceQualityMap({
|
||||
required this.high,
|
||||
required this.medium,
|
||||
required this.low,
|
||||
});
|
||||
|
||||
factory SourceQualityMap.fromJson(Map<String, dynamic> json) =>
|
||||
_$SourceQualityMapFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SourceQualityMapToJson(this);
|
||||
|
||||
operator [](SourceQualities key) {
|
||||
switch (key) {
|
||||
case SourceQualities.high:
|
||||
return high;
|
||||
case SourceQualities.medium:
|
||||
return medium;
|
||||
case SourceQualities.low:
|
||||
return low;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SourceMap {
|
||||
final SourceQualityMap? weba;
|
||||
final SourceQualityMap? m4a;
|
||||
|
||||
const SourceMap({
|
||||
this.weba,
|
||||
this.m4a,
|
||||
});
|
||||
|
||||
factory SourceMap.fromJson(Map<String, dynamic> json) =>
|
||||
_$SourceMapFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SourceMapToJson(this);
|
||||
|
||||
operator [](SourceCodecs key) {
|
||||
switch (key) {
|
||||
case SourceCodecs.weba:
|
||||
return weba;
|
||||
case SourceCodecs.m4a:
|
||||
return m4a;
|
||||
}
|
||||
}
|
||||
}
|
36
lib/services/sourced_track/models/source_map.g.dart
Executable file
36
lib/services/sourced_track/models/source_map.g.dart
Executable file
@ -0,0 +1,36 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'source_map.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap(
|
||||
high: json['high'] as String,
|
||||
medium: json['medium'] as String,
|
||||
low: json['low'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SourceQualityMapToJson(SourceQualityMap instance) =>
|
||||
<String, dynamic>{
|
||||
'high': instance.high,
|
||||
'medium': instance.medium,
|
||||
'low': instance.low,
|
||||
};
|
||||
|
||||
SourceMap _$SourceMapFromJson(Map json) => SourceMap(
|
||||
weba: json['weba'] == null
|
||||
? null
|
||||
: SourceQualityMap.fromJson(
|
||||
Map<String, dynamic>.from(json['weba'] as Map)),
|
||||
m4a: json['m4a'] == null
|
||||
? null
|
||||
: SourceQualityMap.fromJson(
|
||||
Map<String, dynamic>.from(json['m4a'] as Map)),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SourceMapToJson(SourceMap instance) => <String, dynamic>{
|
||||
'weba': instance.weba?.toJson(),
|
||||
'm4a': instance.m4a?.toJson(),
|
||||
};
|
115
lib/services/sourced_track/models/video_info.dart
Executable file
115
lib/services/sourced_track/models/video_info.dart
Executable file
@ -0,0 +1,115 @@
|
||||
import 'package:piped_client/piped_client.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/search.dart';
|
||||
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
class YoutubeVideoInfo {
|
||||
final SearchMode searchMode;
|
||||
final String title;
|
||||
final Duration duration;
|
||||
final String thumbnailUrl;
|
||||
final String id;
|
||||
final int likes;
|
||||
final int dislikes;
|
||||
final int views;
|
||||
final String channelName;
|
||||
final String channelId;
|
||||
final DateTime publishedAt;
|
||||
|
||||
YoutubeVideoInfo({
|
||||
required this.searchMode,
|
||||
required this.title,
|
||||
required this.duration,
|
||||
required this.thumbnailUrl,
|
||||
required this.id,
|
||||
required this.likes,
|
||||
required this.dislikes,
|
||||
required this.views,
|
||||
required this.channelName,
|
||||
required this.publishedAt,
|
||||
required this.channelId,
|
||||
});
|
||||
|
||||
YoutubeVideoInfo.fromJson(Map<String, dynamic> json)
|
||||
: title = json['title'],
|
||||
searchMode = SearchMode.fromString(json['searchMode']),
|
||||
duration = Duration(seconds: json['duration']),
|
||||
thumbnailUrl = json['thumbnailUrl'],
|
||||
id = json['id'],
|
||||
likes = json['likes'],
|
||||
dislikes = json['dislikes'],
|
||||
views = json['views'],
|
||||
channelName = json['channelName'],
|
||||
channelId = json['channelId'],
|
||||
publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'duration': duration.inSeconds,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'id': id,
|
||||
'likes': likes,
|
||||
'dislikes': dislikes,
|
||||
'views': views,
|
||||
'channelName': channelName,
|
||||
'channelId': channelId,
|
||||
'publishedAt': publishedAt.toIso8601String(),
|
||||
'searchMode': searchMode.name,
|
||||
};
|
||||
|
||||
factory YoutubeVideoInfo.fromVideo(Video video) {
|
||||
return YoutubeVideoInfo(
|
||||
searchMode: SearchMode.youtube,
|
||||
title: video.title,
|
||||
duration: video.duration ?? Duration.zero,
|
||||
thumbnailUrl: video.thumbnails.mediumResUrl,
|
||||
id: video.id.value,
|
||||
likes: video.engagement.likeCount ?? 0,
|
||||
dislikes: video.engagement.dislikeCount ?? 0,
|
||||
views: video.engagement.viewCount,
|
||||
channelName: video.author,
|
||||
channelId: '/c/${video.channelId.value}',
|
||||
publishedAt: video.uploadDate ?? DateTime(2003, 9, 9),
|
||||
);
|
||||
}
|
||||
|
||||
factory YoutubeVideoInfo.fromSearchItemStream(
|
||||
PipedSearchItemStream searchItem,
|
||||
SearchMode searchMode,
|
||||
) {
|
||||
return YoutubeVideoInfo(
|
||||
searchMode: searchMode,
|
||||
title: searchItem.title,
|
||||
duration: searchItem.duration,
|
||||
thumbnailUrl: searchItem.thumbnail,
|
||||
id: searchItem.id,
|
||||
likes: 0,
|
||||
dislikes: 0,
|
||||
views: searchItem.views,
|
||||
channelName: searchItem.uploaderName,
|
||||
channelId: searchItem.uploaderUrl ?? "",
|
||||
publishedAt: searchItem.uploadedDate != null
|
||||
? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9)
|
||||
: DateTime(2003, 9, 9),
|
||||
);
|
||||
}
|
||||
|
||||
factory YoutubeVideoInfo.fromStreamResponse(
|
||||
PipedStreamResponse stream, SearchMode searchMode) {
|
||||
return YoutubeVideoInfo(
|
||||
searchMode: searchMode,
|
||||
title: stream.title,
|
||||
duration: stream.duration,
|
||||
thumbnailUrl: stream.thumbnailUrl,
|
||||
id: stream.id,
|
||||
likes: stream.likes,
|
||||
dislikes: stream.dislikes,
|
||||
views: stream.views,
|
||||
channelName: stream.uploader,
|
||||
publishedAt: stream.uploadedDate != null
|
||||
? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9)
|
||||
: DateTime(2003, 9, 9),
|
||||
channelId: stream.uploaderUrl,
|
||||
);
|
||||
}
|
||||
}
|
153
lib/services/sourced_track/sourced_track.dart
Executable file
153
lib/services/sourced_track/sourced_track.dart
Executable file
@ -0,0 +1,153 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:rhythm_box/services/utils.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/sources/piped.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
abstract class SourcedTrack extends Track {
|
||||
final SourceMap source;
|
||||
final List<SourceInfo> siblings;
|
||||
final SourceInfo sourceInfo;
|
||||
|
||||
SourcedTrack({
|
||||
required this.source,
|
||||
required this.siblings,
|
||||
required this.sourceInfo,
|
||||
required Track track,
|
||||
}) {
|
||||
id = track.id;
|
||||
name = track.name;
|
||||
artists = track.artists;
|
||||
album = track.album;
|
||||
durationMs = track.durationMs;
|
||||
discNumber = track.discNumber;
|
||||
explicit = track.explicit;
|
||||
externalIds = track.externalIds;
|
||||
href = track.href;
|
||||
isPlayable = track.isPlayable;
|
||||
linkedFrom = track.linkedFrom;
|
||||
popularity = track.popularity;
|
||||
previewUrl = track.previewUrl;
|
||||
trackNumber = track.trackNumber;
|
||||
type = track.type;
|
||||
uri = track.uri;
|
||||
}
|
||||
|
||||
static SourcedTrack fromJson(Map<String, dynamic> json) {
|
||||
// TODO Follow user preferences
|
||||
const audioSource = "youtube";
|
||||
|
||||
final sourceInfo = SourceInfo.fromJson(json);
|
||||
final source = SourceMap.fromJson(json);
|
||||
final track = Track.fromJson(json);
|
||||
final siblings = (json["siblings"] as List)
|
||||
.map((sibling) => SourceInfo.fromJson(sibling))
|
||||
.toList()
|
||||
.cast<SourceInfo>();
|
||||
|
||||
return switch (audioSource) {
|
||||
"piped" => PipedSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
_ => YoutubeSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static String getSearchTerm(Track track) {
|
||||
final artists = (track.artists ?? [])
|
||||
.map((ar) => ar.name)
|
||||
.toList()
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
final title = ServiceUtils.getTitle(
|
||||
track.name!,
|
||||
artists: artists,
|
||||
onlyCleanArtist: true,
|
||||
).trim();
|
||||
|
||||
return "$title - ${artists.join(", ")}";
|
||||
}
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
// TODO Follow user preferences
|
||||
const audioSource = "youtube";
|
||||
|
||||
try {
|
||||
return switch (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;
|
||||
} on HttpClientClosedException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||
} on VideoUnplayableException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) {
|
||||
// TODO Follow user preferences
|
||||
const audioSource = "youtube";
|
||||
|
||||
return switch (audioSource) {
|
||||
"piped" => PipedSourcedTrack.fetchSiblings(track: track),
|
||||
_ => YoutubeSourcedTrack.fetchSiblings(track: track),
|
||||
};
|
||||
}
|
||||
|
||||
Future<SourcedTrack> copyWithSibling();
|
||||
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling);
|
||||
|
||||
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
|
||||
return swapWithSibling(siblings[index]);
|
||||
}
|
||||
|
||||
String get url {
|
||||
// TODO Follow user preferences
|
||||
const streamMusicCodec = SourceCodecs.weba;
|
||||
|
||||
return getUrlOfCodec(streamMusicCodec);
|
||||
}
|
||||
|
||||
String getUrlOfCodec(SourceCodecs codec) {
|
||||
// TODO Follow user preferences
|
||||
const audioQuality = SourceQualities.high;
|
||||
|
||||
return source[codec]?[audioQuality] ??
|
||||
// this will ensure playback doesn't break
|
||||
source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a]
|
||||
[audioQuality];
|
||||
}
|
||||
|
||||
SourceCodecs get codec {
|
||||
// TODO Follow user preferences
|
||||
const streamMusicCodec = SourceCodecs.weba;
|
||||
|
||||
return streamMusicCodec;
|
||||
}
|
||||
}
|
239
lib/services/sourced_track/sources/piped.dart
Executable file
239
lib/services/sourced_track/sources/piped.dart
Executable file
@ -0,0 +1,239 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:piped_client/piped_client.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/search.dart';
|
||||
import 'package:rhythm_box/services/utils.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/models/video_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||
|
||||
class PipedSourceInfo extends SourceInfo {
|
||||
PipedSourceInfo({
|
||||
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 PipedSourcedTrack extends SourcedTrack {
|
||||
PipedSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static PipedClient _getClient() {
|
||||
// TODO Allow user define their own piped.video instance
|
||||
return PipedClient();
|
||||
}
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
// TODO Add cache query here
|
||||
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(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) {
|
||||
final m4a = manifest.audioStreams
|
||||
.where((audio) => audio.format == PipedAudioStreamFormat.m4a)
|
||||
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
|
||||
|
||||
final weba = manifest.audioStreams
|
||||
.where((audio) => audio.format == PipedAudioStreamFormat.webm)
|
||||
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
|
||||
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: m4a.first.url.toString(),
|
||||
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
|
||||
low: m4a.last.url.toString(),
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: weba.first.url.toString(),
|
||||
medium:
|
||||
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
|
||||
low: weba.last.url.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<SiblingType> toSiblingType(
|
||||
int index,
|
||||
YoutubeVideoInfo item,
|
||||
PipedClient pipedClient,
|
||||
) async {
|
||||
SourceMap? sourceMap;
|
||||
if (index == 0) {
|
||||
final manifest = await pipedClient.streams(item.id);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
}
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: PipedSourceInfo(
|
||||
id: item.id,
|
||||
artist: item.channelName,
|
||||
artistUrl: "https://www.youtube.com/${item.channelId}",
|
||||
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
|
||||
thumbnail: item.thumbnailUrl,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
album: null,
|
||||
),
|
||||
source: sourceMap,
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) async {
|
||||
final pipedClient = _getClient();
|
||||
|
||||
// TODO Allow user search with normal youtube video (`youtube`)
|
||||
const searchMode = SearchMode.youtubeMusic;
|
||||
// TODO Follow user preferences
|
||||
const audioSource = "youtube";
|
||||
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final PipedSearchResult(items: searchResults) = await pipedClient.search(
|
||||
query,
|
||||
searchMode == SearchMode.youtube
|
||||
? PipedFilter.video
|
||||
: PipedFilter.musicSongs,
|
||||
);
|
||||
|
||||
// when falling back to piped API make sure to use the YouTube mode
|
||||
const isYouTubeMusic =
|
||||
audioSource != "piped" ? false : searchMode == SearchMode.youtubeMusic;
|
||||
|
||||
if (isYouTubeMusic) {
|
||||
final artists = (track.artists ?? [])
|
||||
.map((ar) => ar.name)
|
||||
.toList()
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
return await Future.wait(
|
||||
searchResults
|
||||
.map(
|
||||
(result) => YoutubeVideoInfo.fromSearchItemStream(
|
||||
result as PipedSearchItemStream,
|
||||
searchMode,
|
||||
),
|
||||
)
|
||||
.sorted((a, b) => b.views.compareTo(a.views))
|
||||
.where(
|
||||
(item) => artists.any(
|
||||
(artist) =>
|
||||
artist.toLowerCase() == item.channelName.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
|
||||
);
|
||||
}
|
||||
|
||||
if (ServiceUtils.onlyContainsEnglish(query)) {
|
||||
return await Future.wait(
|
||||
searchResults
|
||||
.whereType<PipedSearchItemStream>()
|
||||
.map(
|
||||
(result) => YoutubeVideoInfo.fromSearchItemStream(
|
||||
result,
|
||||
searchMode,
|
||||
),
|
||||
)
|
||||
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
|
||||
);
|
||||
}
|
||||
|
||||
final rankedSiblings = YoutubeSourcedTrack.rankResults(
|
||||
searchResults
|
||||
.map(
|
||||
(result) => YoutubeVideoInfo.fromSearchItemStream(
|
||||
result as PipedSearchItemStream,
|
||||
searchMode,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
track,
|
||||
);
|
||||
|
||||
return await Future.wait(
|
||||
rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(track: this);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> 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 pipedClient = _getClient();
|
||||
|
||||
final manifest = await pipedClient.streams(newSourceInfo.id);
|
||||
|
||||
// TODO Save to cache here
|
||||
|
||||
return PipedSourcedTrack(
|
||||
siblings: newSiblings,
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: newSourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
}
|
273
lib/services/sourced_track/sources/youtube.dart
Executable file
273
lib/services/sourced_track/sources/youtube.dart
Executable file
@ -0,0 +1,273 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:rhythm_box/services/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/song_link/song_link.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/models/video_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
|
||||
final youtubeClient = YoutubeExplode();
|
||||
final officialMusicRegex = RegExp(
|
||||
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
class YoutubeSourceInfo extends SourceInfo {
|
||||
YoutubeSourceInfo({
|
||||
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 YoutubeSourcedTrack extends SourcedTrack {
|
||||
YoutubeSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static Future<YoutubeSourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
// TODO Add cache query here
|
||||
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(track);
|
||||
}
|
||||
|
||||
// TODO Save to cache here
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(StreamManifest manifest) {
|
||||
var m4a = manifest.audioOnly
|
||||
.where((audio) => audio.codec.mimeType == "audio/mp4")
|
||||
.sortByBitrate();
|
||||
|
||||
var weba = manifest.audioOnly
|
||||
.where((audio) => audio.codec.mimeType == "audio/webm")
|
||||
.sortByBitrate();
|
||||
|
||||
m4a = m4a.isEmpty ? weba.toList() : m4a;
|
||||
weba = weba.isEmpty ? m4a.toList() : weba;
|
||||
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: m4a.first.url.toString(),
|
||||
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
|
||||
low: m4a.last.url.toString(),
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: weba.first.url.toString(),
|
||||
medium:
|
||||
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
|
||||
low: weba.last.url.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<SiblingType> toSiblingType(
|
||||
int index,
|
||||
YoutubeVideoInfo item,
|
||||
) async {
|
||||
SourceMap? sourceMap;
|
||||
if (index == 0) {
|
||||
final manifest =
|
||||
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw ClientException("Timeout"),
|
||||
);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
}
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: YoutubeSourceInfo(
|
||||
id: item.id,
|
||||
artist: item.channelName,
|
||||
artistUrl: "https://www.youtube.com/channel/${item.channelId}",
|
||||
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
|
||||
thumbnail: item.thumbnailUrl,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
album: null,
|
||||
),
|
||||
source: sourceMap,
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
static List<YoutubeVideoInfo> rankResults(
|
||||
List<YoutubeVideoInfo> results, Track track) {
|
||||
final artists = (track.artists ?? [])
|
||||
.map((ar) => ar.name)
|
||||
.toList()
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
return results
|
||||
.sorted((a, b) => b.views.compareTo(a.views))
|
||||
.map((sibling) {
|
||||
int score = 0;
|
||||
|
||||
for (final artist in artists) {
|
||||
final isSameChannelArtist =
|
||||
sibling.channelName.toLowerCase() == artist.toLowerCase();
|
||||
final channelContainsArtist = sibling.channelName
|
||||
.toLowerCase()
|
||||
.contains(artist.toLowerCase());
|
||||
|
||||
if (isSameChannelArtist || channelContainsArtist) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
final titleContainsArtist =
|
||||
sibling.title.toLowerCase().contains(artist.toLowerCase());
|
||||
|
||||
if (titleContainsArtist) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
final titleContainsTrackName =
|
||||
sibling.title.toLowerCase().contains(track.name!.toLowerCase());
|
||||
|
||||
final hasOfficialFlag =
|
||||
officialMusicRegex.hasMatch(sibling.title.toLowerCase());
|
||||
|
||||
if (titleContainsTrackName) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (hasOfficialFlag) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (hasOfficialFlag && titleContainsTrackName) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
return (sibling: sibling, score: score);
|
||||
})
|
||||
.sorted((a, b) => b.score.compareTo(a.score))
|
||||
.map((e) => e.sibling)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) async {
|
||||
final links = await SongLinkService.links(track.id!);
|
||||
final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube");
|
||||
|
||||
if (ytLink?.url != null
|
||||
// allows to fetch siblings more results for already sourced track
|
||||
&&
|
||||
track is! SourcedTrack) {
|
||||
try {
|
||||
return [
|
||||
await toSiblingType(
|
||||
0,
|
||||
YoutubeVideoInfo.fromVideo(
|
||||
await youtubeClient.videos.get(ytLink!.url!),
|
||||
),
|
||||
)
|
||||
];
|
||||
} on VideoUnplayableException catch (e) {
|
||||
// Ignore this error and continue with the search
|
||||
log('[Source][YoutubeMusic] Unable to search data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final searchResults = await youtubeClient.search.search(
|
||||
"$query - Topic",
|
||||
filter: TypeFilters.video,
|
||||
);
|
||||
|
||||
if (ServiceUtils.onlyContainsEnglish(query)) {
|
||||
return await Future.wait(searchResults
|
||||
.map(YoutubeVideoInfo.fromVideo)
|
||||
.mapIndexed(toSiblingType));
|
||||
}
|
||||
|
||||
final rankedSiblings = rankResults(
|
||||
searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
|
||||
track,
|
||||
);
|
||||
|
||||
return await Future.wait(rankedSiblings.mapIndexed(toSiblingType));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YoutubeSourcedTrack?> 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 manifest = await youtubeClient.videos.streamsClient
|
||||
.getManifest(newSourceInfo.id)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw ClientException("Timeout"),
|
||||
);
|
||||
|
||||
// TODO Save to cache here
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
siblings: newSiblings,
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: newSourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YoutubeSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(track: this);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user