mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: add invidious audio source and fix auto skipping tracks (#2005)
* feat: add invidious audio source with automatic track switch even on server playback endpoint * fix: switching to different source on playback endpoint error not working * chore: update invidious version * feat: invidious instances customizability
This commit is contained in:
parent
3c45732b0a
commit
9f2d423cfe
BIN
assets/invidious.jpg
Normal file
BIN
assets/invidious.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 98 KiB |
@ -10,6 +10,8 @@ targets:
|
||||
explicit_to_json: true
|
||||
drift_dev:
|
||||
options:
|
||||
databases:
|
||||
app_db: lib/models/database/database.dart
|
||||
sql:
|
||||
dialect: sqlite
|
||||
options:
|
||||
|
1
drift_schemas/app_db/drift_schema_v1.json
Normal file
1
drift_schemas/app_db/drift_schema_v1.json
Normal file
File diff suppressed because one or more lines are too long
1
drift_schemas/app_db/drift_schema_v2.json
Normal file
1
drift_schemas/app_db/drift_schema_v2.json
Normal file
File diff suppressed because one or more lines are too long
@ -49,6 +49,7 @@ class Assets {
|
||||
AssetGenImage('assets/bengali-patterns-bg.jpg');
|
||||
static const AssetGenImage branding = AssetGenImage('assets/branding.png');
|
||||
static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png');
|
||||
static const AssetGenImage invidious = AssetGenImage('assets/invidious.jpg');
|
||||
static const AssetGenImage jiosaavn = AssetGenImage('assets/jiosaavn.png');
|
||||
static const AssetGenImage likedTracks =
|
||||
AssetGenImage('assets/liked-tracks.jpg');
|
||||
@ -95,6 +96,7 @@ class Assets {
|
||||
bengaliPatternsBg,
|
||||
branding,
|
||||
emptyBox,
|
||||
invidious,
|
||||
jiosaavn,
|
||||
likedTracks,
|
||||
placeholder,
|
||||
|
@ -190,6 +190,9 @@
|
||||
"piped_instance": "Piped Server Instance",
|
||||
"piped_description": "The Piped server instance to use for track matching",
|
||||
"piped_warning": "Some of them might not work well. So use at your own risk",
|
||||
"invidious_instance": "Invidious Server Instance",
|
||||
"invidious_description": "The Invidious server instance to use for track matching",
|
||||
"invidious_warning": "Some of them might not work well. So use at your own risk",
|
||||
"generate_playlist": "Generate Playlist",
|
||||
"track_exists": "Track {track} already exists",
|
||||
"replace_downloaded_tracks": "Replace all downloaded tracks",
|
||||
@ -307,6 +310,7 @@
|
||||
"youtube_source_description": "Recommended and works best.",
|
||||
"piped_source_description": "Feeling free? Same as YouTube but a lot free.",
|
||||
"jiosaavn_source_description": "Best for South Asian region.",
|
||||
"invidious_source_description": "Similar to Piped but with higher availability.",
|
||||
"highest_quality": "Highest Quality: {quality}",
|
||||
"select_audio_source": "Select Audio Source",
|
||||
"endless_playback_description": "Automatically append new songs\nto the end of the queue",
|
||||
|
@ -16,7 +16,7 @@ _$WebSocketLoadEventDataPlaylistImpl
|
||||
? null
|
||||
: PlaylistSimple.fromJson(
|
||||
Map<String, dynamic>.from(json['collection'] as Map)),
|
||||
initialIndex: json['initialIndex'] as int?,
|
||||
initialIndex: (json['initialIndex'] as num?)?.toInt(),
|
||||
$type: json['runtimeType'] as String?,
|
||||
);
|
||||
|
||||
@ -39,7 +39,7 @@ _$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson(
|
||||
? null
|
||||
: AlbumSimple.fromJson(
|
||||
Map<String, dynamic>.from(json['collection'] as Map)),
|
||||
initialIndex: json['initialIndex'] as int?,
|
||||
initialIndex: (json['initialIndex'] as num?)?.toInt(),
|
||||
$type: json['runtimeType'] as String?,
|
||||
);
|
||||
|
||||
|
@ -9,6 +9,7 @@ import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotify/spotify.dart' hide Playlist;
|
||||
import 'package:spotube/models/database/database.steps.dart';
|
||||
import 'package:spotube/models/lyrics.dart';
|
||||
import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
|
||||
import 'package:spotube/services/kv_store/kv_store.dart';
|
||||
@ -57,7 +58,20 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onUpgrade: stepByStep(from1To2: (m, schema) async {
|
||||
// Add invidiousInstance column to preferences table
|
||||
await m.addColumn(
|
||||
schema.preferencesTable,
|
||||
schema.preferencesTable.invidiousInstance,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
|
File diff suppressed because it is too large
Load Diff
652
lib/models/database/database.steps.dart
Normal file
652
lib/models/database/database.steps.dart
Normal file
@ -0,0 +1,652 @@
|
||||
import 'package:drift/internal/versioned_schema.dart' as i0;
|
||||
import 'package:drift/drift.dart' as i1;
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/utils/migrations/adapters.dart'; // ignore_for_file: type=lint,unused_import
|
||||
|
||||
// GENERATED BY drift_dev, DO NOT MODIFY.
|
||||
final class Schema2 extends i0.VersionedSchema {
|
||||
Schema2({required super.database}) : super(version: 2);
|
||||
@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 Shape2 preferencesTable = Shape2(
|
||||
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_27,
|
||||
_column_28,
|
||||
_column_29,
|
||||
_column_30,
|
||||
_column_31,
|
||||
],
|
||||
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 Shape0 extends i0.VersionedTable {
|
||||
Shape0({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get cookie =>
|
||||
columnsByName['cookie']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get accessToken =>
|
||||
columnsByName['access_token']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get expiration =>
|
||||
columnsByName['expiration']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('id', aliasedName, false,
|
||||
hasAutoIncrement: true,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultConstraints:
|
||||
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
|
||||
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('cookie', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('access_token', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>('expiration', aliasedName, false,
|
||||
type: i1.DriftSqlType.dateTime);
|
||||
|
||||
class Shape1 extends i0.VersionedTable {
|
||||
Shape1({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get elementType =>
|
||||
columnsByName['element_type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get elementId =>
|
||||
columnsByName['element_id']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('name', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_5(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('element_type', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_6(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('element_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape2 extends i0.VersionedTable {
|
||||
Shape2({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 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<String> _column_7(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('audio_quality', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(SourceQualities.high.name));
|
||||
i1.GeneratedColumn<bool> _column_8(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('album_color_sync', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("album_color_sync" IN (0, 1))'),
|
||||
defaultValue: const Constant(true));
|
||||
i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('amoled_dark_theme', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("amoled_dark_theme" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
i1.GeneratedColumn<bool> _column_10(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('check_update', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("check_update" IN (0, 1))'),
|
||||
defaultValue: const Constant(true));
|
||||
i1.GeneratedColumn<bool> _column_11(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('normalize_audio', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("normalize_audio" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
i1.GeneratedColumn<bool> _column_12(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('show_system_tray_icon', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("show_system_tray_icon" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
i1.GeneratedColumn<bool> _column_13(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('system_title_bar', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("system_title_bar" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
i1.GeneratedColumn<bool> _column_14(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('skip_non_music', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("skip_non_music" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('close_behavior', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(CloseBehavior.close.name));
|
||||
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('accent_color_scheme', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: const Constant("Blue:0xFF2196F3"));
|
||||
i1.GeneratedColumn<String> _column_17(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('layout_mode', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(LayoutMode.adaptive.name));
|
||||
i1.GeneratedColumn<String> _column_18(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('locale', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue:
|
||||
const Constant('{"languageCode":"system","countryCode":"system"}'));
|
||||
i1.GeneratedColumn<String> _column_19(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('market', aliasedName, false,
|
||||
type: i1.DriftSqlType.string, defaultValue: Constant(Market.US.name));
|
||||
i1.GeneratedColumn<String> _column_20(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('search_mode', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(SearchMode.youtube.name));
|
||||
i1.GeneratedColumn<String> _column_21(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('download_location', aliasedName, false,
|
||||
type: i1.DriftSqlType.string, defaultValue: const Constant(""));
|
||||
i1.GeneratedColumn<String> _column_22(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('local_library_location', aliasedName, false,
|
||||
type: i1.DriftSqlType.string, defaultValue: const Constant(""));
|
||||
i1.GeneratedColumn<String> _column_23(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('piped_instance', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: const Constant("https://pipedapi.kavin.rocks"));
|
||||
i1.GeneratedColumn<String> _column_24(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('invidious_instance', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: const Constant("https://inv.nadeko.net"));
|
||||
i1.GeneratedColumn<String> _column_25(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('theme_mode', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(ThemeMode.system.name));
|
||||
i1.GeneratedColumn<String> _column_26(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('audio_source', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(AudioSource.youtube.name));
|
||||
i1.GeneratedColumn<String> _column_27(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('stream_music_codec', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(SourceCodecs.weba.name));
|
||||
i1.GeneratedColumn<String> _column_28(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('download_music_codec', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(SourceCodecs.m4a.name));
|
||||
i1.GeneratedColumn<bool> _column_29(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('discord_presence', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("discord_presence" IN (0, 1))'),
|
||||
defaultValue: const Constant(true));
|
||||
i1.GeneratedColumn<bool> _column_30(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('endless_playback', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("endless_playback" IN (0, 1))'),
|
||||
defaultValue: const Constant(true));
|
||||
i1.GeneratedColumn<bool> _column_31(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('enable_connect', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("enable_connect" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
|
||||
class Shape3 extends i0.VersionedTable {
|
||||
Shape3({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<String> get username =>
|
||||
columnsByName['username']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get passwordHash =>
|
||||
columnsByName['password_hash']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<DateTime> _column_32(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
|
||||
type: i1.DriftSqlType.dateTime, defaultValue: currentDateAndTime);
|
||||
i1.GeneratedColumn<String> _column_33(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('username', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_34(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('password_hash', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape4 extends i0.VersionedTable {
|
||||
Shape4({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get start =>
|
||||
columnsByName['start']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get end =>
|
||||
columnsByName['end']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get trackId =>
|
||||
columnsByName['track_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_35(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('start', aliasedName, false,
|
||||
type: i1.DriftSqlType.int);
|
||||
i1.GeneratedColumn<int> _column_36(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('end', aliasedName, false,
|
||||
type: i1.DriftSqlType.int);
|
||||
i1.GeneratedColumn<String> _column_37(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('track_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape5 extends i0.VersionedTable {
|
||||
Shape5({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get trackId =>
|
||||
columnsByName['track_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get sourceId =>
|
||||
columnsByName['source_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get sourceType =>
|
||||
columnsByName['source_type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_38(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('source_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_39(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('source_type', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: Constant(SourceType.youtube.name));
|
||||
|
||||
class Shape6 extends i0.VersionedTable {
|
||||
Shape6({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<bool> get playing =>
|
||||
columnsByName['playing']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<String> get loopMode =>
|
||||
columnsByName['loop_mode']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get shuffled =>
|
||||
columnsByName['shuffled']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<String> get collections =>
|
||||
columnsByName['collections']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<bool> _column_40(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('playing', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("playing" IN (0, 1))'));
|
||||
i1.GeneratedColumn<String> _column_41(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('loop_mode', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<bool> _column_42(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('shuffled', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("shuffled" IN (0, 1))'));
|
||||
i1.GeneratedColumn<String> _column_43(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('collections', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape7 extends i0.VersionedTable {
|
||||
Shape7({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get audioPlayerStateId =>
|
||||
columnsByName['audio_player_state_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get index =>
|
||||
columnsByName['index']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_44(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('audio_player_state_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'REFERENCES audio_player_state_table (id)'));
|
||||
i1.GeneratedColumn<int> _column_45(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('index', aliasedName, false,
|
||||
type: i1.DriftSqlType.int);
|
||||
|
||||
class Shape8 extends i0.VersionedTable {
|
||||
Shape8({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get playlistId =>
|
||||
columnsByName['playlist_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get uri =>
|
||||
columnsByName['uri']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get extras =>
|
||||
columnsByName['extras']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get httpHeaders =>
|
||||
columnsByName['http_headers']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_46(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('playlist_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'REFERENCES playlist_table (id)'));
|
||||
i1.GeneratedColumn<String> _column_47(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('uri', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_48(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('extras', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_49(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('http_headers', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape9 extends i0.VersionedTable {
|
||||
Shape9({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<String> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get itemId =>
|
||||
columnsByName['item_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get data =>
|
||||
columnsByName['data']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_50(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('type', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_51(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('item_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_52(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('data', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape10 extends i0.VersionedTable {
|
||||
Shape10({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get trackId =>
|
||||
columnsByName['track_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get data =>
|
||||
columnsByName['data']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
case 1:
|
||||
final schema = Schema2(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from1To2(migrator, schema);
|
||||
return 2;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
}) =>
|
||||
i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
));
|
@ -14,7 +14,8 @@ enum CloseBehavior {
|
||||
enum AudioSource {
|
||||
youtube,
|
||||
piped,
|
||||
jiosaavn;
|
||||
jiosaavn,
|
||||
invidious;
|
||||
|
||||
String get label => name[0].toUpperCase() + name.substring(1);
|
||||
}
|
||||
@ -77,6 +78,8 @@ class PreferencesTable extends Table {
|
||||
text().withDefault(const Constant("")).map(const StringListConverter())();
|
||||
TextColumn get pipedInstance =>
|
||||
text().withDefault(const Constant("https://pipedapi.kavin.rocks"))();
|
||||
TextColumn get invidiousInstance =>
|
||||
text().withDefault(const Constant("https://inv.nadeko.net"))();
|
||||
TextColumn get themeMode =>
|
||||
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
||||
TextColumn get audioSource =>
|
||||
@ -113,6 +116,7 @@ class PreferencesTable extends Table {
|
||||
downloadLocation: "",
|
||||
localLibraryLocation: [],
|
||||
pipedInstance: "https://pipedapi.kavin.rocks",
|
||||
invidiousInstance: "https://inv.nadeko.net",
|
||||
themeMode: ThemeMode.system,
|
||||
audioSource: AudioSource.youtube,
|
||||
streamMusicCodec: SourceCodecs.weba,
|
||||
|
@ -23,6 +23,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.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/sourced_track.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/invidious.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/piped.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
@ -42,6 +43,17 @@ final sourceInfoToIconMap = {
|
||||
),
|
||||
),
|
||||
PipedSourceInfo: const Icon(SpotubeIcons.piped),
|
||||
InvidiousSourceInfo: Container(
|
||||
height: 18,
|
||||
width: 18,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
image: DecorationImage(
|
||||
image: Assets.invidious.provider(),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
class SiblingTracksSheet extends HookConsumerWidget {
|
||||
|
@ -17,6 +17,10 @@ final audioSourceToIconMap = {
|
||||
size: 30,
|
||||
),
|
||||
AudioSource.piped: const Icon(SpotubeIcons.piped, size: 30),
|
||||
AudioSource.invidious: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
child: Assets.invidious.image(width: 48, height: 48),
|
||||
),
|
||||
AudioSource.jiosaavn: Assets.jiosaavn.image(width: 48, height: 48),
|
||||
};
|
||||
|
||||
@ -45,6 +49,7 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget {
|
||||
AudioSource.jiosaavn:
|
||||
"${context.l10n.jiosaavn_source_description}\n"
|
||||
"${context.l10n.highest_quality("320kbps mp")}",
|
||||
AudioSource.invidious: context.l10n.invidious_source_description,
|
||||
},
|
||||
[]);
|
||||
|
||||
@ -104,7 +109,9 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget {
|
||||
title: Align(
|
||||
alignment: switch (preferences.audioSource) {
|
||||
AudioSource.youtube => Alignment.centerLeft,
|
||||
AudioSource.piped => Alignment.center,
|
||||
AudioSource.piped ||
|
||||
AudioSource.invidious =>
|
||||
Alignment.center,
|
||||
AudioSource.jiosaavn => Alignment.centerRight,
|
||||
},
|
||||
child: Text(
|
||||
|
@ -10,7 +10,8 @@ import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/modules/settings/section_card_with_heading.dart';
|
||||
import 'package:spotube/components/adaptive/adaptive_select_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/piped_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/user_preferences/user_preferences_provider.dart';
|
||||
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
@ -135,6 +136,73 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
||||
);
|
||||
}),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: preferences.audioSource != AudioSource.invidious
|
||||
? const SizedBox.shrink()
|
||||
: Consumer(builder: (context, ref, child) {
|
||||
final instanceList = ref.watch(invidiousInstancesProvider);
|
||||
|
||||
return instanceList.when(
|
||||
data: (data) {
|
||||
return AdaptiveSelectTile<String>(
|
||||
secondary: const Icon(SpotubeIcons.piped),
|
||||
title: Text(context.l10n.invidious_instance),
|
||||
subtitle: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: context.l10n.invidious_description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const TextSpan(text: "\n"),
|
||||
TextSpan(
|
||||
text: context.l10n.invidious_warning,
|
||||
style: theme.textTheme.labelMedium,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
value: preferences.invidiousInstance,
|
||||
showValueWhenUnfolded: false,
|
||||
options: data
|
||||
.sortedBy((e) => e.name)
|
||||
.map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: e.details.uri,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${e.name.trim()}\n",
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
TextSpan(
|
||||
text: countryCodeToEmoji(
|
||||
e.details.region,
|
||||
),
|
||||
style: GoogleFonts.notoColorEmoji(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
preferencesNotifier.setInvidiousInstance(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stackTrace) => Text(error.toString()),
|
||||
);
|
||||
}),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: preferences.audioSource != AudioSource.piped
|
||||
@ -159,7 +227,8 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: preferences.searchMode == SearchMode.youtube &&
|
||||
(preferences.audioSource == AudioSource.piped ||
|
||||
preferences.audioSource == AudioSource.youtube)
|
||||
preferences.audioSource == AudioSource.youtube ||
|
||||
preferences.audioSource == AudioSource.invidious)
|
||||
? SwitchListTile(
|
||||
secondary: const Icon(SpotubeIcons.skip),
|
||||
title: Text(context.l10n.skip_non_music),
|
||||
|
@ -0,0 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/invidious.dart';
|
||||
|
||||
final invidiousInstancesProvider = FutureProvider((ref) async {
|
||||
final invidious = ref.watch(invidiousProvider);
|
||||
|
||||
final instances = await invidious.instances();
|
||||
|
||||
return instances
|
||||
.where((instance) => instance.details.type == "https")
|
||||
.toList();
|
||||
});
|
@ -20,6 +20,17 @@ class ServerPlaybackRoutes {
|
||||
|
||||
/// @get('/stream/<trackId>')
|
||||
Future<Response> getStreamTrackId(Request request, String trackId) async {
|
||||
final options = Options(
|
||||
headers: {
|
||||
...request.headers,
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
responseType: ResponseType.stream,
|
||||
validateStatus: (status) => status! < 400,
|
||||
);
|
||||
try {
|
||||
final track =
|
||||
playlist.tracks.firstWhere((element) => element.id == trackId);
|
||||
@ -30,22 +41,33 @@ class ServerPlaybackRoutes {
|
||||
: await ref.read(sourcedTrackProvider(SpotubeMedia(track)).future);
|
||||
|
||||
ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack);
|
||||
|
||||
final res = await dio.get(
|
||||
final res = await dio
|
||||
.get(
|
||||
sourcedTrack!.url,
|
||||
options: Options(
|
||||
options: options.copyWith(
|
||||
headers: {
|
||||
...request.headers,
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
...options.headers!,
|
||||
"host": Uri.parse(sourcedTrack.url).host,
|
||||
"Cache-Control": "max-age=0",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
responseType: ResponseType.stream,
|
||||
validateStatus: (status) => status! < 500,
|
||||
),
|
||||
);
|
||||
)
|
||||
.catchError((e, stack) async {
|
||||
final sourcedTrack = await ref
|
||||
.read(sourcedTrackProvider(SpotubeMedia(track)).notifier)
|
||||
.switchToAlternativeSources();
|
||||
|
||||
ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack);
|
||||
|
||||
return await dio.get(
|
||||
sourcedTrack!.url,
|
||||
options: options.copyWith(
|
||||
headers: {
|
||||
...options.headers!,
|
||||
"host": Uri.parse(sourcedTrack.url).host,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final audioStream =
|
||||
(res.data?.stream as Stream<Uint8List>?)?.asBroadcastStream();
|
||||
|
@ -5,24 +5,44 @@ import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
|
||||
final sourcedTrackProvider =
|
||||
FutureProvider.family<SourcedTrack?, SpotubeMedia?>((ref, media) async {
|
||||
final track = media?.track;
|
||||
if (track == null || track is LocalTrack) {
|
||||
return null;
|
||||
class SourcedTrackNotifier
|
||||
extends FamilyAsyncNotifier<SourcedTrack?, SpotubeMedia?> {
|
||||
@override
|
||||
build(media) async {
|
||||
final track = media?.track;
|
||||
if (track == null || track is LocalTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ref.listen(
|
||||
audioPlayerProvider.select((value) => value.tracks),
|
||||
(old, next) {
|
||||
if (next.isEmpty || next.none((element) => element.id == track.id)) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final sourcedTrack =
|
||||
await SourcedTrack.fetchFromTrack(track: track, ref: ref);
|
||||
|
||||
return sourcedTrack;
|
||||
}
|
||||
|
||||
ref.listen(
|
||||
audioPlayerProvider.select((value) => value.tracks),
|
||||
(old, next) {
|
||||
if (next.isEmpty || next.none((element) => element.id == track.id)) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
},
|
||||
);
|
||||
Future<SourcedTrack?> switchToAlternativeSources() async {
|
||||
if (arg == null) {
|
||||
return null;
|
||||
}
|
||||
return await update((prev) async {
|
||||
return await SourcedTrack.fetchFromTrackAltSource(
|
||||
track: arg!.track,
|
||||
ref: ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final sourcedTrack =
|
||||
await SourcedTrack.fetchFromTrack(track: track, ref: ref);
|
||||
|
||||
return sourcedTrack;
|
||||
});
|
||||
final sourcedTrackProvider = AsyncNotifierProviderFamily<SourcedTrackNotifier,
|
||||
SourcedTrack?, SpotubeMedia?>(
|
||||
() => SourcedTrackNotifier(),
|
||||
);
|
||||
|
@ -167,6 +167,10 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
|
||||
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
|
||||
}
|
||||
|
||||
void setInvidiousInstance(String instance) {
|
||||
setData(PreferencesTableCompanion(invidiousInstance: Value(instance)));
|
||||
}
|
||||
|
||||
void setSearchMode(SearchMode mode) {
|
||||
setData(PreferencesTableCompanion(searchMode: Value(mode)));
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo(
|
||||
artist: json['artist'] as String,
|
||||
thumbnail: json['thumbnail'] as String,
|
||||
pageUrl: json['pageUrl'] as String,
|
||||
duration: Duration(microseconds: json['duration'] as int),
|
||||
duration: Duration(microseconds: (json['duration'] as num).toInt()),
|
||||
artistUrl: json['artistUrl'] as String,
|
||||
album: json['album'] as String?,
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:invidious/invidious.dart';
|
||||
import 'package:piped_client/piped_client.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
|
||||
@ -112,4 +113,24 @@ class YoutubeVideoInfo {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/services/sourced_track/exceptions.dart';
|
||||
import 'package:spotube/services/sourced_track/models/source_info.dart';
|
||||
import 'package:spotube/services/sourced_track/models/source_map.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/invidious.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/piped.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
@ -85,6 +86,13 @@ abstract class SourcedTrack extends Track {
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
AudioSource.invidious => InvidiousSourcedTrack(
|
||||
ref: ref,
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -104,6 +112,49 @@ abstract class SourcedTrack extends Track {
|
||||
return "$title - ${artists.join(", ")}";
|
||||
}
|
||||
|
||||
static fetchFromTrackAltSource({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
try {
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.piped ||
|
||||
AudioSource.invidious ||
|
||||
AudioSource.jiosaavn =>
|
||||
await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
AudioSource.youtube =>
|
||||
await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
};
|
||||
} on TrackNotFoundError catch (_) {
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.piped ||
|
||||
AudioSource.youtube ||
|
||||
AudioSource.invidious =>
|
||||
await JioSaavnSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
weakMatch: true,
|
||||
),
|
||||
AudioSource.jiosaavn =>
|
||||
await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
};
|
||||
} on HttpClientClosedException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref);
|
||||
} on VideoUnplayableException catch (_) {
|
||||
return await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref);
|
||||
} catch (e) {
|
||||
if (e is DioException || e is ClientException || e is SocketException) {
|
||||
return await JioSaavnSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
weakMatch: preferences.audioSource == AudioSource.jiosaavn,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
@ -117,11 +168,14 @@ abstract class SourcedTrack extends Track {
|
||||
await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
AudioSource.jiosaavn =>
|
||||
await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
AudioSource.invidious =>
|
||||
await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref),
|
||||
};
|
||||
} on TrackNotFoundError catch (_) {
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.piped ||
|
||||
AudioSource.youtube =>
|
||||
AudioSource.youtube ||
|
||||
AudioSource.invidious =>
|
||||
await JioSaavnSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
@ -136,11 +190,19 @@ abstract class SourcedTrack extends Track {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref);
|
||||
} catch (e) {
|
||||
if (e is DioException || e is ClientException || e is SocketException) {
|
||||
return await JioSaavnSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
weakMatch: preferences.audioSource == AudioSource.jiosaavn,
|
||||
);
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.piped ||
|
||||
AudioSource.invidious =>
|
||||
await YoutubeSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
),
|
||||
_ => await JioSaavnSourcedTrack.fetchFromTrack(
|
||||
track: track,
|
||||
ref: ref,
|
||||
weakMatch: preferences.audioSource == AudioSource.jiosaavn,
|
||||
)
|
||||
};
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
@ -159,6 +221,8 @@ abstract class SourcedTrack extends Track {
|
||||
YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref),
|
||||
AudioSource.jiosaavn =>
|
||||
JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref),
|
||||
AudioSource.invidious =>
|
||||
InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref),
|
||||
};
|
||||
}
|
||||
|
||||
|
266
lib/services/sourced_track/sources/invidious.dart
Normal file
266
lib/services/sourced_track/sources/invidious.dart
Normal file
@ -0,0 +1,266 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/database/database.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/source_info.dart';
|
||||
import 'package:spotube/services/sourced_track/models/source_map.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 InvidiousSourceInfo extends SourceInfo {
|
||||
InvidiousSourceInfo({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required super.artist,
|
||||
required super.thumbnail,
|
||||
required super.pageUrl,
|
||||
required super.duration,
|
||||
required super.artistUrl,
|
||||
required super.album,
|
||||
});
|
||||
}
|
||||
|
||||
class InvidiousSourcedTrack extends SourcedTrack {
|
||||
InvidiousSourcedTrack({
|
||||
required super.ref,
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final database = ref.read(databaseProvider);
|
||||
final cachedSource = await (database.select(database.sourceMatchTable)
|
||||
..where((s) => s.trackId.equals(track.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, track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(track);
|
||||
}
|
||||
|
||||
await database.into(database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: track.id!,
|
||||
sourceId: siblings.first.info.id,
|
||||
sourceType: const Value(SourceType.youtube),
|
||||
),
|
||||
);
|
||||
|
||||
return InvidiousSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else {
|
||||
final manifest =
|
||||
await invidiousClient.videos.get(cachedSource.sourceId, local: true);
|
||||
|
||||
return InvidiousSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: [],
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: InvidiousSourceInfo(
|
||||
id: manifest.videoId,
|
||||
artist: manifest.author,
|
||||
artistUrl: manifest.authorUrl,
|
||||
pageUrl: "https://www.youtube.com/watch?v=${manifest.videoId}",
|
||||
thumbnail: manifest.videoThumbnails.first.url,
|
||||
title: manifest.title,
|
||||
duration: Duration(seconds: manifest.lengthSeconds),
|
||||
album: null,
|
||||
),
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(InvidiousVideoResponse manifest) {
|
||||
final m4a = manifest.adaptiveFormats
|
||||
.where((audio) => audio.type.contains("audio/mp4"))
|
||||
.sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(b.bitrate)));
|
||||
|
||||
final weba = manifest.adaptiveFormats
|
||||
.where((audio) => audio.type.contains("audio/webm"))
|
||||
.sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(b.bitrate)));
|
||||
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: m4a.first.url.toString(),
|
||||
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
|
||||
low: m4a.last.url.toString(),
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: weba.first.url.toString(),
|
||||
medium:
|
||||
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
|
||||
low: weba.last.url.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<SiblingType> toSiblingType(
|
||||
int index,
|
||||
YoutubeVideoInfo item,
|
||||
InvidiousClient invidiousClient,
|
||||
) async {
|
||||
SourceMap? sourceMap;
|
||||
if (index == 0) {
|
||||
final manifest = await invidiousClient.videos.get(item.id, local: true);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
}
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: InvidiousSourceInfo(
|
||||
id: item.id,
|
||||
artist: item.channelName,
|
||||
artistUrl: "https://www.youtube.com/${item.channelId}",
|
||||
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
|
||||
thumbnail: item.thumbnailUrl,
|
||||
title: item.title,
|
||||
duration: item.duration,
|
||||
album: null,
|
||||
),
|
||||
source: sourceMap,
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
final invidiousClient = ref.read(invidiousProvider);
|
||||
final preference = ref.read(userPreferencesProvider);
|
||||
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final searchResults = await invidiousClient.search.list(
|
||||
query,
|
||||
type: InvidiousSearchType.video,
|
||||
);
|
||||
|
||||
if (ServiceUtils.onlyContainsEnglish(query)) {
|
||||
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(),
|
||||
track,
|
||||
);
|
||||
|
||||
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, track: this);
|
||||
|
||||
return InvidiousSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
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, sourceInfo);
|
||||
|
||||
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: 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,
|
||||
source: toSourceMap(manifest),
|
||||
sourceInfo: newSourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
}
|
122
pubspec.lock
122
pubspec.lock
@ -245,10 +245,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04
|
||||
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.12"
|
||||
version: "2.4.13"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -393,6 +393,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -518,10 +526,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5"
|
||||
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.3+1"
|
||||
version: "5.7.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
disable_battery_optimization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -543,18 +559,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "6acedc562ffeed308049f78fb1906abad3d65714580b6745441ee6d50ec564cd"
|
||||
sha256: df027d168a2985a2e9da900adeba2ab0136f0d84436592cf3cd5135f82c8579c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.18.0"
|
||||
version: "2.21.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: d9b020736ea85fff1568699ce18b89fabb3f0f042e8a7a05e84a3ec20d39acde
|
||||
sha256: "27bab15e7869b69259663590381180117873b9b273a1ea9ebb21bb73133d1233"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.18.0"
|
||||
version: "2.21.0"
|
||||
duration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -992,10 +1008,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.4"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1246,6 +1262,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
invidious:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: invidious
|
||||
sha256: "7cb879c0b4b99aa06ec720af84f6988ff0080bb0434d041f6fb0c4add680ee36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
io:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -1274,18 +1298,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.1"
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969
|
||||
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.7.1"
|
||||
version: "6.8.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1470,6 +1494,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
oauth2:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1887,6 +1919,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1895,6 +1935,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1972,6 +2020,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.4"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.12"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2032,10 +2096,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: ade9a67fd70d0369329ed3373208de7ebd8662470e8c396fc8d0d60f9acdfc9f
|
||||
sha256: c5f63dff8677407ddcddfa4744c176ea6dc44286c47ba9e69e76d8071398034d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.36.0"
|
||||
version: "0.39.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2124,6 +2188,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
test:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: test
|
||||
sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.25.7"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2132,6 +2204,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.4"
|
||||
time:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2380,6 +2460,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
wikipedia_api:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -2445,5 +2533,5 @@ packages:
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
sdks:
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
dart: ">=3.5.3 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
@ -58,6 +58,7 @@ dependencies:
|
||||
html: ^0.15.1
|
||||
image_picker: ^1.1.0
|
||||
intl: any
|
||||
invidious: ^0.1.0
|
||||
json_annotation: ^4.8.1
|
||||
logger: ^2.0.2
|
||||
media_kit: ^1.1.10+1
|
||||
@ -118,14 +119,15 @@ dependencies:
|
||||
tray_manager: ^0.2.2
|
||||
http: ^1.2.1
|
||||
riverpod: ^2.5.1
|
||||
drift: ^2.18.0
|
||||
drift: ^2.21.0
|
||||
sqlite3_flutter_libs: ^0.5.23
|
||||
sqlite3: ^2.4.3
|
||||
encrypt: ^5.0.3
|
||||
go_router: ^14.2.7
|
||||
test: ^1.25.7
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.9
|
||||
build_runner: ^2.4.13
|
||||
crypto: ^3.0.3
|
||||
envied_generator: ^0.5.4+1
|
||||
flutter_gen_runner: ^5.4.0
|
||||
@ -145,7 +147,7 @@ dev_dependencies:
|
||||
pub_api_client: ^2.7.0
|
||||
xml: ^6.5.0
|
||||
io: ^1.0.4
|
||||
drift_dev: ^2.18.0
|
||||
drift_dev: ^2.21.0
|
||||
|
||||
dependency_overrides:
|
||||
web: ^1.1.0
|
||||
|
23
test/drift/app_db/generated/schema.dart
Normal file
23
test/drift/app_db/generated/schema.dart
Normal file
@ -0,0 +1,23 @@
|
||||
// GENERATED CODE, DO NOT EDIT BY HAND.
|
||||
// ignore_for_file: type=lint
|
||||
//@dart=2.12
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/internal/migrations.dart';
|
||||
import 'schema_v2.dart' as v2;
|
||||
import 'schema_v1.dart' as v1;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
|
||||
switch (version) {
|
||||
case 2:
|
||||
return v2.DatabaseAtV2(db);
|
||||
case 1:
|
||||
return v1.DatabaseAtV1(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2];
|
||||
}
|
3333
test/drift/app_db/generated/schema_v1.dart
Normal file
3333
test/drift/app_db/generated/schema_v1.dart
Normal file
File diff suppressed because it is too large
Load Diff
3366
test/drift/app_db/generated/schema_v2.dart
Normal file
3366
test/drift/app_db/generated/schema_v2.dart
Normal file
File diff suppressed because it is too large
Load Diff
128
test/drift/app_db/migration_test.dart
Normal file
128
test/drift/app_db/migration_test.dart
Normal file
@ -0,0 +1,128 @@
|
||||
// ignore_for_file: unused_local_variable, unused_import
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_dev/api/migrations.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'generated/schema.dart';
|
||||
|
||||
import 'generated/schema_v1.dart' as v1;
|
||||
import 'generated/schema_v2.dart' as v2;
|
||||
|
||||
void main() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
late SchemaVerifier verifier;
|
||||
|
||||
setUpAll(() {
|
||||
verifier = SchemaVerifier(GeneratedHelper());
|
||||
});
|
||||
|
||||
group('simple database migrations', () {
|
||||
// These simple tests verify all possible schema updates with a simple (no
|
||||
// data) migration. This is a quick way to ensure that written database
|
||||
// migrations properly alter the schema.
|
||||
final versions = GeneratedHelper.versions;
|
||||
for (final (i, fromVersion) in versions.indexed) {
|
||||
group('from $fromVersion', () {
|
||||
for (final toVersion in versions.skip(i + 1)) {
|
||||
test('to $toVersion', () async {
|
||||
final schema = await verifier.schemaAt(fromVersion);
|
||||
final db = Database(schema.newConnection());
|
||||
await verifier.migrateAndValidate(db, toVersion);
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Simple tests ensure the schema is transformed correctly, but some
|
||||
// migrations benefit from a test verifying that data is transformed correctly
|
||||
// too. This is particularly true for migrations that change existing columns
|
||||
// (e.g. altering their type or constraints). Migrations that only add tables
|
||||
// or columns typically don't need these advanced tests.
|
||||
// TODO: Check whether you have migrations that could benefit from these tests
|
||||
// and adapt this example to your database if necessary:
|
||||
test("migration from v1 to v2 does not corrupt data", () async {
|
||||
// Add data to insert into the old database, and the expected rows after the
|
||||
// migration.
|
||||
final oldAuthenticationTableData = <v1.AuthenticationTableData>[];
|
||||
final expectedNewAuthenticationTableData = <v2.AuthenticationTableData>[];
|
||||
|
||||
final oldBlacklistTableData = <v1.BlacklistTableData>[];
|
||||
final expectedNewBlacklistTableData = <v2.BlacklistTableData>[];
|
||||
|
||||
final oldPreferencesTableData = <v1.PreferencesTableData>[];
|
||||
final expectedNewPreferencesTableData = <v2.PreferencesTableData>[];
|
||||
|
||||
final oldScrobblerTableData = <v1.ScrobblerTableData>[];
|
||||
final expectedNewScrobblerTableData = <v2.ScrobblerTableData>[];
|
||||
|
||||
final oldSkipSegmentTableData = <v1.SkipSegmentTableData>[];
|
||||
final expectedNewSkipSegmentTableData = <v2.SkipSegmentTableData>[];
|
||||
|
||||
final oldSourceMatchTableData = <v1.SourceMatchTableData>[];
|
||||
final expectedNewSourceMatchTableData = <v2.SourceMatchTableData>[];
|
||||
|
||||
final oldAudioPlayerStateTableData = <v1.AudioPlayerStateTableData>[];
|
||||
final expectedNewAudioPlayerStateTableData =
|
||||
<v2.AudioPlayerStateTableData>[];
|
||||
|
||||
final oldPlaylistTableData = <v1.PlaylistTableData>[];
|
||||
final expectedNewPlaylistTableData = <v2.PlaylistTableData>[];
|
||||
|
||||
final oldPlaylistMediaTableData = <v1.PlaylistMediaTableData>[];
|
||||
final expectedNewPlaylistMediaTableData = <v2.PlaylistMediaTableData>[];
|
||||
|
||||
final oldHistoryTableData = <v1.HistoryTableData>[];
|
||||
final expectedNewHistoryTableData = <v2.HistoryTableData>[];
|
||||
|
||||
final oldLyricsTableData = <v1.LyricsTableData>[];
|
||||
final expectedNewLyricsTableData = <v2.LyricsTableData>[];
|
||||
|
||||
await verifier.testWithDataIntegrity(
|
||||
oldVersion: 1,
|
||||
newVersion: 2,
|
||||
createOld: v1.DatabaseAtV1.new,
|
||||
createNew: v2.DatabaseAtV2.new,
|
||||
openTestedDatabase: (x) => AppDatabase(),
|
||||
createItems: (batch, oldDb) {
|
||||
batch.insertAll(oldDb.authenticationTable, oldAuthenticationTableData);
|
||||
batch.insertAll(oldDb.blacklistTable, oldBlacklistTableData);
|
||||
batch.insertAll(oldDb.preferencesTable, oldPreferencesTableData);
|
||||
batch.insertAll(oldDb.scrobblerTable, oldScrobblerTableData);
|
||||
batch.insertAll(oldDb.skipSegmentTable, oldSkipSegmentTableData);
|
||||
batch.insertAll(oldDb.sourceMatchTable, oldSourceMatchTableData);
|
||||
batch.insertAll(
|
||||
oldDb.audioPlayerStateTable, oldAudioPlayerStateTableData);
|
||||
batch.insertAll(oldDb.playlistTable, oldPlaylistTableData);
|
||||
batch.insertAll(oldDb.playlistMediaTable, oldPlaylistMediaTableData);
|
||||
batch.insertAll(oldDb.historyTable, oldHistoryTableData);
|
||||
batch.insertAll(oldDb.lyricsTable, oldLyricsTableData);
|
||||
},
|
||||
validateItems: (newDb) async {
|
||||
expect(expectedNewAuthenticationTableData,
|
||||
await newDb.select(newDb.authenticationTable).get());
|
||||
expect(expectedNewBlacklistTableData,
|
||||
await newDb.select(newDb.blacklistTable).get());
|
||||
expect(expectedNewPreferencesTableData,
|
||||
await newDb.select(newDb.preferencesTable).get());
|
||||
expect(expectedNewScrobblerTableData,
|
||||
await newDb.select(newDb.scrobblerTable).get());
|
||||
expect(expectedNewSkipSegmentTableData,
|
||||
await newDb.select(newDb.skipSegmentTable).get());
|
||||
expect(expectedNewSourceMatchTableData,
|
||||
await newDb.select(newDb.sourceMatchTable).get());
|
||||
expect(expectedNewAudioPlayerStateTableData,
|
||||
await newDb.select(newDb.audioPlayerStateTable).get());
|
||||
expect(expectedNewPlaylistTableData,
|
||||
await newDb.select(newDb.playlistTable).get());
|
||||
expect(expectedNewPlaylistMediaTableData,
|
||||
await newDb.select(newDb.playlistMediaTable).get());
|
||||
expect(expectedNewHistoryTableData,
|
||||
await newDb.select(newDb.historyTable).get());
|
||||
expect(expectedNewLyricsTableData,
|
||||
await newDb.select(newDb.lyricsTable).get());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
@ -1 +1,183 @@
|
||||
{}
|
||||
{
|
||||
"ar": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"bn": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ca": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"cs": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"de": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"eu": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"fa": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"fi": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"hi": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"id": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"it": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ja": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ka": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ne": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"nl": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"pl": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"th": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"tr": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"uk": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"vi": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
],
|
||||
|
||||
"zh": [
|
||||
"invidious_instance",
|
||||
"invidious_description",
|
||||
"invidious_warning",
|
||||
"invidious_source_description"
|
||||
]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user