import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide ConnectionState; import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; import 'package:drift_db_viewer/drift_db_viewer.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/items/images_models.dart'; import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/items/season_model.dart'; import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/syncing/database_item.dart'; import 'package:fladder/models/syncing/download_stream.dart'; import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/models/syncing/sync_settings_model.dart'; import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/profiles/default_profile.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/sync/background_download_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/shared/fladder_snackbar.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; final syncProvider = StateNotifierProvider((ref) => throw UnimplementedError()); final downloadTasksProvider = StateProvider.family((ref, id) => DownloadStream.empty()); class SyncNotifier extends StateNotifier { SyncNotifier(this.ref, this.mobileDirectory) : super(SyncSettingsModel()) { _init(); } final Ref ref; late AppDatabase _db = AppDatabase(ref); final Directory mobileDirectory; final String subPath = "Synced"; bool updatingSyncStatus = false; StreamSubscription>? _subscription; @override set state(SyncSettingsModel value) { super.state = value; updateSyncStates(); } Future updateSyncStates() async { final lastState = (await _db.getAllItems.get()).where((item) => item.unSyncedData && item.userData != null).toList(); if (updatingSyncStatus || lastState.isEmpty) return; updatingSyncStatus = true; try { for (final item in lastState) { if (item.userData == null) continue; final updatedItem = await ref.read(jellyApiProvider).userItemsItemIdUserDataPost(itemId: item.id, body: item.userData); if (updatedItem?.isSuccessful == true) { final syncedItem = item.copyWith(unSyncedData: false); await _db.insertItem(syncedItem); } else { break; } } } catch (e) { // log('Error updating sync states: $e'); } finally { updatingSyncStatus = false; } } void _init() { cleanupTemporaryFiles(); ref.listen( userProvider, (previous, next) { if (previous?.id != next?.id) { if (next?.id != null) { _initializeQueryStream(id: next!.id); } } }, ); ref.listen(connectivityStatusProvider, (_, next) { if (next != ConnectionState.offline) { updateSyncStates(); } }); _initializeQueryStream(); } void _initializeQueryStream({String? id}) async { final userId = id ?? ref.read(userProvider)?.id; _subscription?.cancel(); state = state.copyWith(items: []); if (userId == null) return; final queryStream = _db.getParentItems.watch().distinct(); final initItems = await _db.getParentItems.get(); state = state.copyWith(items: initItems); _subscription = queryStream.listen((items) { state = state.copyWith(items: items); }); } Future cleanupTemporaryFiles() async { // List of directories to check final directories = [ //Desktop directory await getTemporaryDirectory(), //Mobile directory await getApplicationSupportDirectory(), ]; for (final dir in directories) { final List files = dir.listSync(); for (var file in files) { if (file is File) { final fileName = file.path.split(Platform.pathSeparator).last; final fileSize = await file.length(); if (fileName.startsWith('com.bbflight.background_downloader') && fileSize != 0) { try { await file.delete(); log('Deleted temporary file: $fileName from ${dir.path}'); } catch (e) { log('Failed to delete file $fileName: $e'); } } } } } } late final JellyService api = ref.read(jellyApiProvider); String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) ? ref.read(clientSettingsProvider.select((value) => value.syncPath)) : mobileDirectory.path; String? get savePath => _savePath; Directory get mainDirectory => Directory(path.joinAll([_savePath ?? "", subPath])); Directory? get saveDirectory { if (kIsWeb) return null; final directory = _savePath != null ? Directory(path.joinAll([_savePath ?? "", subPath, ref.read(userProvider)?.id ?? "UnknownUser"])) : null; directory?.createSync(recursive: true); if (directory?.existsSync() == true) { final noMedia = File(path.joinAll([directory?.path ?? "", ".nomedia"])); noMedia.writeAsString(''); } return directory; } String? get syncPath => saveDirectory?.path; Future get directorySize async { if (saveDirectory == null) return 0; var files = await saveDirectory!.list(recursive: true).toList(); var dirSize = files.fold(0, (int sum, file) => sum + file.statSync().size); return dirSize; } @override void dispose() { _subscription?.cancel(); super.dispose(); } Future refresh() async => state = state.copyWith(items: (await _db.getParentItems.get())); Future> getNestedChildren(SyncedItem item) async => _db.getNestedChildren(item); Future> getChildren(String parentId) async => await _db.getChildren(parentId).get(); Future> getSiblings(SyncedItem syncedItem) async { if (syncedItem.parentId == null) return []; return getChildren(syncedItem.parentId!); } Future getSyncedItem(String? id) async { if (id == null) return null; return await _db.getItem(id).getSingleOrNull(); } Stream watchItem(String id) => _db.getItem(id).watchSingleOrNull(); Future getParentItem(String id) async => await _db.getParent(id).getSingleOrNull(); Future refreshSyncItem(SyncedItem item) async { List itemsToSync = await getNestedChildren(item); itemsToSync = [item, ...itemsToSync]; SyncedItem parentItem = item; List newItems = []; for (var i = 0; i < itemsToSync.length; i++) { final itemToSync = itemsToSync[i]; final itemResponse = await api.usersUserIdItemsItemIdGetBaseItem( itemId: itemToSync.id, ); final itemModel = ItemBaseModel.fromBaseDto(itemResponse.bodyOrThrow, ref); final syncedParent = await _db.getItem(itemToSync.parentId ?? "").getSingleOrNull(); SyncedItem newSyncedItem = await _syncItemData(syncedParent, itemModel, itemResponse.bodyOrThrow); final updatedItem = itemToSync.copyWith( itemModel: newSyncedItem.createItemModel(ref), sortName: newSyncedItem.sortName, syncing: false, fImages: newSyncedItem.fImages, fTrickPlayModel: newSyncedItem.fTrickPlayModel, subtitles: newSyncedItem.subtitles, userData: UserData.determineLastUserData([item.userData, newSyncedItem.userData]), ); newItems.add(updatedItem); if (itemToSync.id == parentItem.id) { parentItem = updatedItem; } } await _db.insertMultipleEntries(newItems); return parentItem; } Future addSyncItem(BuildContext? context, ItemBaseModel item) async { try { if (context == null) return; if (saveDirectory == null) { String? selectedDirectory = await FilePicker.platform.getDirectoryPath(dialogTitle: context.localized.syncSelectDownloadsFolder); if (selectedDirectory?.isEmpty == true && context.mounted) { fladderSnackbar(context, title: context.localized.syncNoFolderSetup); return; } ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory); } if (context.mounted) { fladderSnackbar(context, title: context.localized.syncAddItemForSyncing(item.detailedName(context) ?? "Unknown")); } final newSync = switch (item) { EpisodeModel episode => await syncSeries(item.parentBaseModel, episode: episode), SeasonModel season => await syncSeries(item.parentBaseModel, season: season), SeriesModel series => await syncSeries(series), MovieModel movie => await syncMovie(movie), _ => null }; if (context.mounted) { fladderSnackbar(context, title: newSync != null ? context.localized.startedSyncingItem(item.detailedName(context) ?? "Unknown") : context.localized.unableToSyncItem(item.detailedName(context) ?? "Unknown")); } return; } catch (e) { log('Error adding sync item: ${e.toString()}'); if (context?.mounted == true) { fladderSnackbar(context!, title: context.localized.somethingWentWrong); } } } void viewDatabase(BuildContext context) => Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) => DriftDbViewer(_db))); Future removeSync(BuildContext context, SyncedItem? item) async { try { if (item == null) return false; final nestedChildren = await getNestedChildren(item); state = state.copyWith( items: state.items .map( (e) => e.copyWith(markedForDelete: e.id == item.id ? true : false), ) .toList()); await ref.read(backgroundDownloaderProvider).cancelTaskWithId(item.id); await _db.deleteAllItems([...nestedChildren, item]); for (var i = 0; i < nestedChildren.length; i++) { final element = nestedChildren[i]; await ref.read(backgroundDownloaderProvider).cancelTaskWithId(element.id); if (await element.directory.exists()) { await element.directory.delete(recursive: true); } } if (await item.directory.exists()) { await item.directory.delete(recursive: true); } return true; } catch (e) { log('Error deleting synced item ${e.toString()}'); state = state.copyWith(items: state.items.map((e) => e.copyWith(markedForDelete: false)).toList()); fladderSnackbar(context, title: context.localized.syncRemoveUnableToDeleteItem); return false; } } //Utility functions Future> saveExternalSubtitles(List? subtitles, SyncedItem item) async { if (subtitles == null) return []; final directory = item.directory; await directory.create(recursive: true); return Stream.fromIterable(subtitles).asyncMap((element) async { if (element.isExternal) { final response = await http.get(Uri.parse(element.url!)); final file = File(path.joinAll([directory.path, "${element.displayTitle}.${element.language}.srt"])); file.writeAsBytesSync(response.bodyBytes); return element.copyWith( url: () => file.path, ); } return element; }).toList(); } Future saveTrickPlayData(ItemBaseModel? item, Directory saveDirectory) async { if (item == null) return null; final trickPlayDirectory = Directory(path.joinAll([saveDirectory.path, SyncedItem.trickPlayPath])) ..createSync(recursive: true); final trickPlayData = await api.getTrickPlay(item: item, ref: ref); final List newStringList = []; for (var index = 0; index < (trickPlayData?.body?.images.length ?? 0); index++) { final image = trickPlayData?.body?.images[index]; if (image != null) { final http.Response response = await http.get(Uri.parse(image)); File? newFile; final fileName = "tile_$index.jpg"; if (response.statusCode == 200) { final Uint8List bytes = response.bodyBytes; newFile = File(path.joinAll([trickPlayDirectory.path, fileName])); await newFile.writeAsBytes(bytes); } if (newFile != null && await newFile.exists()) { newStringList.add(path.joinAll(['TrickPlay', fileName])); } } } return trickPlayData?.body?.copyWith(images: newStringList.toList()); } Future saveImageData(ImagesData? data, Directory saveDirectory) async { if (data == null) return data; if (!saveDirectory.existsSync()) return data; final primary = await urlDataToFileData(data.primary, saveDirectory, "primary.jpg"); final logo = await urlDataToFileData(data.logo, saveDirectory, "logo.jpg"); final backdrops = await Stream.fromIterable(data.backDrop ?? []) .asyncMap((element) async => await urlDataToFileData(element, saveDirectory, "backdrop-${element.key}.jpg")) .toList(); return data.copyWith( primary: () => primary, logo: () => logo, backDrop: () => backdrops.nonNulls.toList(), ); } Future?> saveChapterImages(List? data, Directory itemPath) async { if (data == null) return data; if (!itemPath.existsSync()) return data; if (data.isEmpty) return data; final saveDirectory = Directory(path.joinAll([itemPath.path, SyncedItem.chaptersPath])); await saveDirectory.create(recursive: true); final saveChapters = await Stream.fromIterable(data).asyncMap((event) async { final fileName = "${event.name}.jpg"; final response = await http.get(Uri.parse(event.imageUrl)); final file = File(path.joinAll([saveDirectory.path, fileName])); if (response.bodyBytes.isEmpty) return null; file.writeAsBytesSync(response.bodyBytes); return event.copyWith( imageUrl: path.joinAll([SyncedItem.chaptersPath, fileName]), ); }).toList(); return saveChapters.nonNulls.toList(); } Future urlDataToFileData(ImageData? data, Directory directory, String fileName) async { if (data?.path == null) return null; final response = await http.get(Uri.parse(data?.path ?? "")); final file = File(path.joinAll([directory.path, fileName])); file.writeAsBytesSync(response.bodyBytes); return data?.copyWith(path: fileName); } Future updateItem(SyncedItem item) async { SyncedItem syncedItem = item; try { await ref.read(jellyApiProvider).userItemsItemIdUserDataPost(itemId: syncedItem.id, body: syncedItem.userData); } catch (e) { log('Error updating item: ${syncedItem.id}'); syncedItem = syncedItem.copyWith(unSyncedData: true); } return _db.insertItem(syncedItem); } Future deleteFullSyncFiles(SyncedItem syncedItem, DownloadTask? task) async { await syncedItem.deleteDatFiles(ref); ref.read(downloadTasksProvider(syncedItem.id).notifier).update((state) => DownloadStream.empty()); ref.read(backgroundDownloaderProvider).cancelTaskWithId(syncedItem.id); cleanupTemporaryFiles(); refresh(); return syncedItem; } Future syncFile(SyncedItem syncItem, bool skipDownload) async { cleanupTemporaryFiles(); final playbackResponse = await api.itemsItemIdPlaybackInfoPost( itemId: syncItem.id, body: PlaybackInfoDto( enableDirectPlay: true, enableDirectStream: true, enableTranscoding: false, deviceProfile: ref.read(videoProfileProvider), ), ); final item = syncItem.createItemModel(ref); final directory = await Directory(syncItem.directory.path).create(recursive: true); final newState = VideoStream.fromPlayBackInfo(playbackResponse.bodyOrThrow, ref)?.copyWith(); final subtitles = await saveExternalSubtitles(newState?.mediaStreamsModel?.subStreams, syncItem); final trickPlayFile = await saveTrickPlayData(item, directory); final mediaSegments = (await api.mediaSegmentsGet(id: syncItem.id))?.body; syncItem = syncItem.copyWith( fChapters: await saveChapterImages(item?.overview.chapters, directory) ?? [], subtitles: subtitles, fTrickPlayModel: trickPlayFile, mediaSegments: mediaSegments, ); await updateItem(syncItem); final currentTask = ref.read(downloadTasksProvider(syncItem.id)); final user = ref.read(userProvider); if (user == null) return null; final downloadUrl = path.joinAll([user.server, "Items", syncItem.id, "Download"]); try { if (currentTask.task != null) { await ref.read(backgroundDownloaderProvider).cancelTaskWithId(currentTask.id); } if (!skipDownload) { final downloadTask = DownloadTask( taskId: syncItem.id, url: Uri.parse(downloadUrl).toString(), directory: syncItem.directory.path, filename: syncItem.videoFileName, updates: Updates.statusAndProgress, baseDirectory: BaseDirectory.root, urlQueryParameters: {"api_key": user.credentials.token}, headers: user.credentials.header(ref), requiresWiFi: ref.read(clientSettingsProvider.select((value) => value.requireWifi)), retries: 3, allowPause: true, ); final defaultDownloadStream = DownloadStream(id: syncItem.id, task: downloadTask, status: TaskStatus.enqueued); ref.read(downloadTasksProvider(syncItem.id).notifier).update((state) => defaultDownloadStream); return await ref.read(backgroundDownloaderProvider).enqueue(downloadTask); } } catch (e) { log(e.toString()); return null; } return null; } Future removeAllSyncedData() async { if (await mainDirectory.exists()) { await mainDirectory.delete(recursive: true); } await _db.close(); await _db.clearDatabase(); _db = AppDatabase(ref); state = state.copyWith(items: []); } Future updatePlaybackPosition({String? itemId, required Duration position}) async { if (itemId == null) return; final syncedItem = await _db.getItem(itemId).getSingleOrNull(); if (syncedItem == null) return; final item = syncedItem.itemModel; if (item == null) return; final progress = position.inMilliseconds / (item.overview.runTime?.inMilliseconds ?? 0) * 100; final updatedItem = syncedItem.copyWith( userData: syncedItem.userData?.copyWith( playbackPositionTicks: position.toRuntimeTicks, progress: progress, played: UserData.isPlayed(position, item.overview.runTime ?? Duration.zero), ), ); await _db.insertItem(updatedItem); } Future updatePlayedItem(String? itemId, {DateTime? datePlayed, required bool played, bool responseSuccessful = false}) async { if (itemId == null) return; final syncedItem = _db.getItem(itemId).getSingleOrNull(); syncedItem.then((item) async { if (item == null) return; final updatedUserData = item.userData?.copyWith( played: played, playbackPositionTicks: 0, progress: 0.0, lastPlayed: datePlayed ?? DateTime.now().toUtc(), ); SyncedItem updatedItem = item.copyWith(userData: updatedUserData, unSyncedData: !responseSuccessful); List children = []; final shouldUpdateChildren = {FladderItemType.series, FladderItemType.season}.contains(item.itemModel?.type); if (shouldUpdateChildren) { // Update child items with the same played status, jellyfin server does this was well // when marking a series or season as played children = (await getNestedChildren(item)) .map((e) => e.copyWith( userData: e.userData?.copyWith( played: played, playbackPositionTicks: 0, progress: 0.0, ), )) .toList(); } await _db.insertMultipleEntries([updatedItem, ...children]); }); } Future updateFavoriteItem(String? itemId, {required bool isFavorite, bool responseSuccessful = false}) async { if (itemId == null) return; final syncedItem = _db.getItem(itemId).getSingleOrNull(); syncedItem.then((item) async { if (item == null) return; final updatedUserData = item.userData?.copyWith(isFavourite: isFavorite); final updatedItem = item.copyWith(userData: updatedUserData, unSyncedData: !responseSuccessful); await _db.insertItem(updatedItem); }); } } extension SyncNotifierHelpers on SyncNotifier { Future createSyncItem(BaseItemDto response, {SyncedItem? parent}) async { final ItemBaseModel item = ItemBaseModel.fromBaseDto(response, ref); final existingSyncedItem = await getSyncedItem(item.id); if (existingSyncedItem != null) return existingSyncedItem; SyncedItem syncItem = await _syncItemData(parent, item, response); if (parent == null) { await _db.insertItem(syncItem); } return syncItem.copyWith( fileSize: response.mediaSources?.firstOrNull?.size ?? 0, syncing: false, videoFileName: response.path?.split('/').lastOrNull ?? "", ); } Future _syncItemData(SyncedItem? parent, ItemBaseModel item, BaseItemDto response) async { final Directory? parentDirectory = parent?.directory; final directory = Directory(path.joinAll([(parentDirectory ?? saveDirectory)?.path ?? "", item.id])); await directory.create(recursive: true); File dataFile = File(path.joinAll([directory.path, 'data.json'])); await dataFile.writeAsString(jsonEncode(response.toJson())); final imageData = await saveImageData(item.images, directory); SyncedItem syncItem = SyncedItem( syncing: true, id: item.id, parentId: parent?.id, sortName: response.sortName, fImages: imageData, userId: ref.read(userProvider)?.id ?? "", path: directory.path, userData: item.userData, ); return syncItem; } Future syncMovie(ItemBaseModel item, {bool skipDownload = false}) async { final response = await api.usersUserIdItemsItemIdGetBaseItem( itemId: item.id, ); final itemBaseModel = response.body; if (itemBaseModel == null) return null; SyncedItem syncItem = await createSyncItem(itemBaseModel); if (!syncItem.directory.existsSync()) return null; await _db.insertItem(syncItem); await syncFile(syncItem, skipDownload); return syncItem; } Future syncSeries(SeriesModel item, {SeasonModel? season, EpisodeModel? episode}) async { final response = await api.usersUserIdItemsItemIdGetBaseItem( itemId: item.id, ); List newItems = []; List? itemsToDownload = []; SyncedItem seriesItem = await createSyncItem(response.bodyOrThrow); newItems.add(seriesItem); if (!seriesItem.directory.existsSync()) return null; final seasonsResponse = await api.showsSeriesIdSeasonsGet( seriesId: item.id, isMissing: false, enableUserData: true, fields: [ ItemFields.mediastreams, ItemFields.mediasources, ItemFields.overview, ItemFields.mediasourcecount, ItemFields.airtime, ItemFields.datecreated, ItemFields.datelastmediaadded, ItemFields.datelastrefreshed, ItemFields.sortname, ItemFields.seasonuserdata, ItemFields.externalurls, ItemFields.genres, ItemFields.parentid, ItemFields.path, ItemFields.chapters, ItemFields.trickplay, ], ); final seasons = seasonsResponse.body?.items ?? []; for (var i = 0; i < seasons.length; i++) { final newSeason = seasons[i]; final syncedSeason = await createSyncItem(newSeason, parent: seriesItem); newItems.add(syncedSeason); final episodesResponse = await api.showsSeriesIdEpisodesGet( isMissing: false, enableUserData: true, fields: [ ItemFields.mediastreams, ItemFields.mediasources, ItemFields.overview, ItemFields.mediasourcecount, ItemFields.airtime, ItemFields.datecreated, ItemFields.datelastmediaadded, ItemFields.datelastrefreshed, ItemFields.sortname, ItemFields.seasonuserdata, ItemFields.externalurls, ItemFields.genres, ItemFields.parentid, ItemFields.path, ItemFields.chapters, ItemFields.trickplay, ], seasonId: newSeason.id, seriesId: seriesItem.id, ); final episodes = episodesResponse.body?.items ?? []; final episodeResults = await Future.wait( episodes.map((ep) async { final newEpisode = await createSyncItem(ep, parent: syncedSeason); return (ep, newEpisode); }), ); for (final (ep, newEpisode) in episodeResults) { newItems.add(newEpisode); if (episode?.id == ep.id || newSeason.id == season?.id && !await newEpisode.videoFile.exists()) { itemsToDownload.add(newEpisode); } } } await _db.insertMultipleEntries(newItems); for (var i = 0; i < itemsToDownload.length; i++) { final item = itemsToDownload[i]; //No need to await file sync happens in the background syncFile(item, false); } return seriesItem; } }