From 2f1130b4247ff1fee3bb207d2591788433a2b31e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 19 Dec 2025 23:34:20 +0800 Subject: [PATCH] :sparkles: Remote provider basis (jellyfin) --- lib/data/db.dart | 26 +- lib/data/db.g.dart | 689 +++++++++++++++++++++++++ lib/data/playlist_repository.g.dart | 2 +- lib/data/track_repository.g.dart | 2 +- lib/providers/remote_provider.dart | 205 ++++++++ lib/providers/settings_provider.g.dart | 6 +- lib/ui/screens/settings_screen.dart | 254 +++++++++ pubspec.lock | 24 + pubspec.yaml | 1 + 9 files changed, 1202 insertions(+), 7 deletions(-) create mode 100644 lib/providers/remote_provider.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index c3052ff..117ba00 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -42,6 +42,17 @@ class WatchFolders extends Table { DateTimeColumn get lastScanned => dateTime().nullable()(); } +class RemoteProviders extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get serverUrl => text().unique()(); + TextColumn get name => text()(); + TextColumn get username => text()(); + TextColumn get password => + text()(); // Note: In production, this should be encrypted + BoolColumn get isActive => boolean().withDefault(const Constant(true))(); + DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); +} + class AppSettings extends Table { TextColumn get key => text()(); TextColumn get value => text()(); @@ -51,13 +62,20 @@ class AppSettings extends Table { } @DriftDatabase( - tables: [Tracks, Playlists, PlaylistEntries, WatchFolders, AppSettings], + tables: [ + Tracks, + Playlists, + PlaylistEntries, + WatchFolders, + RemoteProviders, + AppSettings, + ], ) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 6; // Bump version for watch folders and settings + int get schemaVersion => 7; // Bump version for remote providers @override MigrationStrategy get migration { @@ -84,6 +102,10 @@ class AppDatabase extends _$AppDatabase { await m.createTable(watchFolders); await m.createTable(appSettings); } + if (from < 7) { + // Create table for remote providers + await m.createTable(remoteProviders); + } }, ); } diff --git a/lib/data/db.g.dart b/lib/data/db.g.dart index 86f803d..c7e52e4 100644 --- a/lib/data/db.g.dart +++ b/lib/data/db.g.dart @@ -1597,6 +1597,451 @@ class WatchFoldersCompanion extends UpdateCompanion { } } +class $RemoteProvidersTable extends RemoteProviders + with TableInfo<$RemoteProvidersTable, RemoteProvider> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RemoteProvidersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _serverUrlMeta = const VerificationMeta( + 'serverUrl', + ); + @override + late final GeneratedColumn serverUrl = GeneratedColumn( + 'server_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _usernameMeta = const VerificationMeta( + 'username', + ); + @override + late final GeneratedColumn username = GeneratedColumn( + 'username', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _passwordMeta = const VerificationMeta( + 'password', + ); + @override + late final GeneratedColumn password = GeneratedColumn( + 'password', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isActiveMeta = const VerificationMeta( + 'isActive', + ); + @override + late final GeneratedColumn isActive = GeneratedColumn( + 'is_active', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_active" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); + static const VerificationMeta _addedAtMeta = const VerificationMeta( + 'addedAt', + ); + @override + late final GeneratedColumn addedAt = GeneratedColumn( + 'added_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + serverUrl, + name, + username, + password, + isActive, + addedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_providers'; + @override + VerificationContext validateIntegrity( + Insertable 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('server_url')) { + context.handle( + _serverUrlMeta, + serverUrl.isAcceptableOrUnknown(data['server_url']!, _serverUrlMeta), + ); + } else if (isInserting) { + context.missing(_serverUrlMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('username')) { + context.handle( + _usernameMeta, + username.isAcceptableOrUnknown(data['username']!, _usernameMeta), + ); + } else if (isInserting) { + context.missing(_usernameMeta); + } + if (data.containsKey('password')) { + context.handle( + _passwordMeta, + password.isAcceptableOrUnknown(data['password']!, _passwordMeta), + ); + } else if (isInserting) { + context.missing(_passwordMeta); + } + if (data.containsKey('is_active')) { + context.handle( + _isActiveMeta, + isActive.isAcceptableOrUnknown(data['is_active']!, _isActiveMeta), + ); + } + if (data.containsKey('added_at')) { + context.handle( + _addedAtMeta, + addedAt.isAcceptableOrUnknown(data['added_at']!, _addedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + RemoteProvider map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteProvider( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + serverUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_url'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + username: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}username'], + )!, + password: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}password'], + )!, + isActive: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_active'], + )!, + addedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}added_at'], + )!, + ); + } + + @override + $RemoteProvidersTable createAlias(String alias) { + return $RemoteProvidersTable(attachedDatabase, alias); + } +} + +class RemoteProvider extends DataClass implements Insertable { + final int id; + final String serverUrl; + final String name; + final String username; + final String password; + final bool isActive; + final DateTime addedAt; + const RemoteProvider({ + required this.id, + required this.serverUrl, + required this.name, + required this.username, + required this.password, + required this.isActive, + required this.addedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['server_url'] = Variable(serverUrl); + map['name'] = Variable(name); + map['username'] = Variable(username); + map['password'] = Variable(password); + map['is_active'] = Variable(isActive); + map['added_at'] = Variable(addedAt); + return map; + } + + RemoteProvidersCompanion toCompanion(bool nullToAbsent) { + return RemoteProvidersCompanion( + id: Value(id), + serverUrl: Value(serverUrl), + name: Value(name), + username: Value(username), + password: Value(password), + isActive: Value(isActive), + addedAt: Value(addedAt), + ); + } + + factory RemoteProvider.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteProvider( + id: serializer.fromJson(json['id']), + serverUrl: serializer.fromJson(json['serverUrl']), + name: serializer.fromJson(json['name']), + username: serializer.fromJson(json['username']), + password: serializer.fromJson(json['password']), + isActive: serializer.fromJson(json['isActive']), + addedAt: serializer.fromJson(json['addedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'serverUrl': serializer.toJson(serverUrl), + 'name': serializer.toJson(name), + 'username': serializer.toJson(username), + 'password': serializer.toJson(password), + 'isActive': serializer.toJson(isActive), + 'addedAt': serializer.toJson(addedAt), + }; + } + + RemoteProvider copyWith({ + int? id, + String? serverUrl, + String? name, + String? username, + String? password, + bool? isActive, + DateTime? addedAt, + }) => RemoteProvider( + id: id ?? this.id, + serverUrl: serverUrl ?? this.serverUrl, + name: name ?? this.name, + username: username ?? this.username, + password: password ?? this.password, + isActive: isActive ?? this.isActive, + addedAt: addedAt ?? this.addedAt, + ); + RemoteProvider copyWithCompanion(RemoteProvidersCompanion data) { + return RemoteProvider( + id: data.id.present ? data.id.value : this.id, + serverUrl: data.serverUrl.present ? data.serverUrl.value : this.serverUrl, + name: data.name.present ? data.name.value : this.name, + username: data.username.present ? data.username.value : this.username, + password: data.password.present ? data.password.value : this.password, + isActive: data.isActive.present ? data.isActive.value : this.isActive, + addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteProvider(') + ..write('id: $id, ') + ..write('serverUrl: $serverUrl, ') + ..write('name: $name, ') + ..write('username: $username, ') + ..write('password: $password, ') + ..write('isActive: $isActive, ') + ..write('addedAt: $addedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, serverUrl, name, username, password, isActive, addedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteProvider && + other.id == this.id && + other.serverUrl == this.serverUrl && + other.name == this.name && + other.username == this.username && + other.password == this.password && + other.isActive == this.isActive && + other.addedAt == this.addedAt); +} + +class RemoteProvidersCompanion extends UpdateCompanion { + final Value id; + final Value serverUrl; + final Value name; + final Value username; + final Value password; + final Value isActive; + final Value addedAt; + const RemoteProvidersCompanion({ + this.id = const Value.absent(), + this.serverUrl = const Value.absent(), + this.name = const Value.absent(), + this.username = const Value.absent(), + this.password = const Value.absent(), + this.isActive = const Value.absent(), + this.addedAt = const Value.absent(), + }); + RemoteProvidersCompanion.insert({ + this.id = const Value.absent(), + required String serverUrl, + required String name, + required String username, + required String password, + this.isActive = const Value.absent(), + this.addedAt = const Value.absent(), + }) : serverUrl = Value(serverUrl), + name = Value(name), + username = Value(username), + password = Value(password); + static Insertable custom({ + Expression? id, + Expression? serverUrl, + Expression? name, + Expression? username, + Expression? password, + Expression? isActive, + Expression? addedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (serverUrl != null) 'server_url': serverUrl, + if (name != null) 'name': name, + if (username != null) 'username': username, + if (password != null) 'password': password, + if (isActive != null) 'is_active': isActive, + if (addedAt != null) 'added_at': addedAt, + }); + } + + RemoteProvidersCompanion copyWith({ + Value? id, + Value? serverUrl, + Value? name, + Value? username, + Value? password, + Value? isActive, + Value? addedAt, + }) { + return RemoteProvidersCompanion( + id: id ?? this.id, + serverUrl: serverUrl ?? this.serverUrl, + name: name ?? this.name, + username: username ?? this.username, + password: password ?? this.password, + isActive: isActive ?? this.isActive, + addedAt: addedAt ?? this.addedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (serverUrl.present) { + map['server_url'] = Variable(serverUrl.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (password.present) { + map['password'] = Variable(password.value); + } + if (isActive.present) { + map['is_active'] = Variable(isActive.value); + } + if (addedAt.present) { + map['added_at'] = Variable(addedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteProvidersCompanion(') + ..write('id: $id, ') + ..write('serverUrl: $serverUrl, ') + ..write('name: $name, ') + ..write('username: $username, ') + ..write('password: $password, ') + ..write('isActive: $isActive, ') + ..write('addedAt: $addedAt') + ..write(')')) + .toString(); + } +} + class $AppSettingsTable extends AppSettings with TableInfo<$AppSettingsTable, AppSetting> { @override @@ -1814,6 +2259,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { this, ); late final $WatchFoldersTable watchFolders = $WatchFoldersTable(this); + late final $RemoteProvidersTable remoteProviders = $RemoteProvidersTable( + this, + ); late final $AppSettingsTable appSettings = $AppSettingsTable(this); @override Iterable> get allTables => @@ -1824,6 +2272,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { playlists, playlistEntries, watchFolders, + remoteProviders, appSettings, ]; @override @@ -3118,6 +3567,244 @@ typedef $$WatchFoldersTableProcessedTableManager = WatchFolder, PrefetchHooks Function() >; +typedef $$RemoteProvidersTableCreateCompanionBuilder = + RemoteProvidersCompanion Function({ + Value id, + required String serverUrl, + required String name, + required String username, + required String password, + Value isActive, + Value addedAt, + }); +typedef $$RemoteProvidersTableUpdateCompanionBuilder = + RemoteProvidersCompanion Function({ + Value id, + Value serverUrl, + Value name, + Value username, + Value password, + Value isActive, + Value addedAt, + }); + +class $$RemoteProvidersTableFilterComposer + extends Composer<_$AppDatabase, $RemoteProvidersTable> { + $$RemoteProvidersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverUrl => $composableBuilder( + column: $table.serverUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get password => $composableBuilder( + column: $table.password, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isActive => $composableBuilder( + column: $table.isActive, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$RemoteProvidersTableOrderingComposer + extends Composer<_$AppDatabase, $RemoteProvidersTable> { + $$RemoteProvidersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverUrl => $composableBuilder( + column: $table.serverUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get username => $composableBuilder( + column: $table.username, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get password => $composableBuilder( + column: $table.password, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isActive => $composableBuilder( + column: $table.isActive, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RemoteProvidersTableAnnotationComposer + extends Composer<_$AppDatabase, $RemoteProvidersTable> { + $$RemoteProvidersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get serverUrl => + $composableBuilder(column: $table.serverUrl, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get username => + $composableBuilder(column: $table.username, builder: (column) => column); + + GeneratedColumn get password => + $composableBuilder(column: $table.password, builder: (column) => column); + + GeneratedColumn get isActive => + $composableBuilder(column: $table.isActive, builder: (column) => column); + + GeneratedColumn get addedAt => + $composableBuilder(column: $table.addedAt, builder: (column) => column); +} + +class $$RemoteProvidersTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RemoteProvidersTable, + RemoteProvider, + $$RemoteProvidersTableFilterComposer, + $$RemoteProvidersTableOrderingComposer, + $$RemoteProvidersTableAnnotationComposer, + $$RemoteProvidersTableCreateCompanionBuilder, + $$RemoteProvidersTableUpdateCompanionBuilder, + ( + RemoteProvider, + BaseReferences< + _$AppDatabase, + $RemoteProvidersTable, + RemoteProvider + >, + ), + RemoteProvider, + PrefetchHooks Function() + > { + $$RemoteProvidersTableTableManager( + _$AppDatabase db, + $RemoteProvidersTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RemoteProvidersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$RemoteProvidersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$RemoteProvidersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value serverUrl = const Value.absent(), + Value name = const Value.absent(), + Value username = const Value.absent(), + Value password = const Value.absent(), + Value isActive = const Value.absent(), + Value addedAt = const Value.absent(), + }) => RemoteProvidersCompanion( + id: id, + serverUrl: serverUrl, + name: name, + username: username, + password: password, + isActive: isActive, + addedAt: addedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String serverUrl, + required String name, + required String username, + required String password, + Value isActive = const Value.absent(), + Value addedAt = const Value.absent(), + }) => RemoteProvidersCompanion.insert( + id: id, + serverUrl: serverUrl, + name: name, + username: username, + password: password, + isActive: isActive, + addedAt: addedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$RemoteProvidersTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RemoteProvidersTable, + RemoteProvider, + $$RemoteProvidersTableFilterComposer, + $$RemoteProvidersTableOrderingComposer, + $$RemoteProvidersTableAnnotationComposer, + $$RemoteProvidersTableCreateCompanionBuilder, + $$RemoteProvidersTableUpdateCompanionBuilder, + ( + RemoteProvider, + BaseReferences<_$AppDatabase, $RemoteProvidersTable, RemoteProvider>, + ), + RemoteProvider, + PrefetchHooks Function() + >; typedef $$AppSettingsTableCreateCompanionBuilder = AppSettingsCompanion Function({ required String key, @@ -3269,6 +3956,8 @@ class $AppDatabaseManager { $$PlaylistEntriesTableTableManager(_db, _db.playlistEntries); $$WatchFoldersTableTableManager get watchFolders => $$WatchFoldersTableTableManager(_db, _db.watchFolders); + $$RemoteProvidersTableTableManager get remoteProviders => + $$RemoteProvidersTableTableManager(_db, _db.remoteProviders); $$AppSettingsTableTableManager get appSettings => $$AppSettingsTableTableManager(_db, _db.appSettings); } diff --git a/lib/data/playlist_repository.g.dart b/lib/data/playlist_repository.g.dart index 8dd2db2..043a9f6 100644 --- a/lib/data/playlist_repository.g.dart +++ b/lib/data/playlist_repository.g.dart @@ -34,7 +34,7 @@ final class PlaylistRepositoryProvider } String _$playlistRepositoryHash() => - r'614d837f9438d2454778edb4ff60b046418490b8'; + r'20c2c56f237a9e3ac3efe1225d05db8264b19678'; abstract class _$PlaylistRepository extends $AsyncNotifier { FutureOr build(); diff --git a/lib/data/track_repository.g.dart b/lib/data/track_repository.g.dart index 23a1892..5570e4b 100644 --- a/lib/data/track_repository.g.dart +++ b/lib/data/track_repository.g.dart @@ -33,7 +33,7 @@ final class TrackRepositoryProvider TrackRepository create() => TrackRepository(); } -String _$trackRepositoryHash() => r'538fedbc358e305aac4517d2c517a8bdf6bbb75c'; +String _$trackRepositoryHash() => r'655c231192698ef0c31920af846de47def7da81d'; abstract class _$TrackRepository extends $AsyncNotifier { FutureOr build(); diff --git a/lib/providers/remote_provider.dart b/lib/providers/remote_provider.dart new file mode 100644 index 0000000..544751d --- /dev/null +++ b/lib/providers/remote_provider.dart @@ -0,0 +1,205 @@ +import 'package:flutter/foundation.dart'; +import 'package:groovybox/data/db.dart'; +import 'package:groovybox/providers/db_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:drift/drift.dart'; +import 'package:jellyfin_dart/jellyfin_dart.dart'; + +// Simple remote provider using Riverpod +final remoteProvidersProvider = FutureProvider>(( + ref, +) async { + final db = ref.read(databaseProvider); + return await (db.select( + db.remoteProviders, + )..orderBy([(t) => OrderingTerm(expression: t.addedAt)])).get(); +}); + +final activeRemoteProvidersProvider = Provider>((ref) { + final remoteProvidersAsync = ref.watch(remoteProvidersProvider); + return remoteProvidersAsync.when( + data: (providers) => + providers.where((provider) => provider.isActive).toList(), + loading: () => [], + error: (_, _) => [], + ); +}); + +class RemoteProviderService { + final Ref ref; + + RemoteProviderService(this.ref); + + Future addRemoteProvider( + String serverUrl, + String username, + String password, { + String? name, + }) async { + final db = ref.read(databaseProvider); + final providerName = name ?? Uri.parse(serverUrl).host; + + await db + .into(db.remoteProviders) + .insert( + RemoteProvidersCompanion.insert( + serverUrl: serverUrl, + name: providerName, + username: username, + password: password, + ), + ); + + // Invalidate the provider to refresh UI + ref.invalidate(remoteProvidersProvider); + } + + Future removeRemoteProvider(int providerId) async { + final db = ref.read(databaseProvider); + + await (db.delete( + db.remoteProviders, + )..where((t) => t.id.equals(providerId))).go(); + + // Invalidate the provider to refresh UI + ref.invalidate(remoteProvidersProvider); + } + + Future toggleRemoteProvider(int providerId, bool isActive) async { + final db = ref.read(databaseProvider); + + await (db.update(db.remoteProviders)..where((t) => t.id.equals(providerId))) + .write(RemoteProvidersCompanion(isActive: Value(isActive))); + + // Invalidate the provider to refresh UI + ref.invalidate(remoteProvidersProvider); + } + + Future indexRemoteProvider(int providerId) async { + final db = ref.read(databaseProvider); + + // Get provider details + final provider = await (db.select( + db.remoteProviders, + )..where((t) => t.id.equals(providerId))).getSingleOrNull(); + + if (provider == null) { + throw Exception('Remote provider not found: $providerId'); + } + + if (!provider.isActive) { + debugPrint('Provider $providerId is not active, skipping indexing'); + return; + } + + try { + // Create Jellyfin client + final client = JellyfinDart(basePathOverride: provider.serverUrl); + + // Set device info + client.setDeviceId('groovybox-${providerId}'); + client.setVersion('1.0.0'); + + // Authenticate + final userApi = client.getUserApi(); + final authResponse = await userApi.authenticateUserByName( + authenticateUserByName: AuthenticateUserByName( + username: provider.username, + pw: provider.password, + ), + ); + + final token = authResponse.data?.accessToken; + if (token == null) { + throw Exception('Authentication failed for provider ${provider.name}'); + } + + client.setToken(token); + + // Fetch music items + final itemsApi = client.getItemsApi(); + final musicItems = await itemsApi.getItems( + includeItemTypes: [BaseItemKind.audio], + recursive: true, + fields: [ + ItemFields.path, + ItemFields.mediaStreams, + ItemFields.mediaSources, + ItemFields.genres, + ItemFields.tags, + ItemFields.overview, + ], + ); + + final items = musicItems.data?.items ?? []; + + // Convert to tracks and store + for (final item in items) { + await _addRemoteTrack(db, provider, item, token); + } + + debugPrint('Indexed $items.length tracks from $provider.name'); + } catch (e) { + debugPrint('Error indexing remote provider $provider.name: $e'); + rethrow; + } + } + + Future _addRemoteTrack( + AppDatabase db, + RemoteProvider provider, + BaseItemDto item, + String token, + ) async { + // Generate streaming URL + final streamUrl = + '${provider.serverUrl}/Audio/${item.id}/stream.mp3?api_key=$token&static=true'; + + // Extract metadata + final title = item.name ?? 'Unknown Title'; + final artist = + item.albumArtist ?? item.artists?.join(', ') ?? 'Unknown Artist'; + final album = item.album ?? 'Unknown Album'; + final duration = + (item.runTimeTicks ?? 0) ~/ 10000; // Convert ticks to milliseconds + + // Check if track already exists + final existingTrack = await (db.select( + db.tracks, + )..where((t) => t.path.equals(streamUrl))).getSingleOrNull(); + + if (existingTrack != null) { + // Update existing track + await (db.update( + db.tracks, + )..where((t) => t.id.equals(existingTrack.id))).write( + TracksCompanion( + title: Value(title), + artist: Value(artist), + album: Value(album), + duration: Value(duration), + addedAt: Value(DateTime.now()), + ), + ); + } else { + // Insert new track + await db + .into(db.tracks) + .insert( + TracksCompanion.insert( + title: title, + path: streamUrl, // Remote streaming URL + artist: Value(artist), + album: Value(album), + duration: Value(duration), + ), + mode: InsertMode.insertOrIgnore, + ); + } + } +} + +// Provider for the service +final remoteProviderServiceProvider = Provider((ref) { + return RemoteProviderService(ref); +}); diff --git a/lib/providers/settings_provider.g.dart b/lib/providers/settings_provider.g.dart index 9a8f2de..209f25b 100644 --- a/lib/providers/settings_provider.g.dart +++ b/lib/providers/settings_provider.g.dart @@ -87,7 +87,7 @@ final class ImportModeNotifierProvider } String _$importModeNotifierHash() => - r'eaf3dcf7c74dc24d6ebe14840d597e4a79859a63'; + r'4a4f8d3bb378e964f1d67159a650a2d7addeab69'; abstract class _$ImportModeNotifier extends $Notifier { ImportMode build(); @@ -140,7 +140,7 @@ final class AutoScanNotifierProvider } } -String _$autoScanNotifierHash() => r'56f2f1a2f6aef095782a0ed4407a43a8f589dc4b'; +String _$autoScanNotifierHash() => r'e8d7c9bd7059e0117979b120616addcd5c1abb8d'; abstract class _$AutoScanNotifier extends $Notifier { bool build(); @@ -194,7 +194,7 @@ final class WatchForChangesNotifierProvider } String _$watchForChangesNotifierHash() => - r'b4648380ae989e6e36138780d0c925916b6e20b3'; + r'1f15ffac52a0401b14d8cd4e04d39c69d5a2e704'; abstract class _$WatchForChangesNotifier extends $Notifier { bool build(); diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 74a0ce0..fd28f7d 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:groovybox/providers/settings_provider.dart'; import 'package:groovybox/providers/watch_folder_provider.dart'; +import 'package:groovybox/providers/remote_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -12,6 +13,7 @@ class SettingsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settingsAsync = ref.watch(settingsProvider); final watchFoldersAsync = ref.watch(watchFoldersProvider); + final remoteProvidersAsync = ref.watch(remoteProvidersProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -79,6 +81,7 @@ class SettingsScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -186,6 +189,122 @@ class SettingsScreen extends ConsumerWidget { ], ), ), + + // Remote Providers Section + Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Remote Providers', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + IconButton( + onPressed: () => + _indexRemoteProviders(context, ref), + icon: const Icon(Icons.refresh), + tooltip: 'Index Remote Providers', + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + IconButton( + onPressed: () => + _addRemoteProvider(context, ref), + icon: const Icon(Icons.add), + tooltip: 'Add Remote Provider', + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ], + ), + ], + ), + const Text( + 'Connect to remote media servers like Jellyfin to access your music library.', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ).padding(horizontal: 16, top: 16, bottom: 8), + remoteProvidersAsync.when( + data: (providers) => providers.isEmpty + ? const Text( + 'No remote providers added yet.', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ).padding(horizontal: 16, vertical: 8) + : Column( + children: providers + .map( + (provider) => ListTile( + title: Text(provider.name), + subtitle: Text(provider.serverUrl), + contentPadding: const EdgeInsets.only( + left: 16, + right: 16, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: provider.isActive, + onChanged: (value) { + ref + .read( + remoteProviderServiceProvider, + ) + .toggleRemoteProvider( + provider.id, + value, + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + ref + .read( + remoteProviderServiceProvider, + ) + .removeRemoteProvider( + provider.id, + ); + }, + ), + ], + ), + ), + ) + .toList(), + ), + loading: () => const CircularProgressIndicator(), + error: (error, _) => + Text('Error loading providers: $error'), + ), + const SizedBox(height: 8), + ], + ), + ), ], ), ), @@ -237,4 +356,139 @@ class SettingsScreen extends ConsumerWidget { } } } + + void _indexRemoteProviders(BuildContext context, WidgetRef ref) async { + try { + final service = ref.read(remoteProviderServiceProvider); + final providersAsync = ref.read(remoteProvidersProvider); + + providersAsync.when( + data: (providers) async { + final activeProviders = providers.where((p) => p.isActive).toList(); + + if (activeProviders.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No active remote providers to index'), + ), + ); + } + return; + } + + for (final provider in activeProviders) { + try { + await service.indexRemoteProvider(provider.id); + } catch (e) { + debugPrint('Error indexing provider ${provider.name}: $e'); + } + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Indexed ${activeProviders.length} remote provider(s)', + ), + ), + ); + } + }, + loading: () { + // Providers are still loading, do nothing + }, + error: (error, _) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading providers: $error')), + ); + } + }, + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error indexing remote providers: $e')), + ); + } + } + } + + void _addRemoteProvider(BuildContext context, WidgetRef ref) { + final serverUrlController = TextEditingController(); + final usernameController = TextEditingController(); + final passwordController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add Remote Provider'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: serverUrlController, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'https://your-jellyfin-server.com', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + TextField( + controller: usernameController, + decoration: const InputDecoration(labelText: 'Username'), + ), + const SizedBox(height: 16), + TextField( + controller: passwordController, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final serverUrl = serverUrlController.text.trim(); + final username = usernameController.text.trim(); + final password = passwordController.text.trim(); + + if (serverUrl.isEmpty || username.isEmpty || password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All fields are required')), + ); + return; + } + + try { + final service = ref.read(remoteProviderServiceProvider); + await service.addRemoteProvider(serverUrl, username, password); + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added remote provider: $serverUrl'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding provider: $e')), + ); + } + } + }, + child: const Text('Add'), + ), + ], + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index fb1be24..704efc7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + copy_with_extension: + dependency: transitive + description: + name: copy_with_extension + sha256: c9e09bce2fee69729ea55dbd55f7150d4cf6f7e55461091a02839c21346f1b95 + url: "https://pub.dev" + source: hosted + version: "10.0.1" coverage: dependency: transitive description: @@ -345,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -560,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jellyfin_dart: + dependency: "direct main" + description: + name: jellyfin_dart + sha256: "269fddfee36ee4a0fa2d03cf9e6f448d77ca35893faa77723c677889ac3f7e6f" + url: "https://pub.dev" + source: hosted + version: "0.1.2" js: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0a12e9b..a617343 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: palette_generator: ^0.3.3+4 watcher: ^1.2.0 shared_preferences: ^2.3.5 + jellyfin_dart: ^0.1.2 dev_dependencies: flutter_test: