chore: metadata

This commit is contained in:
Kingkor Roy Tirtho 2025-06-13 16:23:41 +06:00
parent 67713c60d4
commit 2d6fe886e2
19 changed files with 5500 additions and 321 deletions

File diff suppressed because one or more lines are too long

View File

@ -102,6 +102,10 @@ class AppRouter extends RootStackRouter {
path: "settings", path: "settings",
page: SettingsRoute.page, page: SettingsRoute.page,
), ),
AutoRoute(
path: "settings/metadata-provider",
page: SettingsMetadataProviderRoute.page,
),
AutoRoute( AutoRoute(
path: "settings/blacklist", path: "settings/blacklist",
page: BlackListRoute.page, page: BlackListRoute.page,

File diff suppressed because it is too large Load Diff

View File

@ -135,4 +135,7 @@ abstract class SpotubeIcons {
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; static const engine = FeatherIcons.server;
static const extensions = FeatherIcons.package;
static const message = FeatherIcons.send;
static const upload = FeatherIcons.uploadCloud;
} }

View File

@ -30,6 +30,7 @@ import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/glance/glance.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/bonsoir.dart';
import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/server/server.dart';
import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart';
@ -145,6 +146,8 @@ class Spotube extends HookConsumerWidget {
ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); ref.listen(audioPlayerStreamListenersProvider, (_, __) {});
ref.listen(bonsoirProvider, (_, __) {}); ref.listen(bonsoirProvider, (_, __) {});
ref.listen(connectClientsProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {});
ref.listen(metadataPluginsProvider, (_, __) {});
ref.listen(metadataPluginApiProvider, (_, __) {});
ref.listen(serverProvider, (_, __) {}); ref.listen(serverProvider, (_, __) {});
ref.listen(trayManagerProvider, (_, __) {}); ref.listen(trayManagerProvider, (_, __) {});

View File

@ -4,6 +4,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/remote.dart';
import 'package:encrypt/encrypt.dart'; import 'package:encrypt/encrypt.dart';
import 'package:media_kit/media_kit.dart' hide Track; import 'package:media_kit/media_kit.dart' hide Track;
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -35,6 +36,7 @@ part 'tables/source_match.dart';
part 'tables/audio_player_state.dart'; part 'tables/audio_player_state.dart';
part 'tables/history.dart'; part 'tables/history.dart';
part 'tables/lyrics.dart'; part 'tables/lyrics.dart';
part 'tables/metadata_plugins.dart';
part 'typeconverters/color.dart'; part 'typeconverters/color.dart';
part 'typeconverters/locale.dart'; part 'typeconverters/locale.dart';
@ -56,13 +58,14 @@ part 'typeconverters/subtitle.dart';
PlaylistMediaTable, PlaylistMediaTable,
HistoryTable, HistoryTable,
LyricsTable, LyricsTable,
MetadataPluginsTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 6; int get schemaVersion => 7;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -115,11 +118,21 @@ class AppDatabase extends _$AppDatabase {
); );
}, },
from5To6: (m, schema) async { from5To6: (m, schema) async {
// Add new column to preferences table try {
await m.addColumn( await m.addColumn(
schema.preferencesTable, schema.preferencesTable,
schema.preferencesTable.connectPort, schema.preferencesTable.connectPort,
); );
} on DriftRemoteException catch (e) {
// If the column already exists, ignore the error
if (e.remoteCause !=
'duplicate column name: ${schema.preferencesTable.connectPort.name}') {
rethrow;
}
}
},
from6To7: (m, schema) async {
await m.createTable(schema.metadataPluginsTable);
}, },
), ),
); );

View File

@ -4275,6 +4275,353 @@ class LyricsTableCompanion extends UpdateCompanion<LyricsTableData> {
} }
} }
class $MetadataPluginsTableTable extends MetadataPluginsTable
with TableInfo<$MetadataPluginsTableTable, MetadataPluginsTableData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$MetadataPluginsTableTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _nameMeta = const VerificationMeta('name');
@override
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name', aliasedName, false,
additionalChecks:
GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 50),
type: DriftSqlType.string,
requiredDuringInsert: true);
static const VerificationMeta _descriptionMeta =
const VerificationMeta('description');
@override
late final GeneratedColumn<String> description = GeneratedColumn<String>(
'description', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _versionMeta =
const VerificationMeta('version');
@override
late final GeneratedColumn<String> version = GeneratedColumn<String>(
'version', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _authorMeta = const VerificationMeta('author');
@override
late final GeneratedColumn<String> author = GeneratedColumn<String>(
'author', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _selectedMeta =
const VerificationMeta('selected');
@override
late final GeneratedColumn<bool> selected = GeneratedColumn<bool>(
'selected', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("selected" IN (0, 1))'),
defaultValue: const Constant(false));
@override
List<GeneratedColumn> get $columns =>
[id, name, description, version, author, selected];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'metadata_plugins_table';
@override
VerificationContext validateIntegrity(
Insertable<MetadataPluginsTableData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('name')) {
context.handle(
_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('description')) {
context.handle(
_descriptionMeta,
description.isAcceptableOrUnknown(
data['description']!, _descriptionMeta));
} else if (isInserting) {
context.missing(_descriptionMeta);
}
if (data.containsKey('version')) {
context.handle(_versionMeta,
version.isAcceptableOrUnknown(data['version']!, _versionMeta));
} else if (isInserting) {
context.missing(_versionMeta);
}
if (data.containsKey('author')) {
context.handle(_authorMeta,
author.isAcceptableOrUnknown(data['author']!, _authorMeta));
} else if (isInserting) {
context.missing(_authorMeta);
}
if (data.containsKey('selected')) {
context.handle(_selectedMeta,
selected.isAcceptableOrUnknown(data['selected']!, _selectedMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
MetadataPluginsTableData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return MetadataPluginsTableData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
name: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}name'])!,
description: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}description'])!,
version: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}version'])!,
author: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}author'])!,
selected: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}selected'])!,
);
}
@override
$MetadataPluginsTableTable createAlias(String alias) {
return $MetadataPluginsTableTable(attachedDatabase, alias);
}
}
class MetadataPluginsTableData extends DataClass
implements Insertable<MetadataPluginsTableData> {
final int id;
final String name;
final String description;
final String version;
final String author;
final bool selected;
const MetadataPluginsTableData(
{required this.id,
required this.name,
required this.description,
required this.version,
required this.author,
required this.selected});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['name'] = Variable<String>(name);
map['description'] = Variable<String>(description);
map['version'] = Variable<String>(version);
map['author'] = Variable<String>(author);
map['selected'] = Variable<bool>(selected);
return map;
}
MetadataPluginsTableCompanion toCompanion(bool nullToAbsent) {
return MetadataPluginsTableCompanion(
id: Value(id),
name: Value(name),
description: Value(description),
version: Value(version),
author: Value(author),
selected: Value(selected),
);
}
factory MetadataPluginsTableData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return MetadataPluginsTableData(
id: serializer.fromJson<int>(json['id']),
name: serializer.fromJson<String>(json['name']),
description: serializer.fromJson<String>(json['description']),
version: serializer.fromJson<String>(json['version']),
author: serializer.fromJson<String>(json['author']),
selected: serializer.fromJson<bool>(json['selected']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'name': serializer.toJson<String>(name),
'description': serializer.toJson<String>(description),
'version': serializer.toJson<String>(version),
'author': serializer.toJson<String>(author),
'selected': serializer.toJson<bool>(selected),
};
}
MetadataPluginsTableData copyWith(
{int? id,
String? name,
String? description,
String? version,
String? author,
bool? selected}) =>
MetadataPluginsTableData(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
version: version ?? this.version,
author: author ?? this.author,
selected: selected ?? this.selected,
);
MetadataPluginsTableData copyWithCompanion(
MetadataPluginsTableCompanion data) {
return MetadataPluginsTableData(
id: data.id.present ? data.id.value : this.id,
name: data.name.present ? data.name.value : this.name,
description:
data.description.present ? data.description.value : this.description,
version: data.version.present ? data.version.value : this.version,
author: data.author.present ? data.author.value : this.author,
selected: data.selected.present ? data.selected.value : this.selected,
);
}
@override
String toString() {
return (StringBuffer('MetadataPluginsTableData(')
..write('id: $id, ')
..write('name: $name, ')
..write('description: $description, ')
..write('version: $version, ')
..write('author: $author, ')
..write('selected: $selected')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, name, description, version, author, selected);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is MetadataPluginsTableData &&
other.id == this.id &&
other.name == this.name &&
other.description == this.description &&
other.version == this.version &&
other.author == this.author &&
other.selected == this.selected);
}
class MetadataPluginsTableCompanion
extends UpdateCompanion<MetadataPluginsTableData> {
final Value<int> id;
final Value<String> name;
final Value<String> description;
final Value<String> version;
final Value<String> author;
final Value<bool> selected;
const MetadataPluginsTableCompanion({
this.id = const Value.absent(),
this.name = const Value.absent(),
this.description = const Value.absent(),
this.version = const Value.absent(),
this.author = const Value.absent(),
this.selected = const Value.absent(),
});
MetadataPluginsTableCompanion.insert({
this.id = const Value.absent(),
required String name,
required String description,
required String version,
required String author,
this.selected = const Value.absent(),
}) : name = Value(name),
description = Value(description),
version = Value(version),
author = Value(author);
static Insertable<MetadataPluginsTableData> custom({
Expression<int>? id,
Expression<String>? name,
Expression<String>? description,
Expression<String>? version,
Expression<String>? author,
Expression<bool>? selected,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (name != null) 'name': name,
if (description != null) 'description': description,
if (version != null) 'version': version,
if (author != null) 'author': author,
if (selected != null) 'selected': selected,
});
}
MetadataPluginsTableCompanion copyWith(
{Value<int>? id,
Value<String>? name,
Value<String>? description,
Value<String>? version,
Value<String>? author,
Value<bool>? selected}) {
return MetadataPluginsTableCompanion(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
version: version ?? this.version,
author: author ?? this.author,
selected: selected ?? this.selected,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (name.present) {
map['name'] = Variable<String>(name.value);
}
if (description.present) {
map['description'] = Variable<String>(description.value);
}
if (version.present) {
map['version'] = Variable<String>(version.value);
}
if (author.present) {
map['author'] = Variable<String>(author.value);
}
if (selected.present) {
map['selected'] = Variable<bool>(selected.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('MetadataPluginsTableCompanion(')
..write('id: $id, ')
..write('name: $name, ')
..write('description: $description, ')
..write('version: $version, ')
..write('author: $author, ')
..write('selected: $selected')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
@ -4295,6 +4642,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
$PlaylistMediaTableTable(this); $PlaylistMediaTableTable(this);
late final $HistoryTableTable historyTable = $HistoryTableTable(this); late final $HistoryTableTable historyTable = $HistoryTableTable(this);
late final $LyricsTableTable lyricsTable = $LyricsTableTable(this); late final $LyricsTableTable lyricsTable = $LyricsTableTable(this);
late final $MetadataPluginsTableTable metadataPluginsTable =
$MetadataPluginsTableTable(this);
late final Index uniqueBlacklist = Index('unique_blacklist', late final Index uniqueBlacklist = Index('unique_blacklist',
'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
late final Index uniqTrackMatch = Index('uniq_track_match', late final Index uniqTrackMatch = Index('uniq_track_match',
@ -4315,6 +4664,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
playlistMediaTable, playlistMediaTable,
historyTable, historyTable,
lyricsTable, lyricsTable,
metadataPluginsTable,
uniqueBlacklist, uniqueBlacklist,
uniqTrackMatch uniqTrackMatch
]; ];
@ -6909,6 +7259,194 @@ typedef $$LyricsTableTableProcessedTableManager = ProcessedTableManager<
), ),
LyricsTableData, LyricsTableData,
PrefetchHooks Function()>; PrefetchHooks Function()>;
typedef $$MetadataPluginsTableTableCreateCompanionBuilder
= MetadataPluginsTableCompanion Function({
Value<int> id,
required String name,
required String description,
required String version,
required String author,
Value<bool> selected,
});
typedef $$MetadataPluginsTableTableUpdateCompanionBuilder
= MetadataPluginsTableCompanion Function({
Value<int> id,
Value<String> name,
Value<String> description,
Value<String> version,
Value<String> author,
Value<bool> selected,
});
class $$MetadataPluginsTableTableFilterComposer
extends Composer<_$AppDatabase, $MetadataPluginsTableTable> {
$$MetadataPluginsTableTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get name => $composableBuilder(
column: $table.name, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get description => $composableBuilder(
column: $table.description, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get version => $composableBuilder(
column: $table.version, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get author => $composableBuilder(
column: $table.author, builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get selected => $composableBuilder(
column: $table.selected, builder: (column) => ColumnFilters(column));
}
class $$MetadataPluginsTableTableOrderingComposer
extends Composer<_$AppDatabase, $MetadataPluginsTableTable> {
$$MetadataPluginsTableTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get name => $composableBuilder(
column: $table.name, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get description => $composableBuilder(
column: $table.description, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get version => $composableBuilder(
column: $table.version, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get author => $composableBuilder(
column: $table.author, builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get selected => $composableBuilder(
column: $table.selected, builder: (column) => ColumnOrderings(column));
}
class $$MetadataPluginsTableTableAnnotationComposer
extends Composer<_$AppDatabase, $MetadataPluginsTableTable> {
$$MetadataPluginsTableTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
GeneratedColumn<String> get description => $composableBuilder(
column: $table.description, builder: (column) => column);
GeneratedColumn<String> get version =>
$composableBuilder(column: $table.version, builder: (column) => column);
GeneratedColumn<String> get author =>
$composableBuilder(column: $table.author, builder: (column) => column);
GeneratedColumn<bool> get selected =>
$composableBuilder(column: $table.selected, builder: (column) => column);
}
class $$MetadataPluginsTableTableTableManager extends RootTableManager<
_$AppDatabase,
$MetadataPluginsTableTable,
MetadataPluginsTableData,
$$MetadataPluginsTableTableFilterComposer,
$$MetadataPluginsTableTableOrderingComposer,
$$MetadataPluginsTableTableAnnotationComposer,
$$MetadataPluginsTableTableCreateCompanionBuilder,
$$MetadataPluginsTableTableUpdateCompanionBuilder,
(
MetadataPluginsTableData,
BaseReferences<_$AppDatabase, $MetadataPluginsTableTable,
MetadataPluginsTableData>
),
MetadataPluginsTableData,
PrefetchHooks Function()> {
$$MetadataPluginsTableTableTableManager(
_$AppDatabase db, $MetadataPluginsTableTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$MetadataPluginsTableTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$MetadataPluginsTableTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
$$MetadataPluginsTableTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<String> name = const Value.absent(),
Value<String> description = const Value.absent(),
Value<String> version = const Value.absent(),
Value<String> author = const Value.absent(),
Value<bool> selected = const Value.absent(),
}) =>
MetadataPluginsTableCompanion(
id: id,
name: name,
description: description,
version: version,
author: author,
selected: selected,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required String name,
required String description,
required String version,
required String author,
Value<bool> selected = const Value.absent(),
}) =>
MetadataPluginsTableCompanion.insert(
id: id,
name: name,
description: description,
version: version,
author: author,
selected: selected,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$MetadataPluginsTableTableProcessedTableManager
= ProcessedTableManager<
_$AppDatabase,
$MetadataPluginsTableTable,
MetadataPluginsTableData,
$$MetadataPluginsTableTableFilterComposer,
$$MetadataPluginsTableTableOrderingComposer,
$$MetadataPluginsTableTableAnnotationComposer,
$$MetadataPluginsTableTableCreateCompanionBuilder,
$$MetadataPluginsTableTableUpdateCompanionBuilder,
(
MetadataPluginsTableData,
BaseReferences<_$AppDatabase, $MetadataPluginsTableTable,
MetadataPluginsTableData>
),
MetadataPluginsTableData,
PrefetchHooks Function()>;
class $AppDatabaseManager { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
@ -6935,4 +7473,6 @@ class $AppDatabaseManager {
$$HistoryTableTableTableManager(_db, _db.historyTable); $$HistoryTableTableTableManager(_db, _db.historyTable);
$$LyricsTableTableTableManager get lyricsTable => $$LyricsTableTableTableManager get lyricsTable =>
$$LyricsTableTableTableManager(_db, _db.lyricsTable); $$LyricsTableTableTableManager(_db, _db.lyricsTable);
$$MetadataPluginsTableTableTableManager get metadataPluginsTable =>
$$MetadataPluginsTableTableTableManager(_db, _db.metadataPluginsTable);
} }

View File

@ -1692,12 +1692,285 @@ class Shape13 extends i0.VersionedTable {
i1.GeneratedColumn<int> _column_56(String aliasedName) => i1.GeneratedColumn<int> _column_56(String aliasedName) =>
i1.GeneratedColumn<int>('connect_port', aliasedName, false, i1.GeneratedColumn<int>('connect_port', aliasedName, false,
type: i1.DriftSqlType.int, defaultValue: const Constant(-1)); type: i1.DriftSqlType.int, defaultValue: const Constant(-1));
final class Schema7 extends i0.VersionedSchema {
Schema7({required super.database}) : super(version: 7);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
audioPlayerStateTable,
playlistTable,
playlistMediaTable,
historyTable,
lyricsTable,
metadataPluginsTable,
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 Shape13 preferencesTable = Shape13(
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_55,
_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_56,
_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);
late final Shape14 metadataPluginsTable = Shape14(
source: i0.VersionedTable(
entityName: 'metadata_plugins_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_57,
_column_58,
_column_59,
_column_60,
_column_61,
],
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 Shape14 extends i0.VersionedTable {
Shape14({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 description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get version =>
columnsByName['version']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get author =>
columnsByName['author']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get selected =>
columnsByName['selected']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_57(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: i1.GeneratedColumn.checkTextLength(
minTextLength: 1, maxTextLength: 50),
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_58(String aliasedName) =>
i1.GeneratedColumn<String>('description', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_59(String aliasedName) =>
i1.GeneratedColumn<String>('version', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_60(String aliasedName) =>
i1.GeneratedColumn<String>('author', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_61(String aliasedName) =>
i1.GeneratedColumn<bool>('selected', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("selected" IN (0, 1))'),
defaultValue: const Constant(false));
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, required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5, required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -1726,6 +1999,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from5To6(migrator, schema); await from5To6(migrator, schema);
return 6; return 6;
case 6:
final schema = Schema7(database: database);
final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema);
return 7;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -1738,6 +2016,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4, required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5, required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) => }) =>
i0.VersionedSchema.stepByStepHelper( i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
@ -1746,4 +2025,5 @@ i1.OnUpgrade stepByStep({
from3To4: from3To4, from3To4: from3To4,
from4To5: from4To5, from4To5: from4To5,
from5To6: from5To6, from5To6: from5To6,
from6To7: from6To7,
)); ));

View File

@ -0,0 +1,10 @@
part of '../database.dart';
class MetadataPluginsTable extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 50)();
TextColumn get description => text()();
TextColumn get version => text()();
TextColumn get author => text()();
BoolColumn get selected => boolean().withDefault(const Constant(false))();
}

View File

@ -0,0 +1,182 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/form/text_form_field.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/provider/metadata_plugin/auth.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:file_picker/file_picker.dart';
@RoutePage()
class SettingsMetadataProviderPage extends HookConsumerWidget {
const SettingsMetadataProviderPage({super.key});
@override
Widget build(BuildContext context, ref) {
final formKey = useMemoized(() => GlobalKey<FormBuilderState>(), []);
final plugins = ref.watch(metadataPluginsProvider);
final pluginsNotifier = ref.watch(metadataPluginsProvider.notifier);
final metadataApi = ref.watch(metadataPluginApiProvider);
final isAuthenticated = ref.watch(metadataAuthenticatedProvider);
final artists = ref.watch(metadataUserArtistsProvider);
return Scaffold(
headers: const [
TitleBar(
title: Text("Metadata provider plugin"),
)
],
child: Padding(
padding: const EdgeInsets.all(8),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Row(
spacing: 8,
children: [
Expanded(
child: FormBuilder(
key: formKey,
child: TextFormBuilderField(
name: "plugin_url",
validator: FormBuilderValidators.url(
protocols: ["http", "https"]),
placeholder: const Text(
"Add GitHub/Codeberg URL to plugin repository "
"or direct link to .smplug file",
),
),
),
),
Tooltip(
tooltip: const TooltipContainer(
child: Text("Download and install plugin from url"),
).call,
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.download),
onPressed: () async {
if (formKey.currentState?.saveAndValidate() ?? false) {
final url = formKey.currentState?.fields["plugin_url"]
?.value as String;
if (url.isNotEmpty) {
final pluginConfig = await pluginsNotifier
.downloadAndCachePlugin(url);
await pluginsNotifier.addPlugin(pluginConfig);
}
}
},
),
),
Tooltip(
tooltip: const TooltipContainer(
child: Text("Upload plugin from file"),
).call,
child: IconButton.primary(
icon: const Icon(SpotubeIcons.upload),
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ["smplug"],
withData: true,
);
if (result == null) return;
final file = result.files.first;
if (file.bytes == null) return;
final pluginConfig = await pluginsNotifier
.extractPluginArchive(file.bytes!);
await pluginsNotifier.addPlugin(pluginConfig);
},
),
),
],
),
),
const SliverGap(20),
SliverList.separated(
itemCount: plugins.asData?.value.plugins.length ?? 0,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final plugin = plugins.asData!.value.plugins[index];
final isDefault = plugins.asData!.value.defaultPlugin == index;
final requiresAuth = isDefault &&
metadataApi.hasValue &&
metadataApi.asData?.value?.signatureFlags.requiresAuth ==
true;
return Card(
child: Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Basic(
title: Text(plugin.name),
subtitle: Text(plugin.description),
trailing: Row(
spacing: 8,
children: [
Button.primary(
enabled: !isDefault,
onPressed: () async {
await pluginsNotifier.setDefaultPlugin(plugin);
},
child: isDefault
? const Text("Default")
: const Text("Make default"),
),
IconButton.destructive(
onPressed: () async {
await pluginsNotifier.removePlugin(plugin);
},
icon: const Icon(SpotubeIcons.trash),
),
],
),
),
if (requiresAuth)
Row(
children: [
const Text("Plugin requires authentication"),
const Spacer(),
if (isAuthenticated.asData?.value != true)
Button.primary(
onPressed: () async {
await metadataApi.asData?.value
?.authenticate();
},
leading: const Icon(SpotubeIcons.login),
child: const Text("Login"),
)
else
Button.destructive(
onPressed: () async {
await metadataApi.asData?.value?.logout();
},
leading: const Icon(SpotubeIcons.logout),
child: const Text("Logout"),
),
],
)
],
),
);
},
),
],
),
),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart' show ListTile; import 'package:flutter/material.dart' show ListTile;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -7,103 +6,29 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.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/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/mobile_login/hooks/login_callback.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/spotify/spotify.dart';
class SettingsAccountSection extends HookConsumerWidget { class SettingsAccountSection extends HookConsumerWidget {
const SettingsAccountSection({super.key}); const SettingsAccountSection({super.key});
@override @override
Widget build(context, ref) { Widget build(context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(authenticationProvider);
final scrobbler = ref.watch(scrobblerProvider); final scrobbler = ref.watch(scrobblerProvider);
final me = ref.watch(meProvider);
final meData = me.asData?.value;
final onLogin = useLoginCallback(ref);
return SectionCardWithHeading( return SectionCardWithHeading(
heading: context.l10n.account, heading: context.l10n.account,
children: [ children: [
if (auth.asData?.value != null) ListTile(
ListTile( leading: const Icon(SpotubeIcons.extensions),
leading: const Icon(SpotubeIcons.user), title: const Text("Metadata provider plugins"),
title: Text(context.l10n.user_profile), subtitle: const Text(
trailing: Padding( "Configure your own playlist/album/artist/feed metadata provider"),
padding: const EdgeInsets.all(8.0), onTap: () {
child: Avatar( context.pushRoute(const SettingsMetadataProviderRoute());
initials: Avatar.getInitials(meData?.displayName ?? "User"), },
provider: UniversalImage.imageProvider( trailing: const Icon(SpotubeIcons.angleRight),
(meData?.images).asUrlString( ),
placeholder: ImagePlaceholder.artist,
),
),
),
),
onTap: () {
context.navigateTo(const ProfileRoute());
},
),
if (auth.asData?.value == null)
LayoutBuilder(builder: (context, constrains) {
return ListTile(
leading: Icon(
SpotubeIcons.spotify,
color: theme.colorScheme.primary,
),
title: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.login_with_spotify,
maxLines: 1,
style: TextStyle(
color: theme.colorScheme.primary,
),
),
),
onTap: constrains.mdAndUp ? null : onLogin,
trailing: constrains.smAndDown
? null
: Button.primary(
onPressed: onLogin,
child: Text(
context.l10n.connect_with_spotify.toUpperCase(),
),
),
);
})
else
Builder(builder: (context) {
return ListTile(
leading: const Icon(SpotubeIcons.spotify),
title: SizedBox(
height: 50,
width: 180,
child: Align(
alignment: Alignment.centerLeft,
child: AutoSizeText(
context.l10n.logout_of_this_account,
maxLines: 1,
),
),
),
trailing: Button.destructive(
onPressed: () async {
ref.read(authenticationProvider.notifier).logout();
context.maybePop();
},
child: Text(context.l10n.logout),
),
);
}),
if (scrobbler.asData?.value == null) if (scrobbler.asData?.value == null)
ListTile( ListTile(
leading: const Icon(SpotubeIcons.lastFm), leading: const Icon(SpotubeIcons.lastFm),

View File

@ -0,0 +1,49 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
class MetadataAuthenticationNotifier extends AsyncNotifier<bool> {
MetadataAuthenticationNotifier();
@override
build() async {
final metadataApi = await ref.watch(metadataPluginApiProvider.future);
if (metadataApi?.signatureFlags.requiresAuth != true) {
return false;
}
final subscription = metadataApi?.authenticatedStream.listen((event) {
state = AsyncValue.data(event);
});
ref.onDispose(() {
subscription?.cancel();
});
return await metadataApi?.isAuthenticated() ?? false;
}
Future<void> login() async {
final metadataApi = await ref.read(metadataPluginApiProvider.future);
if (metadataApi == null || !metadataApi.signatureFlags.requiresAuth) {
return;
}
await metadataApi.authenticate();
}
Future<void> logout() async {
final metadataApi = await ref.read(metadataPluginApiProvider.future);
if (metadataApi == null || !metadataApi.signatureFlags.requiresAuth) {
return;
}
await metadataApi.logout();
}
}
final metadataAuthenticatedProvider =
AsyncNotifierProvider<MetadataAuthenticationNotifier, bool>(
() => MetadataAuthenticationNotifier(),
);

View File

@ -0,0 +1,365 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/metadata_plugin/auth.dart';
import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/metadata/metadata.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:uuid/uuid.dart';
import 'package:archive/archive.dart';
final allowedDomainsRegex = RegExp(
r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+",
);
class MetadataPluginState {
final List<PluginConfiguration> plugins;
final int defaultPlugin;
const MetadataPluginState({
this.plugins = const [],
this.defaultPlugin = -1,
});
PluginConfiguration? get defaultPluginConfig {
if (defaultPlugin < 0 || defaultPlugin >= plugins.length) {
return null;
}
return plugins[defaultPlugin];
}
factory MetadataPluginState.fromJson(Map<String, dynamic> json) {
return MetadataPluginState(
plugins: (json["plugins"] as List<dynamic>)
.map((e) => PluginConfiguration.fromJson(e))
.toList(),
defaultPlugin: json["default_plugin"] ?? -1,
);
}
Map<String, dynamic> toJson() {
return {
"plugins": plugins.map((e) => e.toJson()).toList(),
"default_plugin": defaultPlugin,
};
}
MetadataPluginState copyWith({
List<PluginConfiguration>? plugins,
int? defaultPlugin,
}) {
return MetadataPluginState(
plugins: plugins ?? this.plugins,
defaultPlugin: defaultPlugin ?? this.defaultPlugin,
);
}
}
class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
AppDatabase get database => ref.read(databaseProvider);
@override
build() async {
final database = ref.watch(databaseProvider);
final subscription = database.metadataPluginsTable.select().watch().listen(
(event) async {
state = AsyncValue.data(await toStatePlugins(event));
},
);
ref.onDispose(() {
subscription.cancel();
});
final plugins = await database.metadataPluginsTable.select().get();
return await toStatePlugins(plugins);
}
Future<MetadataPluginState> toStatePlugins(
List<MetadataPluginsTableData> plugins) async {
int defaultPlugin = -1;
final pluginConfigs = plugins.mapIndexed(
(index, plugin) {
if (plugin.selected) {
defaultPlugin = index;
}
return PluginConfiguration(
type: PluginType.metadata,
name: plugin.name,
author: plugin.author,
description: plugin.description,
version: plugin.version,
);
},
).toList();
return MetadataPluginState(
plugins: pluginConfigs,
defaultPlugin: defaultPlugin,
);
}
Uri _getGithubReleasesUrl(String repoUrl) {
final parsedUri = Uri.parse(repoUrl);
final uri = parsedUri.replace(
host: "api.github.com",
path: "/repos/${parsedUri.path}/releases",
queryParameters: {
"per_page": "1",
"page": "1",
},
);
return uri;
}
Uri _getCodebergeReleasesUrl(String repoUrl) {
final parsedUri = Uri.parse(repoUrl);
final uri = parsedUri.replace(
path: "/api/v1/repos/${parsedUri.path}/releases",
queryParameters: {
"limit": "1",
"page": "1",
},
);
return uri;
}
Future<String> _getPluginDownloadUrl(Uri uri) async {
final res = await globalDio.getUri(
uri,
options: Options(responseType: ResponseType.json),
);
if (res.statusCode != 200) {
throw Exception("Failed to get releases");
}
final releases = res.data as List;
if (releases.isEmpty) {
throw Exception("No releases found");
}
final latestRelease = releases.first;
final downloadUrl = (latestRelease["assets"] as List).firstWhere(
(asset) => (asset["name"] as String).endsWith(".smplug"),
)["browser_download_url"];
if (downloadUrl == null) {
throw Exception("No download URL found");
}
return downloadUrl;
}
Future<Directory> _getPluginDir() async => Directory(
join(
(await getApplicationCacheDirectory()).path,
"metadata-plugins",
),
);
Future<PluginConfiguration> extractPluginArchive(List<int> bytes) async {
final archive = ZipDecoder().decodeBytes(bytes);
final pluginJson = archive
.firstWhereOrNull((file) => file.isFile && file.name == "plugin.json");
if (pluginJson == null) {
throw Exception("No plugin.json found");
}
final pluginConfig = PluginConfiguration.fromJson(
jsonDecode(
utf8.decode(pluginJson.content as List<int>),
) as Map<String, dynamic>,
);
final pluginDir = await _getPluginDir();
await pluginDir.create(recursive: true);
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(pluginConfig.name),
);
for (final file in archive) {
if (file.isFile) {
final filename = file.name;
final data = file.content as List<int>;
final extractedFile = File(join(
pluginExtractionDirPath,
filename,
));
await extractedFile.create(recursive: true);
await extractedFile.writeAsBytes(data);
}
}
return pluginConfig;
}
/// Downloads, extracts & caches the plugin from the given URL and returns the plugin config.
/// If only a text/html URL is provided, it will try to get the latest release from
/// the URL for supported websites (github.com, codeberg.org).
Future<PluginConfiguration> downloadAndCachePlugin(String url) async {
final res = await globalDio.head(url);
final isSupportedWebsite =
(res.headers["Content-Type"] as String?)?.startsWith("text/html") ==
true &&
allowedDomainsRegex.hasMatch(url);
String pluginDownloadUrl = url;
if (isSupportedWebsite) {
if (url.contains("github.com")) {
final uri = _getGithubReleasesUrl(url);
pluginDownloadUrl = await _getPluginDownloadUrl(uri);
} else if (url.contains("codeberg.org")) {
final uri = _getCodebergeReleasesUrl(url);
pluginDownloadUrl = await _getPluginDownloadUrl(uri);
} else {
throw Exception("Unsupported website");
}
}
// Now let's download, extract and cache the plugin
final pluginDir = await _getPluginDir();
await pluginDir.create(recursive: true);
final tempPluginName = "${const Uuid().v4()}.smplug";
final pluginFile = File(join(pluginDir.path, tempPluginName));
final pluginRes = await globalDio.download(
pluginDownloadUrl,
pluginFile.path,
options: Options(
responseType: ResponseType.bytes,
followRedirects: true,
receiveTimeout: const Duration(seconds: 30),
),
);
if ((pluginRes.statusCode ?? 500) > 299) {
throw Exception("Failed to download plugin");
}
return await extractPluginArchive(await pluginFile.readAsBytes());
}
Future<void> addPlugin(PluginConfiguration plugin) async {
final pluginRes = await (database.metadataPluginsTable.select()
..where(
(tbl) => tbl.name.equals(plugin.name),
)
..limit(1))
.get();
if (pluginRes.isNotEmpty) {
throw Exception("Plugin already exists");
}
await database.metadataPluginsTable.insertOne(
MetadataPluginsTableCompanion.insert(
name: plugin.name,
author: plugin.author,
description: plugin.description,
version: plugin.version,
),
);
}
Future<void> removePlugin(PluginConfiguration plugin) async {
final pluginDir = await _getPluginDir();
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(plugin.name),
);
final pluginExtractionDir = Directory(pluginExtractionDirPath);
if (pluginExtractionDir.existsSync()) {
await pluginExtractionDir.delete(recursive: true);
}
await database.metadataPluginsTable
.deleteWhere((tbl) => tbl.name.equals(plugin.name));
}
Future<void> setDefaultPlugin(PluginConfiguration plugin) async {
await (database.metadataPluginsTable.update()
..where((tbl) => tbl.name.equals(plugin.name)))
.write(
const MetadataPluginsTableCompanion(selected: Value(true)),
);
}
Future<String> getPluginLibraryCode(PluginConfiguration plugin) async {
final pluginDir = await _getPluginDir();
final pluginExtractionDirPath = join(
pluginDir.path,
ServiceUtils.sanitizeFilename(plugin.name),
);
final libraryFile = File(join(pluginExtractionDirPath, "dist", "index.js"));
if (!libraryFile.existsSync()) {
throw Exception("No dist/index.js found");
}
return await libraryFile.readAsString();
}
}
final metadataPluginsProvider =
AsyncNotifierProvider<MetadataPluginNotifier, MetadataPluginState>(
MetadataPluginNotifier.new,
);
final metadataPluginApiProvider = FutureProvider<MetadataApiSignature?>(
(ref) async {
final defaultPlugin = await ref.watch(
metadataPluginsProvider.selectAsync((data) => data.defaultPluginConfig),
);
if (defaultPlugin == null) {
return null;
}
final pluginsNotifier = ref.read(metadataPluginsProvider.notifier);
final libraryCode =
await pluginsNotifier.getPluginLibraryCode(defaultPlugin);
return MetadataApiSignature.init(libraryCode, defaultPlugin);
},
);
final metadataProviderUserProvider = FutureProvider(
(ref) async {
final metadataApi = await ref.watch(metadataPluginApiProvider.future);
ref.watch(metadataAuthenticatedProvider);
if (metadataApi == null) {
return null;
}
return metadataApi.getMe();
},
);
final metadataUserArtistsProvider =
FutureProvider<List<SpotubeArtistObject>>((ref) async {
final metadataApi = await ref.watch(metadataPluginApiProvider.future);
ref.watch(metadataAuthenticatedProvider);
final userId = await ref.watch(
metadataProviderUserProvider.selectAsync((data) => data?.uid),
);
if (metadataApi == null || userId == null) {
return [];
}
final res = await metadataApi.listUserSavedArtists(userId);
return res.items as List<SpotubeArtistObject>;
});

View File

@ -66,7 +66,7 @@ class PluginSetIntervalApi {
} on Exception catch (e) { } on Exception catch (e) {
print('Exception no clearInterval: $e'); print('Exception no clearInterval: $e');
} on Error catch (e) { } on Error catch (e) {
print('Erro no clearInterval: $e'); print('Error no clearInterval: $e');
} }
}); });
} }

View File

@ -38,6 +38,10 @@ class MetadataApiSignature {
final PluginSetIntervalApi setIntervalApi; final PluginSetIntervalApi setIntervalApi;
late MetadataSignatureFlags _signatureFlags; late MetadataSignatureFlags _signatureFlags;
final StreamController<bool> _authenticatedStreamController;
Stream<bool> get authenticatedStream => _authenticatedStreamController.stream;
MetadataSignatureFlags get signatureFlags => _signatureFlags; MetadataSignatureFlags get signatureFlags => _signatureFlags;
MetadataApiSignature._( MetadataApiSignature._(
@ -46,10 +50,19 @@ class MetadataApiSignature {
this.webViewApi, this.webViewApi,
this.totpGenerator, this.totpGenerator,
this.setIntervalApi, this.setIntervalApi,
); ) : _authenticatedStreamController = StreamController<bool>.broadcast() {
runtime.onMessage("authenticatedStatus", (args) {
if (args[0] is Map && (args[0] as Map).containsKey("authenticated")) {
final authenticated = args[0]["authenticated"] as bool;
_authenticatedStreamController.add(authenticated);
}
});
}
static Future<MetadataApiSignature> init( static Future<MetadataApiSignature> init(
String libraryCode, PluginConfiguration config) async { String libraryCode,
PluginConfiguration config,
) async {
final runtime = getJavascriptRuntime(xhr: true).enableXhr(); final runtime = getJavascriptRuntime(xhr: true).enableXhr();
runtime.enableHandlePromises(); runtime.enableHandlePromises();
await runtime.enableFetch(); await runtime.enableFetch();
@ -106,6 +119,7 @@ class MetadataApiSignature {
Future invoke(String method, [List? args]) async { Future invoke(String method, [List? args]) async {
final completer = Completer(); final completer = Completer();
runtime.onMessage(method, (result) { runtime.onMessage(method, (result) {
if (completer.isCompleted) return;
try { try {
if (result is Map && result.containsKey("error")) { if (result is Map && result.containsKey("error")) {
completer.completeError(result["error"]); completer.completeError(result["error"]);
@ -113,7 +127,10 @@ class MetadataApiSignature {
completer.complete(result is String ? jsonDecode(result) : result); completer.complete(result is String ? jsonDecode(result) : result);
} }
} catch (e, stack) { } catch (e, stack) {
AppLogger.reportError(e, stack); AppLogger.reportError(
"[MetadataApiSignature][invoke] Error in $method: $e",
stack,
);
} }
}); });
final code = """ final code = """
@ -157,6 +174,11 @@ class MetadataApiSignature {
await invoke("metadataApi.authenticate"); await invoke("metadataApi.authenticate");
} }
Future<bool> isAuthenticated() async {
final res = await invoke("metadataApi.isAuthenticated");
return res as bool;
}
Future<void> logout() async { Future<void> logout() async {
await invoke("metadataApi.logout"); await invoke("metadataApi.logout");
} }

View File

@ -71,7 +71,7 @@ packages:
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive: archive:
dependency: transitive dependency: "direct main"
description: description:
name: archive name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"

View File

@ -143,6 +143,7 @@ dependencies:
otp_util: ^1.0.2 otp_util: ^1.0.2
dio_http2_adapter: ^2.6.0 dio_http2_adapter: ^2.6.0
flutter_js: ^0.8.2 flutter_js: ^0.8.2
archive: ^4.0.7
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

View File

@ -8,6 +8,7 @@ import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6; import 'schema_v6.dart' as v6;
import 'schema_v1.dart' as v1; import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2; import 'schema_v2.dart' as v2;
import 'schema_v7.dart' as v7;
import 'schema_v4.dart' as v4; import 'schema_v4.dart' as v4;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@ -24,6 +25,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v1.DatabaseAtV1(db); return v1.DatabaseAtV1(db);
case 2: case 2:
return v2.DatabaseAtV2(db); return v2.DatabaseAtV2(db);
case 7:
return v7.DatabaseAtV7(db);
case 4: case 4:
return v4.DatabaseAtV4(db); return v4.DatabaseAtV4(db);
default: default:
@ -31,5 +34,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6]; static const versions = const [1, 2, 3, 4, 5, 6, 7];
} }

File diff suppressed because it is too large Load Diff