feat: add youtube engine abstraction and yt-dlp integration

This commit is contained in:
Kingkor Roy Tirtho 2025-02-07 23:38:32 +06:00
parent 698fb6ba27
commit d6726bc3b0
24 changed files with 4409 additions and 90 deletions

File diff suppressed because one or more lines are too long

View File

@ -134,4 +134,5 @@ abstract class SpotubeIcons {
static const grid = FeatherIcons.grid; static const grid = FeatherIcons.grid;
static const list = FeatherIcons.list; static const list = FeatherIcons.list;
static const device = FeatherIcons.smartphone; static const device = FeatherIcons.smartphone;
static const engine = FeatherIcons.server;
} }

View File

@ -0,0 +1,33 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
void useCheckYtDlpInstalled(WidgetRef ref) {
final context = useContext();
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final youtubeEngine = ref.read(
userPreferencesProvider.select(
(value) => value.youtubeClientEngine,
),
);
if (youtubeEngine == YoutubeClientEngine.ytDlp &&
!await YtDlpEngine.isInstalled() &&
context.mounted) {
await showDialog(
context: context,
builder: (context) =>
YouTubeEngineNotInstalledDialog(engine: youtubeEngine),
);
}
});
return null;
}, []);
}

View File

@ -415,5 +415,9 @@
"no_tracks_listened_yet": "Looks like you haven't listened to anything yet", "no_tracks_listened_yet": "Looks like you haven't listened to anything yet",
"not_following_artists": "You're not following any artists", "not_following_artists": "You're not following any artists",
"no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet",
"no_logs_found": "No logs found" "no_logs_found": "No logs found",
"youtube_engine": "YouTube Engine",
"youtube_engine_not_installed_title": "{engine} is not installed",
"youtube_engine_not_installed_message": "{engine} is not installed in your system.\nPlease install it and make sure it's available in the PATH variable\n\nAfter installing, restart the app",
"download": "Download"
} }

View File

@ -50,6 +50,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:timezone/data/latest.dart' as tz; import 'package:timezone/data/latest.dart' as tz;
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:yt_dlp_dart/yt_dlp_dart.dart';
Future<void> main(List<String> rawArgs) async { Future<void> main(List<String> rawArgs) async {
if (rawArgs.contains("web_view_title_bar")) { if (rawArgs.contains("web_view_title_bar")) {
@ -79,15 +80,15 @@ Future<void> main(List<String> rawArgs) async {
await FlutterDisplayMode.setHighRefreshRate(); await FlutterDisplayMode.setHighRefreshRate();
} }
if (kIsDesktop) {
await windowManager.setPreventClose(true);
}
if (!kIsWeb) { if (!kIsWeb) {
MetadataGod.initialize(); MetadataGod.initialize();
} }
if (kIsDesktop) { if (kIsDesktop) {
await windowManager.setPreventClose(true);
await YtDlp.instance
.setBinaryLocation("yt-dlp${kIsWindows ? '.exe' : ''}")
.catchError((e, stack) => null);
await FlutterDiscordRPC.initialize(Env.discordAppId); await FlutterDiscordRPC.initialize(Env.discordAppId);
} }

View File

@ -18,6 +18,8 @@ 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';
import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
@ -59,7 +61,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 3; int get schemaVersion => 4;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -78,6 +80,12 @@ class AppDatabase extends _$AppDatabase {
schema.preferencesTable.cacheMusic, schema.preferencesTable.cacheMusic,
); );
}, },
from3To4: (m, schema) async {
await m.addColumn(
schema.preferencesTable,
schema.preferencesTable.youtubeClientEngine,
);
},
), ),
); );
} }

View File

@ -760,6 +760,17 @@ class $PreferencesTableTable extends PreferencesTable
defaultValue: Constant(AudioSource.youtube.name)) defaultValue: Constant(AudioSource.youtube.name))
.withConverter<AudioSource>( .withConverter<AudioSource>(
$PreferencesTableTable.$converteraudioSource); $PreferencesTableTable.$converteraudioSource);
static const VerificationMeta _youtubeClientEngineMeta =
const VerificationMeta('youtubeClientEngine');
@override
late final GeneratedColumnWithTypeConverter<YoutubeClientEngine, String>
youtubeClientEngine = GeneratedColumn<String>(
'youtube_client_engine', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name))
.withConverter<YoutubeClientEngine>(
$PreferencesTableTable.$converteryoutubeClientEngine);
static const VerificationMeta _streamMusicCodecMeta = static const VerificationMeta _streamMusicCodecMeta =
const VerificationMeta('streamMusicCodec'); const VerificationMeta('streamMusicCodec');
@override @override
@ -845,6 +856,7 @@ class $PreferencesTableTable extends PreferencesTable
invidiousInstance, invidiousInstance,
themeMode, themeMode,
audioSource, audioSource,
youtubeClientEngine,
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
discordPresence, discordPresence,
@ -937,6 +949,8 @@ class $PreferencesTableTable extends PreferencesTable
} }
context.handle(_themeModeMeta, const VerificationResult.success()); context.handle(_themeModeMeta, const VerificationResult.success());
context.handle(_audioSourceMeta, const VerificationResult.success()); context.handle(_audioSourceMeta, const VerificationResult.success());
context.handle(
_youtubeClientEngineMeta, const VerificationResult.success());
context.handle(_streamMusicCodecMeta, const VerificationResult.success()); context.handle(_streamMusicCodecMeta, const VerificationResult.success());
context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); context.handle(_downloadMusicCodecMeta, const VerificationResult.success());
if (data.containsKey('discord_presence')) { if (data.containsKey('discord_presence')) {
@ -1025,6 +1039,9 @@ class $PreferencesTableTable extends PreferencesTable
audioSource: $PreferencesTableTable.$converteraudioSource.fromSql( audioSource: $PreferencesTableTable.$converteraudioSource.fromSql(
attachedDatabase.typeMapping.read( attachedDatabase.typeMapping.read(
DriftSqlType.string, data['${effectivePrefix}audio_source'])!), DriftSqlType.string, data['${effectivePrefix}audio_source'])!),
youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine
.fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}youtube_client_engine'])!),
streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec
.fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}stream_music_codec'])!), data['${effectivePrefix}stream_music_codec'])!),
@ -1069,6 +1086,9 @@ class $PreferencesTableTable extends PreferencesTable
const EnumNameConverter<ThemeMode>(ThemeMode.values); const EnumNameConverter<ThemeMode>(ThemeMode.values);
static JsonTypeConverter2<AudioSource, String, String> $converteraudioSource = static JsonTypeConverter2<AudioSource, String, String> $converteraudioSource =
const EnumNameConverter<AudioSource>(AudioSource.values); const EnumNameConverter<AudioSource>(AudioSource.values);
static JsonTypeConverter2<YoutubeClientEngine, String, String>
$converteryoutubeClientEngine =
const EnumNameConverter<YoutubeClientEngine>(YoutubeClientEngine.values);
static JsonTypeConverter2<SourceCodecs, String, String> static JsonTypeConverter2<SourceCodecs, String, String>
$converterstreamMusicCodec = $converterstreamMusicCodec =
const EnumNameConverter<SourceCodecs>(SourceCodecs.values); const EnumNameConverter<SourceCodecs>(SourceCodecs.values);
@ -1100,6 +1120,7 @@ class PreferencesTableData extends DataClass
final String invidiousInstance; final String invidiousInstance;
final ThemeMode themeMode; final ThemeMode themeMode;
final AudioSource audioSource; final AudioSource audioSource;
final YoutubeClientEngine youtubeClientEngine;
final SourceCodecs streamMusicCodec; final SourceCodecs streamMusicCodec;
final SourceCodecs downloadMusicCodec; final SourceCodecs downloadMusicCodec;
final bool discordPresence; final bool discordPresence;
@ -1128,6 +1149,7 @@ class PreferencesTableData extends DataClass
required this.invidiousInstance, required this.invidiousInstance,
required this.themeMode, required this.themeMode,
required this.audioSource, required this.audioSource,
required this.youtubeClientEngine,
required this.streamMusicCodec, required this.streamMusicCodec,
required this.downloadMusicCodec, required this.downloadMusicCodec,
required this.discordPresence, required this.discordPresence,
@ -1190,6 +1212,11 @@ class PreferencesTableData extends DataClass
map['audio_source'] = Variable<String>( map['audio_source'] = Variable<String>(
$PreferencesTableTable.$converteraudioSource.toSql(audioSource)); $PreferencesTableTable.$converteraudioSource.toSql(audioSource));
} }
{
map['youtube_client_engine'] = Variable<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toSql(youtubeClientEngine));
}
{ {
map['stream_music_codec'] = Variable<String>($PreferencesTableTable map['stream_music_codec'] = Variable<String>($PreferencesTableTable
.$converterstreamMusicCodec .$converterstreamMusicCodec
@ -1230,6 +1257,7 @@ class PreferencesTableData extends DataClass
invidiousInstance: Value(invidiousInstance), invidiousInstance: Value(invidiousInstance),
themeMode: Value(themeMode), themeMode: Value(themeMode),
audioSource: Value(audioSource), audioSource: Value(audioSource),
youtubeClientEngine: Value(youtubeClientEngine),
streamMusicCodec: Value(streamMusicCodec), streamMusicCodec: Value(streamMusicCodec),
downloadMusicCodec: Value(downloadMusicCodec), downloadMusicCodec: Value(downloadMusicCodec),
discordPresence: Value(discordPresence), discordPresence: Value(discordPresence),
@ -1273,6 +1301,8 @@ class PreferencesTableData extends DataClass
.fromJson(serializer.fromJson<String>(json['themeMode'])), .fromJson(serializer.fromJson<String>(json['themeMode'])),
audioSource: $PreferencesTableTable.$converteraudioSource audioSource: $PreferencesTableTable.$converteraudioSource
.fromJson(serializer.fromJson<String>(json['audioSource'])), .fromJson(serializer.fromJson<String>(json['audioSource'])),
youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine
.fromJson(serializer.fromJson<String>(json['youtubeClientEngine'])),
streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec
.fromJson(serializer.fromJson<String>(json['streamMusicCodec'])), .fromJson(serializer.fromJson<String>(json['streamMusicCodec'])),
downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec
@ -1316,6 +1346,9 @@ class PreferencesTableData extends DataClass
$PreferencesTableTable.$converterthemeMode.toJson(themeMode)), $PreferencesTableTable.$converterthemeMode.toJson(themeMode)),
'audioSource': serializer.toJson<String>( 'audioSource': serializer.toJson<String>(
$PreferencesTableTable.$converteraudioSource.toJson(audioSource)), $PreferencesTableTable.$converteraudioSource.toJson(audioSource)),
'youtubeClientEngine': serializer.toJson<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toJson(youtubeClientEngine)),
'streamMusicCodec': serializer.toJson<String>($PreferencesTableTable 'streamMusicCodec': serializer.toJson<String>($PreferencesTableTable
.$converterstreamMusicCodec .$converterstreamMusicCodec
.toJson(streamMusicCodec)), .toJson(streamMusicCodec)),
@ -1351,6 +1384,7 @@ class PreferencesTableData extends DataClass
String? invidiousInstance, String? invidiousInstance,
ThemeMode? themeMode, ThemeMode? themeMode,
AudioSource? audioSource, AudioSource? audioSource,
YoutubeClientEngine? youtubeClientEngine,
SourceCodecs? streamMusicCodec, SourceCodecs? streamMusicCodec,
SourceCodecs? downloadMusicCodec, SourceCodecs? downloadMusicCodec,
bool? discordPresence, bool? discordPresence,
@ -1379,6 +1413,7 @@ class PreferencesTableData extends DataClass
invidiousInstance: invidiousInstance ?? this.invidiousInstance, invidiousInstance: invidiousInstance ?? this.invidiousInstance,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
audioSource: audioSource ?? this.audioSource, audioSource: audioSource ?? this.audioSource,
youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine,
streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec,
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
discordPresence: discordPresence ?? this.discordPresence, discordPresence: discordPresence ?? this.discordPresence,
@ -1439,6 +1474,9 @@ class PreferencesTableData extends DataClass
themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode,
audioSource: audioSource:
data.audioSource.present ? data.audioSource.value : this.audioSource, data.audioSource.present ? data.audioSource.value : this.audioSource,
youtubeClientEngine: data.youtubeClientEngine.present
? data.youtubeClientEngine.value
: this.youtubeClientEngine,
streamMusicCodec: data.streamMusicCodec.present streamMusicCodec: data.streamMusicCodec.present
? data.streamMusicCodec.value ? data.streamMusicCodec.value
: this.streamMusicCodec, : this.streamMusicCodec,
@ -1483,6 +1521,7 @@ class PreferencesTableData extends DataClass
..write('invidiousInstance: $invidiousInstance, ') ..write('invidiousInstance: $invidiousInstance, ')
..write('themeMode: $themeMode, ') ..write('themeMode: $themeMode, ')
..write('audioSource: $audioSource, ') ..write('audioSource: $audioSource, ')
..write('youtubeClientEngine: $youtubeClientEngine, ')
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('discordPresence: $discordPresence, ') ..write('discordPresence: $discordPresence, ')
@ -1516,6 +1555,7 @@ class PreferencesTableData extends DataClass
invidiousInstance, invidiousInstance,
themeMode, themeMode,
audioSource, audioSource,
youtubeClientEngine,
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
discordPresence, discordPresence,
@ -1548,6 +1588,7 @@ class PreferencesTableData extends DataClass
other.invidiousInstance == this.invidiousInstance && other.invidiousInstance == this.invidiousInstance &&
other.themeMode == this.themeMode && other.themeMode == this.themeMode &&
other.audioSource == this.audioSource && other.audioSource == this.audioSource &&
other.youtubeClientEngine == this.youtubeClientEngine &&
other.streamMusicCodec == this.streamMusicCodec && other.streamMusicCodec == this.streamMusicCodec &&
other.downloadMusicCodec == this.downloadMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec &&
other.discordPresence == this.discordPresence && other.discordPresence == this.discordPresence &&
@ -1578,6 +1619,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
final Value<String> invidiousInstance; final Value<String> invidiousInstance;
final Value<ThemeMode> themeMode; final Value<ThemeMode> themeMode;
final Value<AudioSource> audioSource; final Value<AudioSource> audioSource;
final Value<YoutubeClientEngine> youtubeClientEngine;
final Value<SourceCodecs> streamMusicCodec; final Value<SourceCodecs> streamMusicCodec;
final Value<SourceCodecs> downloadMusicCodec; final Value<SourceCodecs> downloadMusicCodec;
final Value<bool> discordPresence; final Value<bool> discordPresence;
@ -1606,6 +1648,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.invidiousInstance = const Value.absent(), this.invidiousInstance = const Value.absent(),
this.themeMode = const Value.absent(), this.themeMode = const Value.absent(),
this.audioSource = const Value.absent(), this.audioSource = const Value.absent(),
this.youtubeClientEngine = const Value.absent(),
this.streamMusicCodec = const Value.absent(), this.streamMusicCodec = const Value.absent(),
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.discordPresence = const Value.absent(), this.discordPresence = const Value.absent(),
@ -1635,6 +1678,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.invidiousInstance = const Value.absent(), this.invidiousInstance = const Value.absent(),
this.themeMode = const Value.absent(), this.themeMode = const Value.absent(),
this.audioSource = const Value.absent(), this.audioSource = const Value.absent(),
this.youtubeClientEngine = const Value.absent(),
this.streamMusicCodec = const Value.absent(), this.streamMusicCodec = const Value.absent(),
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.discordPresence = const Value.absent(), this.discordPresence = const Value.absent(),
@ -1664,6 +1708,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Expression<String>? invidiousInstance, Expression<String>? invidiousInstance,
Expression<String>? themeMode, Expression<String>? themeMode,
Expression<String>? audioSource, Expression<String>? audioSource,
Expression<String>? youtubeClientEngine,
Expression<String>? streamMusicCodec, Expression<String>? streamMusicCodec,
Expression<String>? downloadMusicCodec, Expression<String>? downloadMusicCodec,
Expression<bool>? discordPresence, Expression<bool>? discordPresence,
@ -1695,6 +1740,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
if (invidiousInstance != null) 'invidious_instance': invidiousInstance, if (invidiousInstance != null) 'invidious_instance': invidiousInstance,
if (themeMode != null) 'theme_mode': themeMode, if (themeMode != null) 'theme_mode': themeMode,
if (audioSource != null) 'audio_source': audioSource, if (audioSource != null) 'audio_source': audioSource,
if (youtubeClientEngine != null)
'youtube_client_engine': youtubeClientEngine,
if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec,
if (downloadMusicCodec != null) if (downloadMusicCodec != null)
'download_music_codec': downloadMusicCodec, 'download_music_codec': downloadMusicCodec,
@ -1727,6 +1774,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Value<String>? invidiousInstance, Value<String>? invidiousInstance,
Value<ThemeMode>? themeMode, Value<ThemeMode>? themeMode,
Value<AudioSource>? audioSource, Value<AudioSource>? audioSource,
Value<YoutubeClientEngine>? youtubeClientEngine,
Value<SourceCodecs>? streamMusicCodec, Value<SourceCodecs>? streamMusicCodec,
Value<SourceCodecs>? downloadMusicCodec, Value<SourceCodecs>? downloadMusicCodec,
Value<bool>? discordPresence, Value<bool>? discordPresence,
@ -1755,6 +1803,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
invidiousInstance: invidiousInstance ?? this.invidiousInstance, invidiousInstance: invidiousInstance ?? this.invidiousInstance,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
audioSource: audioSource ?? this.audioSource, audioSource: audioSource ?? this.audioSource,
youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine,
streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec,
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
discordPresence: discordPresence ?? this.discordPresence, discordPresence: discordPresence ?? this.discordPresence,
@ -1845,6 +1894,11 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
.$converteraudioSource .$converteraudioSource
.toSql(audioSource.value)); .toSql(audioSource.value));
} }
if (youtubeClientEngine.present) {
map['youtube_client_engine'] = Variable<String>($PreferencesTableTable
.$converteryoutubeClientEngine
.toSql(youtubeClientEngine.value));
}
if (streamMusicCodec.present) { if (streamMusicCodec.present) {
map['stream_music_codec'] = Variable<String>($PreferencesTableTable map['stream_music_codec'] = Variable<String>($PreferencesTableTable
.$converterstreamMusicCodec .$converterstreamMusicCodec
@ -1894,6 +1948,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
..write('invidiousInstance: $invidiousInstance, ') ..write('invidiousInstance: $invidiousInstance, ')
..write('themeMode: $themeMode, ') ..write('themeMode: $themeMode, ')
..write('audioSource: $audioSource, ') ..write('audioSource: $audioSource, ')
..write('youtubeClientEngine: $youtubeClientEngine, ')
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('discordPresence: $discordPresence, ') ..write('discordPresence: $discordPresence, ')
@ -4565,6 +4620,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder
Value<String> invidiousInstance, Value<String> invidiousInstance,
Value<ThemeMode> themeMode, Value<ThemeMode> themeMode,
Value<AudioSource> audioSource, Value<AudioSource> audioSource,
Value<YoutubeClientEngine> youtubeClientEngine,
Value<SourceCodecs> streamMusicCodec, Value<SourceCodecs> streamMusicCodec,
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> discordPresence, Value<bool> discordPresence,
@ -4595,6 +4651,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder
Value<String> invidiousInstance, Value<String> invidiousInstance,
Value<ThemeMode> themeMode, Value<ThemeMode> themeMode,
Value<AudioSource> audioSource, Value<AudioSource> audioSource,
Value<YoutubeClientEngine> youtubeClientEngine,
Value<SourceCodecs> streamMusicCodec, Value<SourceCodecs> streamMusicCodec,
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> discordPresence, Value<bool> discordPresence,
@ -4702,6 +4759,12 @@ class $$PreferencesTableTableFilterComposer
column: $table.audioSource, column: $table.audioSource,
builder: (column) => ColumnWithTypeConverterFilters(column)); builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnWithTypeConverterFilters<YoutubeClientEngine, YoutubeClientEngine,
String>
get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnWithTypeConverterFilters<SourceCodecs, SourceCodecs, String> ColumnWithTypeConverterFilters<SourceCodecs, SourceCodecs, String>
get streamMusicCodec => $composableBuilder( get streamMusicCodec => $composableBuilder(
column: $table.streamMusicCodec, column: $table.streamMusicCodec,
@ -4812,6 +4875,10 @@ class $$PreferencesTableTableOrderingComposer
ColumnOrderings<String> get audioSource => $composableBuilder( ColumnOrderings<String> get audioSource => $composableBuilder(
column: $table.audioSource, builder: (column) => ColumnOrderings(column)); column: $table.audioSource, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get streamMusicCodec => $composableBuilder( ColumnOrderings<String> get streamMusicCodec => $composableBuilder(
column: $table.streamMusicCodec, column: $table.streamMusicCodec,
builder: (column) => ColumnOrderings(column)); builder: (column) => ColumnOrderings(column));
@ -4915,6 +4982,10 @@ class $$PreferencesTableTableAnnotationComposer
$composableBuilder( $composableBuilder(
column: $table.audioSource, builder: (column) => column); column: $table.audioSource, builder: (column) => column);
GeneratedColumnWithTypeConverter<YoutubeClientEngine, String>
get youtubeClientEngine => $composableBuilder(
column: $table.youtubeClientEngine, builder: (column) => column);
GeneratedColumnWithTypeConverter<SourceCodecs, String> get streamMusicCodec => GeneratedColumnWithTypeConverter<SourceCodecs, String> get streamMusicCodec =>
$composableBuilder( $composableBuilder(
column: $table.streamMusicCodec, builder: (column) => column); column: $table.streamMusicCodec, builder: (column) => column);
@ -4985,6 +5056,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<String> invidiousInstance = const Value.absent(), Value<String> invidiousInstance = const Value.absent(),
Value<ThemeMode> themeMode = const Value.absent(), Value<ThemeMode> themeMode = const Value.absent(),
Value<AudioSource> audioSource = const Value.absent(), Value<AudioSource> audioSource = const Value.absent(),
Value<YoutubeClientEngine> youtubeClientEngine =
const Value.absent(),
Value<SourceCodecs> streamMusicCodec = const Value.absent(), Value<SourceCodecs> streamMusicCodec = const Value.absent(),
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> discordPresence = const Value.absent(), Value<bool> discordPresence = const Value.absent(),
@ -5014,6 +5087,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
invidiousInstance: invidiousInstance, invidiousInstance: invidiousInstance,
themeMode: themeMode, themeMode: themeMode,
audioSource: audioSource, audioSource: audioSource,
youtubeClientEngine: youtubeClientEngine,
streamMusicCodec: streamMusicCodec, streamMusicCodec: streamMusicCodec,
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
discordPresence: discordPresence, discordPresence: discordPresence,
@ -5043,6 +5117,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<String> invidiousInstance = const Value.absent(), Value<String> invidiousInstance = const Value.absent(),
Value<ThemeMode> themeMode = const Value.absent(), Value<ThemeMode> themeMode = const Value.absent(),
Value<AudioSource> audioSource = const Value.absent(), Value<AudioSource> audioSource = const Value.absent(),
Value<YoutubeClientEngine> youtubeClientEngine =
const Value.absent(),
Value<SourceCodecs> streamMusicCodec = const Value.absent(), Value<SourceCodecs> streamMusicCodec = const Value.absent(),
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> discordPresence = const Value.absent(), Value<bool> discordPresence = const Value.absent(),
@ -5072,6 +5148,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
invidiousInstance: invidiousInstance, invidiousInstance: invidiousInstance,
themeMode: themeMode, themeMode: themeMode,
audioSource: audioSource, audioSource: audioSource,
youtubeClientEngine: youtubeClientEngine,
streamMusicCodec: streamMusicCodec, streamMusicCodec: streamMusicCodec,
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
discordPresence: discordPresence, discordPresence: discordPresence,

View File

@ -1,11 +1,11 @@
// dart format width=80 // dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1; import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import import 'package:drift/drift.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY. // GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema2 extends i0.VersionedSchema { final class Schema2 extends i0.VersionedSchema {
@ -907,9 +907,291 @@ i1.GeneratedColumn<bool> _column_53(String aliasedName) =>
defaultConstraints: i1.GeneratedColumn.constraintIsAlways( defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("cache_music" IN (0, 1))'), 'CHECK ("cache_music" IN (0, 1))'),
defaultValue: const Constant(true)); defaultValue: const Constant(true));
final class Schema4 extends i0.VersionedSchema {
Schema4({required super.database}) : super(version: 4);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
audioPlayerStateTable,
playlistTable,
playlistMediaTable,
historyTable,
lyricsTable,
uniqueBlacklist,
uniqTrackMatch,
];
late final Shape0 authenticationTable = Shape0(
source: i0.VersionedTable(
entityName: 'authentication_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 blacklistTable = Shape1(
source: i0.VersionedTable(
entityName: 'blacklist_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_5,
_column_6,
],
attachedDatabase: database,
),
alias: null);
late final Shape12 preferencesTable = Shape12(
source: i0.VersionedTable(
entityName: 'preferences_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_23,
_column_24,
_column_25,
_column_26,
_column_54,
_column_27,
_column_28,
_column_29,
_column_30,
_column_31,
_column_53,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 scrobblerTable = Shape3(
source: i0.VersionedTable(
entityName: 'scrobbler_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_32,
_column_33,
_column_34,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 skipSegmentTable = Shape4(
source: i0.VersionedTable(
entityName: 'skip_segment_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_35,
_column_36,
_column_37,
_column_32,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 sourceMatchTable = Shape5(
source: i0.VersionedTable(
entityName: 'source_match_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_37,
_column_38,
_column_39,
_column_32,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 audioPlayerStateTable = Shape6(
source: i0.VersionedTable(
entityName: 'audio_player_state_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_40,
_column_41,
_column_42,
_column_43,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 playlistTable = Shape7(
source: i0.VersionedTable(
entityName: 'playlist_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_44,
_column_45,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 playlistMediaTable = Shape8(
source: i0.VersionedTable(
entityName: 'playlist_media_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_46,
_column_47,
_column_48,
_column_49,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 historyTable = Shape9(
source: i0.VersionedTable(
entityName: 'history_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_32,
_column_50,
_column_51,
_column_52,
],
attachedDatabase: database,
),
alias: null);
late final Shape10 lyricsTable = Shape10(
source: i0.VersionedTable(
entityName: 'lyrics_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_37,
_column_52,
],
attachedDatabase: database,
),
alias: null);
final i1.Index uniqueBlacklist = i1.Index('unique_blacklist',
'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
final i1.Index uniqTrackMatch = i1.Index('uniq_track_match',
'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)');
}
class Shape12 extends i0.VersionedTable {
Shape12({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get audioQuality =>
columnsByName['audio_quality']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get albumColorSync =>
columnsByName['album_color_sync']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get amoledDarkTheme =>
columnsByName['amoled_dark_theme']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get checkUpdate =>
columnsByName['check_update']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get normalizeAudio =>
columnsByName['normalize_audio']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get showSystemTrayIcon =>
columnsByName['show_system_tray_icon']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get systemTitleBar =>
columnsByName['system_title_bar']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get skipNonMusic =>
columnsByName['skip_non_music']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get closeBehavior =>
columnsByName['close_behavior']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get accentColorScheme =>
columnsByName['accent_color_scheme']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get layoutMode =>
columnsByName['layout_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get locale =>
columnsByName['locale']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get market =>
columnsByName['market']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get searchMode =>
columnsByName['search_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadLocation =>
columnsByName['download_location']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get localLibraryLocation =>
columnsByName['local_library_location']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get pipedInstance =>
columnsByName['piped_instance']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get invidiousInstance =>
columnsByName['invidious_instance']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get themeMode =>
columnsByName['theme_mode']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get audioSource =>
columnsByName['audio_source']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get youtubeClientEngine =>
columnsByName['youtube_client_engine']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get streamMusicCodec =>
columnsByName['stream_music_codec']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadMusicCodec =>
columnsByName['download_music_codec']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get discordPresence =>
columnsByName['discord_presence']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get endlessPlayback =>
columnsByName['endless_playback']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get enableConnect =>
columnsByName['enable_connect']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get cacheMusic =>
columnsByName['cache_music']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_54(String aliasedName) =>
i1.GeneratedColumn<String>('youtube_client_engine', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name));
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -923,6 +1205,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema); await from2To3(migrator, schema);
return 3; return 3;
case 3:
final schema = Schema4(database: database);
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -932,9 +1219,11 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) => }) =>
i0.VersionedSchema.stepByStepHelper( i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
from2To3: from2To3, from2To3: from2To3,
from3To4: from3To4,
)); ));

View File

@ -20,6 +20,26 @@ enum AudioSource {
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }
enum YoutubeClientEngine {
ytDlp("yt-dlp"),
youtubeExplode("YouTubeExplode"),
newPipe("NewPipe");
final String label;
const YoutubeClientEngine(this.label);
bool isAvailableForPlatform() {
return switch (this) {
YoutubeClientEngine.youtubeExplode =>
YouTubeExplodeEngine.isAvailableForPlatform,
YoutubeClientEngine.ytDlp => YtDlpEngine.isAvailableForPlatform,
// TODO: Implement new pipe support
YoutubeClientEngine.newPipe => false,
};
}
}
enum MusicCodec { enum MusicCodec {
m4a._("M4a (Best for downloaded music)"), m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
@ -84,6 +104,8 @@ class PreferencesTable extends Table {
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))(); textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
TextColumn get audioSource => TextColumn get audioSource =>
textEnum<AudioSource>().withDefault(Constant(AudioSource.youtube.name))(); textEnum<AudioSource>().withDefault(Constant(AudioSource.youtube.name))();
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
TextColumn get streamMusicCodec => TextColumn get streamMusicCodec =>
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))(); textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
TextColumn get downloadMusicCodec => TextColumn get downloadMusicCodec =>
@ -120,6 +142,7 @@ class PreferencesTable extends Table {
invidiousInstance: "https://inv.nadeko.net", invidiousInstance: "https://inv.nadeko.net",
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
audioSource: AudioSource.youtube, audioSource: AudioSource.youtube,
youtubeClientEngine: YoutubeClientEngine.youtubeExplode,
streamMusicCodec: SourceCodecs.m4a, streamMusicCodec: SourceCodecs.m4a,
downloadMusicCodec: SourceCodecs.m4a, downloadMusicCodec: SourceCodecs.m4a,
discordPresence: true, discordPresence: true,

View File

@ -19,6 +19,7 @@ import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart';
import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/video_info.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/sourced_track.dart';
@ -68,6 +69,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final preferences = ref.watch(userPreferencesProvider); final preferences = ref.watch(userPreferencesProvider);
final youtubeEngine = ref.watch(youtubeEngineProvider);
final isSearching = useState(false); final isSearching = useState(false);
final searchMode = useState(preferences.searchMode); final searchMode = useState(preferences.searchMode);
@ -115,14 +117,14 @@ class SiblingTracksSheet extends HookConsumerWidget {
activeSourceInfo, activeSourceInfo,
); );
} else { } else {
final resultsYt = await youtubeClient.search.search(searchTerm.trim()); final resultsYt = await youtubeEngine.searchVideos(searchTerm.trim());
final searchResults = await Future.wait( final searchResults = await Future.wait(
resultsYt resultsYt
.map(YoutubeVideoInfo.fromVideo) .map(YoutubeVideoInfo.fromVideo)
.mapIndexed((i, video) async { .mapIndexed((i, video) async {
final siblingType = final siblingType =
await YoutubeSourcedTrack.toSiblingType(i, video); await YoutubeSourcedTrack.toSiblingType(i, video, ref);
return siblingType.info; return siblingType.info;
}), }),
); );
@ -139,6 +141,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
searchMode.value, searchMode.value,
activeTrack, activeTrack,
preferences.audioSource, preferences.audioSource,
youtubeEngine,
]); ]);
final siblings = useMemoized( final siblings = useMemoized(
@ -151,12 +154,15 @@ class SiblingTracksSheet extends HookConsumerWidget {
[activeTrack, isFetchingActiveTrack], [activeTrack, isFetchingActiveTrack],
); );
final previousActiveTrack = usePrevious(activeTrack);
useEffect(() { useEffect(() {
/// Populate sibling when active track changes
if (previousActiveTrack?.id == activeTrack?.id) return;
if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
activeTrackNotifier.populateSibling(); activeTrackNotifier.populateSibling();
} }
return null; return null;
}, [activeTrack]); }, [activeTrack, previousActiveTrack]);
final itemBuilder = useCallback( final itemBuilder = useCallback(
(SourceInfo sourceInfo) { (SourceInfo sourceInfo) {

View File

@ -0,0 +1,65 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:url_launcher/url_launcher.dart';
const engineDownloadUrls = {
YoutubeClientEngine.ytDlp:
"https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#installation",
};
class YouTubeEngineNotInstalledDialog extends HookConsumerWidget {
final YoutubeClientEngine engine;
const YouTubeEngineNotInstalledDialog({
super.key,
required this.engine,
});
@override
Widget build(BuildContext context, ref) {
return AlertDialog(
title: Row(
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(SpotubeIcons.error, color: Colors.red),
Text(
context.l10n.youtube_engine_not_installed_title(engine.label),
style: const TextStyle(color: Colors.red),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
Text(
context.l10n.youtube_engine_not_installed_message(engine.label),
),
if (engineDownloadUrls[engine] != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${context.l10n.download}:"),
Button.link(
child: Text(engineDownloadUrls[engine]!.split("?").first),
onPressed: () async {
launchUrl(Uri.parse(engineDownloadUrls[engine]!));
},
),
],
),
],
),
actions: [
Button.secondary(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.ok),
),
],
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart';
import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/bottom_player.dart';
import 'package:spotube/modules/root/sidebar/sidebar.dart'; import 'package:spotube/modules/root/sidebar/sidebar.dart';
import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart';
@ -21,9 +22,11 @@ class RootAppPage extends HookConsumerWidget {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
ref.listen(glanceProvider, (_, __) {}); ref.listen(glanceProvider, (_, __) {});
useGlobalSubscriptions(ref); useGlobalSubscriptions(ref);
useDownloaderDialogs(ref); useDownloaderDialogs(ref);
useEndlessPlayback(ref); useEndlessPlayback(ref);
useCheckYtDlpInstalled(ref);
useEffect(() { useEffect(() {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(

View File

@ -13,11 +13,13 @@ import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart';
import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart';
import 'package:spotube/provider/audio_player/sources/invidious_instances_provider.dart'; import 'package:spotube/provider/audio_player/sources/invidious_instances_provider.dart';
import 'package:spotube/provider/audio_player/sources/piped_instances_provider.dart'; import 'package:spotube/provider/audio_player/sources/piped_instances_provider.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/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class SettingsPlaybackSection extends HookConsumerWidget { class SettingsPlaybackSection extends HookConsumerWidget {
@ -195,13 +197,36 @@ class SettingsPlaybackSection extends HookConsumerWidget {
}, },
), ),
), ),
AnimatedCrossFade( switch (preferences.audioSource) {
duration: const Duration(milliseconds: 300), AudioSource.youtube => AdaptiveSelectTile<YoutubeClientEngine>(
crossFadeState: preferences.audioSource == AudioSource.youtube secondary: const Icon(SpotubeIcons.engine),
? CrossFadeState.showFirst title: Text(context.l10n.youtube_engine),
: CrossFadeState.showSecond, value: preferences.youtubeClientEngine,
firstChild: const SizedBox.shrink(), options: YoutubeClientEngine.values
secondChild: AdaptiveSelectTile<SearchMode>( .where((e) => e.isAvailableForPlatform())
.map((e) => SelectItemButton(
value: e,
child: Text(e.label),
))
.toList(),
onChanged: (value) async {
if (value == null) return;
if (value == YoutubeClientEngine.ytDlp &&
!await YtDlpEngine.isInstalled() &&
context.mounted) {
await showDialog(
context: context,
builder: (context) =>
YouTubeEngineNotInstalledDialog(engine: value),
);
return;
}
preferencesNotifier.setYoutubeClientEngine(value);
},
),
AudioSource.piped ||
AudioSource.invidious =>
AdaptiveSelectTile<SearchMode>(
secondary: const Icon(SpotubeIcons.search), secondary: const Icon(SpotubeIcons.search),
title: Text(context.l10n.search_mode), title: Text(context.l10n.search_mode),
value: preferences.searchMode, value: preferences.searchMode,
@ -216,7 +241,8 @@ class SettingsPlaybackSection extends HookConsumerWidget {
preferencesNotifier.setSearchMode(value); preferencesNotifier.setSearchMode(value);
}, },
), ),
), _ => const SizedBox.shrink(),
},
AnimatedCrossFade( AnimatedCrossFade(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
crossFadeState: preferences.searchMode == SearchMode.youtube && crossFadeState: preferences.searchMode == SearchMode.youtube &&

View File

@ -207,6 +207,10 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
setData(PreferencesTableCompanion(audioSource: Value(type))); setData(PreferencesTableCompanion(audioSource: Value(type)));
} }
void setYoutubeClientEngine(YoutubeClientEngine engine) {
setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine)));
}
void setSystemTitleBar(bool isSystemTitleBar) { void setSystemTitleBar(bool isSystemTitleBar) {
setData( setData(
PreferencesTableCompanion( PreferencesTableCompanion(

View File

@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart';
import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart';
final youtubeEngineProvider = Provider((ref) {
final engineMode = ref.watch(
userPreferencesProvider.select((value) => value.youtubeClientEngine),
);
if (engineMode == YoutubeClientEngine.newPipe) {
throw UnimplementedError();
} else if (engineMode == YoutubeClientEngine.ytDlp &&
YtDlpEngine.isAvailableForPlatform) {
return YtDlpEngine();
} else {
return YouTubeExplodeEngine();
}
});

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/song_link/song_link.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart';
@ -15,7 +16,6 @@ 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';
final youtubeClient = YoutubeExplode();
final officialMusicRegex = RegExp( final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false, caseSensitive: false,
@ -43,24 +43,15 @@ class YoutubeSourcedTrack extends SourcedTrack {
required super.ref, required super.ref,
}); });
static Future<StreamManifest> _getStreamManifest(String id) async {
return youtubeClient.videos.streamsClient.getManifest(
id,
requireWatchPage: false,
ytClients: [
YoutubeApiClient.android,
YoutubeApiClient.mweb,
],
);
}
static Future<YoutubeSourcedTrack> fetchFromTrack({ static Future<YoutubeSourcedTrack> fetchFromTrack({
required Track track, required Track track,
required Ref ref, required Ref ref,
}) async { }) async {
// Indicates the track is requesting a stream refresh // Indicates the track is requesting a stream refresh
if (track is YoutubeSourcedTrack) { if (track is YoutubeSourcedTrack) {
final manifest = await _getStreamManifest(track.sourceInfo.id); final manifest = await ref
.read(youtubeEngineProvider)
.getStreamManifest(track.sourceInfo.id);
final sourcedTrack = YoutubeSourcedTrack( final sourcedTrack = YoutubeSourcedTrack(
ref: ref, ref: ref,
@ -108,8 +99,10 @@ class YoutubeSourcedTrack extends SourcedTrack {
track: track, track: track,
); );
} }
final item = await youtubeClient.videos.get(cachedSource.sourceId); final (item, manifest) = await ref
final manifest = await _getStreamManifest(cachedSource.sourceId); .read(youtubeEngineProvider)
.getVideoWithStreamInfo(cachedSource.sourceId);
final sourcedTrack = YoutubeSourcedTrack( final sourcedTrack = YoutubeSourcedTrack(
ref: ref, ref: ref,
siblings: [], siblings: [],
@ -162,10 +155,13 @@ class YoutubeSourcedTrack extends SourcedTrack {
static Future<SiblingType> toSiblingType( static Future<SiblingType> toSiblingType(
int index, int index,
YoutubeVideoInfo item, YoutubeVideoInfo item,
dynamic ref,
) async { ) async {
assert(ref is WidgetRef || ref is Ref, "Invalid ref type");
SourceMap? sourceMap; SourceMap? sourceMap;
if (index == 0) { if (index == 0) {
final manifest = await _getStreamManifest(item.id); final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(item.id);
sourceMap = toSourceMap(manifest); sourceMap = toSourceMap(manifest);
} }
@ -188,11 +184,8 @@ class YoutubeSourcedTrack extends SourcedTrack {
static List<YoutubeVideoInfo> rankResults( static List<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> results, Track track) { List<YoutubeVideoInfo> results, Track track) {
final artists = (track.artists ?? []) final artists =
.map((ar) => ar.name) (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList();
.toList()
.whereNotNull()
.toList();
return results return results
.sorted((a, b) => b.views.compareTo(a.views)) .sorted((a, b) => b.views.compareTo(a.views))
@ -259,8 +252,9 @@ class YoutubeSourcedTrack extends SourcedTrack {
await toSiblingType( await toSiblingType(
0, 0,
YoutubeVideoInfo.fromVideo( YoutubeVideoInfo.fromVideo(
await youtubeClient.videos.get(ytLink!.url!), await ref.read(youtubeEngineProvider).getVideo(ytLink!.url!),
), ),
ref,
) )
]; ];
} on VideoUnplayableException catch (e, stack) { } on VideoUnplayableException catch (e, stack) {
@ -271,15 +265,13 @@ class YoutubeSourcedTrack extends SourcedTrack {
final query = SourcedTrack.getSearchTerm(track); final query = SourcedTrack.getSearchTerm(track);
final searchResults = await youtubeClient.search.search( final searchResults =
"$query - Topic", await ref.read(youtubeEngineProvider).searchVideos("$query - Topic");
filter: TypeFilters.video,
);
if (ServiceUtils.onlyContainsEnglish(query)) { if (ServiceUtils.onlyContainsEnglish(query)) {
return await Future.wait(searchResults return await Future.wait(searchResults
.map(YoutubeVideoInfo.fromVideo) .map(YoutubeVideoInfo.fromVideo)
.mapIndexed(toSiblingType)); .mapIndexed((index, info) => toSiblingType(index, info, ref)));
} }
final rankedSiblings = rankResults( final rankedSiblings = rankResults(
@ -287,7 +279,10 @@ class YoutubeSourcedTrack extends SourcedTrack {
track, track,
); );
return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); return await Future.wait(
rankedSiblings
.mapIndexed((index, info) => toSiblingType(index, info, ref)),
);
} }
@override @override
@ -305,7 +300,9 @@ class YoutubeSourcedTrack extends SourcedTrack {
final newSiblings = siblings.where((s) => s.id != sibling.id).toList() final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo); ..insert(0, sourceInfo);
final manifest = await _getStreamManifest(newSourceInfo.id); final manifest = await ref
.read(youtubeEngineProvider)
.getStreamManifest(newSourceInfo.id);
final database = ref.read(databaseProvider); final database = ref.read(databaseProvider);

View File

@ -0,0 +1,14 @@
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
abstract interface class YouTubeEngine {
static bool get isAvailableForPlatform => false;
static Future<bool> isInstalled() async {
return false;
}
Future<Video> getVideo(String videoId);
Future<StreamManifest> getStreamManifest(String videoId);
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId);
Future<List<Video>> searchVideos(String query);
}

View File

@ -0,0 +1,47 @@
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YouTubeExplodeEngine implements YouTubeEngine {
static final YoutubeExplode _youtubeExplode = YoutubeExplode();
static bool get isAvailableForPlatform => true;
static Future<bool> isInstalled() async {
return true;
}
@override
Future<StreamManifest> getStreamManifest(String videoId) {
return _youtubeExplode.videos.streamsClient.getManifest(
videoId,
requireWatchPage: false,
ytClients: [
YoutubeApiClient.android,
YoutubeApiClient.mweb,
],
);
}
@override
Future<Video> getVideo(String videoId) {
return _youtubeExplode.videos.get(videoId);
}
@override
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async {
final video = await getVideo(videoId);
final streamManifest = await getStreamManifest(videoId);
return (video, streamManifest);
}
@override
Future<List<Video>> searchVideos(String query) {
return _youtubeExplode.search
.search(
query,
filter: TypeFilters.video,
)
.then((searchList) => searchList.toList());
}
}

View File

@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
import 'package:spotube/utils/platform.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:yt_dlp_dart/yt_dlp_dart.dart';
// ignore: depend_on_referenced_packages
import 'package:http_parser/http_parser.dart';
class YtDlpEngine implements YouTubeEngine {
StreamManifest _parseFormats(List formats, videoId) {
final audioOnlyStreams = formats
.where(
(f) => f["resolution"] == "audio only" && f["manifest_url"] == null,
)
.sorted((a, b) => a["quality"] > b["quality"] ? 1 : -1)
.map((f) {
final filesize = f["filesize"] ?? f["filesize_approx"];
return AudioOnlyStreamInfo(
VideoId(videoId),
0,
Uri.parse(f["url"]),
StreamContainer.parse(
f["container"]?.replaceAll("_dash", "").replaceAll("m4a", "mp4"),
),
filesize != null ? FileSize(filesize) : FileSize.unknown,
Bitrate(
(((f["abr"] ?? f["tbr"] ?? 0) * 1000) as num).toInt(),
),
f["acodec"] ?? "webm",
f["format_note"],
[],
MediaType.parse(
"audio/${f["audio_ext"]}",
),
null,
);
});
return StreamManifest(audioOnlyStreams);
}
Video _parseInfo(Map<String, dynamic> info) {
final publishDate = info["upload_date"] != null
? DateTime.fromMillisecondsSinceEpoch(
int.parse(info["upload_date"]) * 1000,
)
: DateTime.now();
return Video(
VideoId(info["id"]),
info["title"],
info["channel"],
ChannelId(info["channel_id"]),
publishDate,
info["upload_date"] as String? ?? DateTime.now().toString(),
publishDate,
info["description"] ?? "",
Duration(seconds: (info["duration"] as num).toInt()),
ThumbnailSet(info["id"]),
info["tags"]?.cast<String>() ?? <String>[],
Engagement(
info["view_count"],
info["like_count"],
null,
),
info["is_live"] ?? false,
);
}
static bool get isAvailableForPlatform => kIsDesktop;
static Future<bool> isInstalled() async {
return isAvailableForPlatform &&
await YtDlp.instance.checkAvailableInPath();
}
@override
Future<StreamManifest> getStreamManifest(String videoId) async {
final formats = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%(formats)j",
extraArgs: [
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors"
],
) as List;
return _parseFormats(formats, videoId);
}
@override
Future<Video> getVideo(String videoId) async {
final info = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%()j",
extraArgs: [
"--skip-download",
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
],
) as Map<String, dynamic>;
return _parseInfo(info);
}
@override
Future<(Video, StreamManifest)> getVideoWithStreamInfo(String videoId) async {
final info = await YtDlp.instance.extractInfo(
"https://www.youtube.com/watch?v=$videoId",
formatSpecifiers: "%()j",
extraArgs: [
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
],
) as Map<String, dynamic>;
return (_parseInfo(info), _parseFormats(info["formats"], videoId));
}
@override
Future<List<Video>> searchVideos(String query) async {
final stdout = await YtDlp.instance.extractInfoString(
"ytsearch10:$query",
formatSpecifiers: "%()j",
extraArgs: [
"--skip-download",
"--no-check-certificate",
"--geo-bypass",
"--quiet",
"--ignore-errors",
"--flat-playlist",
"--no-playlist",
],
);
final json = jsonDecode(
"[${stdout.split("\n").where((s) => s.trim().isNotEmpty).join(",")}]",
) as List;
return json.map((e) => _parseInfo(e)).toList();
}
}

View File

@ -2734,6 +2734,13 @@ packages:
url: "https://github.com/Hexer10/youtube_explode_dart.git" url: "https://github.com/Hexer10/youtube_explode_dart.git"
source: git source: git
version: "2.3.7" version: "2.3.7"
yt_dlp_dart:
dependency: "direct main"
description:
path: "../yt_dlp_dart"
relative: true
source: path
version: "1.0.0"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.1 <4.0.0"
flutter: ">=3.27.0" flutter: ">=3.27.0"

View File

@ -140,6 +140,10 @@ dependencies:
git: git:
url: https://github.com/Hexer10/youtube_explode_dart.git url: https://github.com/Hexer10/youtube_explode_dart.git
ref: e519db65ad0b0a40b12f69285932f9db509da3cf ref: e519db65ad0b0a40b12f69285932f9db509da3cf
yt_dlp_dart:
git:
url: https://github.com/KRTirtho/yt_dlp_dart.git
ref: 4199bb019542bae361fbb38b3448b3583fbca022
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

View File

@ -3,6 +3,7 @@
// ignore_for_file: type=lint // ignore_for_file: type=lint
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart'; import 'package:drift/internal/migrations.dart';
import 'schema_v4.dart' as v4;
import 'schema_v3.dart' as v3; import 'schema_v3.dart' as v3;
import 'schema_v2.dart' as v2; import 'schema_v2.dart' as v2;
import 'schema_v1.dart' as v1; import 'schema_v1.dart' as v1;
@ -11,6 +12,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
switch (version) { switch (version) {
case 4:
return v4.DatabaseAtV4(db);
case 3: case 3:
return v3.DatabaseAtV3(db); return v3.DatabaseAtV3(db);
case 2: case 2:
@ -22,5 +25,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
} }
} }
static const versions = const [1, 2, 3]; static const versions = const [1, 2, 3, 4];
} }

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"bn": [ "bn": [
@ -34,7 +38,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ca": [ "ca": [
@ -53,7 +61,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"cs": [ "cs": [
@ -72,7 +84,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"de": [ "de": [
@ -91,7 +107,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"es": [ "es": [
@ -110,7 +130,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"eu": [ "eu": [
@ -129,7 +153,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"fa": [ "fa": [
@ -148,7 +176,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"fi": [ "fi": [
@ -167,7 +199,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"fr": [ "fr": [
@ -186,7 +222,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"hi": [ "hi": [
@ -205,7 +245,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"id": [ "id": [
@ -224,7 +268,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"it": [ "it": [
@ -243,7 +291,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ja": [ "ja": [
@ -262,7 +314,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ka": [ "ka": [
@ -281,7 +337,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ko": [ "ko": [
@ -300,7 +360,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ne": [ "ne": [
@ -319,7 +383,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"nl": [ "nl": [
@ -338,7 +406,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"pl": [ "pl": [
@ -357,7 +429,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"pt": [ "pt": [
@ -376,7 +452,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"ru": [ "ru": [
@ -395,7 +475,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"th": [ "th": [
@ -414,7 +498,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"tr": [ "tr": [
@ -433,7 +521,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"uk": [ "uk": [
@ -452,7 +544,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"vi": [ "vi": [
@ -471,7 +567,11 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
], ],
"zh": [ "zh": [
@ -490,6 +590,10 @@
"no_tracks_listened_yet", "no_tracks_listened_yet",
"not_following_artists", "not_following_artists",
"no_favorite_albums_yet", "no_favorite_albums_yet",
"no_logs_found" "no_logs_found",
"youtube_engine",
"youtube_engine_not_installed_title",
"youtube_engine_not_installed_message",
"download"
] ]
} }