refactor: lastfm scrobbling to drift db

This commit is contained in:
Kingkor Roy Tirtho 2024-06-17 18:08:57 +06:00
parent d18f74fd65
commit b9b7d5c8aa
9 changed files with 525 additions and 134 deletions

View File

@ -1,7 +1,7 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/spotify/spotify.dart';
typedef UseTrackToggleLike = ({

View File

@ -22,6 +22,7 @@ part 'database.g.dart';
part 'tables/authentication.dart';
part 'tables/blacklist.dart';
part 'tables/preferences.dart';
part 'tables/scrobbler.dart';
part 'tables/skip_segment.dart';
part 'tables/source_match.dart';
@ -35,6 +36,7 @@ part 'typeconverters/encrypted_text.dart';
AuthenticationTable,
BlacklistTable,
PreferencesTable,
ScrobblerTable,
SkipSegmentTable,
SourceMatchTable,
],

View File

@ -1731,6 +1731,260 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
}
}
class $ScrobblerTableTable extends ScrobblerTable
with TableInfo<$ScrobblerTableTable, ScrobblerTableData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ScrobblerTableTable(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 _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _usernameMeta =
const VerificationMeta('username');
@override
late final GeneratedColumn<String> username = GeneratedColumn<String>(
'username', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _passwordHashMeta =
const VerificationMeta('passwordHash');
@override
late final GeneratedColumn<String> passwordHash = GeneratedColumn<String>(
'password_hash', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns => [id, createdAt, username, passwordHash];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'scrobbler_table';
@override
VerificationContext validateIntegrity(Insertable<ScrobblerTableData> 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('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('username')) {
context.handle(_usernameMeta,
username.isAcceptableOrUnknown(data['username']!, _usernameMeta));
} else if (isInserting) {
context.missing(_usernameMeta);
}
if (data.containsKey('password_hash')) {
context.handle(
_passwordHashMeta,
passwordHash.isAcceptableOrUnknown(
data['password_hash']!, _passwordHashMeta));
} else if (isInserting) {
context.missing(_passwordHashMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
ScrobblerTableData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ScrobblerTableData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
username: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}username'])!,
passwordHash: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}password_hash'])!,
);
}
@override
$ScrobblerTableTable createAlias(String alias) {
return $ScrobblerTableTable(attachedDatabase, alias);
}
}
class ScrobblerTableData extends DataClass
implements Insertable<ScrobblerTableData> {
final int id;
final DateTime createdAt;
final String username;
final String passwordHash;
const ScrobblerTableData(
{required this.id,
required this.createdAt,
required this.username,
required this.passwordHash});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['created_at'] = Variable<DateTime>(createdAt);
map['username'] = Variable<String>(username);
map['password_hash'] = Variable<String>(passwordHash);
return map;
}
ScrobblerTableCompanion toCompanion(bool nullToAbsent) {
return ScrobblerTableCompanion(
id: Value(id),
createdAt: Value(createdAt),
username: Value(username),
passwordHash: Value(passwordHash),
);
}
factory ScrobblerTableData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ScrobblerTableData(
id: serializer.fromJson<int>(json['id']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
username: serializer.fromJson<String>(json['username']),
passwordHash: serializer.fromJson<String>(json['passwordHash']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'createdAt': serializer.toJson<DateTime>(createdAt),
'username': serializer.toJson<String>(username),
'passwordHash': serializer.toJson<String>(passwordHash),
};
}
ScrobblerTableData copyWith(
{int? id,
DateTime? createdAt,
String? username,
String? passwordHash}) =>
ScrobblerTableData(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
username: username ?? this.username,
passwordHash: passwordHash ?? this.passwordHash,
);
@override
String toString() {
return (StringBuffer('ScrobblerTableData(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('username: $username, ')
..write('passwordHash: $passwordHash')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, createdAt, username, passwordHash);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ScrobblerTableData &&
other.id == this.id &&
other.createdAt == this.createdAt &&
other.username == this.username &&
other.passwordHash == this.passwordHash);
}
class ScrobblerTableCompanion extends UpdateCompanion<ScrobblerTableData> {
final Value<int> id;
final Value<DateTime> createdAt;
final Value<String> username;
final Value<String> passwordHash;
const ScrobblerTableCompanion({
this.id = const Value.absent(),
this.createdAt = const Value.absent(),
this.username = const Value.absent(),
this.passwordHash = const Value.absent(),
});
ScrobblerTableCompanion.insert({
this.id = const Value.absent(),
this.createdAt = const Value.absent(),
required String username,
required String passwordHash,
}) : username = Value(username),
passwordHash = Value(passwordHash);
static Insertable<ScrobblerTableData> custom({
Expression<int>? id,
Expression<DateTime>? createdAt,
Expression<String>? username,
Expression<String>? passwordHash,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (createdAt != null) 'created_at': createdAt,
if (username != null) 'username': username,
if (passwordHash != null) 'password_hash': passwordHash,
});
}
ScrobblerTableCompanion copyWith(
{Value<int>? id,
Value<DateTime>? createdAt,
Value<String>? username,
Value<String>? passwordHash}) {
return ScrobblerTableCompanion(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
username: username ?? this.username,
passwordHash: passwordHash ?? this.passwordHash,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (username.present) {
map['username'] = Variable<String>(username.value);
}
if (passwordHash.present) {
map['password_hash'] = Variable<String>(passwordHash.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ScrobblerTableCompanion(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('username: $username, ')
..write('passwordHash: $passwordHash')
..write(')'))
.toString();
}
}
class $SkipSegmentTableTable extends SkipSegmentTable
with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> {
@override
@ -2324,6 +2578,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this);
late final $PreferencesTableTable preferencesTable =
$PreferencesTableTable(this);
late final $ScrobblerTableTable scrobblerTable = $ScrobblerTableTable(this);
late final $SkipSegmentTableTable skipSegmentTable =
$SkipSegmentTableTable(this);
late final $SourceMatchTableTable sourceMatchTable =
@ -2340,6 +2595,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
uniqueBlacklist,
@ -3081,6 +3337,128 @@ class $$PreferencesTableTableOrderingComposer
ColumnOrderings(column, joinBuilders: joinBuilders));
}
typedef $$ScrobblerTableTableInsertCompanionBuilder = ScrobblerTableCompanion
Function({
Value<int> id,
Value<DateTime> createdAt,
required String username,
required String passwordHash,
});
typedef $$ScrobblerTableTableUpdateCompanionBuilder = ScrobblerTableCompanion
Function({
Value<int> id,
Value<DateTime> createdAt,
Value<String> username,
Value<String> passwordHash,
});
class $$ScrobblerTableTableTableManager extends RootTableManager<
_$AppDatabase,
$ScrobblerTableTable,
ScrobblerTableData,
$$ScrobblerTableTableFilterComposer,
$$ScrobblerTableTableOrderingComposer,
$$ScrobblerTableTableProcessedTableManager,
$$ScrobblerTableTableInsertCompanionBuilder,
$$ScrobblerTableTableUpdateCompanionBuilder> {
$$ScrobblerTableTableTableManager(
_$AppDatabase db, $ScrobblerTableTable table)
: super(TableManagerState(
db: db,
table: table,
filteringComposer:
$$ScrobblerTableTableFilterComposer(ComposerState(db, table)),
orderingComposer:
$$ScrobblerTableTableOrderingComposer(ComposerState(db, table)),
getChildManagerBuilder: (p) =>
$$ScrobblerTableTableProcessedTableManager(p),
getUpdateCompanionBuilder: ({
Value<int> id = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String> username = const Value.absent(),
Value<String> passwordHash = const Value.absent(),
}) =>
ScrobblerTableCompanion(
id: id,
createdAt: createdAt,
username: username,
passwordHash: passwordHash,
),
getInsertCompanionBuilder: ({
Value<int> id = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
required String username,
required String passwordHash,
}) =>
ScrobblerTableCompanion.insert(
id: id,
createdAt: createdAt,
username: username,
passwordHash: passwordHash,
),
));
}
class $$ScrobblerTableTableProcessedTableManager extends ProcessedTableManager<
_$AppDatabase,
$ScrobblerTableTable,
ScrobblerTableData,
$$ScrobblerTableTableFilterComposer,
$$ScrobblerTableTableOrderingComposer,
$$ScrobblerTableTableProcessedTableManager,
$$ScrobblerTableTableInsertCompanionBuilder,
$$ScrobblerTableTableUpdateCompanionBuilder> {
$$ScrobblerTableTableProcessedTableManager(super.$state);
}
class $$ScrobblerTableTableFilterComposer
extends FilterComposer<_$AppDatabase, $ScrobblerTableTable> {
$$ScrobblerTableTableFilterComposer(super.$state);
ColumnFilters<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<String> get username => $state.composableBuilder(
column: $state.table.username,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<String> get passwordHash => $state.composableBuilder(
column: $state.table.passwordHash,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
}
class $$ScrobblerTableTableOrderingComposer
extends OrderingComposer<_$AppDatabase, $ScrobblerTableTable> {
$$ScrobblerTableTableOrderingComposer(super.$state);
ColumnOrderings<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get username => $state.composableBuilder(
column: $state.table.username,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get passwordHash => $state.composableBuilder(
column: $state.table.passwordHash,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
}
typedef $$SkipSegmentTableTableInsertCompanionBuilder
= SkipSegmentTableCompanion Function({
Value<int> id,
@ -3370,6 +3748,8 @@ class _$AppDatabaseManager {
$$BlacklistTableTableTableManager(_db, _db.blacklistTable);
$$PreferencesTableTableTableManager get preferencesTable =>
$$PreferencesTableTableTableManager(_db, _db.preferencesTable);
$$ScrobblerTableTableTableManager get scrobblerTable =>
$$ScrobblerTableTableTableManager(_db, _db.scrobblerTable);
$$SkipSegmentTableTableTableManager get skipSegmentTable =>
$$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable);
$$SourceMatchTableTableTableManager get sourceMatchTable =>

View File

@ -0,0 +1,8 @@
part of '../database.dart';
class ScrobblerTable extends Table {
IntColumn get id => integer().autoIncrement()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get username => text()();
TextColumn get passwordHash => text().map(EncryptedTextConverter())();
}

View File

@ -7,7 +7,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
class LastFMLoginPage extends HookConsumerWidget {
static const name = "lastfm_login";

View File

@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/profile/profile.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/service_utils.dart';
@ -119,7 +119,7 @@ class SettingsAccountSection extends HookConsumerWidget {
),
);
}),
if (scrobbler == null)
if (scrobbler.asData?.value == null)
ListTile(
leading: const Icon(SpotubeIcons.lastFm),
title: Text(context.l10n.login_with_lastfm),

View File

@ -11,7 +11,7 @@ import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/proxy_playlist/player_listeners.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/scrobbler_provider.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/server/sourced_track.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';

View File

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/logger/logger.dart';
class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
final StreamController<Track> _scrobbleController =
StreamController<Track>.broadcast();
@override
build() async {
final database = ref.watch(databaseProvider);
final loginInfo = await (database.select(database.scrobblerTable)
..where((t) => t.id.equals(0)))
.getSingleOrNull();
final subscription =
database.select(database.scrobblerTable).watch().listen((event) async {
if (event.isNotEmpty) {
state = await AsyncValue.guard(
() async => Scrobblenaut(
lastFM: await LastFM.authenticateWithPasswordHash(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: event.first.username,
passwordHash: event.first.passwordHash,
),
),
);
} else {
state = const AsyncValue.data(null);
}
});
final scrobblerSubscription =
_scrobbleController.stream.listen((track) async {
try {
await state.asData?.value?.track.scrobble(
artist: track.artists!.first.name!,
track: track.name!,
album: track.album!.name!,
chosenByUser: true,
duration: track.duration,
timestamp: DateTime.now().toUtc(),
trackNumber: track.trackNumber,
);
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
}
});
ref.onDispose(() {
subscription.cancel();
scrobblerSubscription.cancel();
});
if (loginInfo == null) {
return null;
}
return Scrobblenaut(
lastFM: await LastFM.authenticateWithPasswordHash(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: loginInfo.username,
passwordHash: loginInfo.passwordHash,
),
);
}
Future<void> login(
String username,
String password,
) async {
final database = ref.read(databaseProvider);
final lastFm = await LastFM.authenticate(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: username,
password: password,
);
if (!lastFm.isAuth) throw Exception("Invalid credentials");
await database.into(database.scrobblerTable).insert(
ScrobblerTableCompanion.insert(
id: const Value(0),
username: username,
passwordHash: lastFm.passwordHash!,
),
);
}
Future<void> logout() async {
state = const AsyncValue.data(null);
final database = ref.read(databaseProvider);
await database.delete(database.scrobblerTable).go();
}
void scrobble(Track track) {
_scrobbleController.add(track);
}
Future<void> love(Track track) async {
await state.asData?.value?.track.love(
artist: track.artists!.asString(),
track: track.name!,
);
}
Future<void> unlove(Track track) async {
await state.asData?.value?.track.unLove(
artist: track.artists!.asString(),
track: track.name!,
);
}
}
final scrobblerProvider =
AsyncNotifierProvider<ScrobblerNotifier, Scrobblenaut?>(
() => ScrobblerNotifier(),
);

View File

@ -1,129 +0,0 @@
import 'dart:async';
import 'package:spotube/services/logger/logger.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
class ScrobblerState {
final String username;
final String passwordHash;
final Scrobblenaut scrobblenaut;
ScrobblerState({
required this.username,
required this.passwordHash,
required this.scrobblenaut,
});
Map<String, dynamic> toJson() {
return {
'username': username,
'passwordHash': passwordHash,
};
}
}
class ScrobblerNotifier extends PersistedStateNotifier<ScrobblerState?> {
final Scrobblenaut? scrobblenaut;
/// Directly scrobbling in set state of [ProxyPlaylistNotifier]
/// brings extra latency in playback
final StreamController<Track> _scrobbleController =
StreamController<Track>.broadcast();
ScrobblerNotifier()
: scrobblenaut = null,
super(null, "scrobbler", encrypted: true) {
_scrobbleController.stream.listen((track) async {
try {
await state?.scrobblenaut.track.scrobble(
artist: track.artists!.first.name!,
track: track.name!,
album: track.album!.name!,
chosenByUser: true,
duration: track.duration,
timestamp: DateTime.now().toUtc(),
trackNumber: track.trackNumber,
);
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
}
});
}
Future<void> login(
String username,
String password,
) async {
final lastFm = await LastFM.authenticate(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: username,
password: password,
);
if (!lastFm.isAuth) throw Exception("Invalid credentials");
state = ScrobblerState(
username: username,
passwordHash: lastFm.passwordHash!,
scrobblenaut: Scrobblenaut(lastFM: lastFm),
);
}
Future<void> logout() async {
state = null;
}
void scrobble(Track track) {
_scrobbleController.add(track);
}
Future<void> love(Track track) async {
await state?.scrobblenaut.track.love(
artist: track.artists!.asString(),
track: track.name!,
);
}
Future<void> unlove(Track track) async {
await state?.scrobblenaut.track.unLove(
artist: track.artists!.asString(),
track: track.name!,
);
}
@override
FutureOr<ScrobblerState?> fromJson(Map<String, dynamic> json) async {
if (json.isEmpty) {
return null;
}
return ScrobblerState(
username: json['username'],
passwordHash: json['passwordHash'],
scrobblenaut: Scrobblenaut(
lastFM: await LastFM.authenticateWithPasswordHash(
apiKey: Env.lastFmApiKey,
apiSecret: Env.lastFmApiSecret,
username: json["username"],
passwordHash: json["passwordHash"],
),
),
);
}
@override
Map<String, dynamic> toJson() {
return state?.toJson() ?? {};
}
}
final scrobblerProvider =
StateNotifierProvider<ScrobblerNotifier, ScrobblerState?>(
(ref) => ScrobblerNotifier(),
);