feat: implement new SourcedTrack for youtube and piped

This commit is contained in:
Kingkor Roy Tirtho 2023-11-12 13:33:12 +06:00
parent 0c22469503
commit a4c0191d36
9 changed files with 809 additions and 0 deletions

View File

@ -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';

View 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);
}

View 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',
};

View 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});

View 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]);
}
}

View 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,
);
}
}

View 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,
);
}
}

View File

@ -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:

View File

@ -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