mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-06 07:29:42 +00:00
chore: create sourced track from active audio source plugin
This commit is contained in:
parent
3bc296cf22
commit
99a84aa6dc
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
|
||||||
});
|
|
||||||
@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -162,7 +162,6 @@ class YouTubeExplodeEngine implements YouTubeEngine {
|
|||||||
requireWatchPage: false,
|
requireWatchPage: false,
|
||||||
ytClients: [
|
ytClients: [
|
||||||
YoutubeApiClient.ios,
|
YoutubeApiClient.ios,
|
||||||
YoutubeApiClient.android,
|
|
||||||
YoutubeApiClient.androidVr,
|
YoutubeApiClient.androidVr,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
11
pubspec.lock
11
pubspec.lock
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
3472
test/drift/app_db/generated/schema_v10.dart
Normal file
3472
test/drift/app_db/generated/schema_v10.dart
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user