chore: create sourced track from active audio source plugin

This commit is contained in:
Kingkor Roy Tirtho 2025-11-03 13:32:48 +06:00
parent 3bc296cf22
commit 99a84aa6dc
24 changed files with 3810 additions and 2021 deletions

View File

@ -16,7 +16,6 @@ import 'package:spotube/models/metadata/market.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:flutter/widgets.dart' hide Table, Key, View; import 'package:flutter/widgets.dart' hide Table, Key, View;
import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
@ -65,7 +64,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 9; int get schemaVersion => 10;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {

View File

@ -11,17 +11,6 @@ enum CloseBehavior {
close, close,
} }
enum AudioSource {
youtube("YouTube"),
piped("Piped"),
jiosaavn("JioSaavn"),
invidious("Invidious"),
dabMusic("DAB Music");
final String label;
const AudioSource(this.label);
}
enum YoutubeClientEngine { enum YoutubeClientEngine {
ytDlp("yt-dlp"), ytDlp("yt-dlp"),
youtubeExplode("YouTubeExplode"), youtubeExplode("YouTubeExplode"),
@ -56,8 +45,6 @@ enum SearchMode {
class PreferencesTable extends Table { class PreferencesTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get audioQuality => textEnum<SourceQualities>()
.withDefault(Constant(SourceQualities.high.name))();
BoolColumn get albumColorSync => BoolColumn get albumColorSync =>
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get amoledDarkTheme => BoolColumn get amoledDarkTheme =>
@ -95,14 +82,9 @@ class PreferencesTable extends Table {
text().withDefault(const Constant("https://inv.nadeko.net"))(); text().withDefault(const Constant("https://inv.nadeko.net"))();
TextColumn get themeMode => TextColumn get themeMode =>
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))(); textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
TextColumn get audioSource => TextColumn get audioSourceId => text().nullable()();
textEnum<AudioSource>().withDefault(Constant(AudioSource.youtube.name))();
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>() TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
TextColumn get streamMusicCodec =>
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
TextColumn get downloadMusicCodec =>
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
BoolColumn get discordPresence => BoolColumn get discordPresence =>
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get endlessPlayback => BoolColumn get endlessPlayback =>
@ -116,7 +98,6 @@ class PreferencesTable extends Table {
static PreferencesTableData defaults() { static PreferencesTableData defaults() {
return PreferencesTableData( return PreferencesTableData(
id: 0, id: 0,
audioQuality: SourceQualities.high,
albumColorSync: true, albumColorSync: true,
amoledDarkTheme: false, amoledDarkTheme: false,
checkUpdate: true, checkUpdate: true,
@ -135,10 +116,8 @@ class PreferencesTable extends Table {
pipedInstance: "https://pipedapi.kavin.rocks", pipedInstance: "https://pipedapi.kavin.rocks",
invidiousInstance: "https://inv.nadeko.net", invidiousInstance: "https://inv.nadeko.net",
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
audioSource: AudioSource.youtube, audioSourceId: null,
youtubeClientEngine: YoutubeClientEngine.youtubeExplode, youtubeClientEngine: YoutubeClientEngine.youtubeExplode,
streamMusicCodec: SourceCodecs.m4a,
downloadMusicCodec: SourceCodecs.m4a,
discordPresence: true, discordPresence: true,
endlessPlayback: true, endlessPlayback: true,
enableConnect: false, enableConnect: false,

View File

@ -1,26 +1,14 @@
part of '../database.dart'; part of '../database.dart';
enum SourceType {
youtube._("YouTube"),
youtubeMusic._("YouTube Music"),
jiosaavn._("JioSaavn"),
dabMusic._("DAB Music");
final String label;
const SourceType._(this.label);
}
@TableIndex( @TableIndex(
name: "uniq_track_match", name: "uniq_track_match",
columns: {#trackId, #sourceId, #sourceType}, columns: {#trackId, #sourceInfo, #sourceType},
unique: true, unique: true,
) )
class SourceMatchTable extends Table { class SourceMatchTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get trackId => text()(); TextColumn get trackId => text()();
TextColumn get sourceId => text()(); TextColumn get sourceInfo => text()();
TextColumn get sourceType => TextColumn get sourceType => text()();
textEnum<SourceType>().withDefault(Constant(SourceType.youtube.name))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
} }

View File

@ -1,122 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
part 'track_sources.freezed.dart'; part 'track_sources.freezed.dart';
part 'track_sources.g.dart'; part 'track_sources.g.dart';
@freezed
class TrackSourceQuery with _$TrackSourceQuery {
TrackSourceQuery._();
factory TrackSourceQuery({
required String id,
required String title,
required List<String> artists,
required String album,
required int durationMs,
required String isrc,
required bool explicit,
}) = _TrackSourceQuery;
factory TrackSourceQuery.fromJson(Map<String, dynamic> json) =>
_$TrackSourceQueryFromJson(json);
factory TrackSourceQuery.fromTrack(SpotubeFullTrackObject track) {
return TrackSourceQuery(
id: track.id,
title: track.name,
artists: track.artists.map((e) => e.name).toList(),
album: track.album.name,
durationMs: track.durationMs,
isrc: track.isrc,
explicit: track.explicit,
);
}
/// Parses [SpotubeMedia]'s [uri] property to create a [TrackSourceQuery].
factory TrackSourceQuery.parseUri(String url) {
final isLocal = !url.startsWith("http");
if (isLocal) {
try {
return TrackSourceQuery(
id: url,
title: '',
artists: [],
album: '',
durationMs: 0,
isrc: '',
explicit: false,
);
} catch (e, stackTrace) {
AppLogger.log.e(
"Failed to parse local track URI: $url\n$e",
stackTrace: stackTrace,
);
}
}
final uri = Uri.parse(url);
return TrackSourceQuery(
id: uri.pathSegments.last,
title: uri.queryParameters['title'] ?? '',
artists: uri.queryParameters['artists']?.split(',') ?? [],
album: uri.queryParameters['album'] ?? '',
durationMs: int.tryParse(uri.queryParameters['durationMs'] ?? '0') ?? 0,
isrc: uri.queryParameters['isrc'] ?? '',
explicit: uri.queryParameters['explicit']?.toLowerCase() == 'true',
);
}
String queryString() {
return toJson()
.entries
.map((e) =>
"${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value is List<String> ? e.value.join(",") : e.value.toString())}")
.join("&");
}
}
@freezed
class TrackSourceInfo with _$TrackSourceInfo {
factory TrackSourceInfo({
required String id,
required String title,
required String artists,
required String thumbnail,
required String pageUrl,
required int durationMs,
}) = _TrackSourceInfo;
factory TrackSourceInfo.fromJson(Map<String, dynamic> json) =>
_$TrackSourceInfoFromJson(json);
}
@freezed
class TrackSource with _$TrackSource {
factory TrackSource({
required String url,
required SourceQualities quality,
required SourceCodecs codec,
required String bitrate,
required String qualityLabel,
}) = _TrackSource;
factory TrackSource.fromJson(Map<String, dynamic> json) =>
_$TrackSourceFromJson(json);
}
@JsonSerializable() @JsonSerializable()
class BasicSourcedTrack { class BasicSourcedTrack {
final TrackSourceQuery query; final SpotubeFullTrackObject query;
final AudioSource source; final SpotubeAudioSourceMatchObject info;
final TrackSourceInfo info; final String source;
final List<TrackSource> sources; final List<SpotubeAudioSourceStreamObject> sources;
final List<TrackSourceInfo> siblings; final List<SpotubeAudioSourceMatchObject> siblings;
BasicSourcedTrack({ BasicSourcedTrack({
required this.query, required this.query,
required this.source, required this.source,

View File

@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
final codecs = SourceCodecs.values.map((s) => s.name); final codecs = SourceCodecs.values.map((s) => s.name);

View File

@ -49,7 +49,7 @@ class PlayerView extends HookConsumerWidget {
final activeSourceCodec = useMemoized( final activeSourceCodec = useMemoized(
() { () {
return currentActiveTrackSource return currentActiveTrackSource
?.getSourceOfCodec(currentActiveTrackSource.codec); ?.getStreamOfCodec(currentActiveTrackSource.codec);
}, },
[currentActiveTrackSource?.sources, currentActiveTrackSource?.codec], [currentActiveTrackSource?.sources, currentActiveTrackSource?.codec],
); );

View File

@ -22,7 +22,7 @@ class Sidebar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.sizeOf(context);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));

View File

@ -23,7 +23,6 @@ import 'package:spotube/provider/audio_player/sources/piped_instances_provider.d
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';

View File

@ -12,7 +12,6 @@ import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';

View File

@ -20,7 +20,6 @@ import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';

View File

@ -10,7 +10,6 @@ import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';

View File

@ -1,34 +0,0 @@
import 'package:spotube/models/playback/track_sources.dart';
enum SourceCodecs {
m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"),
mp3._("MP3 (Widely supported audio format)"),
flac._("FLAC (Lossless, best quality)\nLarge file size");
final String label;
const SourceCodecs._(this.label);
}
enum SourceQualities {
uncompressed(3),
high(2),
medium(1),
low(0);
final int priority;
const SourceQualities(this.priority);
bool operator <(SourceQualities other) {
return priority < other.priority;
}
operator >(SourceQualities other) {
return priority > other.priority;
}
}
typedef SiblingType<T extends TrackSourceInfo> = ({
T info,
List<TrackSource>? source
});

View File

@ -1,136 +0,0 @@
import 'package:invidious/invidious.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/models/database/database.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,
);
}
factory YoutubeVideoInfo.fromSearchResponse(
InvidiousSearchResponseVideo searchResponse,
SearchMode searchMode,
) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: searchResponse.title,
duration: Duration(seconds: searchResponse.lengthSeconds),
thumbnailUrl: searchResponse.videoThumbnails.first.url,
id: searchResponse.videoId,
likes: 0,
dislikes: 0,
views: searchResponse.viewCount,
channelName: searchResponse.author,
channelId: searchResponse.authorId,
publishedAt:
DateTime.fromMillisecondsSinceEpoch(searchResponse.published * 1000),
);
}
}

View File

@ -1,18 +1,28 @@
import 'package:collection/collection.dart'; import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:collection/collection.dart';
import 'package:spotube/services/sourced_track/sources/dab_music.dart'; import 'package:dio/dio.dart';
import 'package:spotube/services/sourced_track/sources/invidious.dart'; import 'package:drift/drift.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/extensions/string.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
abstract class SourcedTrack extends BasicSourcedTrack { final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false,
);
class SourcedTrack extends BasicSourcedTrack {
final Ref ref; final Ref ref;
SourcedTrack({ SourcedTrack({
@ -24,72 +34,10 @@ abstract class SourcedTrack extends BasicSourcedTrack {
required super.sources, required super.sources,
}); });
static SourcedTrack fromJson( static String getSearchTerm(SpotubeFullTrackObject track) {
Map<String, dynamic> json, {
required Ref ref,
}) {
final preferences = ref.read(userPreferencesProvider);
final info = TrackSourceInfo.fromJson(json["info"]);
final query = TrackSourceQuery.fromJson(json["query"]);
final source = AudioSource.values.firstWhereOrNull(
(source) => source.name == json["source"],
) ??
preferences.audioSource;
final siblings = (json["siblings"] as List)
.map((s) => TrackSourceInfo.fromJson(s))
.toList();
final sources =
(json["sources"] as List).map((s) => TrackSource.fromJson(s)).toList();
return switch (preferences.audioSource) {
AudioSource.youtube => YoutubeSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
info: info,
query: query,
sources: sources,
),
AudioSource.piped => PipedSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
info: info,
query: query,
sources: sources,
),
AudioSource.jiosaavn => JioSaavnSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
info: info,
query: query,
sources: sources,
),
AudioSource.invidious => InvidiousSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
info: info,
query: query,
sources: sources,
),
AudioSource.dabMusic => DABMusicSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
info: info,
query: query,
sources: sources,
),
};
}
static String getSearchTerm(TrackSourceQuery track) {
final title = ServiceUtils.getTitle( final title = ServiceUtils.getTitle(
track.title, track.name,
artists: track.artists, artists: track.artists.map((e) => e.name).toList(),
onlyCleanArtist: true, onlyCleanArtist: true,
).trim(); ).trim();
@ -99,61 +47,256 @@ abstract class SourcedTrack extends BasicSourcedTrack {
} }
static Future<SourcedTrack> fetchFromQuery({ static Future<SourcedTrack> fetchFromQuery({
required TrackSourceQuery query, required SpotubeFullTrackObject query,
required Ref ref, required Ref ref,
}) async { }) async {
final preferences = ref.read(userPreferencesProvider); final audioSource = await ref.read(audioSourcePluginProvider.future);
try { final audioSourceConfig = await ref.read(metadataPluginsProvider
return switch (preferences.audioSource) { .selectAsync((data) => data.defaultAudioSourcePluginConfig));
AudioSource.youtube => if (audioSource == null || audioSourceConfig == null) {
await YoutubeSourcedTrack.fetchFromTrack(query: query, ref: ref), throw Exception("Dude wat?");
AudioSource.piped => }
await PipedSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.invidious => final database = ref.read(databaseProvider);
await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref), final cachedSource = await (database.select(database.sourceMatchTable)
AudioSource.jiosaavn => ..where((s) =>
await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref), s.trackId.equals(query.id) &
AudioSource.dabMusic => s.sourceType.equals(audioSourceConfig.slug))
await DABMusicSourcedTrack.fetchFromTrack(query: query, ref: ref), ..limit(1)
}; ..orderBy([
} catch (e) { (s) =>
if (preferences.audioSource == AudioSource.youtube) { OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
rethrow; ]))
.get()
.then((s) => s.firstOrNull);
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
} }
return await YoutubeSourcedTrack.fetchFromTrack(query: query, ref: ref); await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
source: jsonEncode(siblings.first),
sourceType: Value(audioSourceConfig.slug),
),
);
return SourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
info: siblings.first.info,
source: audioSourceConfig.slug,
sources: siblings.first.source ?? [],
query: query,
);
} }
final item =
SpotubeAudioSourceMatchObject.fromJson(jsonDecode(cachedSource.source));
final manifest = await audioSource.audioSource.streams(item);
final sourcedTrack = SourcedTrack(
ref: ref,
siblings: [],
sources: manifest,
info: item,
query: query,
source: audioSourceConfig.slug,
);
AppLogger.log.i("${query.name}: ${sourcedTrack.url}");
return sourcedTrack;
} }
static Future<List<SiblingType>> fetchSiblings({ static List<SpotubeAudioSourceMatchObject> rankResults(
required TrackSourceQuery query, List<SpotubeAudioSourceMatchObject> results,
SpotubeFullTrackObject track,
) {
return results
.map((sibling) {
int score = 0;
for (final artist in track.artists) {
final isSameChannelArtist =
sibling.artists.any((a) => a.toLowerCase() == artist.name);
if (isSameChannelArtist) {
score += 1;
}
final titleContainsArtist =
sibling.title.toLowerCase().contains(artist.name.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<SpotubeAudioSourceMatchObject>> fetchSiblings({
required SpotubeFullTrackObject query,
required Ref ref, required Ref ref,
}) { }) async {
final preferences = ref.read(userPreferencesProvider); final audioSource = await ref.read(audioSourcePluginProvider.future);
return switch (preferences.audioSource) { if (audioSource == null) {
AudioSource.piped => throw Exception("Dude wat?");
PipedSourcedTrack.fetchSiblings(query: query, ref: ref), }
AudioSource.youtube =>
YoutubeSourcedTrack.fetchSiblings(query: query, ref: ref), final videoResults = <SpotubeAudioSourceMatchObject>[];
AudioSource.jiosaavn =>
JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref), final searchResults = await audioSource.audioSource.matches(query);
AudioSource.invidious =>
InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref), if (ServiceUtils.onlyContainsEnglish(query.name)) {
AudioSource.dabMusic => videoResults.addAll(searchResults);
DABMusicSourcedTrack.fetchSiblings(query: query, ref: ref), } else {
}; videoResults.addAll(rankResults(searchResults, query));
}
return videoResults.toSet().toList();
} }
Future<SourcedTrack> copyWithSibling(); Future<SourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling); return SourcedTrack(
ref: ref,
siblings: fetchedSiblings.where((s) => s.id != info.id).toList(),
source: source,
sources: sources,
info: info,
query: query,
);
}
Future<SourcedTrack?> swapWithSibling(
SpotubeAudioSourceMatchObject sibling) async {
if (sibling.id == info.id) {
return null;
}
final audioSource = await ref.read(audioSourcePluginProvider.future);
final audioSourceConfig = await ref.read(metadataPluginsProvider
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
if (audioSource == null || audioSourceConfig == null) {
throw Exception("Dude wat?");
}
// 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, info);
final manifest = await audioSource.audioSource.streams(newSourceInfo);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
source: jsonEncode(siblings.first),
sourceType: Value(audioSourceConfig.slug),
createdAt: Value(DateTime.now()),
),
mode: InsertMode.replace,
);
return SourcedTrack(
ref: ref,
source: source,
siblings: newSiblings,
sources: manifest,
info: newSourceInfo,
query: query,
);
}
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) { Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
return swapWithSibling(siblings[index]); return swapWithSibling(siblings[index]);
} }
Future<SourcedTrack> refreshStream(); Future<SourcedTrack> refreshStream() async {
final audioSource = await ref.read(audioSourcePluginProvider.future);
final audioSourceConfig = await ref.read(metadataPluginsProvider
.selectAsync((data) => data.defaultAudioSourcePluginConfig));
if (audioSource == null || audioSourceConfig == null) {
throw Exception("Dude wat?");
}
List<SpotubeAudioSourceStreamObject> validStreams = [];
final stringBuffer = StringBuffer();
for (final source in sources) {
final res = await globalDio.head(
source.url,
options:
Options(validateStatus: (status) => status != null && status < 500),
);
stringBuffer.writeln(
"[${query.id}] ${res.statusCode} ${source.container} ${source.codec} ${source.bitrate}",
);
if (res.statusCode! < 400) {
validStreams.add(source);
}
}
AppLogger.log.d(stringBuffer.toString());
if (validStreams.isEmpty) {
validStreams = await audioSource.audioSource.streams(info);
}
final sourcedTrack = SourcedTrack(
ref: ref,
siblings: siblings,
source: source,
sources: validStreams,
info: info,
query: query,
);
AppLogger.log.i("Refreshing ${query.name}: ${sourcedTrack.url}");
return sourcedTrack;
}
String? get url { String? get url {
final preferences = ref.read(userPreferencesProvider); final preferences = ref.read(userPreferencesProvider);
@ -170,58 +313,75 @@ abstract class SourcedTrack extends BasicSourcedTrack {
/// ///
/// If no sources match the codec, it will return the first or last source /// If no sources match the codec, it will return the first or last source
/// based on the user's audio quality preference. /// based on the user's audio quality preference.
TrackSource? getSourceOfCodec(SourceCodecs codec) { SpotubeAudioSourceStreamObject? getStreamOfQuality(
final preferences = ref.read(userPreferencesProvider); SpotubeAudioSourceContainerPreset preset,
int qualityIndex,
) {
final quality = preset.qualities[qualityIndex];
final exactMatch = sources.firstWhereOrNull( final exactMatch = sources.firstWhereOrNull(
(source) => (source) {
source.codec == codec && source.quality == preferences.audioQuality, if (source.container != preset.name) return false;
if (quality case SpotubeAudioLosslessContainerQuality()) {
return source.sampleRate == quality.sampleRate &&
source.bitDepth == quality.bitDepth;
} else {
return source.bitrate ==
(preset as SpotubeAudioLossyContainerQuality).bitrate;
}
},
); );
if (exactMatch != null) { if (exactMatch != null) {
return exactMatch; return exactMatch;
} }
final sameCodecSources = sources // Find the closest to preset
.where((source) => source.codec == codec) SpotubeAudioSourceStreamObject? closest;
.toList() for (final source in sources) {
.sorted((a, b) { if (source.container != preset.name) continue;
final aDiff = (a.quality.index - preferences.audioQuality.index).abs();
final bDiff = (b.quality.index - preferences.audioQuality.index).abs();
return aDiff != bDiff ? aDiff - bDiff : a.quality.index - b.quality.index;
}).toList();
if (sameCodecSources.isNotEmpty) { if (quality case SpotubeAudioLosslessContainerQuality()) {
return preferences.audioQuality > SourceQualities.low final sourceBps = (source.bitDepth ?? 0) * (source.sampleRate ?? 0);
? sameCodecSources.first final qualityBps = quality.bitDepth * quality.sampleRate;
: sameCodecSources.last; final closestBps =
(closest?.bitDepth ?? 0) * (closest?.sampleRate ?? 0);
if (sourceBps == qualityBps) {
closest = source;
break;
}
final closestDiff = (closestBps - qualityBps).abs();
final sourceDiff = (sourceBps - qualityBps).abs();
if (sourceDiff < closestDiff) {
closest = source;
}
} else {
final presetBitrate =
(preset as SpotubeAudioLossyContainerQuality).bitrate;
if (presetBitrate == source.bitrate) {
closest = source;
break;
}
final closestDiff = (closest?.bitrate ?? 0) - presetBitrate;
final sourceDiff = (source.bitrate ?? 0) - presetBitrate;
if (sourceDiff < closestDiff) {
closest = source;
}
}
} }
final fallbackSource = sources.sorted((a, b) { return closest;
final aDiff = (a.quality.index - preferences.audioQuality.index).abs();
final bDiff = (b.quality.index - preferences.audioQuality.index).abs();
return aDiff != bDiff ? aDiff - bDiff : a.quality.index - b.quality.index;
});
return preferences.audioQuality > SourceQualities.low
? fallbackSource.firstOrNull
: fallbackSource.lastOrNull;
} }
String? getUrlOfCodec(SourceCodecs codec) { String? getUrlOfQuality(
return getSourceOfCodec(codec)?.url; SpotubeAudioSourceContainerPreset preset,
} int qualityIndex,
) {
SourceCodecs get codec { return getStreamOfQuality(preset, qualityIndex)?.url;
final preferences = ref.read(userPreferencesProvider);
return switch (preferences.audioSource) {
AudioSource.dabMusic =>
preferences.audioQuality == SourceQualities.uncompressed
? SourceCodecs.flac
: SourceCodecs.mp3,
AudioSource.jiosaavn => SourceCodecs.m4a,
_ => preferences.streamMusicCodec
};
} }
} }

View File

@ -1,303 +0,0 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:dab_music_api/dab_music_api.dart';
final dabMusicApiClient = DabMusicApiClient(
Dio(),
baseUrl: "https://dab.yeet.su/api",
);
/// Only Music source that can't support database caching due to having no endpoint.
/// But ISRC search is 100% reliable so caching is actually not necessary.
class DABMusicSourcedTrack extends SourcedTrack {
DABMusicSourcedTrack({
required super.ref,
required super.source,
required super.siblings,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required TrackSourceQuery query,
required Ref ref,
}) async {
try {
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) => OrderingTerm(
expression: s.createdAt,
mode: OrderingMode.desc,
),
]))
.get()
.then((s) => s.firstOrNull);
if (cachedSource != null &&
cachedSource.sourceType == SourceType.dabMusic) {
final json = jsonDecode(cachedSource.sourceId);
final info = TrackSourceInfo.fromJson(json["info"]);
final source = (json["sources"] as List?)
?.map((s) => TrackSource.fromJson(s))
.toList();
final [updatedSource] = await fetchSources(
info.id,
ref.read(userPreferencesProvider).audioQuality,
const AudioQuality(
isHiRes: true,
maximumBitDepth: 16,
maximumSamplingRate: 44.1,
),
);
return DABMusicSourcedTrack(
ref: ref,
source: AudioSource.dabMusic,
siblings: [],
info: info,
query: query,
sources: [
source!.first.copyWith(url: updatedSource.url),
],
);
}
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: jsonEncode({
"info": siblings.first.info.toJson(),
"sources": (siblings.first.source ?? [])
.map((s) => s.toJson())
.toList(),
}),
sourceType: const Value(SourceType.dabMusic),
),
);
return DABMusicSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
sources: siblings.first.source!,
info: siblings.first.info,
query: query,
source: AudioSource.dabMusic,
);
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
rethrow;
}
}
static Future<List<TrackSource>> fetchSources(
String id,
SourceQualities quality,
AudioQuality trackMaximumQuality,
) async {
try {
final isUncompressed = quality == SourceQualities.uncompressed;
final streamResponse = await dabMusicApiClient.music.getStream(
trackId: id,
quality: isUncompressed ? "27" : "5",
);
if (streamResponse.url == null) {
throw Exception("No stream URL found for track ID: $id");
}
// kbps = (bitDepth * sampleRate * channels) / 1000
final uncompressedBitrate = !isUncompressed
? 0
: ((trackMaximumQuality.maximumBitDepth ?? 0) *
((trackMaximumQuality.maximumSamplingRate ?? 0) * 1000) *
2) /
1000;
return [
TrackSource(
url: streamResponse.url!,
quality: isUncompressed
? SourceQualities.uncompressed
: SourceQualities.high,
bitrate:
isUncompressed ? "${uncompressedBitrate.floor()}kbps" : "320kbps",
codec: isUncompressed ? SourceCodecs.flac : SourceCodecs.mp3,
qualityLabel: isUncompressed
? "${trackMaximumQuality.maximumBitDepth}bit • ${trackMaximumQuality.maximumSamplingRate}kHz • FLAC • Stereo"
: "MP3 • 320kbps • mp3 • Stereo",
),
];
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
rethrow;
}
}
static Future<SiblingType> toSiblingType(
Ref ref,
int index,
Track result,
) async {
try {
List<TrackSource>? source;
if (index == 0) {
source = await fetchSources(
result.id.toString(),
ref.read(userPreferencesProvider).audioQuality,
result.audioQuality!,
);
}
final SiblingType sibling = (
info: TrackSourceInfo(
artists: result.artist!,
durationMs: Duration(seconds: result.duration!).inMilliseconds,
id: result.id.toString(),
pageUrl: "https://dab.yeet.su/music/${result.id}",
thumbnail: result.albumCover!,
title: result.title!,
),
source: source,
);
return sibling;
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
rethrow;
}
}
static Future<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
}) async {
try {
List<Track> results = [];
if (query.isrc.isNotEmpty) {
final res =
await dabMusicApiClient.music.getSearch(q: query.isrc, limit: 1);
results = res.tracks ?? <Track>[];
}
if (results.isEmpty) {
final res = await dabMusicApiClient.music.getSearch(
q: SourcedTrack.getSearchTerm(query),
limit: 5,
);
results = res.tracks ?? <Track>[];
}
if (results.isEmpty) {
return [];
}
final matchedResults =
results.mapIndexed((index, d) => toSiblingType(ref, index, d));
return Future.wait(matchedResults);
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
rethrow;
}
}
@override
Future<DABMusicSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return DABMusicSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
info: info,
query: query,
sources: sources,
);
}
@override
Future<DABMusicSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == this.info.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, this.info);
final source = await fetchSources(
sibling.id,
ref.read(userPreferencesProvider).audioQuality,
const AudioQuality(
isHiRes: true,
maximumBitDepth: 16,
maximumSamplingRate: 44.1,
),
);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: jsonEncode({
"info": newSourceInfo.toJson(),
"sources": source.map((s) => s.toJson()).toList(),
}),
sourceType: const Value(SourceType.dabMusic),
// 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 DABMusicSourcedTrack(
ref: ref,
siblings: newSiblings,
sources: source,
info: newSourceInfo,
query: query,
source: AudioSource.dabMusic,
);
}
@override
Future<SourcedTrack> refreshStream() async {
// There's no need to refresh the stream for DABMusicSourcedTrack
return this;
}
}

View File

@ -1,263 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:invidious/invidious.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
final invidiousProvider = Provider<InvidiousClient>(
(ref) {
final invidiousInstance = ref.watch(
userPreferencesProvider.select((s) => s.invidiousInstance),
);
return InvidiousClient(server: invidiousInstance);
},
);
class InvidiousSourcedTrack extends SourcedTrack {
InvidiousSourcedTrack({
required super.ref,
required super.source,
required super.siblings,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required TrackSourceQuery query,
required Ref ref,
}) async {
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
]))
.getSingleOrNull();
final invidiousClient = ref.read(invidiousProvider);
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.youtube),
),
);
return InvidiousSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
sources: siblings.first.source as List<TrackSource>,
info: siblings.first.info,
query: query,
source: audioSource,
);
} else {
final manifest =
await invidiousClient.videos.get(cachedSource.sourceId, local: true);
return InvidiousSourcedTrack(
ref: ref,
siblings: [],
sources: toSources(manifest),
info: TrackSourceInfo(
id: manifest.videoId,
artists: manifest.author,
pageUrl: "https://www.youtube.com/watch?v=${manifest.videoId}",
thumbnail: manifest.videoThumbnails.first.url,
title: manifest.title,
durationMs: Duration(seconds: manifest.lengthSeconds).inMilliseconds,
),
query: query,
source: audioSource,
);
}
}
static List<TrackSource> toSources(InvidiousVideoResponse manifest) {
return manifest.adaptiveFormats.map((stream) {
var isWebm = stream.type.contains("audio/webm");
return TrackSource(
url: stream.url.toString(),
quality: switch (stream.qualityLabel) {
"high" => SourceQualities.high,
"medium" => SourceQualities.medium,
_ => SourceQualities.low,
},
codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a,
bitrate: stream.bitrate,
qualityLabel:
"${isWebm ? "Opus" : "AAC"}${stream.bitrate.replaceAll("kbps", "")}kbps "
"${isWebm ? "weba" : "m4a"} • Stereo",
);
}).toList();
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
InvidiousClient invidiousClient,
) async {
List<TrackSource>? sourceMap;
if (index == 0) {
final manifest = await invidiousClient.videos.get(item.id, local: true);
sourceMap = toSources(manifest);
}
final SiblingType sibling = (
info: TrackSourceInfo(
id: item.id,
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
durationMs: item.duration.inMilliseconds,
),
source: sourceMap,
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
}) async {
final invidiousClient = ref.read(invidiousProvider);
final preference = ref.read(userPreferencesProvider);
final searchQuery = SourcedTrack.getSearchTerm(query);
final searchResults = await invidiousClient.search.list(
searchQuery,
type: InvidiousSearchType.video,
);
if (ServiceUtils.onlyContainsEnglish(searchQuery)) {
return await Future.wait(
searchResults
.whereType<InvidiousSearchResponseVideo>()
.map(
(result) => YoutubeVideoInfo.fromSearchResponse(
result,
preference.searchMode,
),
)
.mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)),
);
}
final rankedSiblings = YoutubeSourcedTrack.rankResults(
searchResults
.whereType<InvidiousSearchResponseVideo>()
.map(
(result) => YoutubeVideoInfo.fromSearchResponse(
result,
preference.searchMode,
),
)
.toList(),
query,
);
return await Future.wait(
rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)),
);
}
@override
Future<SourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return InvidiousSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
info: info,
query: query,
sources: sources,
);
}
@override
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.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, info);
final pipedClient = ref.read(invidiousProvider);
final manifest =
await pipedClient.videos.get(newSourceInfo.id, local: true);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// 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 InvidiousSourcedTrack(
ref: ref,
siblings: newSiblings,
sources: toSources(manifest),
info: newSourceInfo,
query: query,
source: source,
);
}
@override
Future<SourcedTrack> refreshStream() async {
final manifest =
await ref.read(invidiousProvider).videos.get(info.id, local: true);
return InvidiousSourcedTrack(
ref: ref,
siblings: siblings,
sources: toSources(manifest),
info: info,
query: query,
source: source,
);
}
}

View File

@ -1,231 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:jiosaavn/jiosaavn.dart';
import 'package:spotube/extensions/string.dart';
final jiosaavnClient = JioSaavnClient();
class JioSaavnSourcedTrack extends SourcedTrack {
JioSaavnSourcedTrack({
required super.ref,
required super.source,
required super.siblings,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required TrackSourceQuery query,
required Ref ref,
bool weakMatch = false,
}) async {
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
]))
.getSingleOrNull();
if (cachedSource == null ||
cachedSource.sourceType != SourceType.jiosaavn) {
final siblings =
await fetchSiblings(ref: ref, query: query, weakMatch: weakMatch);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.jiosaavn),
),
);
return JioSaavnSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
sources: siblings.first.source!,
info: siblings.first.info,
query: query,
source: AudioSource.jiosaavn,
);
}
final [item] =
await jiosaavnClient.songs.detailsById([cachedSource.sourceId]);
final (:info, :source) = toSiblingType(item);
return JioSaavnSourcedTrack(
ref: ref,
siblings: [],
sources: source!,
query: query,
info: info,
source: AudioSource.jiosaavn,
);
}
static SiblingType toSiblingType(SongResponse result) {
final SiblingType sibling = (
info: TrackSourceInfo(
artists: [
result.primaryArtists,
if (result.featuredArtists.isNotEmpty) ", ",
result.featuredArtists
].join("").unescapeHtml(),
durationMs:
Duration(seconds: int.parse(result.duration)).inMilliseconds,
id: result.id,
pageUrl: result.url,
thumbnail: result.image?.last.link ?? "",
title: result.name!.unescapeHtml(),
),
source: result.downloadUrl!.map((link) {
return TrackSource(
url: link.link,
quality: link.quality == "320kbps"
? SourceQualities.high
: link.quality == "160kbps"
? SourceQualities.medium
: SourceQualities.low,
codec: SourceCodecs.m4a,
bitrate: link.quality,
qualityLabel: "AAC • ${link.quality} • MP4 • Stereo",
);
}).toList()
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
bool weakMatch = false,
}) async {
final searchQuery = SourcedTrack.getSearchTerm(query);
final SongSearchResponse(:results) =
await jiosaavnClient.search.songs(searchQuery, limit: 20);
final trackArtistNames = query.artists;
final matchedResults = results
.where(
(s) {
s.name?.unescapeHtml().contains(query.title) ?? false;
final sameName = s.name?.unescapeHtml() == query.title;
final artistNames = [
s.primaryArtists,
if (s.featuredArtists.isNotEmpty) ", ",
s.featuredArtists
].join("").unescapeHtml();
final sameArtists = artistNames.split(", ").any(
(artist) => trackArtistNames.any((ar) => artist == ar),
);
if (weakMatch) {
final containsName =
s.name?.unescapeHtml().contains(query.title) ?? false;
final containsPrimaryArtist = s.primaryArtists
.unescapeHtml()
.contains(trackArtistNames.first);
return containsName && containsPrimaryArtist;
}
return sameName && sameArtists;
},
)
.map(toSiblingType)
.toList();
if (weakMatch && matchedResults.isEmpty) {
return results.map(toSiblingType).toList();
}
return matchedResults;
}
@override
Future<JioSaavnSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return JioSaavnSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
info: info,
query: query,
sources: sources,
);
}
@override
Future<JioSaavnSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == this.info.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, this.info);
final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]);
final (:info, :source) = toSiblingType(item);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: info.id,
sourceType: const Value(SourceType.jiosaavn),
// 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 JioSaavnSourcedTrack(
ref: ref,
siblings: newSiblings,
sources: source!,
info: newSourceInfo,
query: query,
source: AudioSource.jiosaavn,
);
}
@override
Future<SourcedTrack> refreshStream() async {
// There's no need to refresh the stream for JioSaavnSourcedTrack
return this;
}
}

View File

@ -1,292 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/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.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required TrackSourceQuery query,
required Ref ref,
}) async {
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
]))
.getSingleOrNull();
final preferences = ref.read(userPreferencesProvider);
final pipedClient = ref.read(pipedProvider);
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: Value(
preferences.searchMode == SearchMode.youtube
? SourceType.youtube
: SourceType.youtubeMusic,
),
),
);
return PipedSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: audioSource,
info: siblings.first.info,
query: query,
sources: siblings.first.source!,
);
} else {
final manifest = await pipedClient.streams(cachedSource.sourceId);
return PipedSourcedTrack(
ref: ref,
siblings: [],
sources: toSources(manifest),
info: TrackSourceInfo(
id: manifest.id,
artists: manifest.uploader,
pageUrl: "https://www.youtube.com/watch?v=${manifest.id}",
thumbnail: manifest.thumbnailUrl,
title: manifest.title,
durationMs: manifest.duration.inMilliseconds,
),
query: query,
source: audioSource,
);
}
}
static List<TrackSource> toSources(PipedStreamResponse manifest) {
return manifest.audioStreams.map((audio) {
final isMp4 = audio.format == PipedAudioStreamFormat.m4a;
return TrackSource(
url: audio.url.toString(),
quality: switch (audio.quality) {
"high" => SourceQualities.high,
"medium" => SourceQualities.medium,
_ => SourceQualities.low,
},
codec: isMp4 ? SourceCodecs.m4a : SourceCodecs.weba,
bitrate: audio.bitrate.toString(),
qualityLabel:
"${isMp4 ? "AAC" : "Opus"}${(audio.bitrate / 1000).floor()}kbps "
"${isMp4 ? "m4a" : "weba"} • Stereo",
);
}).toList();
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
PipedClient pipedClient,
) async {
List<TrackSource>? sources;
if (index == 0) {
final manifest = await pipedClient.streams(item.id);
sources = toSources(manifest);
}
final SiblingType sibling = (
info: TrackSourceInfo(
id: item.id,
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
durationMs: item.duration.inMilliseconds,
),
source: sources,
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
}) async {
final pipedClient = ref.read(pipedProvider);
final preference = ref.read(userPreferencesProvider);
final searchQuery = SourcedTrack.getSearchTerm(query);
final PipedSearchResult(items: searchResults) = await pipedClient.search(
searchQuery,
preference.searchMode == SearchMode.youtube
? PipedFilter.videos
: PipedFilter.musicSongs,
);
// when falling back to piped API make sure to use the YouTube mode
final isYouTubeMusic = preference.audioSource != AudioSource.piped
? false
: preference.searchMode == SearchMode.youtubeMusic;
if (isYouTubeMusic) {
final artists = query.artists;
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(searchQuery)) {
return await Future.wait(
searchResults
.whereType<PipedSearchItemStream>()
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result,
preference.searchMode,
),
)
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
final rankedSiblings = YoutubeSourcedTrack.rankResults(
searchResults
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result as PipedSearchItemStream,
preference.searchMode,
),
)
.toList(),
query,
);
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, query: query);
return PipedSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
info: info,
query: query,
sources: sources,
);
}
@override
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.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, info);
final pipedClient = ref.read(pipedProvider);
final manifest = await pipedClient.streams(newSourceInfo.id);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// 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 PipedSourcedTrack(
ref: ref,
siblings: newSiblings,
sources: toSources(manifest),
info: newSourceInfo,
query: query,
source: source,
);
}
@override
Future<SourcedTrack> refreshStream() async {
final manifest = await ref.read(pipedProvider).streams(info.id);
return PipedSourcedTrack(
ref: ref,
siblings: siblings,
info: info,
source: source,
query: query,
sources: toSources(manifest),
);
}
}

View File

@ -1,439 +0,0 @@
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/song_link/song_link.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
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.info,
required super.query,
required super.sources,
required super.ref,
});
static Future<YoutubeSourcedTrack> fetchFromTrack({
required TrackSourceQuery query,
required Ref ref,
}) async {
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
]))
.get()
.then((s) => s.firstOrNull);
if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.youtube),
),
);
return YoutubeSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
info: siblings.first.info,
source: audioSource,
sources: siblings.first.source ?? [],
query: query,
);
}
final (item, manifest) = await ref
.read(youtubeEngineProvider)
.getVideoWithStreamInfo(cachedSource.sourceId);
final sourcedTrack = YoutubeSourcedTrack(
ref: ref,
siblings: [],
sources: toTrackSources(manifest),
info: TrackSourceInfo(
id: item.id.value,
artists: item.author,
pageUrl: item.url,
thumbnail: item.thumbnails.highResUrl,
title: item.title,
durationMs: item.duration?.inMilliseconds ?? 0,
),
query: query,
source: audioSource,
);
AppLogger.log.i("${query.title}: ${sourcedTrack.url}");
return sourcedTrack;
}
static List<TrackSource> toTrackSources(StreamManifest manifest) {
return manifest.audioOnly.map((streamInfo) {
var isWebm = streamInfo.codec.mimeType == "audio/webm";
return TrackSource(
url: streamInfo.url.toString(),
quality: switch (streamInfo.qualityLabel) {
"medium" => SourceQualities.medium,
"high" => SourceQualities.high,
"low" => SourceQualities.low,
_ => SourceQualities.high,
},
codec: isWebm ? SourceCodecs.weba : SourceCodecs.m4a,
bitrate: streamInfo.bitrate.bitsPerSecond.toString(),
qualityLabel:
"${isWebm ? "Opus" : "AAC"}${(streamInfo.bitrate.kiloBitsPerSecond).floor()}kbps "
"${isWebm ? "weba" : "m4a"} • Stereo",
);
}).toList();
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
dynamic ref,
) async {
assert(ref is WidgetRef || ref is Ref, "Invalid ref type");
List<TrackSource>? sourceMap;
if (index == 0) {
final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(item.id);
sourceMap = toTrackSources(manifest);
}
final SiblingType sibling = (
info: TrackSourceInfo(
id: item.id,
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
durationMs: item.duration.inMilliseconds,
),
source: sourceMap,
);
return sibling;
}
static List<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> results, TrackSourceQuery track) {
return results
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in track.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.title.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<YoutubeVideoInfo>> fetchFromIsrc({
required TrackSourceQuery track,
required Ref ref,
}) async {
final isrcResults = <YoutubeVideoInfo>[];
final isrc = track.isrc;
if (isrc.isNotEmpty) {
final searchedVideos =
await ref.read(youtubeEngineProvider).searchVideos(isrc.toString());
if (searchedVideos.isNotEmpty) {
AppLogger.log
.d("${track.title} ISRC $isrc Total ${searchedVideos.length}");
final stringBuffer = StringBuffer();
final filteredMatches = searchedVideos
.map<YoutubeVideoInfo>(YoutubeVideoInfo.fromVideo)
.map((YoutubeVideoInfo videoInfo) {
final ytWords = videoInfo.title
.toLowerCase()
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
final spWords = track.title
.toLowerCase()
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
// Single word and duration match with 3 second tolerance
if (ytWords.any((word) => spWords.contains(word)) &&
(videoInfo.duration -
Duration(milliseconds: track.durationMs))
.abs()
.inMilliseconds <=
3000) {
stringBuffer.writeln(
"ISRC MATCH: ${videoInfo.id} ${videoInfo.title} by ${videoInfo.channelName} ${videoInfo.duration}",
);
return videoInfo;
}
return null;
})
.nonNulls
.toList();
AppLogger.log.d(stringBuffer.toString());
isrcResults.addAll(filteredMatches);
}
}
return isrcResults;
}
static Future<List<SiblingType>> fetchSiblings({
required TrackSourceQuery query,
required Ref ref,
}) async {
final videoResults = <YoutubeVideoInfo>[];
if (query is! SourcedTrack) {
final isrcResults = await fetchFromIsrc(
track: query,
ref: ref,
);
videoResults.addAll(isrcResults);
if (isrcResults.isEmpty) {
AppLogger.log.w("No ISRC results found, falling back to SongLink");
final links = await SongLinkService.links(query.id);
final stringBuffer = links.fold(
StringBuffer(),
(previousValue, element) {
previousValue.writeln(
"SongLink ${query.id} ${element.platform} ${element.url}");
return previousValue;
},
);
AppLogger.log.d(stringBuffer.toString());
final ytLink = links.firstWhereOrNull(
(link) => link.platform == "youtube",
);
if (ytLink?.url != null) {
try {
videoResults.add(
YoutubeVideoInfo.fromVideo(await ref
.read(youtubeEngineProvider)
.getVideo(Uri.parse(ytLink!.url!).queryParameters["v"]!)),
);
} on VideoUnplayableException catch (e, stack) {
// Ignore this error and continue with the search
AppLogger.reportError(e, stack);
}
} else {
AppLogger.log.w("No YouTube link found in SongLink results");
}
}
}
final searchQuery = SourcedTrack.getSearchTerm(query);
final searchResults =
await ref.read(youtubeEngineProvider).searchVideos(searchQuery);
if (ServiceUtils.onlyContainsEnglish(searchQuery)) {
videoResults
.addAll(searchResults.map(YoutubeVideoInfo.fromVideo).toList());
} else {
videoResults.addAll(rankResults(
searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
query,
));
}
final seenIds = <String>{};
int index = 0;
return await Future.wait(
videoResults.map((videoResult) async {
// Deduplicate results
if (!seenIds.contains(videoResult.id)) {
seenIds.add(videoResult.id);
return await toSiblingType(index++, videoResult, ref);
}
return null;
}),
).then((s) => s.whereType<SiblingType>().toList());
}
@override
Future<YoutubeSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.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, info);
final manifest = await ref
.read(youtubeEngineProvider)
.getStreamManifest(newSourceInfo.id);
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// 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 YoutubeSourcedTrack(
ref: ref,
source: source,
siblings: newSiblings,
sources: toTrackSources(manifest),
info: newSourceInfo,
query: query,
);
}
@override
Future<YoutubeSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return YoutubeSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
sources: sources,
info: info,
query: query,
);
}
@override
Future<SourcedTrack> refreshStream() async {
List<TrackSource> validStreams = [];
final stringBuffer = StringBuffer();
for (final source in sources) {
final res = await globalDio.head(
source.url,
options:
Options(validateStatus: (status) => status != null && status < 500),
);
stringBuffer.writeln(
"[${query.id}] ${res.statusCode} ${source.quality} ${source.codec} ${source.bitrate}",
);
if (res.statusCode! < 400) {
validStreams.add(source);
}
}
AppLogger.log.d(stringBuffer.toString());
if (validStreams.isEmpty) {
final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(info.id);
validStreams = toTrackSources(manifest);
}
final sourcedTrack = YoutubeSourcedTrack(
ref: ref,
siblings: siblings,
source: source,
sources: validStreams,
info: info,
query: query,
);
AppLogger.log.i("Refreshing ${query.title}: ${sourcedTrack.url}");
return sourcedTrack;
}
}

View File

@ -162,7 +162,6 @@ class YouTubeExplodeEngine implements YouTubeEngine {
requireWatchPage: false, requireWatchPage: false,
ytClients: [ ytClients: [
YoutubeApiClient.ios, YoutubeApiClient.ios,
YoutubeApiClient.android,
YoutubeApiClient.androidVr, YoutubeApiClient.androidVr,
], ],
); );

View File

@ -2888,12 +2888,11 @@ packages:
youtube_explode_dart: youtube_explode_dart:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." name: youtube_explode_dart
ref: HEAD sha256: "947ba05e0c4f050743e480e7bca3575ff6427d86cc898c1a69f5e1d188cdc9e0"
resolved-ref: caa3023386dbc10e69c99f49f491148094874671 url: "https://pub.dev"
url: "https://github.com/Coronon/youtube_explode_dart" source: hosted
source: git version: "2.5.3"
version: "2.5.2"
yt_dlp_dart: yt_dlp_dart:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -138,8 +138,7 @@ dependencies:
wikipedia_api: ^0.1.0 wikipedia_api: ^0.1.0
win32_registry: ^1.1.5 win32_registry: ^1.1.5
window_manager: ^0.4.3 window_manager: ^0.4.3
youtube_explode_dart: youtube_explode_dart: ^2.5.3
git: https://github.com/Coronon/youtube_explode_dart
yt_dlp_dart: yt_dlp_dart:
git: git:
url: https://github.com/KRTirtho/yt_dlp_dart.git url: https://github.com/KRTirtho/yt_dlp_dart.git

View File

@ -12,6 +12,7 @@ import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7; import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8; import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9; import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -35,10 +36,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v8.DatabaseAtV8(db); return v8.DatabaseAtV8(db);
case 9: case 9:
return v9.DatabaseAtV9(db); return v9.DatabaseAtV9(db);
case 10:
return v10.DatabaseAtV10(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
} }

File diff suppressed because it is too large Load Diff