mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-08 16:27:31 +00:00
feat: implement new SourcedTrack for youtube and piped
This commit is contained in:
parent
0c22469503
commit
a4c0191d36
@ -20,6 +20,7 @@ import 'package:spotube/l10n/l10n.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/models/skip_segment.dart';
|
||||
import 'package:spotube/models/source_match.dart';
|
||||
import 'package:spotube/provider/palette_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -75,6 +76,8 @@ Future<void> main(List<String> rawArgs) async {
|
||||
Hive.registerAdapter(SkipSegmentAdapter());
|
||||
Hive.registerAdapter(SearchModeAdapter());
|
||||
|
||||
Hive.registerAdapter(SourceMatchAdapter());
|
||||
|
||||
// Cache versioning entities with Adapter
|
||||
MatchedTrack.version = 'v1';
|
||||
SkipSegment.version = 'v1';
|
||||
|
||||
54
lib/models/source_match.dart
Normal file
54
lib/models/source_match.dart
Normal file
@ -0,0 +1,54 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'source_match.g.dart';
|
||||
|
||||
@JsonEnum()
|
||||
@HiveType(typeId: 5)
|
||||
enum SourceType {
|
||||
@HiveField(0)
|
||||
youtube._("YouTube"),
|
||||
|
||||
@HiveField(1)
|
||||
youtubeMusic._("YouTube Music"),
|
||||
|
||||
@HiveField(2)
|
||||
jiosaavn._("JioSaavn");
|
||||
|
||||
final String label;
|
||||
|
||||
const SourceType._(this.label);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@HiveType(typeId: 6)
|
||||
class SourceMatch {
|
||||
@HiveField(0)
|
||||
String id;
|
||||
|
||||
@HiveField(1)
|
||||
String sourceId;
|
||||
|
||||
@HiveField(2)
|
||||
SourceType sourceType;
|
||||
|
||||
@HiveField(3)
|
||||
DateTime createdAt;
|
||||
|
||||
SourceMatch({
|
||||
required this.id,
|
||||
required this.sourceId,
|
||||
required this.sourceType,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory SourceMatch.fromJson(Map<String, dynamic> json) =>
|
||||
_$SourceMatchFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SourceMatchToJson(this);
|
||||
|
||||
static String version = 'v1';
|
||||
static final boxName = "oss.krtirtho.spotube.source_matches.$version";
|
||||
|
||||
static LazyBox<SourceMatch> get box => Hive.lazyBox<SourceMatch>(boxName);
|
||||
}
|
||||
119
lib/models/source_match.g.dart
Normal file
119
lib/models/source_match.g.dart
Normal file
@ -0,0 +1,119 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'source_match.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SourceMatchAdapter extends TypeAdapter<SourceMatch> {
|
||||
@override
|
||||
final int typeId = 6;
|
||||
|
||||
@override
|
||||
SourceMatch read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SourceMatch(
|
||||
id: fields[0] as String,
|
||||
sourceId: fields[1] as String,
|
||||
sourceType: fields[2] as SourceType,
|
||||
createdAt: fields[3] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SourceMatch obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.sourceId)
|
||||
..writeByte(2)
|
||||
..write(obj.sourceType)
|
||||
..writeByte(3)
|
||||
..write(obj.createdAt);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SourceMatchAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SourceTypeAdapter extends TypeAdapter<SourceType> {
|
||||
@override
|
||||
final int typeId = 5;
|
||||
|
||||
@override
|
||||
SourceType read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return SourceType.youtube;
|
||||
case 1:
|
||||
return SourceType.youtubeMusic;
|
||||
case 2:
|
||||
return SourceType.jiosaavn;
|
||||
default:
|
||||
return SourceType.youtube;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SourceType obj) {
|
||||
switch (obj) {
|
||||
case SourceType.youtube:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case SourceType.youtubeMusic:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
case SourceType.jiosaavn:
|
||||
writer.writeByte(2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SourceTypeAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SourceMatch _$SourceMatchFromJson(Map<String, dynamic> json) => SourceMatch(
|
||||
id: json['id'] as String,
|
||||
sourceId: json['sourceId'] as String,
|
||||
sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SourceMatchToJson(SourceMatch instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'sourceId': instance.sourceId,
|
||||
'sourceType': _$SourceTypeEnumMap[instance.sourceType]!,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
const _$SourceTypeEnumMap = {
|
||||
SourceType.youtube: 'youtube',
|
||||
SourceType.youtubeMusic: 'youtubeMusic',
|
||||
SourceType.jiosaavn: 'jiosaavn',
|
||||
};
|
||||
16
lib/services/sourced_track/enums.dart
Normal file
16
lib/services/sourced_track/enums.dart
Normal file
@ -0,0 +1,16 @@
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
|
||||
enum SourceCodecs {
|
||||
mp4,
|
||||
weba,
|
||||
m4a,
|
||||
}
|
||||
|
||||
enum SourceQualities {
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
}
|
||||
|
||||
typedef SourceMap = Map<SourceCodecs, Map<SourceQualities, String>>;
|
||||
typedef SiblingType = ({SourceInfo info, SourceMap? source});
|
||||
94
lib/services/sourced_track/sourced_track.dart
Normal file
94
lib/services/sourced_track/sourced_track.dart
Normal file
@ -0,0 +1,94 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class SourceInfo {
|
||||
final String id;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String? album;
|
||||
|
||||
final String thumbnail;
|
||||
final String pageUrl;
|
||||
|
||||
SourceInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
required this.thumbnail,
|
||||
required this.pageUrl,
|
||||
this.album,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class SourcedTrack extends Track {
|
||||
final SourceMap source;
|
||||
final List<SourceInfo> siblings;
|
||||
final SourceInfo sourceInfo;
|
||||
final Ref ref;
|
||||
|
||||
SourcedTrack({
|
||||
required this.ref,
|
||||
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 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,
|
||||
required Ref ref,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
Future<SourcedTrack> copyWithSibling();
|
||||
|
||||
Future<SourcedTrack> swapWithSibling(SourceInfo sibling);
|
||||
|
||||
Future<SourcedTrack> swapWithSiblingOfIndex(int index) {
|
||||
return swapWithSibling(siblings[index]);
|
||||
}
|
||||
}
|
||||
251
lib/services/sourced_track/sources/piped.dart
Normal file
251
lib/services/sourced_track/sources/piped.dart
Normal file
@ -0,0 +1,251 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:piped_client/piped_client.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/models/source_match.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
final pipedProvider = Provider<PipedClient>(
|
||||
(ref) {
|
||||
final instance =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.pipedInstance));
|
||||
return PipedClient(instance: instance);
|
||||
},
|
||||
);
|
||||
|
||||
class PipedSourcedTrack extends SourcedTrack {
|
||||
PipedSourcedTrack({
|
||||
required super.ref,
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final cachedSource = await SourceMatch.box.get(track.id);
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
final pipedClient = ref.read(pipedProvider);
|
||||
|
||||
if (cachedSource == null) {
|
||||
final siblings = await fetchSiblings(ref: ref, track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundException(track);
|
||||
}
|
||||
|
||||
await SourceMatch.box.put(
|
||||
track.id!,
|
||||
SourceMatch(
|
||||
id: track.id!,
|
||||
sourceType: preferences.searchMode == SearchMode.youtube
|
||||
? SourceType.youtube
|
||||
: SourceType.youtubeMusic,
|
||||
createdAt: DateTime.now(),
|
||||
sourceId: siblings.first.info.id,
|
||||
),
|
||||
);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else {
|
||||
final manifest = await pipedClient.streams(cachedSource.sourceId);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: [],
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: SourceInfo(
|
||||
id: manifest.id,
|
||||
artist: manifest.uploader,
|
||||
pageUrl: "https://www.youtube.com/watch?v=${manifest.id}",
|
||||
thumbnail: manifest.thumbnailUrl,
|
||||
title: manifest.title,
|
||||
album: null,
|
||||
),
|
||||
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 {
|
||||
SourceCodecs.mp4: {
|
||||
SourceQualities.high: m4a.first.url.toString(),
|
||||
SourceQualities.medium:
|
||||
(m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
|
||||
SourceQualities.low: m4a.last.url.toString(),
|
||||
},
|
||||
SourceCodecs.weba: {
|
||||
SourceQualities.high: weba.first.url.toString(),
|
||||
SourceQualities.medium:
|
||||
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
|
||||
SourceQualities.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: SourceInfo(
|
||||
id: item.id,
|
||||
artist: item.channelName,
|
||||
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
|
||||
thumbnail: item.thumbnailUrl,
|
||||
title: item.title,
|
||||
album: null,
|
||||
),
|
||||
source: sourceMap,
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final pipedClient = ref.read(pipedProvider);
|
||||
final preference = ref.read(userPreferencesProvider);
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final PipedSearchResult(items: searchResults) = await pipedClient.search(
|
||||
query,
|
||||
preference.searchMode == SearchMode.youtube
|
||||
? PipedFilter.video
|
||||
: PipedFilter.musicSongs,
|
||||
);
|
||||
|
||||
final isYouTubeMusic = preference.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,
|
||||
preference.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
|
||||
.map(
|
||||
(result) => YoutubeVideoInfo.fromSearchItemStream(
|
||||
result as PipedSearchItemStream,
|
||||
preference.searchMode,
|
||||
),
|
||||
)
|
||||
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
|
||||
);
|
||||
}
|
||||
|
||||
final rankedSiblings = YoutubeSourcedTrack.rankResults(
|
||||
searchResults
|
||||
.map(
|
||||
(result) => YoutubeVideoInfo.fromSearchItemStream(
|
||||
result as PipedSearchItemStream,
|
||||
preference.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(ref: ref, track: this);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
ref: ref,
|
||||
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 ||
|
||||
siblings.none((s) => s.id == sibling.id)) {
|
||||
throw Exception("Invalid sibling");
|
||||
}
|
||||
|
||||
final newSourceInfo = siblings.firstWhere((s) => s.id == sibling.id);
|
||||
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||
..insert(0, sourceInfo);
|
||||
|
||||
final pipedClient = ref.read(pipedProvider);
|
||||
|
||||
final manifest = await pipedClient.streams(newSourceInfo.id);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: newSiblings,
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: newSourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
252
lib/services/sourced_track/sources/youtube.dart
Normal file
252
lib/services/sourced_track/sources/youtube.dart
Normal file
@ -0,0 +1,252 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/source_match.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/utils/service_utils.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 YoutubeSourcedTrack extends SourcedTrack {
|
||||
YoutubeSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
required super.ref,
|
||||
});
|
||||
|
||||
static Future<YoutubeSourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final cachedSource = await SourceMatch.box.get(track.id);
|
||||
|
||||
if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
|
||||
final siblings = await fetchSiblings(ref: ref, track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundException(track);
|
||||
}
|
||||
|
||||
await SourceMatch.box.put(
|
||||
track.id!,
|
||||
SourceMatch(
|
||||
id: track.id!,
|
||||
sourceType: SourceType.youtube,
|
||||
createdAt: DateTime.now(),
|
||||
sourceId: siblings.first.info.id,
|
||||
),
|
||||
);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else {
|
||||
final item = await youtubeClient.videos.get(cachedSource.sourceId);
|
||||
final manifest = await youtubeClient.videos.streamsClient.getManifest(
|
||||
cachedSource.sourceId,
|
||||
);
|
||||
return YoutubeSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: [],
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: SourceInfo(
|
||||
id: item.id.value,
|
||||
artist: item.author,
|
||||
pageUrl: item.url,
|
||||
thumbnail: item.thumbnails.highResUrl,
|
||||
title: item.title,
|
||||
album: null,
|
||||
),
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(StreamManifest manifest) {
|
||||
final m4a = manifest.audioOnly
|
||||
.where((audio) => audio.codec.mimeType == "audio/mp4")
|
||||
.sortByBitrate();
|
||||
|
||||
final weba = manifest.audioOnly
|
||||
.where((audio) => audio.codec.mimeType == "audio/webm")
|
||||
.sortByBitrate();
|
||||
|
||||
return {
|
||||
SourceCodecs.mp4: {
|
||||
SourceQualities.high: m4a.first.url.toString(),
|
||||
SourceQualities.medium:
|
||||
(m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
|
||||
SourceQualities.low: m4a.last.url.toString(),
|
||||
},
|
||||
SourceCodecs.weba: {
|
||||
SourceQualities.high: weba.first.url.toString(),
|
||||
SourceQualities.medium:
|
||||
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
|
||||
SourceQualities.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);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
}
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: SourceInfo(
|
||||
id: item.id,
|
||||
artist: item.channelName,
|
||||
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
|
||||
thumbnail: item.thumbnailUrl,
|
||||
title: item.title,
|
||||
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,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final searchResults = await youtubeClient.search.search(
|
||||
query,
|
||||
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 ||
|
||||
siblings.none((s) => s.id == sibling.id)) {
|
||||
throw Exception("Invalid sibling");
|
||||
}
|
||||
|
||||
final newSourceInfo = 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);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: newSiblings,
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: newSourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YoutubeSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
pubspec.lock
17
pubspec.lock
@ -385,6 +385,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
dart_des:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_des
|
||||
sha256: "0a66afb8883368c824497fd2a1fd67bdb1a785965a3956728382c03d40747c33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1165,6 +1173,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
jiosaavn:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "8a7cda9b8b687cde28e0f7fcb10adb0d4fde1007"
|
||||
url: "https://github.com/KRTirtho/jiosaavn.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -106,6 +106,9 @@ dependencies:
|
||||
simple_icons: ^7.10.0
|
||||
audio_service_mpris: ^0.1.0
|
||||
file_picker: ^6.0.0
|
||||
jiosaavn:
|
||||
git:
|
||||
url: https://github.com/KRTirtho/jiosaavn.git
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user