From 2d12b3202162ae97ad0a953849c978db3c095bed Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Tue, 16 Jul 2024 02:44:22 +0300 Subject: [PATCH] feat(yt): mark video as watched works flawlessly with connection issues & accounts switching --- lib/base/audio_handler.dart | 38 ++++ lib/controller/connectivity.dart | 15 ++ .../info_controllers/yt_history_linker.dart | 185 ++++++++++++++++++ .../controller/youtube_info_controller.dart | 29 +-- pubspec.yaml | 3 + 5 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 lib/youtube/controller/info_controllers/yt_history_linker.dart diff --git a/lib/base/audio_handler.dart b/lib/base/audio_handler.dart index 1453dea3..5329f153 100644 --- a/lib/base/audio_handler.dart +++ b/lib/base/audio_handler.dart @@ -532,6 +532,12 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { } }); + // -- hmm marking local tracks as yt-watched..? + // final trackYoutubeId = tr.youtubeID; + // if (trackYoutubeId.isNotEmpty) { + // YoutubeInfoController.history.markVideoWatched(videoId: trackYoutubeId, streamResult: null, errorOnMissingParam: false); + // } + Duration? duration; Future setPls() async { @@ -850,6 +856,13 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { VideoStreamInfo? _ytNotificationVideoInfo; File? _ytNotificationVideoThumbnail; + /// Shows error if [marked] is not true. + void _onVideoMarkWatchResultError(bool? marked) { + if (marked != true) { + snackyy(message: 'Failed to mark video as watched.', top: false, isError: true); + } + } + Future onItemPlayYoutubeID( Q pi, YoutubeID item, @@ -1023,6 +1036,16 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { if (checkInterrupted()) return; + Completer? markedAsWatched; + + // only if was playing + if (okaySetFromCache() && (streamsResult != null || !ConnectivityController.inst.hasConnection)) { + // -- allow when no connection bcz this function won't try again with no connection, + // -- so we force call here and let `markVideoWatched` do the job when there is proper connection. + markedAsWatched = Completer(); + markedAsWatched.complete(YoutubeInfoController.history.markVideoWatched(videoId: item.id, streamResult: streamsResult)); + } + if (ConnectivityController.inst.hasConnection) { try { isFetchingInfo.value = true; @@ -1040,6 +1063,21 @@ class NamidaAudioVideoHandler extends BasicAudioHandler { snackyy(message: 'Error getting streams', top: false, isError: true); return null; }); + if (streamsResult != null) { + if (markedAsWatched != null) { + // -- older request was initiated, wait to see the value. + markedAsWatched.future.then( + (marked) { + if (marked != true && streamsResult != null) { + YoutubeInfoController.history.markVideoWatched(videoId: item.id, streamResult: streamsResult).then(_onVideoMarkWatchResultError); + } + }, + ); + } else { + // -- no old requests, force mark + YoutubeInfoController.history.markVideoWatched(videoId: item.id, streamResult: streamsResult).then(_onVideoMarkWatchResultError); + } + } duration ??= streamsResult?.audioStreams.firstOrNull?.duration; onInfoOrThumbObtained(info: streamsResult?.info); if (checkInterrupted(refreshNoti: false)) return; // -- onInfoOrThumbObtained refreshes notification. diff --git a/lib/controller/connectivity.dart b/lib/controller/connectivity.dart index 0ebb2c5a..8fae3f2e 100644 --- a/lib/controller/connectivity.dart +++ b/lib/controller/connectivity.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'package:namida/core/extensions.dart'; import 'package:namida/core/utils.dart'; class ConnectivityController { @@ -24,10 +26,23 @@ class ConnectivityController { connections.contains(ConnectivityResult.other); _hasHighConnection.value = highConnection; _hasConnection.value = true; + if (_onConnectionRestored.isNotEmpty) { + _onConnectionRestored.loop((item) => item()); + } } }); } + final _onConnectionRestored = []; + + void registerOnConnectionRestored(void Function() fn) { + _onConnectionRestored.add(fn); + } + + void removeOnConnectionRestored(void Function() fn) { + _onConnectionRestored.remove(fn); + } + bool get hasConnection => _hasConnection.value; bool get hasHighConnection => _hasHighConnection.value; diff --git a/lib/youtube/controller/info_controllers/yt_history_linker.dart b/lib/youtube/controller/info_controllers/yt_history_linker.dart new file mode 100644 index 00000000..ab873691 --- /dev/null +++ b/lib/youtube/controller/info_controllers/yt_history_linker.dart @@ -0,0 +1,185 @@ +part of '../youtube_info_controller.dart'; + +class _YoutubeHistoryLinker { + final String? Function() activeAccId; + _YoutubeHistoryLinker(this.activeAccId); + + late String _dbDirectory; + void init(String directory) { + _dbDirectory = directory; + _ensureDBOpened(); + ConnectivityController.inst.registerOnConnectionRestored(_onConnectionRestored); + } + + void _onConnectionRestored() { + if (_hasPendingRequests) executePendingRequests(); + } + + String? _dbOpenedAccId; + void _ensureDBOpened() { + final accId = activeAccId(); + if (accId == _dbOpenedAccId) return; // if both null, means no db will be opened, meaning db operations will not execute.. keikaku dori + + _dbOpenedAccId = accId; + _pendingRequestsDBIdle?.close(); + _pendingRequestsDBIdle = DBWrapper.open(_dbDirectory, 'pending_history_$accId'); + _pendingRequestsCompleter?.completeIfWasnt(); + _pendingRequestsCompleter = null; + executePendingRequests(); + } + + DBWrapper? _pendingRequestsDBIdle; + DBWrapper? get _pendingRequestsDB { + _ensureDBOpened(); + return _pendingRequestsDBIdle; + } + + bool get _hasConnection => ConnectivityController.inst.hasConnection; + + bool _hasPendingRequests = true; + + Completer? _pendingRequestsCompleter; + + void _addPendingRequest({required String videoId, required VideoStreamsResult? streamResult}) { + _hasPendingRequests = true; + final db = _pendingRequestsDB; + if (db == null) return; + + final vId = streamResult?.videoId ?? videoId; + final key = "${vId}_${DateTime.now().millisecondsSinceEpoch}"; + final map = { + 'videoId': vId, + 'statsPlaybackUrl': streamResult?.statsPlaybackUrl, + 'statsWatchtimeUrl': streamResult?.statsWatchtimeUrl, + }; + db.putAsync(key, map); + } + + List getPendingRequestsSync() { + final list = []; + _pendingRequestsDB?.loadEverythingKeyed( + (key, value) { + list.add(key); + }, + ); + return list; + } + + void executePendingRequests() async { + if (!_hasConnection) return null; + + if (_pendingRequestsCompleter != null) { + // -- already executing + return; + } + + _pendingRequestsCompleter ??= Completer(); + + final queue = Queue(parallel: 1); + + bool hadError = false; + + int itemsAddedToQueue = 0; + + final db = _pendingRequestsDB; + + db?.loadEverythingKeyed( + (key, value) { + if (hadError) return; + if (!_hasConnection) { + hadError = true; + return; + } + + itemsAddedToQueue++; + + queue.add( + () async { + if (hadError) return; + if (!_hasConnection) { + hadError = true; + return; + } + + bool added = false; + try { + String? statsPlaybackUrl = value['statsPlaybackUrl']; + String? statsWatchtimeUrl = value['statsWatchtimeUrl']; + if (statsPlaybackUrl == null && _hasConnection) { + final videoId = value['videoId'] ?? key.substring(0, 11); + final streamsRes = await YoutubeInfoController.video.fetchVideoStreams(videoId, forceRequest: true); + statsPlaybackUrl = streamsRes?.statsPlaybackUrl; + statsWatchtimeUrl ??= streamsRes?.statsWatchtimeUrl; + } + if (statsPlaybackUrl != null) { + // -- we check beforehand to supress internal error + final res = await YoutiPie.history.addVideoToHistory( + statsPlaybackUrl: statsPlaybackUrl, + statsWatchtimeUrl: statsWatchtimeUrl, + ); + added = res.$1; + } + } catch (_) {} + if (added || _hasConnection) { + // had connection but didnt mark. idc + db.deleteAsync(key); + } else { + hadError = true; // no connection, will not proceed anymore + } + }, + ); + }, + ); + + if (itemsAddedToQueue > 0) await queue.onComplete; + + if (!hadError) _hasPendingRequests = false; + + _pendingRequestsCompleter?.completeIfWasnt(); + _pendingRequestsCompleter = null; + } + + Future markVideoWatched({required String videoId, required VideoStreamsResult? streamResult, bool errorOnMissingParam = true}) async { + if (_hasPendingRequests) { + executePendingRequests(); + } + + if (_pendingRequestsCompleter != null) { + await _pendingRequestsCompleter!.future; + } + + bool added = false; + + if (_hasConnection && !_hasPendingRequests) { + String? statsPlaybackUrl = streamResult?.statsPlaybackUrl; + String? statsWatchtimeUrl = streamResult?.statsWatchtimeUrl; + if (statsPlaybackUrl == null) { + final streamsRes = await YoutubeInfoController.video.fetchVideoStreams(videoId, forceRequest: false); + statsPlaybackUrl = streamsRes?.statsPlaybackUrl; + statsWatchtimeUrl ??= streamsRes?.statsWatchtimeUrl; + } + if (statsPlaybackUrl != null || errorOnMissingParam) { + final res = await YoutiPie.history.addVideoToHistory( + statsPlaybackUrl: statsPlaybackUrl, + statsWatchtimeUrl: statsWatchtimeUrl, + ); + added = res.$1; + } + } + if (added) { + return added; + } else { + _addPendingRequest(videoId: videoId, streamResult: streamResult); + return null; + } + } + + Future fetchHistory({ExecuteDetails? details}) { + return YoutiPie.history.fetchHistory(details: details); + } + + YoutiPieHistoryResult? fetchHistorySync() { + final cache = YoutiPie.cacheBuilder.forHistoryVideos(); + return cache.read(); + } +} diff --git a/lib/youtube/controller/youtube_info_controller.dart b/lib/youtube/controller/youtube_info_controller.dart index dcc797ad..489fe342 100644 --- a/lib/youtube/controller/youtube_info_controller.dart +++ b/lib/youtube/controller/youtube_info_controller.dart @@ -1,19 +1,11 @@ library namidayoutubeinfo; +import 'dart:async'; import 'dart:io'; import 'package:logger/logger.dart'; -import 'package:namida/class/video.dart'; -import 'package:namida/controller/connectivity.dart'; -import 'package:namida/controller/navigator_controller.dart'; -import 'package:namida/controller/player_controller.dart'; -import 'package:namida/controller/settings_controller.dart'; -import 'package:namida/core/constants.dart'; -import 'package:namida/core/extensions.dart'; -import 'package:namida/core/translations/language.dart'; -import 'package:namida/core/utils.dart'; -import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; - +import 'package:namico_db_wrapper/namico_db_wrapper.dart'; +import 'package:queue/queue.dart'; import 'package:youtipie/class/channels/channel_page_about.dart'; import 'package:youtipie/class/channels/channel_page_result.dart'; import 'package:youtipie/class/channels/channel_tab.dart'; @@ -21,6 +13,7 @@ import 'package:youtipie/class/channels/tabs/channel_tab_videos_result.dart'; import 'package:youtipie/class/execute_details.dart'; import 'package:youtipie/class/related_videos_request_params.dart'; import 'package:youtipie/class/result_wrapper/comment_result.dart'; +import 'package:youtipie/class/result_wrapper/history_result.dart'; import 'package:youtipie/class/result_wrapper/related_videos_result.dart'; import 'package:youtipie/class/result_wrapper/search_result.dart'; import 'package:youtipie/class/stream_info_item/stream_info_item.dart'; @@ -30,7 +23,19 @@ import 'package:youtipie/core/enum.dart'; import 'package:youtipie/core/http.dart'; import 'package:youtipie/youtipie.dart' hide logger; +import 'package:namida/class/video.dart'; +import 'package:namida/controller/connectivity.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/player_controller.dart'; +import 'package:namida/controller/settings_controller.dart'; +import 'package:namida/core/constants.dart'; +import 'package:namida/core/extensions.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/core/utils.dart'; +import 'package:namida/youtube/controller/yt_miniplayer_ui_controller.dart'; + part 'info_controllers/yt_channel_info_controller.dart'; +part 'info_controllers/yt_history_linker.dart'; part 'info_controllers/yt_search_info_controller.dart'; part 'info_controllers/yt_various_utils.dart'; part 'info_controllers/yt_video_info_controller.dart'; @@ -41,6 +46,7 @@ class YoutubeInfoController { static const video = _VideoInfoController(); static const playlist = YoutiPie.playlist; + static final history = _YoutubeHistoryLinker(() => YoutiPie.activeAccountDetails.value?.id); static const userplaylist = YoutiPie.userplaylist; static const comment = YoutiPie.comment; static const commentAction = YoutiPie.commentAction; @@ -58,6 +64,7 @@ class YoutubeInfoController { checkJSPlayer: false, // we properly check for jsplayer with each streams request if needed, checkHasConnectionCallback: () => ConnectivityController.inst.hasConnection, ); + history.init(AppDirs.YOUTIPIE_CACHE); YoutiPie.setLogs(_YTReportingLog()); } diff --git a/pubspec.yaml b/pubspec.yaml index 50adcca1..da19714b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,9 @@ dependencies: vibration: ^1.8.4 flutter_displaymode: ^0.6.0 flutter_udid: ^3.0.0 + namico_db_wrapper: + git: + url: https://github.com/namidaco/namico_db_wrapper # ---- Audio Indexing & Playback ---- just_audio: