From 3301c6549b7bd89ceb1d5ddf16718cfc391dd52c Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 1 Nov 2023 17:21:43 +0100 Subject: [PATCH 1/4] :sparkles: Write offline listens to a file Useful if you want to manually export them to ListenBrainz or similar later on. --- lib/main.dart | 48 ++++++++------ .../music_player_background_task.dart | 48 +++++++++----- lib/services/offline_listen_helper.dart | 64 +++++++++++++++++++ 3 files changed, 122 insertions(+), 38 deletions(-) create mode 100644 lib/services/offline_listen_helper.dart diff --git a/lib/main.dart b/lib/main.dart index 33aa5c349..2057db61c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; @@ -18,37 +19,37 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'generate_material_color.dart'; +import 'models/finamp_models.dart'; +import 'models/jellyfin_models.dart'; import 'models/locale_adapter.dart'; import 'models/theme_mode_adapter.dart'; -import 'screens/language_selection_screen.dart'; -import 'services/locale_helper.dart'; -import 'services/theme_mode_helper.dart'; -import 'setup_logging.dart'; -import 'screens/user_selector.dart'; -import 'screens/music_screen.dart'; -import 'screens/view_selector.dart'; +import 'screens/add_download_location_screen.dart'; +import 'screens/add_to_playlist_screen.dart'; import 'screens/album_screen.dart'; -import 'screens/player_screen.dart'; -import 'screens/splash_screen.dart'; +import 'screens/artist_screen.dart'; +import 'screens/audio_service_settings_screen.dart'; import 'screens/downloads_error_screen.dart'; import 'screens/downloads_screen.dart'; -import 'screens/artist_screen.dart'; +import 'screens/downloads_settings_screen.dart'; +import 'screens/language_selection_screen.dart'; +import 'screens/layout_settings_screen.dart'; import 'screens/logs_screen.dart'; +import 'screens/music_screen.dart'; +import 'screens/player_screen.dart'; import 'screens/settings_screen.dart'; -import 'screens/transcoding_settings_screen.dart'; -import 'screens/downloads_settings_screen.dart'; -import 'screens/add_download_location_screen.dart'; -import 'screens/audio_service_settings_screen.dart'; +import 'screens/splash_screen.dart'; import 'screens/tabs_settings_screen.dart'; -import 'screens/add_to_playlist_screen.dart'; -import 'screens/layout_settings_screen.dart'; +import 'screens/transcoding_settings_screen.dart'; +import 'screens/user_selector.dart'; +import 'screens/view_selector.dart'; import 'services/audio_service_helper.dart'; -import 'services/jellyfin_api_helper.dart'; -import 'services/downloads_helper.dart'; import 'services/download_update_stream.dart'; +import 'services/downloads_helper.dart'; +import 'services/jellyfin_api_helper.dart'; +import 'services/locale_helper.dart'; import 'services/music_player_background_task.dart'; -import 'models/jellyfin_models.dart'; -import 'models/finamp_models.dart'; +import 'services/theme_mode_helper.dart'; +import 'setup_logging.dart'; void main() async { // If the app has failed, this is set to true. If true, we don't attempt to run the main app since the error app has started. @@ -60,6 +61,7 @@ void main() async { _migrateSortOptions(); _setupFinampUserHelper(); _setupJellyfinApiData(); + _setupOfflineListenLogHelper(); await _setupDownloader(); await _setupDownloadsHelper(); await _setupAudioServiceHelper(); @@ -91,6 +93,10 @@ void _setupJellyfinApiData() { GetIt.instance.registerSingleton(JellyfinApiHelper()); } +void _setupOfflineListenLogHelper() { + GetIt.instance.registerSingleton(OfflineListenLogHelper()); +} + Future _setupDownloadsHelper() async { GetIt.instance.registerSingleton(DownloadsHelper()); final downloadsHelper = GetIt.instance(); @@ -485,4 +491,4 @@ class _DummyCallback { IsolateNameServer.lookupPortByName('downloader_send_port'); send!.send([id, status, progress]); } -} +} \ No newline at end of file diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 1867fba2d..9292148ca 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:android_id/android_id.dart'; import 'package:audio_service/audio_service.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; import 'package:just_audio/just_audio.dart'; @@ -97,6 +98,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { ); final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); final _jellyfinApiHelper = GetIt.instance(); + final _offlineListenLogHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); /// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a @@ -203,6 +205,12 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { if (playbackInfo != null) { await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); } + } else { + final currentIndex = _player.currentIndex; + if (_queueAudioSource.length != 0 && currentIndex != null) { + final item = _getQueueItem(currentIndex); + _offlineListenLogHelper.logOfflineListen(item); + } } // Stop playing audio. @@ -446,34 +454,40 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { MediaItem? previousItem, PlaybackState? previousState, ) async { - if (FinampSettingsHelper.finampSettings.isOffline) { - return; - } - - final jellyfinApiHelper = GetIt.instance(); + final isOffline = FinampSettingsHelper.finampSettings.isOffline; if (previousItem != null && previousState != null && // don't submit stop events for idle tracks (at position 0 and not playing) (previousState.playing || previousState.updatePosition != Duration.zero)) { - final playbackData = generatePlaybackProgressInfoFromState( - previousItem, - previousState, - ); + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + previousItem, + previousState, + ); - if (playbackData != null) { - await jellyfinApiHelper.stopPlaybackProgress(playbackData); + if (playbackData != null) { + try { + await _jellyfinApiHelper.stopPlaybackProgress(playbackData); + } catch (e) { + _offlineListenLogHelper.logOfflineListen(previousItem); + } + } + } else { + _offlineListenLogHelper.logOfflineListen(previousItem); } } - final playbackData = generatePlaybackProgressInfoFromState( - currentItem, - currentState, - ); + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + currentItem, + currentState, + ); - if (playbackData != null) { - await jellyfinApiHelper.reportPlaybackStart(playbackData); + if (playbackData != null) { + await _jellyfinApiHelper.reportPlaybackStart(playbackData); + } } } diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart new file mode 100644 index 000000000..e743c329b --- /dev/null +++ b/lib/services/offline_listen_helper.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Logs offline listens or failed submissions to a file. +class OfflineListenLogHelper { + final _logger = Logger("OfflineListenLogHelper"); + + Future get _logDirectory async { + if (!Platform.isAndroid) { + return await getApplicationDocumentsDirectory(); + } + + final List? dirs = + await getExternalStorageDirectories(type: StorageDirectory.documents); + return dirs?.first ?? await getApplicationDocumentsDirectory(); + } + + Future get _logFile async { + final Directory directory = await _logDirectory; + return File("${directory.path}/listens.json"); + } + + Future logOfflineListen(MediaItem item) async { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final itemJson = item.extras!["itemJson"]; + final id = itemJson["Id"]; + final name = itemJson["Name"]; + final albumArtist = itemJson["AlbumArtist"]; + final album = itemJson["Album"]; + final trackMbid = itemJson["ProviderIds"]?["MusicBrainzTrack"]; + + await _logOfflineListen(timestamp, id, name, albumArtist, album, trackMbid); + } + + Future _logOfflineListen( + int timestamp, + String id, + String name, + String? artist, + String? album, + String? trackMbid, + ) async { + final data = { + 'timestamp': timestamp, + 'id': id, + 'title': name, + 'artist': artist, + 'album': album, + 'track_mbid': trackMbid, + }; + final content = json.encode(data) + Platform.lineTerminator; + + final file = await _logFile; + try { + file.writeAsString(content, mode: FileMode.append, flush: true); + } catch (e) { + _logger.warning("Failed to write listen to file: $content"); + } + } +} \ No newline at end of file From 3bd08b1ceab22ea5f18902b25f1c3a23c2892173 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 6 Dec 2023 00:52:11 +0100 Subject: [PATCH 2/4] :recycle: Add user ID to offline listens log --- lib/services/finamp_user_helper.dart | 5 +++- lib/services/offline_listen_helper.dart | 33 +++++++++++++++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/lib/services/finamp_user_helper.dart b/lib/services/finamp_user_helper.dart index 03124be25..f8662919f 100644 --- a/lib/services/finamp_user_helper.dart +++ b/lib/services/finamp_user_helper.dart @@ -13,6 +13,9 @@ class FinampUserHelper { /// Checks if there are any saved users. bool get isUsersEmpty => _finampUserBox.isEmpty; + /// Loads the id from CurrentUserId. Returns null if no id is stored. + String? get currentUserId => _currentUserIdBox.get("CurrentUserId"); + /// Loads the FinampUser with the id from CurrentUserId. Returns null if no /// user exists. FinampUser? get currentUser => @@ -61,4 +64,4 @@ class FinampUserHelper { _finampUserBox.delete(id); } -} +} \ No newline at end of file diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart index e743c329b..66a182fdf 100644 --- a/lib/services/offline_listen_helper.dart +++ b/lib/services/offline_listen_helper.dart @@ -2,12 +2,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; +import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; /// Logs offline listens or failed submissions to a file. class OfflineListenLogHelper { final _logger = Logger("OfflineListenLogHelper"); + final _finampUserHelper = GetIt.instance(); Future get _logDirectory async { if (!Platform.isAndroid) { @@ -25,32 +28,36 @@ class OfflineListenLogHelper { } Future logOfflineListen(MediaItem item) async { - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final itemJson = item.extras!["itemJson"]; - final id = itemJson["Id"]; - final name = itemJson["Name"]; - final albumArtist = itemJson["AlbumArtist"]; - final album = itemJson["Album"]; - final trackMbid = itemJson["ProviderIds"]?["MusicBrainzTrack"]; - await _logOfflineListen(timestamp, id, name, albumArtist, album, trackMbid); + await _logOfflineListen( + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + itemId: itemJson["Id"], + name: itemJson["Name"], + artist: itemJson["AlbumArtist"], + album: itemJson["Album"], + trackMbid: itemJson["ProviderIds"]?["MusicBrainzTrack"], + userId: _finampUserHelper.currentUserId, + ); } - Future _logOfflineListen( - int timestamp, - String id, - String name, + Future _logOfflineListen({ + required int timestamp, + required String itemId, + required String name, String? artist, String? album, String? trackMbid, - ) async { + String? userId, + }) async { final data = { 'timestamp': timestamp, - 'id': id, + 'item_id': itemId, 'title': name, 'artist': artist, 'album': album, 'track_mbid': trackMbid, + 'user_id': userId, }; final content = json.encode(data) + Platform.lineTerminator; From 1071e8720b129cedaa4ef3490339f056926e4f2e Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 6 Dec 2023 00:57:13 +0100 Subject: [PATCH 3/4] :memo: Add documentation to logOfflineListen functions --- lib/services/offline_listen_helper.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart index 66a182fdf..3e5937e0c 100644 --- a/lib/services/offline_listen_helper.dart +++ b/lib/services/offline_listen_helper.dart @@ -27,6 +27,9 @@ class OfflineListenLogHelper { return File("${directory.path}/listens.json"); } + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. Future logOfflineListen(MediaItem item) async { final itemJson = item.extras!["itemJson"]; @@ -41,6 +44,11 @@ class OfflineListenLogHelper { ); } + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. + /// The [timestamp] provided to this function should be in seconds + /// and marks the time the track was stopped. Future _logOfflineListen({ required int timestamp, required String itemId, From 0b2518cbfe3a14f7d41e9535a15ea9686ca0f823 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Wed, 6 Dec 2023 01:35:37 +0100 Subject: [PATCH 4/4] :sparkles: Also write offline listens to Hive box --- lib/main.dart | 2 + lib/models/finamp_models.dart | 40 ++++++++++++++++++- lib/models/finamp_models.g.dart | 52 +++++++++++++++++++++++++ lib/services/offline_listen_helper.dart | 38 +++++++++--------- 4 files changed, 112 insertions(+), 20 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2057db61c..04ff52c00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -175,6 +175,7 @@ Future setupHive() async { Hive.registerAdapter(DownloadedImageAdapter()); Hive.registerAdapter(ThemeModeAdapter()); Hive.registerAdapter(LocaleAdapter()); + Hive.registerAdapter(OfflineListenAdapter()); await Future.wait([ Hive.openBox("DownloadedParents"), Hive.openBox("DownloadedItems"), @@ -186,6 +187,7 @@ Future setupHive() async { Hive.openBox("DownloadedImageIds"), Hive.openBox("ThemeMode"), Hive.openBox(LocaleHelper.boxName), + Hive.openBox("OfflineListens") ]); // If the settings box is empty, we add an initial settings value here. diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index e4d037a28..618947427 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -5,12 +5,12 @@ import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:uuid/uuid.dart'; import 'package:path/path.dart' as path_helper; +import 'package:uuid/uuid.dart'; import '../services/finamp_settings_helper.dart'; -import 'jellyfin_models.dart'; import '../services/get_internal_song_dir.dart'; +import 'jellyfin_models.dart'; part 'finamp_models.g.dart'; @@ -571,3 +571,39 @@ class DownloadedImage { downloadLocationId: downloadLocationId, ); } + +@HiveType(typeId: 43) +class OfflineListen { + OfflineListen({ + required this.timestamp, + required this.userId, + required this.itemId, + required this.name, + this.artist, + this.album, + this.trackMbid, + }); + + /// The stop timestamp of the listen, measured in seconds since the epoch. + @HiveField(0) + int timestamp; + + @HiveField(1) + String userId; + + @HiveField(2) + String itemId; + + @HiveField(3) + String name; + + @HiveField(4) + String? artist; + + @HiveField(5) + String? album; + + // The MusicBrainz ID of the track, if available. + @HiveField(6) + String? trackMbid; +} \ No newline at end of file diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index e8e2fe943..36d1d1b57 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -372,6 +372,58 @@ class DownloadedImageAdapter extends TypeAdapter { typeId == other.typeId; } +class OfflineListenAdapter extends TypeAdapter { + @override + final int typeId = 43; + + @override + OfflineListen read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return OfflineListen( + timestamp: fields[0] as int, + userId: fields[1] as String, + itemId: fields[2] as String, + name: fields[3] as String, + artist: fields[4] as String?, + album: fields[5] as String?, + trackMbid: fields[6] as String?, + ); + } + + @override + void write(BinaryWriter writer, OfflineListen obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.timestamp) + ..writeByte(1) + ..write(obj.userId) + ..writeByte(2) + ..write(obj.itemId) + ..writeByte(3) + ..write(obj.name) + ..writeByte(4) + ..write(obj.artist) + ..writeByte(5) + ..write(obj.album) + ..writeByte(6) + ..write(obj.trackMbid); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OfflineListenAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class TabContentTypeAdapter extends TypeAdapter { @override final int typeId = 36; diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart index 3e5937e0c..0affc86d5 100644 --- a/lib/services/offline_listen_helper.dart +++ b/lib/services/offline_listen_helper.dart @@ -2,8 +2,10 @@ import 'dart:convert'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/services/finamp_user_helper.dart'; import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; @@ -33,15 +35,17 @@ class OfflineListenLogHelper { Future logOfflineListen(MediaItem item) async { final itemJson = item.extras!["itemJson"]; - await _logOfflineListen( + final offlineListen = OfflineListen( timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + userId: _finampUserHelper.currentUserId!, itemId: itemJson["Id"], name: itemJson["Name"], artist: itemJson["AlbumArtist"], album: itemJson["Album"], trackMbid: itemJson["ProviderIds"]?["MusicBrainzTrack"], - userId: _finampUserHelper.currentUserId, ); + + await _logOfflineListen(offlineListen); } /// Logs a listen to a file. @@ -49,23 +53,21 @@ class OfflineListenLogHelper { /// This is used when the user is offline or submitting live playback events fails. /// The [timestamp] provided to this function should be in seconds /// and marks the time the track was stopped. - Future _logOfflineListen({ - required int timestamp, - required String itemId, - required String name, - String? artist, - String? album, - String? trackMbid, - String? userId, - }) async { + Future _logOfflineListen(OfflineListen listen) async { + Hive.box("OfflineListens").add(listen); + + _exportOfflineListenToFile(listen); + } + + Future _exportOfflineListenToFile(OfflineListen listen) async { final data = { - 'timestamp': timestamp, - 'item_id': itemId, - 'title': name, - 'artist': artist, - 'album': album, - 'track_mbid': trackMbid, - 'user_id': userId, + 'timestamp': listen.timestamp, + 'item_id': listen.itemId, + 'title': listen.name, + 'artist': listen.artist, + 'album': listen.album, + 'track_mbid': listen.trackMbid, + 'user_id': listen.userId, }; final content = json.encode(data) + Platform.lineTerminator;